ORM и ActiveRecord в 0.5 и выше

Выход версии 0.5 для меня было нечто большим, чем добавление страницы активности и ленты топиков из подписанных блогов. В новой версии реализованы ORM и ActiveRecord. Вместе они дают мощнейший инструментарий для разработчика, избавляя того от кучи однотипного кода, который приходилось писать каждый раз при разработке плагина. Тот-же форум, о котором будет идти речь в статье, после обновления похудел на 2177 строк кода. В этой статье я хочу углубиться в ORM и AR на примере создания плагина для LiveStreet.

Почти год назад пользователь Ajaxy опубликовал статью о новшествах в транковой версии.

Отталкивался я от той статьи, но она всего-лишь вводная часть того инструментария. Там нет ни слова о кеше, постраничности, древовидного вида массива. Я постараюсь подать как можно больше информации читателю не нагружая его лишним. Начнем?

Я буду рассматривать все на примере плагина forum. Для начала нам надо создать скелет плагина:

plugin

Исходники файлов можно посмотреть на гитхабе, на описании плагина я останавливаться не буду.

Теперь создадим модуль с именем forum.

plugin

Нам надо описать модуль как ModuleORM, и, в методе Init нам надо наследовать родителя метода Init у обычного модуля:

class PluginForum_ModuleForum extends ModuleORM {

	public function Init() {
		parent::Init();
	}

}


Прежде чем описывать сущности, надо обдумать все связи между ними, чтобы было легче манипулировать через геттеры:

relations

И описать созданные сущности:

Category.entity.class.php

class PluginForum_ModuleForum_EntityCategory extends EntityORM {}


Forum.entity.class.php

class PluginForum_ModuleForum_EntityForum extends EntityORM {
	protected $aRelations = array(
		'category'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'PluginForum_ModuleForum_EntityCategory','category_id'),
		'user'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'ModuleUser_EntityUser','user_id'),
		'topic'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'PluginForum_ModuleForum_EntityTopic','topic_id'),
		'post'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'PluginForum_ModuleForum_EntityPost','post_id'),
	);
}


Topic.entity.class.php

class PluginForum_ModuleForum_EntityTopic extends EntityORM {
	protected $aRelations = array(
		'user'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'ModuleUser_EntityUser','user_id'),
		'post'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'PluginForum_ModuleForum_EntityPost','post_id'),
		'forum'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'PluginForum_ModuleForum_EntityForum','forum_id'),
	);
}


Post.entity.class.php

class PluginForum_ModuleForum_EntityPost extends EntityORM {
	protected $aRelations = array(
		'user'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'ModuleUser_EntityUser','user_id'),
		'topic'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'PluginForum_ModuleForum_EntityTopic','topic_id'),
		'forum'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'PluginForum_ModuleForum_EntityForum','forum_id'),
	);
}


Read.entity.class.php

class PluginForum_ModuleForum_EntityRead extends EntityORM {}


Теперь всем этим можно манипулировать без описания классов в модуле плагина, например:

$this->PluginForum_ModuleForum_GetCategoryItemsAll(); // получим все категории


$this->PluginForum_ModuleForum_GetForumItemsByCategoryId($oCategory->getId()); // получим все форумы по ID категории


$this->PluginForum_ModuleForum_GetTopicItemsByForumId($oForum->getId(),array('#page' => array(1,15), '#cache'=>'')); 
/**
 * Получим массив для вывода с постраничностью и без кеширования запроса вида:
 * array(
 *   'collection' => array of objects
 *   'count' => integer
 * );
 * Где array('#page' => array('номер страницы', 'элементов на страницу'))
 * Где array('#cache' => '') отказ от кеширования запроса
 */


Фильтр #cache также может принимать следующиме параметры:

array(
	'#cache' => array(
		'keys', 'tags', 'time'
	)
);


Есть несколько типов связей:

protected $aRelations = array(
	'entity'=>array(EntityORM::RELATION_TYPE_BELONGS_TO,'ModuleSome_EntitySome','field_id'), // Получаем один элемент по одному элементу. Например, в таблице prefix_forum_topic есть поле с ID'шником пользователя, тогда мы получим юзера по ID с этого поля
	'entity'=>array(EntityORM::RELATION_TYPE_HAS_MANY,'ModuleSome_EntitySome','field_id'), // Получает массив элементов
	'entity'=>array(EntityORM::RELATION_TYPE_HAS_ONE,'ModuleSome_EntitySome','field_id'), // Это, например, когда у пользователя (основная сущность) есть сессия, и она хранится в отдельной таблице © ort
	'entity'=>array(EntityORM::RELATION_TYPE_MANY_TO_MANY,'ModuleSome_EntitySome','field_id'), // Получает массив элементов по массиву
	EntityORM::RELATION_TYPE_TREE // Строит дерево, необходимо наличие поля parent_id в таблице
);


После описания сущностей, нам будут доступные следующие методы:

/**
 * Конструкции вида [name] сделаны для примера
 */

/**
 * Работа с объектами
 */
Add();
Update();
Save(); // Совмещает и Update() и Add(), первый выполняется если сущность уже есть в бд, второй когда ее еще нет © ort
Delete();
Reload();
// пример:
$oParam->setTitle('Пара-пам-пам');
$oParam->Update();

/**
 * Запросы на получение объекта/массива объектов
 */
ShowColumnsFrom[Table]();
LoadTreeOf[Entity]();
Get[Entity]ItemsBy[Filter, Array, JoinTable, Gte, Lte, Gt, Lt, Like, In]();
Get[Entity]All();
Get[Entity]ItemsAll();
/**
 * Методы, доступные только для типа tree
 */
GetChildrenOf[Entity]();
GetParentOf[Entity]();
GetDescendantsOf[Entity]();
GetAncestorsOf[Entity]();

// пример:
$this->ModuleHabr_GetTopicItemsAll();

/**
 * А так-же сгенерированные через singleton Engine, работа с самим объектом
 */
get[Column]();
// пример:
$oParam->getTitle();


Таблицы в базе данных должны называться следующим образом: prefix_<module-name>_<entity-name> (или прописать их названия в конфиге), а если же название сущности совпадает с названием модуля, то достаточно назвать таблицу так: prefix_<module-name>. Поля тоже имеют свои стандарты: <entity-name>_<field-name>, или просто <field-name>.

Вот и все, господа, спасибо за уделенное внимание. Напоминаю, что свежие исходники форума можно посмотреть в git'е.

PS: Жду конструктивной критики. Данная статья является кросс-постом оригинальной статьи на Хабре.

34 комментария

avatar
статья определенно в избранные.
avatar
Будет ли обновляться функционал ORM, а именно не хватает некоторой гибкости при формировании условий в запросах?
Кроме заявленных Gte, Lte, Gt, Lt, Like и In иногда требуется использовать «OR» или формировать более сложные запросы:
(Условие1 AND Условие2) OR (Условие3 AND Условие4)

Также при работе с JoinTable или с отношением ManyToMany проблематично вставить в запрос дополнительные условия, так как там жестко прописано
WHERE a.?#=?

и нельзя применить допустим:
WHERE a.?# IN (?a)

когда надо дернуть пак записей
avatar
Кроме заявленных Gte, Lte, Gt, Lt, Like и In иногда требуется использовать «OR» или формировать более сложные запросы
для сложных условий нужно использовать #where
avatar
Точно! Нашел в коде:
// '#where' => array('id = ?d OR name = ?' => array(1,'admin'));

А насчет связанных таблиц такая ситуация.
1. Таблица `category`
2. Таблица `topic`
3. Таблица `property`

Связи:
`category` — `topic` ManyToMany
`category` — `property` ManyToMany

Требуется вытянуть все `property` из всех `category`, в которых состоит `topic`
Через ORM делается довольно геморройно:
Сначала надо получить все категории топика

$aCategories = $oTopic->getCategories();

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

$aProperties = array();
foreach ($aCategories as $oCategory) {
  foreach ($oCategory->getProperties(array('#index-from-primary')) as $oProperty) {
		$aProperties[$oProperty->getId()] = $oProperty;
  }
}

Возможно, когда врублен кэш, это и несущественная потеря в скорости, но все равно весьма затратная процедура
avatar
Собственно, мапперы никто не отменял.

ORM штука хорошая для стандартных задач, что составляет 80% работы. А если хочется что-то… по-хитрее — вперед к SQL.
avatar
Ну никогда нет конца совершенству. Если добавить всего одну строчку в MapperORM, которая будет расширять условия запроса фильтром из аргумента функции, то можно все сделать проще:

$aFilter = array(
            'a.category_id' => $oTopic->getCategories('#index-from-primary')
         );
$aProperties = $this->ModuleName_GetPropertyItemsByJoinTable(
        ....., // параметры связывания таблиц 
        $aFilter
);

Вообще возможность пережать массив фильтра есть, но он не перекрывает впередистоящие условия.
avatar
Еще когда-то писал в Trac про расширение типов возможных связанных данных в методе GetItemsByFilter() при наличии $aFilter['#with'].

Сейчас там только два варианта:
RELATION_TYPE_BELONGS_TO
RELATION_TYPE_HAS_ONE
avatar
Я получаю записи из одной таблицы. Мне нужно приJOINить вторую таблицу и получить из нее дополнительное поле. Как это сделать? Как пример, есть товары, наценка на них храниться в таблице категорий.
avatar
т.е. нужно получить категорию для каждого товара? тогда нужна связь RELATION_TYPE_BELONGS_TO (если у одного товара может быть только одна категория).
Если задача стоит именно в join'е, то можно использовать '#join'=>'JOIN blabla as b ON blabla'
avatar
Если я использую RELATION_TYPE_BELONGS_TO — то у меня появляется дополнительный запрос.
Если использовать '#join'=>'JOIN blabla as b ON blabla', то я могу приджойнить таблицу, лишь для дополнительной фильтрации, потому что в запросе жестко стоит t.*.

Мне нужно что-то типа такого.
#join'=>array('JOIN blabla as b ON blabla' => 'b.*' )
т.е. приджойни таблицу и возьми из нее все столбцы.
avatar
avatar
Огромнейшее СПАСИБО!
Максим, с тобой приятно работать)
avatar
$aFilter = array(
    '#join' => array(
        'INNER JOIN '.Config::Get('db.table.category').' c ON t.category_id = c.id' => 'c.product_prefix'
    ),
);
$aProduct = $this->Shop_GetProductItemsByFilter($aFilter);


Warning: array_merge(): Argument #2 is not an array in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 323

Warning: array_merge(): Argument #1 is not an array in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 326

Warning: array_values() expects parameter 1 to be array, null given in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 144

Warning: array_merge(): Argument #2 is not an array in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 144

Warning: call_user_func_array() expects parameter 2 to be array, null given in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 146

Warning: array_merge(): Argument #2 is not an array in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 323

Warning: array_merge(): Argument #1 is not an array in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 326

Warning: array_values() expects parameter 1 to be array, null given in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 177

Warning: array_merge(): Argument #2 is not an array in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 177

Warning: call_user_func_array() expects parameter 2 to be array, null given in /home/nikolai/www/new.vsedivani.com/framework/classes/engine/MapperORM.class.php on line 178

Вот такие ошибки возникли. Не работает.
Максим, поправь пожалуйста. Очень нужно! Заранее Спасибо.
avatar
а от куда этот синтаксис?
'#join' => array(
        'INNER JOIN '.Config::Get('db.table.category').' c ON t.category_id = c.id' => 'c.product_prefix'
    ),

надо так:
'#select' => array('t.*','c.product_prefix'),
'#join' => array(
        'INNER JOIN '.Config::Get('db.table.category').' c ON t.category_id = c.id'
    ),
avatar
Синтаксис взял из своего коммента)
Ты просто не написал как правильно писать запрос. Спасибо.
avatar
ну я ссылку на коммит оставил, из него должно было быть понятно )
avatar
то можно использовать '#join'=>'JOIN blabla as b ON blabla'
но в дев версии лс, а не в 1.0.3
avatar
Есть 3 таблицы
1. товары (primary — id)
2. характеристики (primary — id)
3. связь характеристик и товаров (Ключа нет).

Можно как-то удалить одним запросам все записи связей, т.е. выполнить через ORM запрос
DELETE FROM product_chars WHERE product_id = 9
avatar
нет
avatar
Может так попробовать:?
DELETE FROM a, b, c
USING
    a
    INNER JOIN b ON b.a_id = a.id
    LEFT JOIN c ON c.a_id = a.id
WHERE ...
avatar
дело не в самом запросе, его составить можно. вопрос в том, умеет ли орм удалять сразу объекты из бд по условию. этого орм делать не умеет.
avatar
Можно
$oProduct=$this->Shop_GetProductById(9);
$oProduct->prop->clear();
$oProduct->save();

Где prop это объявленная связь many_to_many в сущности продукта.
avatar
Спасибо! :)
avatar
Мне надо получить данные произвольно ORDER BY RAND() LIMIT 20. Сделать это с помощью ORM. Возможно?
avatar
в devel версии с git такое возможно:

$this->Shop_GetProductItemsByFilter(array('#order'=>'rand()','#limit'=>20));
avatar
Есть сущность заказ, которой принадлежит сущность товар
Установлена связь
class ModuleShop_EntityOrder extends EntityORM {

	protected $aRelations=array(
		'user' => array(
			self::RELATION_TYPE_BELONGS_TO,
			'ModuleUser_EntityUser', 'user_id'
		),
		'products' => array(
			self::RELATION_TYPE_MANY_TO_MANY,
			'ModuleShop_EntityProduct','product_id',
			'ModuleShop_EntityOrderProducts','order_id',
		),
	);
}


Извлекаются товары
$aFilter = array(
	'#where' => array(
		't.id = ?'=> array($iOrderId)
	),
	'#with' => array('user', 'products'),
	'#limit' => 1,
	'#cache' => ''
);
$oOrder = $this->Shop_GeеOrderItemsByFilter($aFilter);


В логе видно запрос и что есть результат 1 строка
[2014-06-28 12:27:06] db_query.DEBUG 1727 7a8945c: 
SELECT t.`order_guid` as t_join_order_guid, t.`product_id` as t_join_product_id, t.`product_price` as t_join_product_price, t.`product_cost_price` as t_join_product_cost_price, t.`product_count` as t_join_product_count, t.`product_opt` as t_join_product_opt, t.`fabric_id` as t_join_fabric_id, t.`fabric_margin` as t_join_fabric_margin, t.`companion_id` as t_join_companion_id, t.`companion_margin` as t_join_companion_margin, b.* 
FROM `vd_shop_order_products` t 
LEFT JOIN `vd_shop_product` b ON b.`id` = t.`product_id` 
WHERE t.`order_id` in ( '1012' )    []
[2014-06-28 12:27:06] db_query.DEBUG 1727 7a8945c:   -- 0 ms; returned 1 row(s) []


Но товаров в сущности заказа нет.
$oOrder->getProducts() = ПУСТО
ORMRelationManyToMany Object
(
    [aCollection:protected] => Array
        (
        )

 )


Не могу понять где ошибка.
avatar
Заработало только когда я связь переименовал из 'products' в 'order_products'.
avatar
странная проблема, возможно был включен кеш, в котором были старые данные
avatar
да. может я от усталости затупил. для сброски кеша специально использовал
'#cache' => ''
и вручную мемкеш перезапускал.

По ORM очень не хватает нормально мануала с примерами: как связи организовывать более сложные.
avatar
что, например, вы имеет ввиду под сложными связями? в связи ещё можно добавить только параметры, больше ничего.
avatar
Получил сущность заказа и в ней товары.
$oOrder->getProducts()


Связь в сущности заказа выглядит так
'products' => array(
	self::RELATION_TYPE_MANY_TO_MANY,
	'ModuleShop_EntityProduct','product_id',
	'ModuleShop_EntityOrderProducts','order_id',
),


У товаров есть вложенная сущность order_products
[_aData:protected] => Array
        (
            [id] => 220
...
            [category_id] => 19
            [make_id] => 2
            [_relation_entity] => ModuleShop_EntityOrderProducts Object
                (
                    [_aOriginalData:protected] => Array
                        (
                            [order_id] => 3
                            [order_guid] => 03ef8a7f-5757-11e2-885d-e4d374cb9eb6
                            [product_opt] => {"ppy":"2200"}
                            ....
                        )


Как получить ее? Мне нужны опции.
Пробую вот так:
{foreach $oOrder->getProducts() as $oProduct}
    {$oProducе->getProductOpt()}
{/foreach}
avatar
не совсем понятно
нужно привести полное описание связей всех сущностей (кодом)
avatar
Есть таблица товаров, есть таблица заказов, а есть кросс-таблица связей товары в заказе.
order_id, product_id, product_price, product_opt (ОПЦИИ).

В сущности заказа указана связь
class ModuleShop_EntityOrder extends EntityORM {
	protected $aRelations=array(
		'products' => array(
			self::RELATION_TYPE_MANY_TO_MANY,
			'ModuleShop_EntityProduct','product_id',
			'ModuleShop_EntityOrderProducts','order_id',
		),
	);
}


Получаем заказ
$oOrder = $this->Shop_GetOrderByFilter(array('#with' => array('products')));

Получаем товары в заказе
$oOrder->getProducts();

Теперь нужно получить опции товара из кросс таблицы
{foreach $oOrder->getProducts() as $oProduct}
    {$oProduct->getProductOpt()} {* НЕ ПРАВИЛЬНЫЙ КОД. КАК НАДО? *}
{/foreach}

Сущность товара в заказе (см. цикл выше.) выглядит вот так:
[_aData:protected] => Array
        (
            [id] => 220
...
            [category_id] => 19
            [make_id] => 2
            [_relation_entity] => ModuleShop_EntityOrderProducts Object
                (
                    [_aOriginalData:protected] => Array
                        (
                            [order_id] => 3
                            [order_guid] => 03ef8a7f-5757-11e2-885d-e4d374cb9eb6
                            [product_opt] => {"ppy":"2200"}
                            ....
                        )

Т.е. нужно получить данные
[product_opt]
из
[_relation_entity]
avatar
Можно было бы так: $oProduct->_getDataOne('_relation_entity')->getProductOpt()
Сейчас добавил метод и стало так: $oProduct->_getManyToManyRelationEntity()->getProductOpt()
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.