FieldableBehavior::_attachEntityFields()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nc 3
nop 2
dl 0
loc 12
rs 9.4285
c 0
b 0
f 0
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 Field\Model\Behavior;
13
14
use Cake\Collection\CollectionInterface;
15
use Cake\Datasource\EntityInterface;
16
use Cake\Error\FatalErrorException;
17
use Cake\Event\Event;
18
use Cake\ORM\Behavior;
19
use Cake\ORM\Entity;
20
use Cake\ORM\Query;
21
use Cake\ORM\Table;
22
use Cake\ORM\TableRegistry;
23
use Cake\Validation\Validator;
24
use Eav\Model\Behavior\EavBehavior;
25
use Field\Collection\FieldCollection;
26
use Field\Model\Entity\Field;
27
use \ArrayObject;
28
29
/**
30
 * Fieldable Behavior.
31
 *
32
 * A more flexible EAV approach. Allows additional fields to be attached to Tables.
33
 * Any Table (Contents, Users, etc.) can use this behavior to make itself `fieldable`
34
 * and thus allow fields to be attached to it.
35
 *
36
 * The Field API defines two primary data structures, FieldInstance and FieldValue:
37
 *
38
 * - FieldInstance: is a Field attached to a single Table. (Schema equivalent: column)
39
 * - FieldValue: is the stored data for a particular [FieldInstance, Entity]
40
 *   tuple of your Table. (Schema equivalent: cell value)
41
 *
42
 * **This behavior allows you to add _virtual columns_ to your table schema.**
43
 *
44
 * @link https://github.com/quickapps/docs/blob/2.x/en/developers/field-api.rst
45
 */
46
class FieldableBehavior extends EavBehavior
47
{
48
49
    /**
50
     * Used for reduce BD queries and allow inter-method communication.
51
     * Example, it allows to pass some information from beforeDelete() to
52
     * afterDelete().
53
     *
54
     * @var array
55
     */
56
    protected $_cache = [];
57
58
    /**
59
     * Default configuration.
60
     *
61
     * These are merged with user-provided configuration when the behavior is used.
62
     * Available options are:
63
     *
64
     * - `bundle`: Bundle within this the table. Can be a string or a callable
65
     *   method that must return a string to use as bundle. Default null. If set to
66
     *   a callable function, it will receive the entity being saved as first
67
     *   argument, so you can calculate a bundle name for each particular entity.
68
     *
69
     * - `enabled`: True enables this behavior or false for disable. Default to
70
     *   true.
71
     *
72
     * - `cache`: Column-based cache. See EAV plugin's documentation.
73
     *
74
     * Bundles are usually set to dynamic values. For example, for the "contents"
75
     * table we have "content" entities, but we may have "article contents", "page
76
     * contents", etc. depending on the "type of content" they are; is said that
77
     * "article" and "page" **are bundles** of "contents" table.
78
     *
79
     * @var array
80
     */
81
    protected $_fieldableDefaultConfig = [
82
        'bundle' => null,
83
        'implementedMethods' => [
84
            'attachFields' => 'attachEntityFields',
85
            'fieldable' => 'fieldable',
86
        ],
87
    ];
88
89
    /**
90
     * Instance of EavAttributes table.
91
     *
92
     * @var \Eav\Model\Table\EavAttributesTable
93
     */
94
    public $Attributes = null;
95
96
    /**
97
     * Constructor.
98
     *
99
     * @param \Cake\ORM\Table $table The table this behavior is attached to
100
     * @param array $config Configuration array for this behavior
101
     */
102
    public function __construct(Table $table, array $config = [])
103
    {
104
        $this->_defaultConfig = array_merge($this->_defaultConfig, $this->_fieldableDefaultConfig);
105
        $this->Attributes = TableRegistry::get('Eav.EavAttributes');
106
        $this->Attributes->hasOne('Instance', [
107
            'className' => 'Field.FieldInstances',
108
            'foreignKey' => 'eav_attribute_id',
109
            'propertyName' => 'instance',
110
        ]);
111
        parent::__construct($table, $config);
112
    }
113
114
    /**
115
     * Returns a list of events this class is implementing. When the class is
116
     * registered in an event manager, each individual method will be associated
117
     * with the respective event.
118
     *
119
     * @return void
120
     */
121
    public function implementedEvents()
122
    {
123
        $events = [
124
            'Model.beforeFind' => ['callable' => 'beforeFind', 'priority' => 15],
125
            'Model.beforeSave' => ['callable' => 'beforeSave', 'priority' => 15],
126
            'Model.afterSave' => ['callable' => 'afterSave', 'priority' => 15],
127
            'Model.beforeDelete' => ['callable' => 'beforeDelete', 'priority' => 15],
128
            'Model.afterDelete' => ['callable' => 'afterDelete', 'priority' => 15],
129
        ];
130
131
        return $events;
132
    }
133
134
    /**
135
     * Modifies the query object in order to merge custom fields records into each
136
     * entity under the `_fields` property.
137
     *
138
     * You can enable or disable this behavior for a single `find()` or `get()`
139
     * operation by setting `fieldable` or `eav` to false in the options array for
140
     * find method. e.g.:
141
     *
142
     * ```php
143
     * $contents = $this->Contents->find('all', ['fieldable' => false]);
144
     * $content = $this->Contents->get($id, ['fieldable' => false]);
145
     * ```
146
     *
147
     * It also looks for custom fields in WHERE clause. This will search entities in
148
     * all bundles this table may have, if you need to restrict the search to an
149
     * specific bundle you must use the `bundle` key in find()'s options:
150
     *
151
     * ```php
152
     * $this->Contents
153
     *     ->find('all', ['bundle' => 'articles'])
154
     *     ->where(['article-title' => 'My first article!']);
155
     * ```
156
     *
157
     * The `bundle` option has no effects if no custom fields are given in the
158
     * WHERE clause.
159
     *
160
     * @param \Cake\Event\Event $event The beforeFind event that was triggered
161
     * @param \Cake\ORM\Query $query The original query to modify
162
     * @param \ArrayObject $options Additional options given as an array
163
     * @param bool $primary Whether this find is a primary query or not
164
     * @return void
165
     */
166
    public function beforeFind(Event $event, Query $query, ArrayObject $options, $primary)
167
    {
168
        $status = array_key_exists('fieldable', $options) ? $options['fieldable'] : $this->config('status');
169
        if (!$status) {
170
            return;
171
        }
172
173
        if (array_key_exists('eav', $options)) {
174
            unset($options['eav']);
175
        }
176
177
        return parent::beforeFind($event, $query, $options, $primary);
178
    }
179
180
    /**
181
     * {@inheritDoc}
182
     */
183
    protected function _hydrateEntities(CollectionInterface $entities, array $args)
184
    {
185
        return $entities->map(function ($entity) use ($args) {
186
            if ($entity instanceof EntityInterface) {
187
                $entity = $this->_prepareCachedColumns($entity);
188
                $entity = $this->_attachEntityFields($entity, $args);
189
190
                if ($entity === null) {
191
                    return self::NULL_ENTITY;
192
                }
193
            }
194
195
            return $entity;
196
        })
197
        ->filter(function ($entity) {
198
            return $entity !== self::NULL_ENTITY;
199
        });
200
    }
201
202
    /**
203
     * Attaches entity's field under the `_fields` property, this method is invoked
204
     * by `beforeFind()` when iterating results sets.
205
     *
206
     * @param \Cake\Datasource\EntityInterface $entity The entity being altered
207
     * @param array $args Arguments given to the originating `beforeFind()`
208
     */
209
    protected function _attachEntityFields(EntityInterface $entity, array $args)
210
    {
211
        $entity = $this->attachEntityFields($entity);
212
        foreach ($entity->get('_fields') as $field) {
213
            $result = $field->beforeFind((array)$args['options'], $args['primary']);
214
            if ($result === null) {
215
                return null; // remove entity from collection
216
            }
217
        }
218
219
        return $entity;
220
    }
221
222
    /**
223
     * Before an entity is saved.
224
     *
225
     * Here is where we dispatch each custom field's `$_POST` information to its
226
     * corresponding Field Handler, so they can operate over their values.
227
     *
228
     * Fields Handler's `beforeSave()` method is automatically invoked for each
229
     * attached field for the entity being processed, your field handler should look
230
     * as follow:
231
     *
232
     * ```php
233
     * use Field\Handler;
234
     *
235
     * class TextField extends Handler
236
     * {
237
     *     public function beforeSave(Field $field, $post)
238
     *     {
239
     *          // alter $field, and do nifty things with $post
240
     *          // return FALSE; will halt the operation
241
     *     }
242
     * }
243
     * ```
244
     *
245
     * Field Handlers should **alter** `$field->value` and `$field->extra` according
246
     * to its needs using the provided **$post** argument.
247
     *
248
     * **NOTE:** Returning boolean FALSE will halt the whole Entity's save operation.
249
     *
250
     * @param \Cake\Event\Event $event The event that was triggered
251
     * @param \Cake\Datasource\EntityInterface $entity The entity being saved
252
     * @param \ArrayObject $options Additional options given as an array
253
     * @throws \Cake\Error\FatalErrorException When using this behavior in non-atomic mode
254
     * @return bool True if save operation should continue
255
     */
256
    public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options)
257
    {
258
        if (!$this->config('status')) {
259
            return true;
260
        }
261
262
        if (!$options['atomic']) {
263
            throw new FatalErrorException(__d('field', 'Entities in fieldable tables can only be saved using transaction. Set [atomic = true]'));
264
        }
265
266
        if (!$this->_validation($entity)) {
267
            return false;
268
        }
269
270
        $this->_cache['createValues'] = [];
271
        foreach ($this->_attributesForEntity($entity) as $attr) {
272
            if (!$this->_toolbox->propertyExists($entity, $attr->get('name'))) {
273
                continue;
274
            }
275
276
            $field = $this->_prepareMockField($entity, $attr);
277
            $result = $field->beforeSave($this->_fetchPost($field));
278
279
            if ($result === false) {
280
                $this->attachEntityFields($entity);
281
282
                return false;
283
            }
284
285
            $data = [
286
                'eav_attribute_id' => $field->get('metadata')->get('attribute_id'),
287
                'entity_id' => $this->_toolbox->getEntityId($entity),
288
                "value_{$field->metadata['type']}" => $field->get('value'),
289
                'extra' => $field->get('extra'),
290
            ];
291
292
            if ($field->get('metadata')->get('value_id')) {
293
                $valueEntity = TableRegistry::get('Eav.EavValues')->get($field->get('metadata')->get('value_id'));
294
                $valueEntity = TableRegistry::get('Eav.EavValues')->patchEntity($valueEntity, $data, ['validate' => false]);
295
            } else {
296
                $valueEntity = TableRegistry::get('Eav.EavValues')->newEntity($data, ['validate' => false]);
297
            }
298
299
            if ($entity->isNew() || $valueEntity->isNew()) {
300
                $this->_cache['createValues'][] = $valueEntity;
301
            } elseif (!TableRegistry::get('Eav.EavValues')->save($valueEntity)) {
302
                $this->attachEntityFields($entity);
303
                $event->stopPropagation();
304
305
                return false;
306
            }
307
        }
308
309
        $this->attachEntityFields($entity);
310
311
        return true;
312
    }
313
314
    /**
315
     * After an entity is saved.
316
     *
317
     * @param \Cake\Event\Event $event The event that was triggered
318
     * @param \Cake\Datasource\EntityInterface $entity The entity that was saved
319
     * @param \ArrayObject $options Additional options given as an array
320
     * @return bool True always
321
     */
322
    public function afterSave(Event $event, EntityInterface $entity, ArrayObject $options)
323
    {
324
        if (!$this->config('status')) {
325
            return true;
326
        }
327
328
        // as we don't know entity's ID on beforeSave, we must delay values storage;
329
        // all this occurs inside a transaction so we are safe
330
        if (!empty($this->_cache['createValues'])) {
331
            foreach ($this->_cache['createValues'] as $valueEntity) {
332
                $valueEntity->set('entity_id', $this->_toolbox->getEntityId($entity));
333
                $valueEntity->unsetProperty('id');
334
                TableRegistry::get('Eav.EavValues')->save($valueEntity);
335
            }
336
            $this->_cache['createValues'] = [];
337
        }
338
339
        foreach ($this->_attributesForEntity($entity) as $attr) {
340
            $field = $this->_prepareMockField($entity, $attr);
341
            $field->afterSave();
342
        }
343
344
        if ($this->config('cacheMap')) {
345
            $this->updateEavCache($entity);
346
        }
347
348
        return true;
349
    }
350
351
    /**
352
     * Deletes an entity from a fieldable table.
353
     *
354
     * @param \Cake\Event\Event $event The event that was triggered
355
     * @param \Cake\Datasource\EntityInterface $entity The entity being deleted
356
     * @param \ArrayObject $options Additional options given as an array
357
     * @return bool
358
     * @throws \Cake\Error\FatalErrorException When using this behavior in non-atomic mode
359
     */
360
    public function beforeDelete(Event $event, EntityInterface $entity, ArrayObject $options)
361
    {
362
        if (!$this->config('status')) {
363
            return true;
364
        }
365
366
        if (!$options['atomic']) {
367
            throw new FatalErrorException(__d('field', 'Entities in fieldable tables can only be deleted using transaction. Set [atomic = true]'));
368
        }
369
370
        foreach ($this->_attributesForEntity($entity) as $attr) {
371
            $field = $this->_prepareMockField($entity, $attr);
372
            $result = $field->beforeDelete();
373
374
            if ($result === false) {
375
                $event->stopPropagation();
376
377
                return false;
378
            }
379
380
            // holds in cache field mocks, so we can catch them on afterDelete
381
            $this->_cache['afterDelete'][] = $field;
382
        }
383
384
        return true;
385
    }
386
387
    /**
388
     * After an entity was removed from database.
389
     *
390
     * **NOTE:** This method automatically removes all field values from
391
     * `eav_values` database table for each entity.
392
     *
393
     * @param \Cake\Event\Event $event The event that was triggered
394
     * @param \Cake\Datasource\EntityInterface $entity The entity that was deleted
395
     * @param \ArrayObject $options Additional options given as an array
396
     * @throws \Cake\Error\FatalErrorException When using this behavior in non-atomic mode
397
     * @return void
398
     */
399
    public function afterDelete(Event $event, EntityInterface $entity, ArrayObject $options)
400
    {
401
        if (!$this->config('status')) {
402
            return;
403
        }
404
405
        if (!$options['atomic']) {
406
            throw new FatalErrorException(__d('field', 'Entities in fieldable tables can only be deleted using transactions. Set [atomic = true]'));
407
        }
408
409
        if (!empty($this->_cache['afterDelete'])) {
410
            foreach ((array)$this->_cache['afterDelete'] as $field) {
411
                $field->afterDelete();
412
            }
413
            $this->_cache['afterDelete'] = [];
414
        }
415
416
        parent::afterDelete($event, $entity, $options);
417
    }
418
419
    /**
420
     * Gets/sets fieldable behavior status.
421
     *
422
     * @param array|bool|null $status If set to a boolean value then turns on/off
423
     *  this behavior
424
     * @return bool|void
425
     */
426
    public function fieldable($status = null)
427
    {
428
        return $this->eav($status);
429
    }
430
431
    /**
432
     * The method which actually fetches custom fields.
433
     *
434
     * Fetches all Entity's fields under the `_fields` property.
435
     *
436
     * @param \Cake\Datasource\EntityInterface $entity The entity where to fetch fields
437
     * @return \Cake\Datasource\EntityInterface
438
     */
439
    public function attachEntityFields(EntityInterface $entity)
440
    {
441
        $_fields = [];
442
        foreach ($this->_attributesForEntity($entity) as $attr) {
443
            $field = $this->_prepareMockField($entity, $attr);
444
            if ($entity->has($field->get('name'))) {
445
                $this->_fetchPost($field);
446
            }
447
448
            $field->fieldAttached();
449
            $_fields[] = $field;
450
        }
451
452
        $entity->set('_fields', new FieldCollection($_fields));
453
454
        return $entity;
455
    }
456
457
    /**
458
     * Triggers before/after validate events.
459
     *
460
     * @param \Cake\Datasource\EntityInterface $entity The entity being validated
461
     * @return bool True if save operation should continue, false otherwise
462
     */
463
    protected function _validation(EntityInterface $entity)
464
    {
465
        $validator = new Validator();
466
        $hasErrors = false;
467
468
        foreach ($this->_attributesForEntity($entity) as $attr) {
469
            $field = $this->_prepareMockField($entity, $attr);
470
            $result = $field->validate($validator);
471
472
            if ($result === false) {
473
                $this->attachEntityFields($entity);
474
475
                return false;
476
            }
477
478
            $errors = $validator->errors($entity->toArray(), $entity->isNew());
479
            $entity->errors($errors);
480
481
            if (!empty($errors)) {
482
                $hasErrors = true;
483
                if ($entity->has('_fields')) {
484
                    $entityErrors = $entity->errors();
485
                    foreach ($entity->get('_fields') as $field) {
486
                        $postData = $entity->get($field->name);
487
                        if (!empty($entityErrors[$field->name])) {
488
                            $field->set('value', $postData);
489
                            $field->metadata->set('errors', (array)$entityErrors[$field->name]);
490
                        }
491
                    }
492
                }
493
            }
494
        }
495
496
        return !$hasErrors;
497
    }
498
499
    /**
500
     * Alters the given $field and fetches incoming POST data, both "value" and
501
     * "extra" property will be automatically filled for the given $field entity.
502
     *
503
     * @param \Field\Model\Entity\Field $field The field entity for which fetch POST information
504
     * @return mixed Raw POST information
505
     */
506
    protected function _fetchPost(Field $field)
507
    {
508
        $post = $field
509
            ->get('metadata')
510
            ->get('entity')
511
            ->get($field->get('name'));
512
513
        // auto-magic
514
        if (is_array($post)) {
515
            $field->set('extra', $post);
516
            $field->set('value', null);
517
        } else {
518
            $field->set('extra', null);
519
            $field->set('value', $post);
520
        }
521
522
        return $post;
523
    }
524
525
    /**
526
     * Gets all attributes that should be attached to the given entity, this entity
527
     * will be used as context to calculate the proper bundle.
528
     *
529
     * @param \Cake\Datasource\EntityInterface $entity Entity context
530
     * @return array
531
     */
532
    protected function _attributesForEntity(EntityInterface $entity)
533
    {
534
        $bundle = $this->_resolveBundle($entity);
535
        $attrs = $this->_toolbox->attributes($bundle);
536
        $attrByIds = []; // attrs indexed by id
537
        $attrByNames = []; // attrs indexed by name
538
539
        foreach ($attrs as $name => $attr) {
540
            $attrByNames[$name] = $attr;
541
            $attrByIds[$attr->get('id')] = $attr;
542
            $attr->set(':value', null);
543
        }
544
545
        if (!empty($attrByIds)) {
546
            $instances = $this->Attributes->Instance
547
                ->find()
548
                ->where(['eav_attribute_id IN' => array_keys($attrByIds)])
549
                ->all();
550
            foreach ($instances as $instance) {
551
                if (!empty($attrByIds[$instance->get('eav_attribute_id')])) {
552
                    $attr = $attrByIds[$instance->get('eav_attribute_id')];
553
                    if (!$attr->has('instance')) {
554
                        $attr->set('instance', $instance);
555
                    }
556
                }
557
            }
558
        }
559
560
        $values = $this->_fetchValues($entity, array_keys($attrByNames));
561
        foreach ($values as $value) {
562
            if (!empty($attrByNames[$value->get('eav_attribute')->get('name')])) {
563
                $attrByNames[$value->get('eav_attribute')->get('name')]->set(':value', $value);
564
            }
565
        }
566
567
        return $this->_toolbox->attributes($bundle);
568
    }
569
570
    /**
571
     * Retrieves stored values for all virtual properties by name. This gets all
572
     * values at once.
573
     *
574
     * This method is used to reduce the number of SQl queries, so we get all
575
     * values at once in a single Select instead of creating a select for every
576
     * field attached to the given entity.
577
     *
578
     * @param \Cake\Datasource\EntityInterface $entity The entity for which get related values
579
     * @param array $attrNames List of attribute names for which get their values
580
     * @return \Cake\Datasource\ResultSetInterface
581
     */
582
    protected function _fetchValues(EntityInterface $entity, array $attrNames = [])
583
    {
584
        $bundle = $this->_resolveBundle($entity);
585
        $conditions = [
586
            'EavAttribute.table_alias' => $this->_table->table(),
587
            'EavValues.entity_id' => $entity->get((string)$this->_table->primaryKey()),
588
        ];
589
590
        if ($bundle) {
591
            $conditions['EavAttribute.bundle'] = $bundle;
592
        }
593
594
        if (!empty($attrNames)) {
595
            $conditions['EavAttribute.name IN'] = $attrNames;
596
        }
597
598
        $storedValues = TableRegistry::get('Eav.EavValues')
599
            ->find()
600
            ->contain(['EavAttribute'])
601
            ->where($conditions)
602
            ->all();
603
604
        return $storedValues;
605
    }
606
607
    /**
608
     * Creates a new Virtual "Field" to be attached to the given entity.
609
     *
610
     * This mock Field represents a new property (table column) of the entity.
611
     *
612
     * @param \Cake\Datasource\EntityInterface $entity The entity where the
613
     *  generated virtual field will be attached
614
     * @param \Cake\Datasource\EntityInterface $attribute The attribute where to get
615
     *  the information when creating the mock field.
616
     * @return \Field\Model\Entity\Field
617
     */
618
    protected function _prepareMockField(EntityInterface $entity, EntityInterface $attribute)
619
    {
620
        $type = $this->_toolbox->mapType($attribute->get('type'));
621
        if (!$attribute->has(':value')) {
622
            $bundle = $this->_resolveBundle($entity);
623
            $conditions = [
624
                'EavAttribute.table_alias' => $this->_table->table(),
625
                'EavAttribute.name' => $attribute->get('name'),
626
                'EavValues.entity_id' => $entity->get((string)$this->_table->primaryKey()),
627
            ];
628
629
            if ($bundle) {
630
                $conditions['EavAttribute.bundle'] = $bundle;
631
            }
632
633
            $storedValue = TableRegistry::get('Eav.EavValues')
634
                ->find()
635
                ->contain(['EavAttribute'])
636
                ->select(['id', "value_{$type}", 'extra'])
637
                ->where($conditions)
638
                ->limit(1)
639
                ->first();
640
        } else {
641
            $storedValue = $attribute->get(':value');
642
        }
643
644
        $mockField = new Field([
645
            'name' => $attribute->get('name'),
646
            'label' => $attribute->get('instance')->get('label'),
647
            'value' => null,
648
            'extra' => null,
649
            'metadata' => new Entity([
650
                'value_id' => null,
651
                'instance_id' => $attribute->get('instance')->get('id'),
652
                'attribute_id' => $attribute->get('id'),
653
                'entity_id' => $this->_toolbox->getEntityId($entity),
654
                'table_alias' => $attribute->get('table_alias'),
655
                'type' => $type,
656
                'bundle' => $attribute->get('bundle'),
657
                'handler' => $attribute->get('instance')->get('handler'),
658
                'required' => $attribute->get('instance')->required,
659
                'description' => $attribute->get('instance')->description,
660
                'settings' => $attribute->get('instance')->settings,
661
                'view_modes' => $attribute->get('instance')->view_modes,
662
                'entity' => $entity,
663
                'errors' => [],
664
            ]),
665
        ]);
666
667
        if ($storedValue) {
668
            $mockField->set('value', $this->_toolbox->marshal($storedValue->get("value_{$type}"), $type));
669
            $mockField->set('extra', $storedValue->get('extra'));
670
            $mockField->metadata->set('value_id', $storedValue->id);
671
        }
672
673
        $mockField->isNew($entity->isNew());
674
675
        return $mockField;
676
    }
677
678
    /**
679
     * Resolves `bundle` name using $entity as context.
680
     *
681
     * @param \Cake\Datasource\EntityInterface $entity Entity to use as context when resolving bundle
682
     * @return string Bundle name as string value, it may be an empty string if no bundle should be applied
683
     */
684
    protected function _resolveBundle(EntityInterface $entity)
685
    {
686
        $bundle = $this->config('bundle');
687
        if (is_callable($bundle)) {
688
            $callable = $this->config('bundle');
689
            $bundle = $callable($entity);
690
        }
691
692
        return (string)$bundle;
693
    }
694
695
    /**
696
     * Ensures that virtual properties are included in the marshalling process.
697
     *
698
     * @param \Cake\ORM\Marhshaller $marshaller The marhshaller of the table the behavior is attached to.
699
     * @param array $map The property map being built.
700
     * @param array $options The options array used in the marshalling call.
701
     * @return array A map of `[property => callable]` of additional properties to marshal.
702
     */
703
    public function buildMarshalMap($marshaller, $map, $options)
704
    {
705
        return [];
706
    }
707
}
708