Skip to content

Instantly share code, notes, and snippets.

@greabock
Last active July 24, 2018 15:11
Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save greabock/e63da9e3d1b26e6cd7a3 to your computer and use it in GitHub Desktop.
Save greabock/e63da9e3d1b26e6cd7a3 to your computer and use it in GitHub Desktop.

#Волшебный Eloquent

И снова здравствуйте! Помните я говорил, что хочу рассказать в следющей статье о выборке данных? Так вот - я соврал. Нет, я по-прежнему хочу рассказать о практической работе с моделями... но люди из нашего дружного чата убедили меня, что пока еще рано и тема сисек стрктур данных раскрыта не доконца. А ведь все мы прекрасно знаем, как (до зуда в пятой точке) неприятно, когда остается некая недосказанность...

Итак... Я все же засскажу о выборке, но касаться это будет древовидных структур.

##Часть вторая."Ландшафтный Дизайн" или "Будни Садовода"

И так древовидные структуры... Они же - замыкающиеся связи.
Они же - ветвящиеся множества.

Представим, что у нас есть некоторое дерево категорий, некоторой вложенной структуры, наподобие папок в вашей операционной системе, и нам нужно построить такие деревья из обычнх списков.

Тут стоит сделать небольшое отступление. На самом деле, папки в вашей операционке не вложены одна в другую физически, а хранятся в одной общей свалке, а за их вложенность отвечает их адресная принадлежность. Обращали когда нибудь внимание на разницу во времени между перемещением папки (вырезать вставить) и ее копированием? Все верно - перемещение происходит намного быстрее. Это связано с тем, что ОС, при перемещении (в отличии от копирования) не перезаписывает эти данные на диск заново. Вместо этого, у папки всего лишь меняется адрес ее "проживания". В то время, как при копировании создается новая запись. Так вот эта самая адресация папок и есть вся суть древовидной структуры.

К сожаелению, Laravel не предоставяет решений для древовидных структур из коробки. #Конец end

Нет! Ну конечно же не конец! Мне еще есть что вам сказать.
Во-первых: есть множество готовых пакетов для работы с различными древовидными структурами.
Во-вторых: даже при использовании готовых пакетов, важно не только уметь ими пользоваться, но и понимать их устройство. Понимание устройства структур данных и их взаимодействия - это одна из тех вещей, что отличают программиста от веб-мастера.

Стоит ометить, что Eloquent в этой части будет не столь волшебный каким он был в предыдущей. Многие примеры были набросаны "на скорую руку" и наверняка могут иметь более изящные альтернативы, но их суть от этого не изменится.

Типы древовидных структур
Ниже я привожу четыре, известных мне, типа древовидных структур (далее - "ДС"):

  1. Adjасency Lists
  2. Matherialized Path
  3. Closure Table
  4. Nested Sets

Начнем выращивать деревья?

####Глава первая. Adjаcency Lists Первый тип - AL (смежные списки), это отец всех древовидных структур. Он прост и понятен настолько, насколько это вообще возможно. У каждого из вас есть отчество, верно? Ну если Вы не уроженец Объединенного Пиндостана, конечно. Так вот Ваше отчество - это что-то вроде внешнего ключа на общую "таблицу людей". Это указание на Вашего родителя. Стоп. Так вы же и сами из "таблицы людей"? Нет? Хм... странно. И все же, предположим, что вы - человеки. Тогда внешний ключ отчества из вашей таблицы людей ссылается на идентификатор человека - вашего родителя - в вашей же таблице. То есть - замыкается. Это именно то, почему ДС иногда называют замыкающимися связями. Таблица в этом случае будет выглядеть так:

id name parent_id
1 God null
2 Adam 1
3 Cain 2
4 Abel 2
5 Seth 2
6 Enoch 3
7 Enos 5

Модель:

class Client extends Eloquent{
  
    public function children()
    {
        return $this->hasMany('Client', 'parent_id', 'id');
    }
    
     public function parent()
    {
        return $this->belongsTo('Client', 'parent_id', 'id');
    }
    
}

Adjacency Lists

Как видно из структуры модели, мы можем выбрать детей для родителя, или родителя для ребенка, используя соответствующие методы children() (дети) и parent() (родитель). Еще мы можем легко переместить всю ветвь дерева, просто поменяв родителя у корня ветви. Кроме того, мы можем выбрать все записи дерева и построить из них дерево основываясь на полях id и parent_id, используя рекурсивную функцию, или построив ссылочную рекурсию.

дерево

Что мы неможем сделать столь же легко - так это выбрать или удалить участок(ветвь) дерева.

#####Выборка ветвей дерева Для решения этой проблемы существует четыре пути: первый вообще нельзя использовать, второй - иногда можно, тертий - хоть и рабочий, но велосипедистый костыль, четвертый - средствами Eloquent, но тоже не идеален.

Путь первый: полная рекурсивная выборка
Здесь все довольно очевидно.

/**
* Внимание! НЕ ДЕЛАЙТЕ ТАК! 
**/

use Illuminate\Database\Eloquent\Collection as Collection;

class Client extends Eloquent{

    public function getDescendants($id = null)
	{
		if( ! isset($this->descendants) ) $this->descendants = new Collection;
		if( ! $id) $id = $this->id;
		
		foreach($this->whereParentId($id)->get() as $object)
		{
			$this->descendants->add($object);
			$this->getDescendants($object->id); // рекурсия внутри цикла
		}
		
		return $this->descendants;
	}
}

Сложность вычисления алгоритма зашкаливает. Фактически, каждый выбраный элемент генерирует новый запрос в базу данных, если у него есть дети, то для каждого из них генерируется новый запрос и т.д.

$client = Client::find(1);
$descendants = $client->getDescendants();

пруф
Вывод: так делать нельзя.

Путь второй: рекурсивная выборка по слоям

Давайте немного уменьшим количество запростов к бд...

/**
* Так делать можно... иногда.
**/
use Illuminate\Database\Eloquent\Collection as Collection; 

class Client extends Eloquent{

    public function getDescendants( array $ids = [] )
    {
		if( ! isset($this->descendants) ) $this->descendants = new Collection;

		if( ! $ids ) $ids = [$this->id];

		$nestedIds = [];
        
		foreach($this->whereIn('parent_id', $ids)->get() as $object)
		{
				$this->descendants->add($object);
				
				$nestedIds[] = $object->id;
		}
        
		if( $nestedIds ) $this->getDescendants($nestedIds);
        
		return $this->descendants;
	}
    
}

Ну вот, теперь из полей id полученых клиентов формируется массив, и для кажого уровня вложенности генерируется лишь один запрос... Тоесть сложность алгоритма зависит от количества уровней вложеннности.

$client = Client::find(1);
$descendants = $client->getDescendants();

пруф
Для небольшой вложенности, вполне себе приемлемый вариант.

Путь третий: составление рекурсивного запроса
И все же, есть вариант, вытащить все записи одним запросом... но с некоторыми оговорками...

Во-первых, нужно заранее знать конечную степень вложенности.
Во-вторых, степень вложенности может быть ограничена настройками/возможностями бд. В-третьих, может случиться так, что сам запрос будет работать медленнее чем несколько запросов попроще.

use Illuminate\Database\Eloquent\Collection as Collection; 

class Client extends Eloquent{
    public function getDescendants($levels = 0, $id = null)
	{
		if( ! $id) $id = $this->id;

		$base = "SELECT * FROM `clients` WHERE `parent_id` = $id";
		
		if ( $levels > 1)
		{
			$subRequests = [];
			$outer = '( SELECT * FROM `clients` WHERE `parent_id` IN %nested% )';
			$inner = "( SELECT `id` FROM `clients` WHERE `parent_id` = $id )";
			
			if($levels > 2)
			{
				$core =  '( SELECT `id` FROM `clients` WHERE `parent_id` IN %nested% )';
				$tempInner = $inner;
				
				for($i = $levels-2; $i > 0; $i--)
				{
					$subRequests[] = str_replace('%nested%', str_replace('%nested%',  $tempInner, $core), $outer);
					$tempInner = str_replace('%nested%', $temp, $core);
				}
			}
			$subRequests[] = str_replace('%nested%', $inner, $outer);
			$subRequests[] = '( '. $base . ')';

			$base = implode(' UNION ALL ', $subRequests);

			$base .= ' ORDER BY `id`';
		}

		$this->descendants = new Collection;
		
		return $this->descendants->make(DB::select($base));
	}
}

Эм... как бы вам это объяснить? :) Это велосипедистый костыль на UNION'ax... Он формирует множество вложенных подзапросов вида:

( SELECT * FROM `clients` WHERE `parent_id` = 1 )
UNION ALL
( SELECT * FROM `clients` WHERE `parent_id` IN
    ( SELECT `id` FROM `clients` WHERE `parent_id` = 1 ))
UNION ALL
 ( SELECT * FROM `clients` WHERE `parent_id` IN 
    ( SELECT `id` FROM `clients` WHERE `parent_id` IN
        ( SELECT `id` FROM `clients` WHERE `parent_id` = 1 )))
// И так далее...

Чем больше уровней вложенности, тем длиннее запрос.

$client = Client::find(1);
$descendants = $client->getDescendants(3); //где 3 - это уровень вложенности.

пруф Как я и говорил: костыльно, но зато одним запросом.

Кроме того, я не раз слышал, что подобный запрос можно реализовать на JOIN'ax, но сам я сходу не додумался как это сделать, а пятиминутное гугление не принесло плодов. Если есть читатели, которые знают как это сделать при помощи оператора JOIN, то пусть отпишутся в комментариях.

Updated: поступила инфа, что финальный запрос на JOIN'ax должен выглядеть приблизительно так:

SELECT *
FROM clients AS l1
LEFT JOIN clients AS l2 ON l2.parent_id = l1.id
LEFT JOIN clients AS l3 ON l3.parent_id = l2.id
WHERE l1.parent_id = 1

результат выборки:

Правда, построить дерево из такой структуры не так легко. (кореспондент - VladShcherbin)


Ну а теперь, на сладкое, немного волшебства от Big-Shark'a...

Путь четвертый: средствами Eloquent

Client::with('children.children.children')->find(1);

пруф По дополнительному запросу, для каждого уровня вложенности. Ну и как видно из кода, конечная вложенность должна быть известна. Из плюсов, можно отметить, что на выходе получается уже готовое дерево.

#####Добавление элементов Осуществляется без проблем, простым указанием parent_id. В Laravel можно использовать метод associate();

#####Перемещение ветвей Это самая простая операция, возможная в AL. У корня ветви просто меняется parent_id. Все готово.

#####Удаление ветвей Собираем id всех потомков в массив и делаем Client::whereIn('id', $ids)->delete(). Если в реляционной таблице установлен on delete cascade то достаточно удалить сам корень.

###заключение Мы рассмотрели самую базовую ДС. Все прочие структуры используют ее в той или иной степени. В следующей главе разговор пойдет о Matherialized Path. Удачи!


ссылки по теме:
рекурсивная функция
ссылочная рекурсия

@constb
Copy link

constb commented Feb 16, 2015

В Laravel можно использовать метод attach();

для отношений один-ко-многим используется associate. не помню точно, но по-моему attach есть только у многих-ко-многим.

Client::whereIn('id', $ids)->delete() проще записать как Client::destroy($ids)

исправил, спасибо

@greabock
Copy link
Author

greabock commented Jun 5, 2015

@constb
Исправил, спасибо

@pitersky
Copy link

картинка поломалась (большой белый квадрат вместо чего-то, что я думаю, было картинкой)

@pitersky
Copy link

деревья из обычнх списков -> обычных

@projct1
Copy link

projct1 commented Aug 10, 2015

Про удаление ветвей: зачем это делать вручную если база сделает всё автоматом при onCascade равным delete.

@greabock
Copy link
Author

@rorc не все базы данных - реляционные

@greabock
Copy link
Author

@Shilka это не картинка, это типа "конец" )

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment