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 | /** |
||
41 | * Name of inverse association. |
||
42 | * |
||
43 | * @var string |
||
44 | */ |
||
45 | const INVERSE_ASSOCIATION_NAME = '_InverseAssociation'; |
||
46 | |||
47 | /** |
||
48 | * Association. |
||
49 | * |
||
50 | * @var \Cake\ORM\Association |
||
51 | */ |
||
52 | protected $Association; |
||
53 | |||
54 | /** |
||
55 | * Action used for listing entities. |
||
56 | * |
||
57 | * @var \BEdita\Core\Model\Action\BaseAction |
||
58 | */ |
||
59 | protected $ListAction; |
||
60 | |||
61 | /** |
||
62 | * {@inheritDoc} |
||
63 | */ |
||
64 | protected function initialize(array $config) |
||
65 | { |
||
66 | $this->Association = $this->getConfig('association'); |
||
67 | |||
68 | $table = $this->Association->getTarget(); |
||
69 | $this->ListAction = new ListEntitiesAction(compact('table')); |
||
70 | } |
||
71 | |||
72 | /** |
||
73 | * Build conditions for primary key. |
||
74 | * |
||
75 | * @param \Cake\ORM\Table $table Table object. |
||
76 | * @param mixed $primaryKey Primary key. |
||
77 | * @return array |
||
78 | * @throws \Cake\Datasource\Exception\InvalidPrimaryKeyException Throws an exception if primary key is invalid. |
||
79 | */ |
||
80 | protected function primaryKeyConditions(Table $table, $primaryKey) |
||
81 | { |
||
82 | $primaryKeyFields = array_map([$table, 'aliasField'], (array)$table->getPrimaryKey()); |
||
83 | |||
84 | $primaryKey = (array)$primaryKey; |
||
85 | if (count($primaryKeyFields) !== count($primaryKey)) { |
||
86 | $primaryKey = $primaryKey ?: [null]; |
||
87 | $primaryKey = array_map(function ($key) { |
||
88 | return var_export($key, true); |
||
89 | }, $primaryKey); |
||
90 | |||
91 | throw new InvalidPrimaryKeyException(__( |
||
92 | 'Record not found in table "{0}" with primary key [{1}]', |
||
93 | $table->getTable(), |
||
94 | implode($primaryKey, ', ') |
||
0 ignored issues
–
show
|
|||
95 | )); |
||
96 | } |
||
97 | |||
98 | return array_combine($primaryKeyFields, $primaryKey); |
||
99 | } |
||
100 | |||
101 | /** |
||
102 | * Check that the entity for which associated entities should be listed actually exists. |
||
103 | * |
||
104 | * @param array $data Data. |
||
105 | * @return void |
||
106 | * @throws \InvalidArgumentException Throws an exception if required option `primaryKey` is missing. |
||
107 | * @throws \Cake\Datasource\Exception\RecordNotFoundException Throws an exception if the record could not be found. |
||
108 | */ |
||
109 | protected function checkEntityExists(array $data) |
||
110 | { |
||
111 | if (empty($data['primaryKey'])) { |
||
112 | throw new \InvalidArgumentException(__d('bedita', 'Missing required option "{0}"', 'primaryKey')); |
||
113 | } |
||
114 | |||
115 | $source = $this->Association->getSource(); |
||
116 | $conditions = $this->primaryKeyConditions($source, $data['primaryKey']); |
||
117 | |||
118 | $existing = $source->find() |
||
119 | ->where($conditions) |
||
120 | ->count(); |
||
121 | if (!$existing) { |
||
122 | throw new RecordNotFoundException(__('Record not found in table "{0}"', $source->getTable())); |
||
123 | } |
||
124 | } |
||
125 | |||
126 | /** |
||
127 | * Build inverse association for joining. |
||
128 | * |
||
129 | * @return \Cake\ORM\Association |
||
130 | * @throws \LogicException Throws an exception if an Association of an unknown type is passed. |
||
131 | */ |
||
132 | protected function buildInverseAssociation() |
||
133 | { |
||
134 | $sourceTable = $this->Association->getTarget(); |
||
135 | $targetTable = TableRegistry::get(static::INVERSE_ASSOCIATION_NAME, [ |
||
136 | 'className' => $this->Association->getSource()->getRegistryAlias(), |
||
137 | ]); |
||
138 | $targetTable->setTable($this->Association->getSource()->getTable()); |
||
139 | $propertyName = Inflector::underscore(static::INVERSE_ASSOCIATION_NAME); |
||
140 | |||
141 | $options = compact('propertyName', 'sourceTable', 'targetTable'); |
||
142 | if ($this->Association instanceof HasOne || $this->Association instanceof HasMany) { |
||
143 | $options += [ |
||
144 | 'foreignKey' => $this->Association->getForeignKey(), |
||
145 | 'bindingKey' => $this->Association->getBindingKey(), |
||
146 | ]; |
||
147 | |||
148 | $association = new BelongsTo(static::INVERSE_ASSOCIATION_NAME, $options); |
||
149 | } elseif ($this->Association instanceof BelongsTo) { |
||
150 | $options += [ |
||
151 | 'foreignKey' => $this->Association->getForeignKey(), |
||
152 | 'bindingKey' => $this->Association->getBindingKey(), |
||
153 | ]; |
||
154 | |||
155 | $association = new HasMany(static::INVERSE_ASSOCIATION_NAME, $options); |
||
156 | } elseif ($this->Association instanceof BelongsToMany) { |
||
157 | $options += [ |
||
158 | 'through' => $this->Association->junction()->getRegistryAlias(), |
||
159 | 'foreignKey' => $this->Association->getTargetForeignKey(), |
||
160 | 'targetForeignKey' => $this->Association->getForeignKey(), |
||
161 | 'conditions' => $this->Association->getConditions(), |
||
162 | ]; |
||
163 | |||
164 | $association = new BelongsToMany(static::INVERSE_ASSOCIATION_NAME, $options); |
||
165 | } else { |
||
166 | throw new \LogicException(sprintf('Unknown association type "%s"', get_class($this->Association))); |
||
167 | } |
||
168 | |||
169 | return $sourceTable->associations()->add($association->getName(), $association); |
||
170 | } |
||
171 | |||
172 | /** |
||
173 | * Build the query object. |
||
174 | * |
||
175 | * @param mixed $primaryKey Primary key |
||
176 | * @param array $data Data. |
||
177 | * @param \Cake\ORM\Association $inverseAssociation Inverse association. |
||
178 | * @return \Cake\ORM\Query |
||
179 | * @throws \LogicException Throws an exception if the result of the inner invoked action is not a Query object. |
||
180 | */ |
||
181 | protected function buildQuery($primaryKey, array $data, Association $inverseAssociation) |
||
182 | { |
||
183 | $joinData = !empty($data['joinData']); |
||
184 | $list = !empty($data['list']); |
||
185 | $only = (array)Hash::get($data, 'only', []); |
||
186 | unset($data['joinData'], $data['list'], $data['only']); |
||
187 | |||
188 | $table = $this->Association->getTarget(); |
||
189 | $query = $this->ListAction->execute($data); |
||
190 | if (!($query instanceof Query)) { |
||
191 | $type = is_object($query) ? get_class($query) : gettype($query); |
||
192 | |||
193 | throw new \LogicException(sprintf('Instance of "%s" expected, got "%s"', Query::class, $type)); |
||
194 | } |
||
195 | |||
196 | if ($list) { |
||
197 | $primaryKeyFields = array_map([$table, 'aliasField'], (array)$table->getPrimaryKey()); |
||
198 | $query = $query->select($primaryKeyFields); |
||
199 | } |
||
200 | if (!empty($only)) { |
||
201 | $query = $query->where(function (QueryExpression $exp) use ($table, $only) { |
||
202 | return $exp->in($table->aliasField($table->getPrimaryKey()), $only); |
||
203 | }); |
||
204 | } |
||
205 | if ($this->Association instanceof BelongsToMany && $joinData) { |
||
206 | $query = $query->select($this->Association->junction()); |
||
207 | } |
||
208 | if ($this->Association instanceof BelongsToMany || $this->Association instanceof HasMany) { |
||
209 | $query = $query->order($this->Association->getSort()); |
||
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 | $entity->unsetProperty('_matchingData'); |
||
228 | $entity->setHidden([$inverseAssociation->getProperty()], true); |
||
229 | |||
230 | if (!empty($joinData)) { |
||
231 | $entity->set('_joinData', $joinData); |
||
232 | } |
||
233 | |||
234 | return $entity; |
||
235 | }); |
||
236 | }); |
||
237 | } |
||
238 | |||
239 | /** |
||
240 | * {@inheritDoc} |
||
241 | * |
||
242 | * @return \Cake\ORM\Query|\Cake\Datasource\EntityInterface|null |
||
243 | */ |
||
244 | public function execute(array $data = []) |
||
245 | { |
||
246 | $this->checkEntityExists($data); |
||
247 | $primaryKey = $data['primaryKey']; |
||
248 | unset($data['primaryKey']); |
||
249 | |||
250 | $inverseAssociation = $this->buildInverseAssociation(); |
||
251 | $query = $this->buildQuery($primaryKey, $data, $inverseAssociation); |
||
252 | |||
253 | // remove temporary alias of inverse association from TableRegistry |
||
254 | TableRegistry::remove(static::INVERSE_ASSOCIATION_NAME); |
||
255 | |||
256 | if ($this->Association instanceof HasOne || $this->Association instanceof BelongsTo) { |
||
257 | return $query->first(); |
||
258 | } |
||
259 | |||
260 | return $query; |
||
261 | } |
||
262 | } |
||
263 |
This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.
If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.