Динамические атрибуты для товаров (используем 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
<?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
<?php foreach ($product->all_attribute_values as $attrValue): ?>
    <?php if ($attrValue->value): ?>
        <p><?php echo CHtml::encode($attrValue->attribute->name); ?>: <?php echo CHtml::encode($attrValue->value); ?></p>
    <?php endif; ?>
<?php endforeach; ?>

или сформировать массив и методом array_merge слить с массивом атрибутов для CDetailView:

<?php
<?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
<?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, в которых будет записан тип поля и сериализованный массив строк для выпадающего списка.

Также мы не касались реализации поиска и сортировки по динамическим полям. Но это уже другая история.

Комментарии

 

Alexander

Можно ли както организовать фильтрацию по значениям аттрибутов?
То есть например получить товары у которых некоторые аттрибуты имеют определенные значения?

Ответить

 

Дмитрий Елисеев

Если не хочется делать трёхэтажный реляционный запрос, то можно сначала из ShopAttributeValue получить массив значений product_id (например, через queryColumn() DAO или через AR) с нужным значением и просто вывести товары используя выборку id IN (...).
Это можно выполнить в цикле по списку атрибутов:

foreach (...){
    $product_ids = ...;
    $criteria->addInCondition('t.id', $product_ids);
}

Но лучше получить сначала чистый массив и подставить в IN уже его:

foreach (...){
    $product_ids =  array_intersect($product_ids, ...);
}
$criteria->addInCondition('t.id', $product_ids);

То есть сначала выбираем все значения, которые подходят условиям поиска, а потом берём из них пересекающий массив $product_ids и выводим товары по этому массиву.

Ответить

 

Alexander

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

Ответить

 

t0os – scriptidy.com

А почему ShopType привязывается к продукту, а не к ShopCategory, что, вроде бы, логичней?

Ответить

 

t0os – scriptidy.com

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

Ответить

 

Дмитрий Елисеев

Привязывал и к продукту, и к категории на разных сайтах. Ну это как для проекта удобнее.

Ответить

 

Алексей

Очень интересная статья. Хотелось бы узнать насчет как раз расширенного случая когда нужно делать варианты полей разных типов. Как лучше в данном случае делать? Хранить значения в одной таблице или лучше для каждого типа создать свою таблицу.
Если лучше второй вариант, то тогда не пойму как организовать все это на уii.

Ответить

 

Дмитрий Елисеев

Если вопрос о вариантах ответов для выбора из выпадающих списков, то либо хранить варианты ответов в отдельной таблице с полями id, atribute_id, sort, variant или в той же таблице attribute в новом поле variants, перечисляя их через точку с запятой.

Можно добавить поле field_type, и в форме редактирования сделать вывод нужного поля:

<?php if ($attr->field_type == 'text'): ?>
    <?php echo CHtml::textArea(...); ?>
<?php elseif ($attr->field_type == 'dropdown'): ?>
    <?php echo CHtml::dropDownList(..., ..., explode('; ', $attr->variants)); ?>
<?php else: ?>
<?php elseif ($attr->field_type == 'checkbox'): ?>
    <?php echo CHtml::checkBox(...); ?>
<?php else: ?>
    <?php echo CHtml::textField(...); ?>
<?php endif; ?> 
Ответить

 

standalone

Спасибо. Отлично, еще бы продолжение на счет расширения данной темы (типы, варианты и т.д.) В идеале, было бы круто реализовать как в eximum commerce.

Ответить

 

standalone

А вот как быть когда есть вышеописанная схема и нужно пользователю выбирать один из атрибутов (размер, цвет), как такое реализовать, где хранить выбранное значение?

Ответить

 

Дмитрий Елисеев

Для администратора в модель ShopAttribute и её таблицу можно добавить поле variants, в котором хранить серилизованный массив вариантов этого атрибута для подстановки в форму редактирования товара.

Аналогично можно реализовать поддержку хранения массива в поле самого значения value (серилизацией или функциями implode и explode через точку с запятой), а в карточке товара разложить массив и передать его в DropDownList.

Ответить

 

Андрей

варианты ответа это всё хорошо конечно, но поясните что у вас хранится в поле alias ? название модели ShopProduct или что? Из за этого alias я не знаю как заполнять форму добавления поля. И напишите хоть 1 пример как разобрать items для формы.
И что в этой комбинации может записываться в value

$items[$attr->alias] = array(
  'model'=>$attr,
  'value'=>'',
);
Ответить

 

Дмитрий Елисеев

Поле alias - транслит от title. Вместо него можно использовать и id.
А пример скоро допишу.

Ответить

 

Андрей

код,код,код...
пример хорошо бы! и универсальности добавить, не все же луюди магазины пишут.

Ответить

 

more

Подскажите как реализовать следующее: есть eav и выбор например размера товара в карточке товара и есть корзина, в которую добавляется товар. Так вот вопрос в том, где хранить это значение, которое выбирает пользователь. Товар то в корзине может быть много и соответственно, где эти характеристики хранить?

Ответить

 

Дмитрий Елисеев

Если в обычной корзине элементы хранятся только одномерным массивом ID => количество:

[
    105 => 3,
    106 => 1,
]

то в новую корзину вместо ID уже нужно сохранять весь комплект item:

[
    0 => [
        'item' => [
            'id' => 105,
            'color' => 'red',
            'size' => 'XL',
        ],
        'count' => 3,
    ],
    1 => [
        'item' => [
            'id' => 105,
            'color' => 'green',
            'size' => 'M',
        ],
        'count' => 1,
    ]
]

Соответственно, нужно переделать методы 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 не очень сложно.

Ответить

 

Сергей

Время :) Время :)

Ответить

 

Дмитрий Елисеев

Ну... значит не судьба.

Ответить

 

Сергей

Да не, перепишу, че :)

Ответить

 

Сергей

Я думал у вас просто в загашнике то лежит уже. Выложили бы исходник.

Ответить

 

Saidazim Nazirov

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

в редактирование будет небходимо создать разного поля зависит от типа (от textField до checkboxList) и где (на аком моделе надо сделать)
и ешо один вопрос для полей типа checkboxList каком виде надо хранит значении в "tbl_shop_attribute_value" чтобы потом можно было построит солжный поиски (есть поля типа checkboxList и надо найти такого продуктов выброно значения 1 и 2)

зарание спасибо

Ответить

 

Saidazim Nazirov

был бы хорошо если были с примерами (если есть готовое решение то вобше круто)

Ответить

 

Дмитрий

Нашел опечатку:

$items[] = array(
    'label'=>$attrValue->attribute->name,
    'value'=>$attrValue->value,
);

Спасибо за уроки по Yii/Yii2

Ответить

 

Александр

Дмитрий, благодарю за уроки!
Не рассматривали вариант, когда при добавлении нового поля у товара, в таблицу tbl_shop_attribute_value добавляется новая колонка (ALTER TABLE ADD COLUMN)?
В этом случае делать поиск по динамическим полям товара становится элементарно одним простым запросом.
У товара делается одна связь с таблицей tbl_shop_attribute_value по product_id. Правда здесь усложняется процесс выбора полей для показа и сортировки - одной связью inshort_attribute_values здесь не отделаешься, но думаю, что выигрыш в поиске по таблице полей здесь перевешивает.

Благодарю!

Ответить

 

Дмитрий Елисеев

Есть вариант. Можно и сразу в product столбцы для атрибутов добавлять. Но если атрибуты будут разные для товаров разной категории, то получим сотни полей. Если пробовать по всем проставлять индексы, то в MySQL сможем сделать всего 64. И вставка записей будет каждый раз все индексы пересчитывать.

Ответить

 

Олег Григорьев

А если добавлять на каждую категорию по таблице?
Есть под рукой ссылка на инфу, какой самый оптимальный способ сортировать по динамичным аттрибутам, не выгружая все данные в PHP для обхода? Был бы благодарен.

Ответить

 

Дмитрий Елисеев

Как удобнее, так и делайте. А вообще можно выгрузить всё в ElasticSearch и искать там.

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>