PlaceholdersBehavior::extractPlaceholders()   B
last analyzed

Complexity

Conditions 8
Paths 8

Size

Total Lines 37
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 23
c 1
b 0
f 0
dl 0
loc 37
rs 8.4444
cc 8
nc 8
nop 2
1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * BEdita, API-first content management framework
6
 * Copyright 2022 Atlas Srl, Chialab Srl
7
 *
8
 * This file is part of BEdita: you can redistribute it and/or modify
9
 * it under the terms of the GNU Lesser General Public License as published
10
 * by the Free Software Foundation, either version 3 of the License, or
11
 * (at your option) any later version.
12
 *
13
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
14
 */
15
16
namespace BEdita\Placeholders\Model\Behavior;
17
18
use BEdita\Core\Model\Action\SetRelatedObjectsAction;
19
use Cake\Database\Expression\QueryExpression;
20
use Cake\Datasource\EntityInterface;
21
use Cake\Event\Event;
22
use Cake\ORM\Association;
23
use Cake\ORM\Behavior;
24
use Cake\ORM\Table;
25
use InvalidArgumentException;
26
use RuntimeException;
27
28
/**
29
 * Placeholders behavior
30
 */
31
class PlaceholdersBehavior extends Behavior
32
{
33
    use GetAssociationTrait;
34
35
    /**
36
     * The default regex to use to interpolate placeholders data.
37
     *
38
     * @var string
39
     */
40
    protected const REGEX = '/<!--\s*BE-PLACEHOLDER\.(?P<id>\d+)(?:\.(?P<params>[A-Za-z0-9+=-]+))?\s*-->/';
41
42
    /**
43
     * Default configurations. Available configurations include:
44
     *
45
     * - `relation`: name of the BEdita relation to use.
46
     * - `fields`: list of fields from which placeholders should be extracted.
47
     * - `extract`: extract function that will be called on each entity; it will receive
48
     *      the entity instance and an array of fields as input, and is expected to return
49
     *      a list of associative arrays with `id` and `params` fields.
50
     *      If `null`, uses {@see \BEdita\Core\Model\Behavior\PlaceholdersBehavior::extractPlaceholders()}.
51
     *
52
     * @var array<string, mixed>
53
     */
54
    protected $_defaultConfig = [
55
        'relation' => 'placeholder',
56
        'fields' => ['description', 'body'],
57
        'extract' => null,
58
    ];
59
60
    /**
61
     * Extract placeholders from an entity.
62
     *
63
     * @param \Cake\Datasource\EntityInterface $entity The entity from which to extract placeholder references.
64
     * @param string[] $fields Field names.
65
     * @return array[] A list of arrays, each with `id` and `params` set.
66
     */
67
    public static function extractPlaceholders(EntityInterface $entity, array $fields): array
68
    {
69
        $placeholders = [];
70
        foreach ($fields as $field) {
71
            $datum = $entity->get($field);
72
            if (empty($datum)) {
73
                continue;
74
            }
75
76
            if (
77
                !is_string($datum) ||
78
                preg_match_all(static::REGEX, $datum, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE) === false
79
            ) {
80
                throw new RuntimeException(__d('bedita', 'Error extracting placeholders'));
81
            }
82
83
            foreach ($matches as $match) {
84
                $offsetBytes = $match[0][1]; // This is the offset in bytes!!
85
                $offset = mb_strlen(substr($datum, 0, $offsetBytes)); // Turn bytes offset into character offset.
86
                $length = mb_strlen($match[0][0]);
87
                $id = (int)$match['id'][0];
88
                $params = null;
89
                if (!empty($match['params'][0])) {
90
                    $params = base64_decode($match['params'][0]);
91
                }
92
93
                if (!isset($placeholders[$id])) {
94
                    $placeholders[$id] = [
95
                        'id' => $id,
96
                        'params' => [],
97
                    ];
98
                }
99
                $placeholders[$id]['params'][$field][] = compact('offset', 'length', 'params');
100
            }
101
        }
102
103
        return array_values($placeholders);
104
    }
105
106
    /**
107
     * Add associations using placeholder relation.
108
     *
109
     * @param \Cake\Event\Event $event Fired event.
110
     * @param \Cake\Datasource\EntityInterface $entity Entity.
111
     * @return void
112
     */
113
    public function afterSave(Event $event, EntityInterface $entity): void
114
    {
115
        $association = $this->getAssociation($this->getConfigOrFail('relation'));
116
        $fields = $this->getConfig('fields', []);
117
        $anyDirty = array_reduce(
118
            $fields,
119
            function (bool $isDirty, string $field) use ($entity): bool {
120
                return $isDirty || $entity->isDirty($field);
121
            },
122
            false
123
        );
124
        if ($association === null || $anyDirty === false) {
125
            // Nothing to do.
126
            return;
127
        }
128
        if (!in_array($association->type(), [Association::ONE_TO_MANY, Association::MANY_TO_MANY])) {
129
            throw new InvalidArgumentException(sprintf('Invalid association type "%s"', get_class($association)));
130
        }
131
132
        $extract = $this->getConfig('extract', [static::class, 'extractPlaceholders']);
133
        $placeholders = $extract($entity, $fields);
134
        $relatedEntities = $this->prepareEntities($association->getTarget(), $placeholders);
135
136
        $action = new SetRelatedObjectsAction(compact('association'));
137
        $action(compact('entity', 'relatedEntities'));
138
    }
139
140
    /**
141
     * Prepare target entities.
142
     *
143
     * @param \Cake\ORM\Table $table Target table.
144
     * @param array[] $placeholders Placeholders data.
145
     * @return \Cake\Datasource\EntityInterface[]
146
     */
147
    protected function prepareEntities(Table $table, array $placeholders): array
148
    {
149
        /** @var string $pk */
150
        $pk = $table->getPrimaryKey();
151
        $ids = array_column($placeholders, 'id');
152
        if (empty($ids)) {
153
            return [];
154
        }
155
156
        $fields = [$table->aliasField($pk)];
157
        if ($table->hasAssociation('ObjectTypes')) {
158
            /** @var string $fk */
159
            $fk = $table->getAssociation('ObjectTypes')->getForeignKey();
160
            $fields = array_merge($fields, [$table->aliasField($fk)]);
161
        }
162
163
        return $table->find()
164
            ->select($fields)
165
            ->where(function (QueryExpression $exp) use ($table, $pk, $ids): QueryExpression {
166
                return $exp->in($table->aliasField($pk), $ids);
167
            })
168
            ->all()
169
            ->map(function (EntityInterface $entity) use ($pk, $placeholders): EntityInterface {
170
                $id = $entity->get($pk);
171
                foreach ($placeholders as $datum) {
172
                    if ($datum['id'] == $id) {
173
                        $entity->set('_joinData', [
174
                            'params' => $datum['params'],
175
                        ]);
176
177
                        break;
178
                    }
179
                }
180
181
                return $entity;
182
            })
183
            ->toList();
184
    }
185
}
186