Completed
Push — 4-cactus ( 5614e8...d450f9 )
by Stefano
20s queued 10s
created

AssociatedTrait::difference()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 7
nc 2
nop 1
dl 0
loc 13
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2018 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\Network\Exception\BadRequestException;
18
use Cake\ORM\Association\BelongsToMany;
19
20
/**
21
 * Trait to help with operations on and with associated entities.
22
 *
23
 * @since 4.0.0
24
 *
25
 * @property-read \Cake\ORM\Association\BelongsToMany|\Cake\ORM\Association\HasMany $Association
26
 */
27
trait AssociatedTrait
28
{
29
30
    /**
31
     * Find entity among list of entities.
32
     *
33
     * @param \Cake\Datasource\EntityInterface $needle Entity being searched.
34
     * @param \Cake\Datasource\EntityInterface[] $haystack List of entities.
35
     * @return \Cake\Datasource\EntityInterface|null
36
     */
37
    protected function findMatchingEntity(EntityInterface $needle, array $haystack)
38
    {
39
        $bindingKey = (array)$this->Association->getBindingKey();
40
        foreach ($haystack as $candidate) {
41
            $found = array_reduce(
42
                $bindingKey,
43
                function ($found, $field) use ($candidate, $needle) {
44
                    return $found && $candidate->get($field) === $needle->get($field);
45
                },
46
                true
47
            );
48
49
            if ($found) {
50
                return $candidate;
51
            }
52
        }
53
54
        return null;
55
    }
56
57
    /**
58
     * Compute set-theory intersection between multiple sets of entities.
59
     *
60
     * @param \Cake\Datasource\EntityInterface[] ...$entities Lists of entities.
61
     * @return \Cake\Datasource\EntityInterface[]
62
     */
63
    protected function intersection(array ...$entities)
64
    {
65
        $setA = array_shift($entities);
66
        foreach ($entities as $setB) {
67
            $setA = array_filter(
68
                $setA,
69
                function (EntityInterface $item) use ($setB) {
70
                    return $this->findMatchingEntity($item, $setB) !== null;
71
                }
72
            );
73
        }
74
75
        return $setA;
76
    }
77
78
    /**
79
     * Compute set-theory difference between multiple sets of entities.
80
     *
81
     * @param \Cake\Datasource\EntityInterface[] ...$entities Lists of entities.
82
     * @return \Cake\Datasource\EntityInterface[]
83
     */
84
    protected function difference(array ...$entities)
85
    {
86
        $setA = array_shift($entities);
87
        foreach ($entities as $setB) {
88
            $setA = array_filter(
89
                $setA,
90
                function (EntityInterface $item) use ($setB) {
91
                    return $this->findMatchingEntity($item, $setB) === null;
92
                }
93
            );
94
        }
95
96
        return $setA;
97
    }
98
99
    /**
100
     * Sort an array by copying order from an array that holds analogous elements.
101
     *
102
     * @param \Cake\Datasource\EntityInterface[] $array Array to sort.
103
     * @param \Cake\Datasource\EntityInterface[] $original Array to copy original order from.
104
     * @return \Cake\Datasource\EntityInterface[]
105
     */
106
    protected function sortByOriginalOrder(array $array, array $original)
107
    {
108
        $original = array_values($original);
109
        usort(
110
            $array,
111
            function (EntityInterface $a, EntityInterface $b) use ($original) {
112
                $originalA = $this->findMatchingEntity($a, $original);
113
                $originalB = $this->findMatchingEntity($b, $original);
114
115
                $idxA = array_search($originalA, $original);
116
                $idxB = array_search($originalB, $original);
117
118
                return $idxA - $idxB;
119
            }
120
        );
121
122
        return $array;
123
    }
124
125
    /**
126
     * Find existing associations.
127
     *
128
     * @param \Cake\Datasource\EntityInterface $source Source entity.
129
     * @return \Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[]|null
130
     */
131
    protected function existing(EntityInterface $source)
132
    {
133
        if (!$source->has(($this->Association->getProperty()))) {
134
            $this->Association->getSource()->loadInto($source, [$this->Association->getName()]);
135
        }
136
137
        return $source->get($this->Association->getProperty());
138
    }
139
140
    /**
141
     * Helper method to get extra fields to be set on junction table, derived from Association's conditions.
142
     *
143
     * @param \Cake\Datasource\EntityInterface $source Source entity.
144
     * @param \Cake\Datasource\EntityInterface $target Target entity.
145
     * @return array
146
     */
147
    protected function getJunctionExtraFields(EntityInterface $source, EntityInterface $target)
148
    {
149
        $conditions = $this->Association->getConditions();
150
        $prefix = sprintf('%s.', $this->Association->junction()->getAlias());
151
        $extraFields = [];
152
        foreach ($conditions as $field => $value) {
153
            if (substr($field, 0, strlen($prefix)) !== $prefix) {
154
                continue;
155
            }
156
            $field = substr($field, strlen($prefix));
157
158
            $extraFields[$field] = $value;
159
        }
160
161
        $extraFields += array_combine(
162
            (array)$this->Association->getForeignKey(),
163
            $source->extract((array)$this->Association->getSource()->getPrimaryKey())
164
        );
165
        $extraFields += array_combine(
166
            (array)$this->Association->getTargetForeignKey(),
167
            $target->extract((array)$this->Association->getTarget()->getPrimaryKey())
168
        );
169
170
        return $extraFields;
171
    }
172
173
    /**
174
     * Ensure join data is hydrated.
175
     *
176
     * @param \Cake\Datasource\EntityInterface $source Source entity.
177
     * @param \Cake\Datasource\EntityInterface $target Target entity.
178
     * @return \Cake\Datasource\EntityInterface
179
     */
180
    protected function hydrateLink(EntityInterface $source, EntityInterface $target)
181
    {
182
        if (!($this->Association instanceof BelongsToMany)) {
183
            return $target;
184
        }
185
186
        $data = $target->get('_joinData');
187
        $joinData = $this->Association->junction()->newEntity();
188
        if ($data instanceof EntityInterface) {
189
            $joinData = $data;
190
            $data = [];
191
        }
192
193
        $joinData->set($this->getJunctionExtraFields($source, $target), ['guard' => false]);
194
        $this->Association->junction()->patchEntity($joinData, $data ?: []);
195
        $errors = $joinData->getErrors();
196
        if (!empty($errors)) {
197
            throw new BadRequestException([
0 ignored issues
show
Bug introduced by
array('title' => __d('be...), 'detail' => $errors) of type array<string,mixed|null|string> is incompatible with the type null|string expected by parameter $message of Cake\Network\Exception\B...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

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

249
            throw new BadRequestException(/** @scrutinizer ignore-type */ [
Loading history...
250
                'title' => __d('bedita', 'Invalid data'),
251
                'detail' => $errors,
252
            ]);
253
        }
254
255
        return $existing;
256
    }
257
258
    /**
259
     * Compute difference for the current operation.
260
     *
261
     * @param \Cake\Datasource\EntityInterface $source Source entity.
262
     * @param \Cake\Datasource\EntityInterface[] $targetEntities Target entities.
263
     * @param bool $replace Is this a full-replacement operation?
264
     * @param \Cake\Datasource\EntityInterface[] $affected Entities affected by this operation.
265
     * @return \Cake\Datasource\EntityInterface[]
266
     */
267
    protected function diff(EntityInterface $source, array $targetEntities, $replace, &$affected = [])
268
    {
269
        $existing = (array)$this->existing($source);
270
        $kept = $this->intersection($existing, $targetEntities);
271
272
        $added = array_map(
273
            function (EntityInterface $target) use ($source) {
274
                return $this->hydrateLink($source, $target);
275
            },
276
            $this->difference($targetEntities, $existing)
277
        );
278
        $changed = array_filter(
279
            array_map(
280
                function (EntityInterface $existing) use ($source, $targetEntities) {
281
                    $relatedEntity = $this->findMatchingEntity($existing, $targetEntities);
282
283
                    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

283
                    return $this->patchLink($source, $existing, /** @scrutinizer ignore-type */ $relatedEntity);
Loading history...
284
                },
285
                $kept
286
            )
287
        );
288
        $affected = $diff = array_merge($added, $changed);
289
290
        if ($replace === true) {
291
            $unchanged = $this->difference($kept, $added, $changed);
292
            $deleted = $this->difference($existing, $targetEntities);
293
294
            $affected = array_merge($affected, $deleted);
295
            $diff = array_merge($diff, $unchanged);
296
        }
297
298
        $diff = $this->sortByOriginalOrder($diff, $targetEntities);
299
300
        return $diff;
301
    }
302
}
303