Passed
Push — fix/v4/add-related-objects-loa... ( d289b4 )
by Alberto
03:59
created

AssociatedTrait::hydrateLink()   B

Complexity

Conditions 7
Paths 9

Size

Total Lines 34
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 19
nc 9
nop 2
dl 0
loc 34
rs 8.8333
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2022 ChannelWeb Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
14
namespace BEdita\Core\Model\Action;
15
16
use Cake\Datasource\EntityInterface;
17
use Cake\Event\EventDispatcherTrait;
18
use Cake\Http\Exception\BadRequestException;
19
use Cake\ORM\Association\BelongsToMany;
20
21
/**
22
 * Trait to help with operations on and with associated entities.
23
 *
24
 * @since 4.0.0
25
 *
26
 * @property-read \Cake\ORM\Association\BelongsToMany|\Cake\ORM\Association\HasMany $Association
27
 */
28
trait AssociatedTrait
29
{
30
    use EventDispatcherTrait;
31
32
    /**
33
     * Find entity among list of entities.
34
     *
35
     * @param \Cake\Datasource\EntityInterface $needle Entity being searched.
36
     * @param \Cake\Datasource\EntityInterface[] $haystack List of entities.
37
     * @return \Cake\Datasource\EntityInterface|null
38
     */
39
    protected function findMatchingEntity(EntityInterface $needle, array $haystack)
40
    {
41
        $bindingKey = (array)$this->Association->getBindingKey();
42
        foreach ($haystack as $candidate) {
43
            $found = array_reduce(
44
                $bindingKey,
45
                function ($found, $field) use ($candidate, $needle) {
46
                    return $found && $candidate->get($field) === $needle->get($field);
47
                },
48
                true
49
            );
50
51
            if ($found) {
52
                return $candidate;
53
            }
54
        }
55
56
        return null;
57
    }
58
59
    /**
60
     * Compute set-theory intersection between multiple sets of entities.
61
     *
62
     * @param \Cake\Datasource\EntityInterface[] ...$entities Lists of entities.
63
     * @return \Cake\Datasource\EntityInterface[]
64
     */
65
    protected function intersection(array ...$entities)
66
    {
67
        $setA = array_shift($entities);
68
        foreach ($entities as $setB) {
69
            $setA = array_filter(
70
                $setA,
71
                function (EntityInterface $item) use ($setB) {
72
                    return $this->findMatchingEntity($item, $setB) !== null;
73
                }
74
            );
75
        }
76
77
        return $setA;
78
    }
79
80
    /**
81
     * Compute set-theory difference between multiple sets of entities.
82
     *
83
     * @param \Cake\Datasource\EntityInterface[] ...$entities Lists of entities.
84
     * @return \Cake\Datasource\EntityInterface[]
85
     */
86
    protected function difference(array ...$entities)
87
    {
88
        $setA = array_shift($entities);
89
        foreach ($entities as $setB) {
90
            $setA = array_filter(
91
                $setA,
92
                function (EntityInterface $item) use ($setB) {
93
                    return $this->findMatchingEntity($item, $setB) === null;
94
                }
95
            );
96
        }
97
98
        return $setA;
99
    }
100
101
    /**
102
     * Sort an array by copying order from an array that holds analogous elements.
103
     *
104
     * @param \Cake\Datasource\EntityInterface[] $array Array to sort.
105
     * @param \Cake\Datasource\EntityInterface[] $original Array to copy original order from.
106
     * @return \Cake\Datasource\EntityInterface[]
107
     */
108
    protected function sortByOriginalOrder(array $array, array $original)
109
    {
110
        $original = array_values($original);
111
        usort(
112
            $array,
113
            function (EntityInterface $a, EntityInterface $b) use ($original) {
114
                $originalA = $this->findMatchingEntity($a, $original);
115
                $originalB = $this->findMatchingEntity($b, $original);
116
117
                $idxA = array_search($originalA, $original);
118
                $idxB = array_search($originalB, $original);
119
120
                return $idxA - $idxB;
121
            }
122
        );
123
124
        return $array;
125
    }
126
127
    /**
128
     * Find existing associations.
129
     *
130
     * @param \Cake\Datasource\EntityInterface $source Source entity.
131
     * @return \Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[]|null
132
     */
133
    protected function existing(EntityInterface $source)
134
    {
135
        if (!$source->has(($this->Association->getProperty()))) {
136
            $this->Association->getSource()->loadInto($source, [$this->Association->getName()]);
137
        }
138
139
        return $source->get($this->Association->getProperty());
140
    }
141
142
    /**
143
     * Helper method to get extra fields to be set on junction table, derived from Association's conditions.
144
     *
145
     * @param \Cake\Datasource\EntityInterface $source Source entity.
146
     * @param \Cake\Datasource\EntityInterface $target Target entity.
147
     * @return array
148
     */
149
    protected function getJunctionExtraFields(EntityInterface $source, EntityInterface $target)
150
    {
151
        $conditions = $this->Association->getConditions();
152
        $prefix = sprintf('%s.', $this->Association->junction()->getAlias());
153
        $extraFields = [];
154
        foreach ($conditions as $field => $value) {
155
            if (substr($field, 0, strlen($prefix)) !== $prefix) {
156
                continue;
157
            }
158
            $field = substr($field, strlen($prefix));
159
160
            $extraFields[$field] = $value;
161
        }
162
163
        $extraFields += array_combine(
164
            (array)$this->Association->getForeignKey(),
165
            $source->extract((array)$this->Association->getSource()->getPrimaryKey())
166
        );
167
        $extraFields += array_combine(
168
            (array)$this->Association->getTargetForeignKey(),
169
            $target->extract((array)$this->Association->getTarget()->getPrimaryKey())
170
        );
171
172
        return $extraFields;
173
    }
174
175
    /**
176
     * Ensure join data is hydrated.
177
     *
178
     * @param \Cake\Datasource\EntityInterface $source Source entity.
179
     * @param \Cake\Datasource\EntityInterface $target Target entity.
180
     * @return \Cake\Datasource\EntityInterface
181
     */
182
    protected function hydrateLink(EntityInterface $source, EntityInterface $target)
183
    {
184
        if (!($this->Association instanceof BelongsToMany)) {
185
            return $target;
186
        }
187
188
        $data = $target->get('_joinData');
189
        $joinData = $this->Association->junction()->newEntity();
190
        if ($data instanceof EntityInterface) {
191
            $joinData = $data;
192
            $data = [];
193
        }
194
195
        $joinData->set($this->getJunctionExtraFields($source, $target), ['guard' => false]);
196
197
        // ensure that if source was not linked to target through joinData the join entity is marked as new
198
        // foreign key corresponds to source primary key
199
        $fk = $this->Association->getForeignKey();
200
        if (!$joinData->isNew() && !empty($joinData->extractOriginalChanged([$fk]))) {
0 ignored issues
show
Bug introduced by
The method extractOriginalChanged() does not exist on Cake\Datasource\EntityInterface. Did you maybe mean extract()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

200
        if (!$joinData->isNew() && !empty($joinData->/** @scrutinizer ignore-call */ extractOriginalChanged([$fk]))) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
201
            $joinData->setNew(true);
202
        }
203
204
        $this->Association->junction()->patchEntity($joinData, $data ?: []);
205
        $errors = $joinData->getErrors();
206
        if (!empty($errors)) {
207
            throw new BadRequestException([
0 ignored issues
show
Bug introduced by
array('title' => __d('be...), 'detail' => $errors) of type array<string,mixed|string> is incompatible with the type null|string expected by parameter $message of Cake\Http\Exception\BadR...xception::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

207
            throw new BadRequestException(/** @scrutinizer ignore-type */ [
Loading history...
208
                'title' => __d('bedita', 'Invalid data'),
209
                'detail' => $errors,
210
            ]);
211
        }
212
213
        $target->set('_joinData', $joinData);
214
215
        return $target;
216
    }
217
218
    /**
219
     * Patch an existing link entity if the link itself needs to be updated.
220
     *
221
     * @param \Cake\Datasource\EntityInterface $source Source entity.
222
     * @param \Cake\Datasource\EntityInterface $existing Existing link.
223
     * @param \Cake\Datasource\EntityInterface $new New link data.
224
     * @return \Cake\Datasource\EntityInterface|false
225
     */
226
    protected function patchLink(EntityInterface $source, EntityInterface $existing, EntityInterface $new)
227
    {
228
        if (!($this->Association instanceof BelongsToMany)) {
229
            return false;
230
        }
231
232
        $existingJoin = $existing->get('_joinData');
233
        $newJoin = $new->get('_joinData');
234
        if ($newJoin === null) {
235
            return false;
236
        }
237
        if ($newJoin instanceof EntityInterface) {
238
            $newJoin = $newJoin->toArray();
239
        }
240
        $newJoin = array_diff_key(
241
            $newJoin,
242
            array_flip([$this->Association->getForeignKey(), $this->Association->getTargetForeignKey()])
243
        );
244
245
        $data = [];
246
        foreach ($newJoin as $field => $value) {
247
            if ($existingJoin->get($field) !== $value) {
248
                $data[$field] = $value;
249
            }
250
        }
251
        if (empty($data)) {
252
            return false;
253
        }
254
255
        $existingJoin->set($this->getJunctionExtraFields($source, $new), ['guard' => false]);
256
        $existingJoin = $this->Association->junction()->patchEntity($existingJoin, $data);
257
        $errors = $existingJoin->getErrors();
258
        if (!empty($errors)) {
259
            throw new BadRequestException([
0 ignored issues
show
Bug introduced by
array('title' => __d('be...), 'detail' => $errors) of type array<string,mixed|string> is incompatible with the type null|string expected by parameter $message of Cake\Http\Exception\BadR...xception::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

259
            throw new BadRequestException(/** @scrutinizer ignore-type */ [
Loading history...
260
                'title' => __d('bedita', 'Invalid data'),
261
                'detail' => $errors,
262
            ]);
263
        }
264
265
        return $existing;
266
    }
267
268
    /**
269
     * Compute difference for the current operation.
270
     *
271
     * @param \Cake\Datasource\EntityInterface $source Source entity.
272
     * @param \Cake\Datasource\EntityInterface[] $targetEntities Target entities.
273
     * @param bool $replace Is this a full-replacement operation?
274
     * @param \Cake\Datasource\EntityInterface[] $affected Entities affected by this operation.
275
     * @return \Cake\Datasource\EntityInterface[]
276
     */
277
    protected function diff(EntityInterface $source, array $targetEntities, $replace, &$affected = [])
278
    {
279
        $existing = (array)$this->existing($source);
280
        $kept = $this->intersection($existing, $targetEntities);
281
282
        $added = array_map(
283
            function (EntityInterface $target) use ($source) {
284
                return $this->hydrateLink($source, $target);
285
            },
286
            $this->difference($targetEntities, $existing)
287
        );
288
        $changed = array_filter(
289
            array_map(
290
                function (EntityInterface $existing) use ($source, $targetEntities) {
291
                    $relatedEntity = $this->findMatchingEntity($existing, $targetEntities);
292
293
                    return $this->patchLink($source, $existing, $relatedEntity);
0 ignored issues
show
Bug introduced by
It seems like $relatedEntity can also be of type null; however, parameter $new of BEdita\Core\Model\Action...iatedTrait::patchLink() does only seem to accept Cake\Datasource\EntityInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

293
                    return $this->patchLink($source, $existing, /** @scrutinizer ignore-type */ $relatedEntity);
Loading history...
294
                },
295
                $kept
296
            )
297
        );
298
        $affected = $diff = array_merge($added, $changed);
299
300
        if ($replace === true) {
301
            $unchanged = $this->difference($kept, $added, $changed);
302
            $deleted = $this->difference($existing, $targetEntities);
303
304
            $affected = array_merge($affected, $deleted);
305
            $diff = array_merge($diff, $unchanged);
306
        }
307
308
        $diff = $this->sortByOriginalOrder($diff, $targetEntities);
309
310
        return $diff;
311
    }
312
}
313