Под катом пример добавления в гриде админки Magento 2 дополнительной колонки с данными из таблицы, связанной с основной таблицей грида, и "грязный хак" для работы фильтра по дополнительной колонке. Допускаю, что это не вполне "Magento 2 way", но это как-то работает, а потому — имеет право на существование.
Структура данных
Я решал задачу по формированию реферального дерева клиентов (клиент-родитель привлекает клиента-потомка), поэтому я создал дополнительную таблицу, завязанную на customer_entity. Если коротко, то дополнительная таблица содержит отношение "родитель-потомок" и информацию по дереву ("глубина залегания" клиента и путь к клиенту в дереве).
CREATE TABLE prxgt_dwnl_customer ( customer_id int(10) UNSIGNED NOT NULL COMMENT 'Reference to the customer.', parent_id int(10) UNSIGNED NOT NULL COMMENT 'Reference to the customer''s parent.', depth int(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Depth of the node in the tree.', path varchar(255) NOT NULL COMMENT 'Path to the node - /1/2/3/.../' PRIMARY KEY (customer_id), CONSTRAINT FK_CUSTOMER FOREIGN KEY (customer_id) REFERENCES customer_entity (entity_id) ON DELETE RESTRICT ON UPDATE RESTRICT, CONSTRAINT FK_PARENT FOREIGN KEY (parent_id) REFERENCES prxgt_dwnl_customer (customer_id) ON DELETE RESTRICT ON UPDATE RESTRICT )
UI Component
Моей целью являлись 2 дополнительные колонки к гриду клиентов, содержащие информацию о родителе текущего клиента и о глубине залегания клиента в дереве. Грид клиентов описывается в XML-файле vendor/magento/module-customer/view/adminhtml/ui_component/customer_listing.xml. Нас интересует узел dataSource, а конкретно — имя источника данных (customer_listing_data_source):
<dataSource name="customer_listing_data_source"> <argument name="dataProvider" xsi:type="configurableObject"> <argument name="name" xsi:type="string">customer_listing_data_source</argument> ... </argument> </dataSource>
(что из этого является именем источника данных — атрибут name или argument-узел с именем name, сказать сложно, в Magento еще с первой версии есть хорошая традиция использовать одинаковые названия для различных типов элементов, чтобы держать разработчиков в тонусе)
Data Provider
Источником данных для грида является коллекция, как бы ни банально это звучало. Вот описание источника данных с именем customer_listing_data_source в файле vendor/magento/module-customer/etc/di.xml:
<type name="Magento\Framework\View\Element\UiComponent\DataProvider\CollectionFactory"> <arguments> <argument name="collections" xsi:type="array"> <item name="customer_listing_data_source" xsi:type="string">Magento\Customer\Model\ResourceModel\Grid\Collection</item> ... </argument> </arguments> </type>
Т.е., класс, который поставляет данные для грида клиентов — \Magento\Customer\Model\ResourceModel\Grid\Collection.
Модификация коллекции
Если влезть отладчиком внутрь коллекции, то можно увидеть, что SQL-запрос для выборки данных выглядит примерно так:
SELECT `main_table`.* FROM `customer_grid_flat` AS `main_table`
Это другая хорошая традиция в Magento — преодолевать повышенную неповоротливость приложения, связанную с повышенной гибкостью, путим использования вот таких вот "индексных таблиц". В случае с клиентами flat-таблица есть, вполне возможно, что можно было бы встроиться и в нее, но я искал более универсальный путь. Мне нужен был JOIN.
Возможность JOIN'а я нашел только в методе \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection::_beforeLoad:
protected function _beforeLoad() { ... $this->_eventManager->dispatch('core_collection_abstract_load_before', ['collection' => $this]); ... }
Я подписался в своем модулей на событие core_collection_abstract_load_before (файл etc/events.xml):
<event name="core_collection_abstract_load_before"> <!-- Add additional attributes to the Customer Grid in adminhtml. --> <observer name="praxigento_donwlilne_on_core_collection_abstract_load_before" instance="Praxigento\Downline\Observer\CoreCollectionAbstractLoadBefore"/> </event>
И создал класс, реагирующий на это событие, в котором и модифицировал первоначальный запрос:
class CoreCollectionAbstractLoadBefore implements ObserverInterface { const AS_FLD_CUSTOMER_DEPTH = 'prxgtDwnlCustomerDepth'; const AS_FLD_PARENT_ID = 'prxgtDwnlParentId'; const AS_TBL_CUST = 'prxgtDwnlCust'; public function execute(\Magento\Framework\Event\Observer $observer) { $collection = $observer->getData('collection'); if ($collection instanceof \Magento\Customer\Model\ResourceModel\Grid\Collection) { $query = $collection->getSelect(); $conn = $query->getConnection(); /* LEFT JOIN `prxgt_dwnl_customer` AS `prxgtDwnlCust` */ $tbl = [self::AS_TBL_CUST => $conn->getTableName('prxgt_dwnl_customer')]; $on = self::AS_TBL_CUST . 'customer_id.=main_table.entity_id'; $cols = [ self::AS_FLD_CUSTOMER_DEPTH => 'depth', self::AS_FLD_PARENT_ID => 'parent_id' ]; $query->joinLeft($tbl, $on, $cols); $sql = (string)$query; /* dirty hack for filters goes here ... */ } return; } }
В итоге, после модификации SQL-запрос стал выглядеть примерно так:
SELECT `main_table`.*, `prxgtDwnlCust`.`depth` AS `prxgtDwnlCustomerDepth` `prxgtDwnlCust`.`parent_id` AS `prxgtDwnlParentId` FROM `customer_grid_flat` AS `main_table` LEFT JOIN `prxgt_dwnl_customer` AS `prxgtDwnlCust` ON prxgtDwnlCust.customer_id = main_table.entity_id
Т.к. я использую алиасы для данных из "своей" таблицы (prxgtDwnlCustomerDepth и prxgtDwnlParentId), то я могу не сильно опасаться, что какой-то другой разработчик, применив подобный подход, совпадет со мной по наименованию дополнительных полей (вряд ли кто-то начнет называть свои данные с prxgt), но это же и привело к тому, что фильтрация с грида перестала работать.
Добавление колонки
Чтобы доопределить колонки в гриде нужно создать в своем модуле XML-файл с таким же именем, как и описывающий оригинальный UI-компонент (view/adminhtml/ui_component/customer_listing.xml), и создать в нем дополнительные колонки, используя в качестве имен полей данных алиасы:
<?xml version="1.0" encoding="UTF-8"?> <listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd"> <columns name="customer_columns"> <column name="prxgtDwnlParentId"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> <item name="filter" xsi:type="string">textRange</item> <item name="label" xsi:type="string" translate="true">Parent ID</item> </item> </argument> </column> <column name="prxgtDwnlCustomerDepth"> <argument name="data" xsi:type="array"> <item name="config" xsi:type="array"> <item name="filter" xsi:type="string">textRange</item> <item name="label" xsi:type="string" translate="true">Depth</item> </item> </argument> </column> </columns> </listing>
Результат

(колонки я подвигал руками и попрятал лишнее — отличная функция в новой Magento)
"Грязный хак" для фильтра
EDITED: Более прямое решение — через плагины с использованием метода $collection->addFilterToMap(...). В этом случае происходит изменение коллекции сразу посел ее создания, а не непосредственно перед ее использованием.
Чтобы заработали фильтры по новым столбцам я не придумал ничего лучшего, как сделать обратное преобразование "алиас" => "таблица.поле" все в том же классе по добавлению JOIN'а к первоначальному запросу (CoreCollectionAbstractLoadBefore):
public function execute(\Magento\Framework\Event\Observer $observer) { ... /* the dirty hack */ $where = $query->getPart('where'); $replaced = $this->_replaceAllAliasesInWhere($where); $query->setPart('where', $replaced); ... } protected function _replaceAllAliasesInWhere($where) { $result = []; foreach ($where as $item) { $item = $this->_replaceAliaseInWhere($item, self::AS_FLD_CUSTOMER_DEPTH, self::AS_TBL_CUST, 'depth'); $item = $this->_replaceAliaseInWhere($item, self::AS_FLD_PARENT_ID, self::AS_TBL_CUST, 'parent_id'); $result[] = $item; } return $result; } protected function _replaceAliaseInWhere($where, $fieldAlias, $tableAlias, $fieldName) { $search = "`$fieldAlias`"; $replace = "`$tableAlias`.`$fieldName`"; $result = str_replace($search, $replace, $where); return $result; }
