Passed
Push — 4-cactus ( b2c7a3...687d36 )
by
unknown
09:17
created

ListAssociatedAction::sort()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 2
dl 0
loc 7
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2016 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\Collection\CollectionInterface;
17
use Cake\Database\Expression\QueryExpression;
18
use Cake\Datasource\EntityInterface;
19
use Cake\Datasource\Exception\InvalidPrimaryKeyException;
20
use Cake\Datasource\Exception\RecordNotFoundException;
21
use Cake\ORM\Association;
22
use Cake\ORM\Association\BelongsTo;
23
use Cake\ORM\Association\BelongsToMany;
24
use Cake\ORM\Association\HasMany;
25
use Cake\ORM\Association\HasOne;
26
use Cake\ORM\Query;
27
use Cake\ORM\Table;
28
use Cake\ORM\TableRegistry;
29
use Cake\Utility\Hash;
30
use Cake\Utility\Inflector;
31
32
/**
33
 * Command to list entities associated to another entity.
34
 *
35
 * @since 4.0.0
36
 */
37
class ListAssociatedAction extends BaseAction
38
{
39
    /**
40
     * Name of inverse association.
41
     *
42
     * @var string
43
     */
44
    public const INVERSE_ASSOCIATION_NAME = '_InverseAssociation';
45
46
    /**
47
     * Association.
48
     *
49
     * @var \Cake\ORM\Association
50
     */
51
    protected $Association;
52
53
    /**
54
     * Action used for listing entities.
55
     *
56
     * @var \BEdita\Core\Model\Action\BaseAction
57
     */
58
    protected $ListAction;
59
60
    /**
61
     * @inheritDoc
62
     */
63
    protected function initialize(array $config)
64
    {
65
        $this->Association = $this->getConfig('association');
66
67
        $table = $this->Association->getTarget();
0 ignored issues
show
Bug introduced by
The method getTarget() does not exist on null. ( Ignorable by Annotation )

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

67
        /** @scrutinizer ignore-call */ 
68
        $table = $this->Association->getTarget();

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...
68
        $this->ListAction = new ListEntitiesAction(compact('table'));
69
    }
70
71
    /**
72
     * Build conditions for primary key.
73
     *
74
     * @param \Cake\ORM\Table $table Table object.
75
     * @param mixed $primaryKey Primary key.
76
     * @return array
77
     * @throws \Cake\Datasource\Exception\InvalidPrimaryKeyException Throws an exception if primary key is invalid.
78
     */
79
    protected function primaryKeyConditions(Table $table, $primaryKey)
80
    {
81
        $primaryKeyFields = array_map([$table, 'aliasField'], (array)$table->getPrimaryKey());
82
83
        $primaryKey = (array)$primaryKey;
84
        if (count($primaryKeyFields) !== count($primaryKey)) {
85
            $primaryKey = $primaryKey ?: [null];
86
            $primaryKey = array_map(function ($key) {
87
                return var_export($key, true);
88
            }, $primaryKey);
89
90
            throw new InvalidPrimaryKeyException(__(
91
                'Record not found in table "{0}" with primary key [{1}]',
92
                $table->getTable(),
93
                implode(', ', $primaryKey)
94
            ));
95
        }
96
97
        return array_combine($primaryKeyFields, $primaryKey);
98
    }
99
100
    /**
101
     * Check that the entity for which associated entities should be listed actually exists.
102
     *
103
     * @param array $data Data.
104
     * @return void
105
     * @throws \InvalidArgumentException Throws an exception if required option `primaryKey` is missing.
106
     * @throws \Cake\Datasource\Exception\RecordNotFoundException Throws an exception if the record could not be found.
107
     */
108
    protected function checkEntityExists(array $data)
109
    {
110
        if (empty($data['primaryKey'])) {
111
            throw new \InvalidArgumentException(__d('bedita', 'Missing required option "{0}"', 'primaryKey'));
112
        }
113
114
        $source = $this->Association->getSource();
115
        $conditions = $this->primaryKeyConditions($source, $data['primaryKey']);
116
117
        $existing = $source->find()
118
            ->where($conditions)
119
            ->count();
120
        if (!$existing) {
121
            throw new RecordNotFoundException(__('Record not found in table "{0}"', $source->getTable()));
122
        }
123
    }
124
125
    /**
126
     * Build inverse association for joining.
127
     *
128
     * @return \Cake\ORM\Association
129
     * @throws \LogicException Throws an exception if an Association of an unknown type is passed.
130
     */
131
    protected function buildInverseAssociation()
132
    {
133
        $sourceTable = $this->Association->getTarget();
134
        $targetTable = TableRegistry::getTableLocator()->get(static::INVERSE_ASSOCIATION_NAME, [
135
            'className' => $this->Association->getSource()->getRegistryAlias(),
136
        ]);
137
        $targetTable->setTable($this->Association->getSource()->getTable());
138
        $propertyName = Inflector::underscore(static::INVERSE_ASSOCIATION_NAME);
139
140
        $options = compact('propertyName', 'sourceTable', 'targetTable');
141
        if ($this->Association instanceof HasOne || $this->Association instanceof HasMany) {
142
            $options += [
143
                'foreignKey' => $this->Association->getForeignKey(),
144
                'bindingKey' => $this->Association->getBindingKey(),
145
            ];
146
147
            $association = new BelongsTo(static::INVERSE_ASSOCIATION_NAME, $options);
148
        } elseif ($this->Association instanceof BelongsTo) {
149
            $options += [
150
                'foreignKey' => $this->Association->getForeignKey(),
151
                'bindingKey' => $this->Association->getBindingKey(),
152
            ];
153
154
            $association = new HasMany(static::INVERSE_ASSOCIATION_NAME, $options);
155
        } elseif ($this->Association instanceof BelongsToMany) {
156
            $options += [
157
                'through' => $this->Association->junction()->getRegistryAlias(),
158
                'foreignKey' => $this->Association->getTargetForeignKey(),
159
                'targetForeignKey' => $this->Association->getForeignKey(),
160
                'conditions' => $this->Association->getConditions(),
161
            ];
162
163
            $association = new BelongsToMany(static::INVERSE_ASSOCIATION_NAME, $options);
164
        } else {
165
            throw new \LogicException(sprintf('Unknown association type "%s"', get_class($this->Association)));
166
        }
167
168
        return $sourceTable->associations()->add($association->getName(), $association);
169
    }
170
171
    /**
172
     * Build the query object.
173
     *
174
     * @param mixed $primaryKey Primary key
175
     * @param array $data Data.
176
     * @param \Cake\ORM\Association $inverseAssociation Inverse association.
177
     * @return \Cake\ORM\Query
178
     * @throws \LogicException Throws an exception if the result of the inner invoked action is not a Query object.
179
     */
180
    protected function buildQuery($primaryKey, array $data, Association $inverseAssociation)
181
    {
182
        $joinData = !empty($data['joinData']);
183
        $list = !empty($data['list']);
184
        $only = (array)Hash::get($data, 'only', []);
185
        unset($data['joinData'], $data['list'], $data['only']);
186
187
        $table = $this->Association->getTarget();
188
        $query = $this->ListAction->execute($data);
189
        if (!($query instanceof Query)) {
190
            $type = is_object($query) ? get_class($query) : gettype($query);
191
192
            throw new \LogicException(sprintf('Instance of "%s" expected, got "%s"', Query::class, $type));
193
        }
194
195
        if ($list) {
196
            $primaryKeyFields = array_map([$table, 'aliasField'], (array)$table->getPrimaryKey());
197
            $query = $query->select($primaryKeyFields);
198
        }
199
        if (!empty($only)) {
200
            $query = $query->where(function (QueryExpression $exp) use ($table, $only) {
201
                return $exp->in($table->aliasField($table->getPrimaryKey()), $only);
0 ignored issues
show
Bug introduced by
It seems like $table->getPrimaryKey() can also be of type string[]; however, parameter $field of Cake\ORM\Table::aliasField() does only seem to accept string, 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

201
                return $exp->in($table->aliasField(/** @scrutinizer ignore-type */ $table->getPrimaryKey()), $only);
Loading history...
202
            });
203
        }
204
        if ($this->Association instanceof BelongsToMany && $joinData) {
205
            $query = $query->select($this->Association->junction());
206
        }
207
        if ($this->Association instanceof BelongsToMany || $this->Association instanceof HasMany) {
208
            $sort = $this->sort($this->Association, $primaryKey);
209
            $query = $query->order($sort);
210
        }
211
212
        $primaryKeyConditions = $this->primaryKeyConditions($inverseAssociation->getTarget(), $primaryKey);
213
214
        return $query
215
            ->enableAutoFields(!$list)
216
            ->find($this->Association->getFinder())
217
            ->innerJoinWith($inverseAssociation->getName(), function (Query $query) use ($primaryKeyConditions) {
218
                return $query->where($primaryKeyConditions);
219
            })
220
            ->formatResults(function (CollectionInterface $results) use ($inverseAssociation) {
221
                return $results->map(function (EntityInterface $entity) use ($inverseAssociation) {
222
                    if (!($this->Association instanceof BelongsToMany)) {
223
                        return $entity->setHidden([$inverseAssociation->getProperty()], true);
224
                    }
225
226
                    $joinData = Hash::get($entity, '_matchingData.' . $this->Association->junction()->getAlias());
227
                    unset($entity['_matchingData']);
228
                    $entity->setHidden([$inverseAssociation->getProperty()], true);
229
230
                    if (!empty($joinData)) {
231
                        $this->prepareJoinEntity($joinData);
232
                        $entity->set('_joinData', $joinData);
233
                    }
234
235
                    return $entity;
236
                });
237
            });
238
    }
239
240
    /**
241
     * Prepare `joinData` entity.
242
     *
243
     * @param \Cake\Datasource\EntityInterface $joinData Join data entity.
244
     * @return void
245
     * @codeCoverageIgnore
246
     */
247
    protected function prepareJoinEntity(EntityInterface $joinData): void
0 ignored issues
show
Unused Code introduced by
The parameter $joinData is not used and could be removed. ( Ignorable by Annotation )

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

247
    protected function prepareJoinEntity(/** @scrutinizer ignore-unused */ EntityInterface $joinData): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
248
    {
249
    }
250
251
    /**
252
     * Get association sort by association and primary key.
253
     * When association name is "Children", use Folders.getSort($primaryKey).
254
     *
255
     * @param mixed $primaryKey Primary key
256
     * @param \Cake\ORM\Association $association Association
257
     * @return array
258
     */
259
    protected function sort(Association $association, $primaryKey): array
260
    {
261
        if ($association->getName() === 'Children') {
262
            return (array)TableRegistry::getTableLocator()->get('Folders')->getSort($primaryKey);
263
        }
264
265
        return (array)$association->getSort();
266
    }
267
268
    /**
269
     * {@inheritDoc}
270
     *
271
     * @return \Cake\ORM\Query|\Cake\Datasource\EntityInterface|null
272
     */
273
    public function execute(array $data = [])
274
    {
275
        $this->checkEntityExists($data);
276
        $primaryKey = $data['primaryKey'];
277
        unset($data['primaryKey']);
278
279
        $inverseAssociation = $this->buildInverseAssociation();
280
        $query = $this->buildQuery($primaryKey, $data, $inverseAssociation);
281
282
        // remove temporary alias of inverse association from TableRegistry
283
        TableRegistry::getTableLocator()->remove(static::INVERSE_ASSOCIATION_NAME);
284
285
        if ($this->Association instanceof HasOne || $this->Association instanceof BelongsTo) {
286
            return $query->first();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $query->first() also could return the type array which is incompatible with the documented return type Cake\Datasource\EntityIn...ace|Cake\ORM\Query|null.
Loading history...
287
        }
288
289
        return $query;
290
    }
291
}
292