Динамические атрибуты для товаров (используем EAV в Yii)
В системах управления контентом (CMS) интернет-магазинов (да и часто для других нужд) можно встретить возможность добавлять неограниченное число полей к различным сущностям. Это знакомые всем списки характеристик товаров, а иногда имеется целая система создания новых типов контента (CCK), которой, кстати, славится Drupal. До полноценной системы CCK нам далеко, но реализовать динамические поля для товаров своего интернет-магазина на Yii всё-таки стоит.
В бльшинстве случаев такие системы строятся на принципе модели Entity–Attribute–Value.
В простейшем случае нужны две таблицы:
Список атрибутов с их описаниями tbl_attribute
:
id (первичный ключ) name (название поля) type (тип значения: text, checkbox, ...)
Значения для каждого товара tbl_attribute_value:
id (первичный ключ) product_id (внешний ключ, ссылающийся на продукт) attribute_id (внешний ключ, ссылающийся на описание атрибута tbl_attribute) value (значение)
В итоге, можно отдельно вести список атрибутов и легко получать доступ к атрибутам текущего товара.
Применение к магазину на Yii
Представим, что таблица товаров, типов товаров и модели к ним (ShopProduct и ShopType) у нас уже есть.
Создадим таблицу для атрибутов:
CREATE TABLE IF NOT EXISTS `tbl_shop_attribute` ( `id` int(11) NOT NULL AUTO_INCREMENT, `type_id` int(11) NOT NULL, `sort` int(11) NOT NULL, `alias` varchar(255) NOT NULL, `title` varchar(255) NOT NULL, `inshort` smallint(1) NOT NULL, PRIMARY KEY (`id`), KEY `type_id` (`type_id`) ) ENGINE=MyISAM;
Все атрибуты будут выводиться на странице товара. Параметр inshort
будет показывать, выводить ли атрибут в самом списке товаров. Если такой функционал не нужен, то можно удалить всё с ним связанное.
Также создадим таблицу для значений:
CREATE TABLE IF NOT EXISTS `tbl_shop_attribute_value` ( `id` int(11) NOT NULL AUTO_INCREMENT, `product_id` int(11) NOT NULL, `attribute_id` int(11) NOT NULL, `value` varchar(255) NOT NULL, PRIMARY KEY (`id`), KEY `product_id` (`product_id`) KEY `attribute_id` (`attribute_id`) ) ENGINE=MyISAM;
Здесь мы ограничили значение строкой в 255 символов, но если нужно хранить большие блоки текста, то лучше сменить тип поля на TEXT
.
Сгенерируем для этих таблиц модели:
/** * This is the model class for table "{{shop_attribute}}". * * The followings are the available columns in table '{{shop_attribute}}': * @property integer $id * @property integer $type_id * @property integer $sort * @property string $alias * @property string $title * @property integer $inshort * * @property ShopType $type * @property ShopAttributeValue[] $attribute_values */ class ShopAttribute extends CActiveRecord { /** * Returns the static model of the specified AR class. * @param string $className active record class name. * @return ShopAttribute the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } /** * @return string the associated database table name */ public function tableName() { return '{{shop_attribute}}'; } /** * @return array validation rules for model attributes. */ public function rules() { return array( array('alias, title, type_id', 'required'), array('sort, inshort', 'numerical', 'integerOnly'=>true), array('alias, title', 'length', 'max'=>255), array('type_id', 'numerical', 'integerOnly'=>true), array('id, type_id, alias, title, inshort', 'safe', 'on'=>'search'), ); } /** * @return array relational rules. */ public function relations() { return array( 'type' => array(self::BELONGS_TO, 'ShopType', 'type_id'), 'attribute_values' => array(self::HAS_MANY, 'ShopAttributeValue', 'attribute_id'), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( 'id'=>'ID', 'type_id'=>'Тип товаров', 'sort'=>'Позиция', 'alias'=>'URL', 'title'=>'Атрибут', 'inshort'=>'Отображать в списке товаров', ); } /** * Retrieves a list of models based on the current search/filter conditions. * @return CActiveDataProvider the data provider that can return the models based on the search/filter conditions. */ public function search() { $criteria=new CDbCriteria; $criteria->compare('t.id', $this->id); $criteria->compare('t.type_id', $this->type_id, true); $criteria->compare('t.sort', $this->sort, true); $criteria->compare('t.alias', $this->alias, true); $criteria->compare('t.title', $this->title, true); $criteria->compare('t.inshort', $this->inshort); return new CActiveDataProvider($this, array( 'criteria'=>$criteria, )); } /** scope * @param $type_id * @return ShopAttribute */ public function type($type_id) { $this->getDbCriteria()->mergeWith(array( 'condition' => 't.type_id=:type', 'params'=>array(':type'=>$type_id), )); return $this; } protected function beforeDelete() { if (parent::beforeDelete()) { foreach ($this->attribute_values as $value) $value->delete(); return true; } return false; } }
/** * This is the model class for table "{{shop_attribute_value}}". * * The followings are the available columns in table '{{shop_attribute_value}}': * @property integer $id * @property integer $product_id * @property integer $attribute_id * @property string $value * * @property ShopProduct $product * @property ShopAttribute $attribute */ class ShopAttributeValue extends CActiveRecord { /** * Returns the static model of the specified AR class. * @param string $className active record class name. * @return ShopAttributeValue the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } /** * @return string the associated database table name */ public function tableName() { return '{{shop_attribute_value}}'; } /** * @return array validation rules for model attributes. */ public function rules() { return array( array('product_id, attribute_id', 'required'), array('product_id, attribute_id', 'numerical', 'integerOnly'=>true), array('value', 'length', 'max'=>255), // Please remove those attributes that should not be searched. array('id, product_id, attribute_id, value', 'safe', 'on'=>'search'), ); } /** * @return array relational rules. */ public function relations() { return array( 'product'=>array(self::BELONGS_TO, 'ShopProduct', 'product_id'), 'attribute'=>array(self::BELONGS_TO, 'ShopAttribute', 'attribute_id'), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( 'id' => 'ID', 'product_id' => 'Product', 'attribute_id' => 'Attribute', 'value' => 'Value', ); } /** * Retrieves a list of models based on the current search/filter conditions. * @return CActiveDataProvider the data provider that can return the models based on the search/filter conditions. */ public function search() { $criteria=new CDbCriteria; $criteria->compare('t.id', $this->id); $criteria->compare('t.product_id', $this->product_id); $criteria->compare('t.attribute_id', $this->attribute_id); $criteria->compare('t.value', $this->value, true); return new CActiveDataProvider($this, array( 'criteria'=>$criteria, )); } }
В первую модель мы добавили именованную группу условий type()
с параметром. Этот метод позволит вместо записи
$atts = ShopAttribute::model()->findAllByAttributed(array('type_id'=>$type_id));
выбирать атрибуты для нужного типа товаров используя более удобный вызов
$atts = ShopAttribute::model()->type($type_id)->findAll();
Также добавлена процедура удаления всех значений в метод beforeDelete()
атрибута. Конечно же, мы можем использовать прямой SQL запрос на удаление с помощью DAO, или использовать InnoDB вместо MyISAM и настроить внешние ключи на автоматическое удаление, но это сейчас не важно.
В эти модели мы добавили необходимые отношения друг на друга, на тип товаров и на товар-владелец.
Немного модифицируем модель товара:
<?php /** * This is the model class for table "{{shop_product}}". * * The followings are the available columns in table '{{shop_product}}': * @property integer $id * @property string $artikul * @property integer $type_id * @property integer $category_id * ... * @property ShopAttributeValue[] $inshort_attribute_values * @property ShopAttributeValue[] $all_attribute_values */ class ShopProduct extends CActiveRecord { // ... /** * @return array relational rules. */ public function relations() { return array( 'type' => array(self::BELONGS_TO, 'ShopType', 'type_id'), 'category' => array(self::BELONGS_TO, 'ShopCategory', 'category_id'), 'inshort_attribute_values' => array(self::HAS_MANY, 'ShopAttributeValue', 'product_id', 'condition'=>'attribute.inshort = 1', 'order'=>'attribute.sort ASC', 'with'=>'attribute', ), 'all_attribute_values' => array(self::HAS_MANY, 'ShopAttributeValue', 'product_id', 'order'=>'attribute.sort ASC', 'with'=>'attribute', ), ); } protected function beforeDelete() { if (parent::beforeDelete()) { foreach ($this->all_attribute_values as $val) $$val->delete(); return true; } return false; } private $_url; public function getUrl(){ if (!$this->type || !$this->category) return ''; if ($this->_url === null) $this->_url = Yii::app()->request->baseUrl . '/shop/' . $this->type->alias . '/' . $this->category->alias . '/' . $this->id; return $this->_url; } }
Продукту добавляем отношения для получения значений его атрибутов. Для удобства объявлены два отношения: на все значения для вывода на странице товара и на значения только для вывода в превью товара в списке.
Теперь в карточке товара можно получить массив значений атрибутов и вывести строки простым HTML кодом
<?php foreach ($product->all_attribute_values as $attrValue): if ($attrValue->value): <p> echo CHtml::encode($attrValue->attribute->name); : echo CHtml::encode($attrValue->value); </p> endif; endforeach;
или сформировать массив и методом array_merge
слить с массивом атрибутов для CDetailView
:
<?php $items = array(); foreach ($product->all_attribute_values as $attrValue) { $items[] = array( 'label'=>$attrValue->attribute->name, 'value'=>$attrValue->value, ); } $this->widget('zii.widgets.CDetailView', array( 'data'=>$model, 'attributes'=>array_merge( array( 'artikul', array( 'label'=>'Категория', 'type'=>'html', 'value'=>CHtml::link(CHtml::encode($model->category->title), $model->$model->category->url), ), 'price' ), $items ), ));
Лучше перенести эту логику в модель товара:
class ShopProduct extends CActiveRecord { // ... public function getAttrsDetailViewList($autoVisible=true) { $items = array(); foreach ($this->all_attribute_values as $attrValue) { $items[] = array( 'label'=>$attrValue->attribute->name, 'value'=>$attrValue->value, 'visible'=>$autoVisible ? $attrValue->value : 1, ); } return $items; } }
и в представлении получать список атрибутов, используя метод getAttrsDetailViewList()
:
<?php $this->widget('zii.widgets.CDetailView', array( 'data'=>$model, 'attributes'=>array_merge( array( 'artikul', array( 'label'=>'Категория', 'type'=>'html', 'value'=>CHtml::link(CHtml::encode($model->category->title), $model->$model->category->url), ), 'price' ), $model->getAttrsDetailViewList() ), ));
В элементе списка товаров вместо all_attribute_values
нужно использовать inshort_attribute_values
.
Для панели управления нам теперь нужно создать контроллер для управления списком полей и отдельное дествие actionAttributes($id)
для заполнения значений полей у товара. На этой странице нужно будет выводить список полей для типа текущего товара. Действию передаётся ID товара, и оно получает список атрибутов для типа данного товара и текущих значений:
public function actionAttributes($id) { $product = $this->loadModel($id); $atts = ShopAttribute::model()->type($product->type_id)->findAll(); // Строим массив полей $items = array(); foreach ($attrs as $attr) { $items[$attr->alias] = array( 'model'=>$attr, 'value'=>'', } } // Заполняем текущими значениями $attValues = $product->all_attribute_values; foreach ($attValues as $attValue) $items[$attValue->attribute->alias]['value'] = $attValue->value; // Обновляем/добавляем значения при отправке формы if (isset($_POST['Attributes']) { // ... } // Передаём товар и массив полей в представление $this->render('attributes', array( 'product'=>$product, 'items'=>$items, )); }
А представление на основе этих данных, например, строит форму для табличного ввода.
Аналогично эта же система может быть использована, например, для дополнительных полей профиля пользователя.
Мы рассмотрели упрощённый случай и не затронули многих вопросов. Например, мы не вводили поддержку разных типов полей (чекбоксов, выпадающих списков). Это можно организовать добавлением в таблицу атрибутов новых полей field_type
и variants
, в которых будет записан тип поля и сериализованный массив строк для выпадающего списка.
Также мы не касались реализации поиска и сортировки по динамическим полям. Но это уже другая история.
Можно ли както организовать фильтрацию по значениям аттрибутов?
То есть например получить товары у которых некоторые аттрибуты имеют определенные значения?
Если не хочется делать трёхэтажный реляционный запрос, то можно сначала из ShopAttributeValue получить массив значений product_id (например, через queryColumn() DAO или через AR) с нужным значением и просто вывести товары используя выборку id IN (...).
Это можно выполнить в цикле по списку атрибутов:
Но лучше получить сначала чистый массив и подставить в IN уже его:
То есть сначала выбираем все значения, которые подходят условиям поиска, а потом берём из них пересекающий массив $product_ids и выводим товары по этому массиву.
Пока решил вопрос фильтрации с помощью нескольких запросов по каждой выборке с последующим пересечением массивов.
Я вобщем то не против многоэтажных реляционных запросов, только не понятно как составить.
И еще как сделать сортировку?
А почему ShopType привязывается к продукту, а не к ShopCategory, что, вроде бы, логичней?
Если только не возможность в одной категории хранить товары разного типа, конечно же :)
Привязывал и к продукту, и к категории на разных сайтах. Ну это как для проекта удобнее.
Очень интересная статья. Хотелось бы узнать насчет как раз расширенного случая когда нужно делать варианты полей разных типов. Как лучше в данном случае делать? Хранить значения в одной таблице или лучше для каждого типа создать свою таблицу.
Если лучше второй вариант, то тогда не пойму как организовать все это на уii.
Если вопрос о вариантах ответов для выбора из выпадающих списков, то либо хранить варианты ответов в отдельной таблице с полями id, atribute_id, sort, variant или в той же таблице attribute в новом поле variants, перечисляя их через точку с запятой.
Можно добавить поле field_type, и в форме редактирования сделать вывод нужного поля:
Спасибо. Отлично, еще бы продолжение на счет расширения данной темы (типы, варианты и т.д.) В идеале, было бы круто реализовать как в eximum commerce.
А вот как быть когда есть вышеописанная схема и нужно пользователю выбирать один из атрибутов (размер, цвет), как такое реализовать, где хранить выбранное значение?
Для администратора в модель ShopAttribute и её таблицу можно добавить поле variants, в котором хранить серилизованный массив вариантов этого атрибута для подстановки в форму редактирования товара.
Аналогично можно реализовать поддержку хранения массива в поле самого значения value (серилизацией или функциями implode и explode через точку с запятой), а в карточке товара разложить массив и передать его в DropDownList.
варианты ответа это всё хорошо конечно, но поясните что у вас хранится в поле alias ? название модели ShopProduct или что? Из за этого alias я не знаю как заполнять форму добавления поля. И напишите хоть 1 пример как разобрать items для формы.
И что в этой комбинации может записываться в value
Поле alias - транслит от title. Вместо него можно использовать и id.
А пример скоро допишу.
код,код,код...
пример хорошо бы! и универсальности добавить, не все же луюди магазины пишут.
Подскажите как реализовать следующее: есть eav и выбор например размера товара в карточке товара и есть корзина, в которую добавляется товар. Так вот вопрос в том, где хранить это значение, которое выбирает пользователь. Товар то в корзине может быть много и соответственно, где эти характеристики хранить?
Если в обычной корзине элементы хранятся только одномерным массивом ID => количество:
то в новую корзину вместо ID уже нужно сохранять весь комплект item:
Соответственно, нужно переделать методы add($id, $count) и remove($id) корзины на add($item, $count) и remove($item).
Здраствуйте, какой смысл в таблице tbl_attribute_value использовать id (первичный ключ), если любая строка индентифицируется по паре product_id attribute_id? В каком контексте может быть полезен tbl_attribute_value id?
Спасибо
Вообще-то никакого, если использовать составной ключ.
Здравствуйте. Подскажите пожалуйста, а если на пример у атрибута "цвет = красный" есть только размеры = M,L, а у атрибута цвет=зеленый есть только размер = М , как быть в данной ситуации. Т.е как организовать комбинации атрибутов? Спасибо заранее.
Если не принципиально разделение по двум атрибутам, то можно использовать одно поле для разных комбинаций:
«Красный M»
«Красный L»
«Красный XL»
«Зелёный M»
Только поиск сделать будет сложно. А иначе можно для цвета и размера выделить свои отдельные таблицы и добавить ключ color_id к модели цвета.
На хабре была статья насчет EAV и Elasticsearch - там есть готовый FOS bundle на этот случай.
Было бы крайне интересно и полезно попробовать сделать нечто подобное на Yii - как продолжение темы с EAV
клиент на PHP имеется https://github.com/ruflin/Elastica
у меня пока не хватает смелости взяться за это
Объясните пожалуйста подробнее, как в админке заставить менять значения дополнительных полей
Под Yii2 есть что то подобное?
Переписать реляции на Yii2 не очень сложно.
Время :) Время :)
Ну... значит не судьба.
Да не, перепишу, че :)
Я думал у вас просто в загашнике то лежит уже. Выложили бы исходник.
Доброго времени суток, столкнулся таже самое проблемой зря ранше не читал данный пост но мое решения почти тоже самое и надо ваш совет как организовать заполнения продуктов.
в редактирование будет небходимо создать разного поля зависит от типа (от textField до checkboxList) и где (на аком моделе надо сделать)
и ешо один вопрос для полей типа checkboxList каком виде надо хранит значении в "tbl_shop_attribute_value" чтобы потом можно было построит солжный поиски (есть поля типа checkboxList и надо найти такого продуктов выброно значения 1 и 2)
зарание спасибо
был бы хорошо если были с примерами (если есть готовое решение то вобше круто)
Нашел опечатку:
Спасибо за уроки по Yii/Yii2
Дмитрий, благодарю за уроки!
Не рассматривали вариант, когда при добавлении нового поля у товара, в таблицу tbl_shop_attribute_value добавляется новая колонка (ALTER TABLE ADD COLUMN)?
В этом случае делать поиск по динамическим полям товара становится элементарно одним простым запросом.
У товара делается одна связь с таблицей tbl_shop_attribute_value по product_id. Правда здесь усложняется процесс выбора полей для показа и сортировки - одной связью inshort_attribute_values здесь не отделаешься, но думаю, что выигрыш в поиске по таблице полей здесь перевешивает.
Благодарю!
Есть вариант. Можно и сразу в product столбцы для атрибутов добавлять. Но если атрибуты будут разные для товаров разной категории, то получим сотни полей. Если пробовать по всем проставлять индексы, то в MySQL сможем сделать всего 64. И вставка записей будет каждый раз все индексы пересчитывать.
А если добавлять на каждую категорию по таблице?
Есть под рукой ссылка на инфу, какой самый оптимальный способ сортировать по динамичным аттрибутам, не выгружая все данные в PHP для обхода? Был бы благодарен.
Как удобнее, так и делайте. А вообще можно выгрузить всё в ElasticSearch и искать там.
более сложное решение на основании шаблонов сущностей http://des1roer.blogspot.ru/2016/04/yii-2.html