Completed
Push — 2.0 ( b06b3b...65923f )
by Christopher
03:17
created

EavBehavior::addColumn()   B

Complexity

Conditions 5
Paths 6

Size

Total Lines 43
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 28
c 0
b 0
f 0
nc 6
nop 3
dl 0
loc 43
rs 8.439
1
<?php
2
/**
3
 * Licensed under The GPL-3.0 License
4
 * For full copyright and license information, please see the LICENSE.txt
5
 * Redistributions of files must retain the above copyright notice.
6
 *
7
 * @since    2.0.0
8
 * @author   Christopher Castro <[email protected]>
9
 * @link     http://www.quickappscms.org
10
 * @license  http://opensource.org/licenses/gpl-3.0.html GPL-3.0 License
11
 */
12
namespace Eav\Model\Behavior;
13
14
use Cake\Cache\Cache;
15
use Cake\Collection\CollectionInterface;
16
use Cake\Datasource\EntityInterface;
17
use Cake\Error\FatalErrorException;
18
use Cake\Event\Event;
19
use Cake\ORM\Behavior;
20
use Cake\ORM\Entity;
21
use Cake\ORM\Query;
22
use Cake\ORM\Table;
23
use Cake\ORM\TableRegistry;
24
use Eav\Model\Behavior\EavToolbox;
25
use Eav\Model\Behavior\QueryScope\QueryScopeInterface;
26
use Eav\Model\Behavior\QueryScope\SelectScope;
27
use Eav\Model\Behavior\QueryScope\WhereScope;
28
use Eav\Model\Entity\CachedColumn;
29
use \ArrayObject;
30
31
/**
32
 * EAV Behavior.
33
 *
34
 * Allows additional columns to be added to tables without altering its physical
35
 * schema.
36
 *
37
 * ### Usage:
38
 *
39
 * ```php
40
 * $this->addBehavior('Eav.Eav');
41
 * $this->addColumn('user-age', ['type' => 'integer']);
42
 * ```
43
 *
44
 * Using virtual attributes in WHERE clauses:
45
 *
46
 * ```php
47
 * $adults = $this->Users->find()
48
 *     ->where(['user-age >' => 18])
49
 *     ->all();
50
 * ```
51
 *
52
 * ### Using EAV Cache:
53
 *
54
 * ```php
55
 * $this->addBehavior('Eav.Eav', [
56
 *     'cache' => [
57
 *         'contact_info' => ['user-name', 'user-address'],
58
 *         'eav_all' => '*',
59
 *     ],
60
 * ]);
61
 * ```
62
 *
63
 * Cache all EAV values into a real column named `eav_all`:
64
 *
65
 * ```php
66
 * $this->addBehavior('Eav.Eav', [
67
 *     'cache' => 'eav_all',
68
 * ]);
69
 * ```
70
 *
71
 * @link https://github.com/quickapps/docs/blob/2.x/en/developers/field-api.rst
72
 */
73
class EavBehavior extends Behavior
74
{
75
76
    /**
77
     * Instance of EavToolbox.
78
     *
79
     * @var \Eav\Model\Behavior\EavToolbox
80
     */
81
    protected $_toolbox = null;
82
83
    /**
84
     * Default configuration.
85
     *
86
     * @var array
87
     */
88
    protected $_defaultConfig = [
89
        'enabled' => true,
90
        'cache' => false,
91
        'queryScope' => [
92
            'Eav\\Model\\Behavior\\QueryScope\\SelectScope',
93
            'Eav\\Model\\Behavior\\QueryScope\\WhereScope',
94
            'Eav\\Model\\Behavior\\QueryScope\\OrderScope',
95
        ],
96
        'implementedMethods' => [
97
            'enableEav' => 'enableEav',
98
            'disableEav' => 'disableEav',
99
            'updateEavCache' => 'updateEavCache',
100
            'addColumn' => 'addColumn',
101
            'dropColumn' => 'dropColumn',
102
            'listColumns' => 'listColumns',
103
        ],
104
    ];
105
106
    /**
107
     * Query scopes objects to be applied indexed by unique ID.
108
     *
109
     * @var array
110
     */
111
    protected $_queryScopes = [];
112
113
    /**
114
     * Constructor.
115
     *
116
     * @param \Cake\ORM\Table $table The table this behavior is attached to
117
     * @param array $config Configuration array for this behavior
118
     */
119
    public function __construct(Table $table, array $config = [])
120
    {
121
        $config['cacheMap'] = false; // private config, prevent user modifications
122
        $this->_toolbox = new EavToolbox($table);
123
        parent::__construct($table, $config);
124
125
        if ($this->config('cache')) {
126
            $info = $this->config('cache');
127
            $holders = []; // column => [list of virtual columns]
128
129
            if (is_string($info)) {
130
                $holders[$info] = ['*'];
131
            } elseif (is_array($info)) {
132
                foreach ($info as $column => $fields) {
133
                    if (is_integer($column)) {
134
                        $holders[$fields] = ['*'];
135
                    } else {
136
                        $holders[$column] = ($fields === '*') ? ['*'] : $fields;
137
                    }
138
                }
139
            }
140
141
            $this->config('cacheMap', $holders);
142
        }
143
    }
144
145
    /**
146
     * Enables EAV behavior so virtual columns WILL be fetched from database.
147
     *
148
     * @return void
149
     */
150
    public function enableEav()
151
    {
152
        $this->config('enabled', true);
153
    }
154
155
    /**
156
     * Disables EAV behavior so virtual columns WLL NOT be fetched from database.
157
     *
158
     * @return void
159
     */
160
    public function disableEav()
161
    {
162
        $this->config('enabled', false);
163
    }
164
165
    /**
166
     * Defines a new virtual-column, or update if already defined.
167
     *
168
     * ### Usage:
169
     *
170
     * ```php
171
     * $errors = $this->Users->addColumn('user-age', [
172
     *     'type' => 'integer',
173
     *     'bundle' => 'some-bundle-name',
174
     *     'extra' => [
175
     *         'option1' => 'value1'
176
     *     ]
177
     * ], true);
178
     *
179
     * if (empty($errors)) {
180
     *     // OK
181
     * } else {
182
     *     // ERROR
183
     *     debug($errors);
184
     * }
185
     * ```
186
     *
187
     * The third argument can be set to FALSE to get a boolean response:
188
     *
189
     * ```php
190
     * $success = $this->Users->addColumn('user-age', [
191
     *     'type' => 'integer',
192
     *     'bundle' => 'some-bundle-name',
193
     *     'extra' => [
194
     *         'option1' => 'value1'
195
     *     ]
196
     * ]);
197
     *
198
     * if ($success) {
199
     *     // OK
200
     * } else {
201
     *     // ERROR
202
     * }
203
     * ```
204
     *
205
     * @param string $name Column name. e.g. `user-age`
206
     * @param array $options Column configuration options
207
     * @param bool $errors If set to true will return an array list of errors
208
     *  instead of boolean response. Defaults to TRUE
209
     * @return bool|array True on success or array of error messages, depending on
210
     *  $error argument
211
     * @throws \Cake\Error\FatalErrorException When provided column name collides
212
     *  with existing column names. And when an invalid type is provided
213
     */
214
    public function addColumn($name, array $options = [], $errors = true)
215
    {
216
        if (in_array($name, (array)$this->_table->schema()->columns())) {
217
            throw new FatalErrorException(__d('eav', 'The column name "{0}" cannot be used as it is already defined in the table "{1}"', $name, $this->_table->alias()));
218
        }
219
220
        $data = $options + [
221
            'type' => 'string',
222
            'bundle' => null,
223
            'searchable' => true,
224
        ];
225
226
        $data['type'] = $this->_toolbox->mapType($data['type']);
227
        if (!in_array($data['type'], EavToolbox::$types)) {
228
            throw new FatalErrorException(__d('eav', 'The column {0}({1}) could not be created as "{2}" is not a valid type.', $name, $data['type'], $data['type']));
229
        }
230
231
        $data['name'] = $name;
232
        $data['table_alias'] = $this->_table->table();
233
        $attr = TableRegistry::get('Eav.EavAttributes')->find()
234
            ->where([
235
                'name' => $data['name'],
236
                'table_alias' => $data['table_alias'],
237
                'bundle IS' => $data['bundle'],
238
            ])
239
            ->limit(1)
240
            ->first();
241
242
        if ($attr) {
243
            $attr = TableRegistry::get('Eav.EavAttributes')->patchEntity($attr, $data);
244
        } else {
245
            $attr = TableRegistry::get('Eav.EavAttributes')->newEntity($data);
246
        }
247
248
        $success = (bool)TableRegistry::get('Eav.EavAttributes')->save($attr);
249
        Cache::clear(false, 'eav_table_attrs');
250
251
        if ($errors) {
252
            return (array)$attr->errors();
253
        }
254
255
        return (bool)$success;
256
    }
257
258
    /**
259
     * Drops an existing column.
260
     *
261
     * @param string $name Name of the column to drop
262
     * @param string|null $bundle Removes the column within a particular bundle
263
     * @return bool True on success, false otherwise
264
     */
265
    public function dropColumn($name, $bundle = null)
266
    {
267
        $attr = TableRegistry::get('Eav.EavAttributes')->find()
268
            ->where([
269
                'name' => $name,
270
                'table_alias' => $this->_table->table(),
271
                'bundle IS' => $bundle,
272
            ])
273
            ->limit(1)
274
            ->first();
275
276
        Cache::clear(false, 'eav_table_attrs');
277
        if ($attr) {
278
            return (bool)TableRegistry::get('Eav.EavAttributes')->delete($attr);
279
        }
280
281
        return false;
282
    }
283
284
    /**
285
     * Gets a list of virtual columns attached to this table.
286
     *
287
     * @param string|null $bundle Get attributes within given bundle, or all of them
288
     *  regardless of the bundle if not provided
289
     * @return array Columns information indexed by column name
290
     */
291
    public function listColumns($bundle = null)
292
    {
293
        $columns = [];
294
        foreach ($this->_toolbox->attributes($bundle) as $name => $attr) {
295
            $columns[$name] = [
296
                'id' => $attr->get('id'),
297
                'bundle' => $attr->get('bundle'),
298
                'name' => $name,
299
                'type' => $attr->get('type'),
300
                'searchable ' => $attr->get('searchable'),
301
                'extra ' => $attr->get('extra'),
302
            ];
303
        }
304
305
        return $columns;
306
    }
307
308
    /**
309
     * Update EAV cache for the specified $entity.
310
     *
311
     * @return bool Success
312
     */
313
    public function updateEavCache(EntityInterface $entity)
314
    {
315
        if (!$this->config('cacheMap')) {
316
            return false;
317
        }
318
319
        $attrsById = [];
320
        foreach ($this->_toolbox->attributes() as $attr) {
321
            $attrsById[$attr['id']] = $attr;
322
        }
323
324
        if (empty($attrsById)) {
325
            return true; // nothing to cache
326
        }
327
328
        $values = [];
329
        $query = TableRegistry::get('Eav.EavValues')
330
            ->find('all')
331
            ->where([
332
                'EavValues.eav_attribute_id IN' => array_keys($attrsById),
333
                'EavValues.entity_id' => $this->_toolbox->getEntityId($entity),
334
            ]);
335
336
        foreach ($query as $v) {
337
            $type = $attrsById[$v->get('eav_attribute_id')]->get('type');
338
            $name = $attrsById[$v->get('eav_attribute_id')]->get('name');
339
            $values[$name] = $this->_toolbox->marshal($v->get("value_{$type}"), $type);
340
        }
341
342
        $toUpdate = [];
343
        foreach ((array)$this->config('cacheMap') as $column => $fields) {
344
            $cache = [];
345
            if (in_array('*', $fields)) {
346
                $cache = $values;
347
            } else {
348
                foreach ($fields as $field) {
349
                    if (isset($values[$field])) {
350
                        $cache[$field] = $values[$field];
351
                    }
352
                }
353
            }
354
355
            $toUpdate[$column] = (string)serialize(new CachedColumn($cache));
356
        }
357
358
        if (!empty($toUpdate)) {
359
            $conditions = []; // scope to entity's PK (composed PK supported)
360
            $keys = $this->_table->primaryKey();
361
            $keys = !is_array($keys) ? [$keys] : $keys;
362
            foreach ($keys as $key) {
363
                // TODO: check key exists in entity's visible properties list.
364
                // Throw an error otherwise as PK MUST be correctly calculated.
365
                $conditions[$key] = $entity->get($key);
366
            }
367
368
            if (empty($conditions)) {
369
                return false;
370
            }
371
372
            return (bool)$this->_table->updateAll($toUpdate, $conditions);
373
        }
374
375
        return true;
376
    }
377
378
    /**
379
     * Attaches virtual properties to entities.
380
     *
381
     * This method iterates over each retrieved entity and invokes the
382
     * `attachEntityAttributes()` method. This method should return the altered
383
     * entity object with its virtual properties, however if this method returns
384
     * NULL the entity will be removed from the resulting collection. And if this
385
     * method returns FALSE will stop the find() operation.
386
     *
387
     * This method is also responsible of looking for virtual columns in SELECT and
388
     * WHERE clauses (if applicable) and properly scope the Query object. Query
389
     * scoping is performed by the `_scopeQuery()` method.
390
     *
391
     * @param \Cake\Event\Event $event The beforeFind event that was triggered
392
     * @param \Cake\ORM\Query $query The original query to modify
393
     * @param \ArrayObject $options Additional options given as an array
394
     * @param bool $primary Whether this find is a primary query or not
395
     * @return bool|null
396
     */
397
    public function beforeFind(Event $event, Query $query, ArrayObject $options, $primary)
398
    {
399 View Code Duplication
        if (!$this->config('enabled') ||
400
            (isset($options['eav']) && $options['eav'] === false)
401
        ) {
402
            return true;
403
        }
404
405
        if (!isset($options['bundle'])) {
406
            $options['bundle'] = null;
407
        }
408
409
        $query = $this->_scopeQuery($query, $options['bundle']);
410
        $args = compact('event', 'query', 'options', 'primary');
411
        static $callable = null;
412
413
        if ($callable === null) {
414
            $callable = function ($results) use (&$args) {
415
                return $this->hydrateEntities($results, $args);
416
            };
417
        }
418
419
        return $query->formatResults($callable, Query::PREPEND);
420
    }
421
422
    /**
423
     * Triggered before data is converted into entities.
424
     *
425
     * Converts incoming POST data to its corresponding types.
426
     *
427
     * @param \Cake\Event\Event $event The event that was triggered
428
     * @param \ArrayObject $data The POST data to be merged with entity
429
     * @param \ArrayObject $options The options passed to the marshaller
430
     * @return void
431
     */
432
    public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options)
433
    {
434
        $bundle = !empty($options['bundle']) ? $options['bundle'] : null;
435
        $attrs = array_keys($this->_toolbox->attributes($bundle));
436
        foreach ($data as $property => $value) {
437
            if (!in_array($property, $attrs)) {
438
                continue;
439
            }
440
            $dataType = $this->_toolbox->getType($property);
441
            $marshaledValue = $this->_toolbox->marshal($value, $dataType);
442
            $data[$property] = $marshaledValue;
443
        }
444
    }
445
446
    /**
447
     * Save virtual values after an entity's real values were saved.
448
     *
449
     * @param \Cake\Event\Event $event The event that was triggered
450
     * @param \Cake\Datasource\EntityInterface $entity The entity that was saved
451
     * @param \ArrayObject $options Additional options given as an array
452
     * @return bool True always
453
     */
454
    public function afterSave(Event $event, EntityInterface $entity, ArrayObject $options)
455
    {
456
        $attrsById = [];
457
        $updatedAttrs = [];
458
        $valuesTable = TableRegistry::get('Eav.EavValues');
459
460
        foreach ($this->_toolbox->attributes() as $name => $attr) {
461
            if (!$this->_toolbox->propertyExists($entity, $name)) {
0 ignored issues
show
Compatibility introduced by
$entity of type object<Cake\Datasource\EntityInterface> is not a sub-type of object<Cake\ORM\Entity>. It seems like you assume a concrete implementation of the interface Cake\Datasource\EntityInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
462
                continue;
463
            }
464
            $attrsById[$attr->get('id')] = $attr;
465
        }
466
467
        if (empty($attrsById)) {
468
            return true; // nothing to do
469
        }
470
471
        $values = $valuesTable
472
            ->find()
473
            ->bufferResults(false)
474
            ->where([
475
                'eav_attribute_id IN' => array_keys($attrsById),
476
                'entity_id' => $this->_toolbox->getEntityId($entity),
477
            ])
478
            ->toArray();
479
480
        foreach ($values as $value) {
481
            $updatedAttrs[] = $value->get('eav_attribute_id');
482
            $info = $attrsById[$value->get('eav_attribute_id')];
483
            $type = $this->_toolbox->getType($info->get('name'));
484
485
            $marshaledValue = $this->_toolbox->marshal($entity->get($info->get('name')), $type);
486
            $value->set("value_{$type}", $marshaledValue);
487
            $entity->set($info->get('name'), $marshaledValue);
488
            $valuesTable->save($value);
489
        }
490
491
        foreach ($this->_toolbox->attributes() as $name => $attr) {
492
            if (!$this->_toolbox->propertyExists($entity, $name)) {
0 ignored issues
show
Compatibility introduced by
$entity of type object<Cake\Datasource\EntityInterface> is not a sub-type of object<Cake\ORM\Entity>. It seems like you assume a concrete implementation of the interface Cake\Datasource\EntityInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
493
                continue;
494
            }
495
496
            if (!in_array($attr->get('id'), $updatedAttrs)) {
497
                $type = $this->_toolbox->getType($name);
498
                $value = $valuesTable->newEntity([
499
                    'eav_attribute_id' => $attr->get('id'),
500
                    'entity_id' => $this->_toolbox->getEntityId($entity),
501
                ]);
502
503
                $marshaledValue = $this->_toolbox->marshal($entity->get($name), $type);
504
                $value->set("value_{$type}", $marshaledValue);
505
                $entity->set($name, $marshaledValue);
506
                $valuesTable->save($value);
507
            }
508
        }
509
510
        if ($this->config('cacheMap')) {
511
            $this->updateEavCache($entity);
512
        }
513
514
        return true;
515
    }
516
517
    /**
518
     * After an entity was removed from database. Here is when EAV values are
519
     * removed from DB.
520
     *
521
     * @param \Cake\Event\Event $event The event that was triggered
522
     * @param \Cake\Datasource\EntityInterface $entity The entity that was deleted
523
     * @param \ArrayObject $options Additional options given as an array
524
     * @throws \Cake\Error\FatalErrorException When using this behavior in non-atomic mode
525
     * @return void
526
     */
527
    public function afterDelete(Event $event, EntityInterface $entity, ArrayObject $options)
528
    {
529
        if (!$options['atomic']) {
530
            throw new FatalErrorException(__d('eav', 'Entities in fieldable tables can only be deleted using transactions. Set [atomic = true]'));
531
        }
532
533
        $valuesToDelete = TableRegistry::get('Eav.EavValues')
534
            ->find()
535
            ->contain(['EavAttribute'])
536
            ->where([
537
                'EavAttribute.table_alias' => $this->_table->table(),
538
                'EavValues.entity_id' => $this->_toolbox->getEntityId($entity),
539
            ])
540
            ->all();
541
542
        foreach ($valuesToDelete as $value) {
543
            TableRegistry::get('Eav.EavValues')->delete($value);
544
        }
545
    }
546
547
    /**
548
     * Attach EAV attributes for every entity in the provided result-set.
549
     *
550
     * @param \Cake\Collection\CollectionInterface $entities Set of entities to be
551
     *  processed
552
     * @param array $options Arguments given to `beforeFind()` method, possible keys
553
     *  are "event", "query", "options", "primary"
554
     * @return \Cake\Collection\CollectionInterface New set with altered entities
555
     */
556
    public function hydrateEntities(CollectionInterface $entities, array $options)
557
    {
558
        $values = $this->_prepareSetValues($entities, $options);
559
560
        return $entities->map(function ($entity) use ($values, $options) {
561
            if ($entity instanceof EntityInterface) {
562
                $entity = $this->_prepareCachedColumns($entity);
563
                $entityId = $this->_toolbox->getEntityId($entity);
564
565
                if (isset($values[$entityId])) {
566
                    $options['values'] = isset($values[$entityId]) ? $values[$entityId] : [];
567
                    $entity = $this->hydrateEntity($entity, $options);
568
                }
569
            }
570
571
            if ($entity === false) {
572
                $options['event']->stopPropagation();
573
574
                return;
575
            }
576
577
            if ($entity === null) {
578
                return false;
579
            }
580
581
            return $entity;
582
        });
583
    }
584
585
    /**
586
     * Hydrates a single entity and returns it.
587
     *
588
     * - Returning NULL indicates the entity should be removed from the resulting
589
     *   collection.
590
     *
591
     * - Returning FALSE will stop the entire find() operation.
592
     *
593
     * @param \Cake\Datasource\EntityInterface $entity The entity to hydrate
594
     * @param array $options Arguments given to `beforeFind()` method, possible keys
595
     *  are "event", "query", "options", "primary" and "values" which
596
     *  holds stored virtual values for this particular entity
597
     * @return bool|null|\Cake\Datasource\EntityInterface
598
     */
599
    public function hydrateEntity(EntityInterface $entity, array $options)
600
    {
601
        $values = $options['values'];
602
        foreach ($values as $value) {
603
            if (!$this->_toolbox->propertyExists($entity, $value['property_name'])) {
0 ignored issues
show
Compatibility introduced by
$entity of type object<Cake\Datasource\EntityInterface> is not a sub-type of object<Cake\ORM\Entity>. It seems like you assume a concrete implementation of the interface Cake\Datasource\EntityInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
604
                $entity->set($value['property_name'], $value['value']);
605
                $entity->dirty($value['property_name'], false);
606
            }
607
        }
608
609
        // force cache-columns to be of the proper type as they might be NULL if
610
        // entity has not been updated yet.
611
        if ($this->config('cacheMap')) {
612
            foreach ($this->config('cacheMap') as $column => $fields) {
613
                if ($this->_toolbox->propertyExists($entity, $column) && !($entity->get($column) instanceof Entity)) {
0 ignored issues
show
Compatibility introduced by
$entity of type object<Cake\Datasource\EntityInterface> is not a sub-type of object<Cake\ORM\Entity>. It seems like you assume a concrete implementation of the interface Cake\Datasource\EntityInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
614
                    $entity->set($column, new Entity);
615
                }
616
            }
617
        }
618
619
        return $entity;
620
    }
621
622
    /**
623
     * Retrieves all virtual values of all the entities within the given result-set.
624
     *
625
     * @param \Cake\Collection\CollectionInterface $entities Set of entities
626
     * @param array $options Arguments given to `beforeFind()` method, possible keys
627
     *  are "event", "query", "options", "primary"
628
     * @return array Virtual values indexed by entity ID
629
     */
630
    protected function _prepareSetValues(CollectionInterface $entities, array $options)
631
    {
632
        $entityIds = $this->_toolbox->extractEntityIds($entities);
0 ignored issues
show
Compatibility introduced by
$entities of type object<Cake\Collection\CollectionInterface> is not a sub-type of object<Cake\ORM\ResultSet>. It seems like you assume a concrete implementation of the interface Cake\Collection\CollectionInterface to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
633
        if (empty($entityIds)) {
634
            return [];
635
        }
636
637
        if (empty($this->_queryScopes['Eav\\Model\\Behavior\\QueryScope\\SelectScope'])) {
638
            return [];
639
        }
640
641
        $bundle = !empty($options['bundle']) ? $options['bundle'] : null;
642
        $selectedVirtual = $this->_queryScopes['Eav\\Model\\Behavior\\QueryScope\\SelectScope']->getVirtualColumns($options['query'], $bundle);
643
        $validColumns = array_values($selectedVirtual);
644
        $validNames = array_intersect($this->_toolbox->getAttributeNames($bundle), $validColumns);
645
        $attrsById = [];
646
647
        foreach ($this->_toolbox->attributes($bundle) as $name => $attr) {
648
            if (in_array($name, $validNames)) {
649
                $attrsById[$attr['id']] = $attr;
650
            }
651
        }
652
653
        if (empty($attrsById)) {
654
            return [];
655
        }
656
657
        return TableRegistry::get('Eav.EavValues')
658
            ->find('all')
659
            ->bufferResults(false)
660
            ->where([
661
                'EavValues.eav_attribute_id IN' => array_keys($attrsById),
662
                'EavValues.entity_id IN' => $entityIds,
663
            ])
664
            ->all()
665
            ->map(function ($value) use ($attrsById, $selectedVirtual) {
666
                $attrName = $attrsById[$value->get('eav_attribute_id')]->get('name');
667
                $attrType = $attrsById[$value->get('eav_attribute_id')]->get('type');
668
                $alias = array_search($attrName, $selectedVirtual);
669
670
                return [
671
                    'entity_id' => $value->get('entity_id'),
672
                    'property_name' => is_string($alias) ? $alias : $attrName,
673
                    'value' => $this->_toolbox->marshal($value->get("value_{$attrType}"), $attrType),
674
                ];
675
            })
676
            ->groupBy('entity_id')
677
            ->toArray();
678
    }
679
680
    /**
681
     * Prepares entity's cache-columns (those defined using `cache` option).
682
     *
683
     * @param \Cake\Datasource\EntityInterface $entity The entity to prepare
684
     * @return \Cake\Datasource\EntityInterfa Modified entity
685
     */
686
    protected function _prepareCachedColumns(EntityInterface $entity)
687
    {
688
        if ($this->config('cacheMap')) {
689
            foreach ((array)$this->config('cacheMap') as $column => $fields) {
690
                if (in_array($column, $entity->visibleProperties())) {
691
                    $string = $entity->get($column);
692
                    if ($string == serialize(false) || @unserialize($string) !== false) {
693
                        $entity->set($column, unserialize($string));
694
                    } else {
695
                        $entity->set($column, new CachedColumn());
696
                    }
697
                }
698
            }
699
        }
700
701
        return $entity;
702
    }
703
704
    /**
705
     * Look for virtual columns in some query's clauses.
706
     *
707
     * @param \Cake\ORM\Query $query The query to scope
708
     * @param string|null $bundle Consider attributes only for a specific bundle
709
     * @return \Cake\ORM\Query The modified query object
710
     */
711
    protected function _scopeQuery(Query $query, $bundle = null)
712
    {
713
        $this->_initScopes();
714
        foreach ($this->_queryScopes as $scope) {
715
            if ($scope instanceof QueryScopeInterface) {
716
                $query = $scope->scope($query, $bundle);
717
            }
718
        }
719
720
        return $query;
721
    }
722
723
    /**
724
     * Initializes the scope objects
725
     *
726
     * @return void
727
     */
728
    protected function _initScopes()
729
    {
730
        foreach ((array)$this->config('queryScope') as $className) {
731
            if (!empty($this->_queryScopes[$className])) {
732
                continue;
733
            }
734
735
            if (class_exists($className)) {
736
                $instance = new $className($this->_table);
737
                if ($instance instanceof QueryScopeInterface) {
738
                    $this->_queryScopes[$className] = $instance;
739
                }
740
            }
741
        }
742
    }
743
}
744