Completed
Push — master ( 013c48...b39f28 )
by Kurita
02:41
created

EagerLoader::attachAssociations()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 17
rs 9.4285
cc 3
eloc 11
nc 3
nop 3
1
<?php
2
/**
3
 * EagerLoader class
4
 *
5
 * @internal
6
 */
7
class EagerLoader {
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
8
9
	private static $handlers = array(); // @codingStandardsIgnoreLine
10
11
	private $id; // @codingStandardsIgnoreLine
12
13
	private $metas = array(); // @codingStandardsIgnoreLine
14
15
	private $containOptions = array(  // @codingStandardsIgnoreLine
16
		'conditions' => 1,
17
		'fields' => 1,
18
		'order' => 1,
19
		'limit' => 1,
20
		'offset' => 1,
21
	);
22
23
/**
24
 * Constructor
25
 */
26
	public function __construct() {
27
		ClassRegistry::init('EagerLoader.EagerLoaderModel');
28
		$this->id = max(self::ids()) + 1;
29
	}
30
31
/**
32
 * Handles beforeFind event
33
 *
34
 * @param Model $model Model
35
 * @param array $query Query
36
 * @return array Modified query
37
 */
38
	public static function handleBeforeFind(Model $model, $query) {
39
		if (is_array($query)) {
40
			if (isset($query['contain'])) {
41
				if ($query['contain'] === false) {
42
					$query['recursive'] = -1;
43
				} else {
44
					$EagerLoader = new EagerLoader();
45
					$query = $EagerLoader->transformQuery($model, $query);
46
47
					self::$handlers[$EagerLoader->id] = $EagerLoader;
48
					if (count(self::$handlers) > 1000) {
49
						$id = min(self::ids());
50
						unset(self::$handlers[$id]);
51
					}
52
				}
53
			}
54
		}
55
		return $query;
56
	}
57
58
/**
59
 * Handles afterFind event
60
 *
61
 * @param Model $model Model
62
 * @param array $results Results
63
 * @return array Modified results
64
 * @throws UnexpectedValueException
65
 */
66
	public static function handleAfterFind(Model $model, $results) {
67
		if (is_array($results)) {
68
			$id = Hash::get($results, '0.EagerLoaderModel.id');
69
			if ($id) {
70
				if (empty(self::$handlers[$id])) {
71
					throw new UnexpectedValueException(sprintf('EagerLoader "%s" is not found', $id));
72
				}
73
74
				$EagerLoader = self::$handlers[$id];
75
				unset(self::$handlers[$id]);
76
77
				$results = $EagerLoader->transformResults($model, $results);
78
			}
79
		}
80
		return $results;
81
	}
82
83
/**
84
 * Returns object ids
85
 *
86
 * @return array
87
 */
88
	private static function ids() { // @codingStandardsIgnoreLine
89
		$ids = array_keys(self::$handlers);
90
		if (!$ids) {
91
			return array(0);
92
		}
93
		return $ids;
94
	}
95
96
/**
97
 * Modifies the passed query to fetch the top level attachable associations.
98
 *
99
 * @param Model $model Model
100
 * @param array $query Query
101
 * @return array Modified query
102
 */
103
	private function transformQuery(Model $model, array $query) { // @codingStandardsIgnoreLine
104
		ClassRegistry::init('EagerLoader.EagerLoaderModel');
105
106
		$contain = $this->reformatContain($query['contain']);
107
		foreach ($contain['contain'] as $key => $val) {
108
			$this->parseContain($model, $key, $val);
109
		}
110
111
		$query = $this->attachAssociations($model, $model->alias, $query);
112
113
		$db = $model->getDataSource();
114
		$value = $db->value($this->id);
115
		$name = $db->name('EagerLoaderModel' . '__' . 'id');
116
		$query['fields'][] = "($value) AS $name";
117
		$query['callbacks'] = true;
118
119
		return $query;
120
	}
121
122
/**
123
 * Modifies the results
124
 *
125
 * @param Model $model Model
126
 * @param array $results Results
127
 * @return array Modified results
128
 */
129
	private  function transformResults(Model $model, array $results) { // @codingStandardsIgnoreLine
0 ignored issues
show
Unused Code introduced by
This method is not used, and could be removed.
Loading history...
130
		foreach ($results as &$result) {
131
			unset($result['EagerLoaderModel']);
132
		}
133
		return $this->loadExternal($model, $model->alias, $results);
134
	}
135
136
/**
137
 * Modifies the query to fetch attachable associations.
138
 *
139
 * @param Model $model Model
140
 * @param string $path The target path of the model, such as 'User.Article'
141
 * @param array $query Query
142
 * @return array Modified query
143
 */
144
	private function attachAssociations(Model $model, $path, array $query) { // @codingStandardsIgnoreLine
145
		$query = $this->normalizeQuery($model, $query);
146
147
		foreach ($this->metas($path) as $meta) {
148
			extract($meta);
149
			if ($external) {
150
				$query = $this->addField($query, "$parentAlias.$parentKey");
151
			} else {
152
				$query = $this->buildJoinQuery($target, $query, 'LEFT', array("$parentAlias.$parentKey" => "$alias.$targetKey"), $options);
153
			}
154
		}
155
156
		$query['recursive'] = -1;
157
		$query['contain'] = false;
158
159
		return $query;
160
	}
161
162
/**
163
 * Fetches meta data
164
 *
165
 * @param string $path Path of the association
166
 * @return array
167
 */
168
	private function metas($path) { // @codingStandardsIgnoreLine
169
		if (isset($this->metas[$path])) {
170
			return $this->metas[$path];
171
		}
172
		return array();
173
	}
174
175
/**
176
 * Fetches external associations
177
 *
178
 * @param Model $model Model
179
 * @param string $path The target path of the external primary model, such as 'User.Article'
180
 * @param array $results The results of the parent model
181
 * @return array
182
 */
183
	protected function loadExternal(Model $model, $path, array $results) { // @codingStandardsIgnoreLine
184
		if ($results) {
185
			foreach ($this->metas($path) as $meta) {
186
				extract($meta);
187
				if ($external) {
188
					$results = $this->mergeExternalExternal($model, $results, $meta);
189
				} else {
190
					$results = $this->mergeInternalExternal($model, $results, $meta);
191
				}
192
			}
193
		}
194
		return $results;
195
	}
196
197
/**
198
 * Merges results of external associations of an external association
199
 *
200
 * @param Model $model Model
201
 * @param array $results Results
202
 * @param array $meta Meta data to be used for eager loading
203
 * @return array
204
 */
205
	private function mergeExternalExternal(Model $model, array $results, array $meta) { // @codingStandardsIgnoreLine
0 ignored issues
show
Unused Code introduced by
The parameter $model is not used and could be removed.

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

Loading history...
206
		extract($meta);
207
208
		$db = $target->getDataSource();
209
210
		$assocAlias = $alias;
211
		$assocKey = $targetKey;
212
213
		$options = $this->attachAssociations($target, $aliasPath, $options);
214
		if ($has && $belong) {
215
			$assocAlias = $habtmAlias;
216
			$assocKey = $habtmParentKey;
217
218
			$options = $this->buildJoinQuery($habtm, $options, 'INNER', array(
219
				"$alias.$targetKey" => "$habtmAlias.$habtmTargetKey",
220
			), $options);
221
		}
222
223
		$options = $this->addField($options, "$assocAlias.$assocKey");
224
225
		$ids = Hash::extract($results, "{n}.$parentAlias.$parentKey");
226
		$ids = array_unique($ids);
227
228
		if (!empty($finderQuery)) {
229
			$assocResults = array();
230
			foreach ($ids as $id) {
231
				$eachQuery = str_replace('{$__cakeID__$}', $db->value($id), $finderQuery);
232
				$eachAssocResults = $db->fetchAll($eachQuery, $target->cacheQueries);
233
				$eachAssocResults = Hash::insert($eachAssocResults, "{n}.EagerLoaderModel.assoc_id", $id);
234
				$assocResults = array_merge($assocResults, $eachAssocResults);
235
			}
236
		} elseif ($this->hasLimitOffset($options)) {
237
			$assocResults = array();
238
			foreach ($ids as $id) {
239
				$eachOptions = $options;
240
				$eachOptions['conditions'][] = array("$assocAlias.$assocKey" => $id);
241
				$eachAssocResults = $db->read($target, $eachOptions);
242
				$eachAssocResults = Hash::insert($eachAssocResults, "{n}.EagerLoaderModel.assoc_id", $id);
243
				$assocResults = array_merge($assocResults, $eachAssocResults);
244
			}
245
		} else {
246
			$options['fields'][] = '(' . $db->name($assocAlias . '.' . $assocKey) . ') AS ' . $db->name('EagerLoaderModel' . '__' . 'assoc_id');
247
			$options['conditions'][] = array("$assocAlias.$assocKey" => $ids);
248
			$assocResults = $db->read($target, $options);
249
		}
250
251
		$assocResults = $this->filterResults($parent, $alias, $assocResults);
252
		$assocResults = $this->loadExternal($target, $aliasPath, $assocResults);
253
254
		if ($has && $belong) {
255
			foreach ($assocResults as &$assocResult) {
256
				$assocResult[$alias][$habtmAlias] = $assocResult[$habtmAlias];
257
				unset($assocResult[$habtmAlias]);
258
			}
259
			unset($assocResult);
260
		}
261
262
		foreach ($results as &$result) {
263
			$assoc = array();
264
			foreach ($assocResults as $assocResult) {
265
				if ((string)$result[$parentAlias][$parentKey] === (string)$assocResult['EagerLoaderModel']['assoc_id']) {
266
					$assoc[] = $assocResult[$alias];
267
				}
268
			}
269
			if (!$many) {
270
				$assoc = $assoc ? current($assoc) : array();
271
			}
272
			$result = $this->mergeAssocResult($result, $assoc, $propertyPath);
273
		}
274
275
		return $results;
276
	}
277
278
/**
279
 * Merges results of external associations of an internal association
280
 *
281
 * @param Model $model Model
282
 * @param array $results Results
283
 * @param array $meta Meta data to be used for eager loading
284
 * @return array
285
 */
286
	private function mergeInternalExternal(Model $model, array $results, array $meta) { // @codingStandardsIgnoreLine
287
		extract($meta);
288
289
		$assocResults = array();
290
		foreach ($results as $n => &$result) {
291
			if ($result[$alias][$targetKey] === null) {
292
				// Remove NULL association created by LEFT JOIN
293
				if (empty($eager)) {
294
					$assocResults[$n] = array( $alias => array() );
295
				}
296
			} else {
297
				$assocResults[$n] = array( $alias => $result[$alias] );
298
			}
299
			unset($result[$alias]);
300
		}
301
		unset($result);
302
303
		if (!empty($eager) && !isset($model->$alias)) {
304
			$assocResults = $this->filterResults($parent, $alias, $assocResults);
305
		}
306
		$assocResults = $this->loadExternal($target, $aliasPath, $assocResults);
307
308
		foreach ($results as $n => &$result) {
309
			if (isset($assocResults[$n][$alias])) {
310
				$assoc = $assocResults[$n][$alias];
311
				$result = $this->mergeAssocResult($result, $assoc, $propertyPath);
312
			}
313
		}
314
		unset($result);
315
316
		return $results;
317
	}
318
319
/**
320
 * Merges associated result
321
 *
322
 * @param array $result Results
323
 * @param array $assoc Associated results
324
 * @param string $propertyPath Path of the results
325
 * @return array
326
 */
327
	private function mergeAssocResult(array $result, array $assoc, $propertyPath) { // @codingStandardsIgnoreLine
328
		return Hash::insert($result, $propertyPath, $assoc + (array)Hash::get($result, $propertyPath));
329
	}
330
331
/**
332
 * Reformat `contain` array
333
 *
334
 * @param array|string $contain The value of `contain` option of the query
335
 * @return array
336
 */
337
	private function reformatContain($contain) { // @codingStandardsIgnoreLine
338
		$result = array(
339
			'options' => array(),
340
			'contain' => array(),
341
		);
342
343
		$contain = (array)$contain;
344
		foreach ($contain as $key => $val) {
345
			if (is_int($key)) {
346
				$key = $val;
347
				$val = array();
348
			}
349
350
			if (!isset($this->containOptions[$key])) {
351
				if (strpos($key, '.') !== false) {
352
					$expanded = Hash::expand(array($key => $val));
353
					list($key, $val) = each($expanded);
354
				}
355
				$ref =& $result['contain'][$key];
356
				$ref = Hash::merge((array)$ref, $this->reformatContain($val));
357
			} else {
358
				$result['options'][$key] = $val;
359
			}
360
		}
361
362
		return $result;
363
	}
364
365
/**
366
 * Normalizes the query
367
 *
368
 * @param Model $model Model
369
 * @param array $query Query
370
 * @return array Normalized query
371
 */
372
	private function normalizeQuery(Model $model, array $query) { // @codingStandardsIgnoreLine
373
		$db = $model->getDataSource();
374
375
		$query += array(
376
			'fields' => array(),
377
			'conditions' => array(),
378
			'order' => array()
379
		);
380
381
		if (!$query['fields']) {
382
			$query['fields'] = $db->fields($model, null, array(), false);
383
		}
384
385
		$query['fields'] = (array)$query['fields'];
386
		foreach ($query['fields'] as &$field) {
387
			if ($model->isVirtualField($field)) {
388
				$fields = $db->fields($model, null, array($field), false);
389
				$field = $fields[0];
390
			} else {
391
				$field = $this->normalizeField($model, $field);
392
			}
393
		}
394
		unset($field);
395
396
		$query['conditions'] = (array)$query['conditions'];
397
		foreach ($query['conditions'] as $key => $val) {
398
			if ($model->hasField($key)) {
399
				unset($query['conditions'][$key]);
400
				$key = $this->normalizeField($model, $key);
401
				$query['conditions'][] = array($key => $val);
402
			} elseif ($model->isVirtualField($key)) {
403
				unset($query['conditions'][$key]);
404
				$expression = $db->dispatchMethod('_parseKey', array($key, $val, $model));
405
				$query['conditions'][] = $db->expression($expression);
406
			}
407
		}
408
409
		$order = array();
410
		foreach ((array)$query['order'] as $key => $val) {
411
			if (is_int($key)) {
412
				$val = $this->normalizeField($model, $val);
413
			} else {
414
				$key = $this->normalizeField($model, $key);
415
			}
416
			$order += array($key => $val);
417
		}
418
		$query['order'] = $order;
419
420
		return $query;
421
	}
422
423
/**
424
 * Normalize field
425
 *
426
 * @param Model $model Model
427
 * @param string $field Name of the field
428
 * @return string
429
 */
430
	private function normalizeField(Model $model, $field) { // @codingStandardsIgnoreLine
431
		if ($model->hasField($field)) {
432
			$field = $model->alias . '.' . $field;
433
		} elseif ($model->isVirtualField($field)) {
434
			$db = $model->getDataSource();
435
			$field = $model->getVirtualField($field);
436
			$field = $db->dispatchMethod('_quoteFields', array($field));
437
			$field = '(' . $field . ')';
438
		}
439
		return $field;
440
	}
441
442
/**
443
 * Modifies the query to apply joins.
444
 *
445
 * @param Model $target Model to be joined
446
 * @param array $query Query
447
 * @param string $joinType The type for join
448
 * @param array $keys Key fields being used for join
449
 * @param array $options Extra options for join
450
 * @return array Modified query
451
 */
452
	private function buildJoinQuery(Model $target, array $query, $joinType, array $keys, array $options) { // @codingStandardsIgnoreLine
453
		$db = $target->getDataSource();
454
455
		$options = $this->normalizeQuery($target, $options);
456
		$query['fields'] = array_merge($query['fields'], $options['fields']);
457
		$query = $this->normalizeQuery($target, $query);
458
459
		foreach ($keys as $lhs => $rhs) {
460
			$query = $this->addField($query, $lhs);
461
			$query = $this->addField($query, $rhs);
462
			$options['conditions'][] = array($lhs => $db->identifier($rhs));
463
		}
464
465
		$query['joins'][] = array(
466
			'type' => $joinType,
467
			'table' => $target,
468
			'alias' => $target->alias,
469
			'conditions' => $options['conditions'],
470
		);
471
		return $query;
472
	}
473
474
/**
475
 * Adds a field into the `fields` option of the query
476
 *
477
 * @param array $query Query
478
 * @param string $field Name of the field
479
 * @return Modified query
480
 */
481
	private function addField(array $query, $field) { // @codingStandardsIgnoreLine
482
		if (!in_array($field, $query['fields'], true)) {
483
			$query['fields'][] = $field;
484
		}
485
		return $query;
486
	}
487
488
/**
489
 * Parse the `contain` option of the query recursively
490
 *
491
 * @param Model $parent Parent model of the contained model
492
 * @param string $alias Alias of the contained model
493
 * @param array $contain Reformatted `contain` option for the deep associations
494
 * @param array|null $context Context
495
 * @return array
496
 * @throws InvalidArgumentException
497
 */
498
	private function parseContain(Model $parent, $alias, array $contain, $context = null) { // @codingStandardsIgnoreLine
499
		if ($context === null) {
500
			$context = array(
501
				'root' => $parent->alias,
502
				'aliasPath' => $parent->alias,
503
				'propertyPath' => '',
504
				'forceExternal' => false,
505
			);
506
		}
507
508
		$aliasPath = $context['aliasPath'] . '.' . $alias;
509
		$propertyPath = ($context['propertyPath'] ? $context['propertyPath'] . '.' : '') . $alias;
510
511
		$types = $parent->getAssociated();
512
		if (!isset($types[$alias])) {
513
			throw new InvalidArgumentException(sprintf('Model "%s" is not associated with model "%s"', $parent->alias, $alias), E_USER_WARNING);
514
		}
515
516
		$parentAlias = $parent->alias;
517
		$target = $parent->$alias;
518
		$type = $types[$alias];
519
		$relation = $parent->{$type}[$alias];
520
521
		$options = $contain['options'] + array_intersect_key(Hash::filter($relation), $this->containOptions);
522
523
		$has = (stripos($type, 'has') !== false);
524
		$many = (stripos($type, 'many') !== false);
525
		$belong = (stripos($type, 'belong') !== false);
526
527
		if ($has && $belong) {
528
			$parentKey = $parent->primaryKey;
529
			$targetKey = $target->primaryKey;
530
			$habtmAlias = $relation['with'];
531
			$habtm = $parent->$habtmAlias;
532
			$habtmParentKey = $relation['foreignKey'];
533
			$habtmTargetKey = $relation['associationForeignKey'];
534
		} elseif ($has) {
535
			$parentKey = $parent->primaryKey;
536
			$targetKey = $relation['foreignKey'];
537
		} else {
538
			$parentKey = $relation['foreignKey'];
539
			$targetKey = $target->primaryKey;
540
		}
541
542
		if (!empty($relation['external'])) {
543
			$external = true;
544
		}
545
546
		if (!empty($relation['finderQuery'])) {
547
			$finderQuery = $relation['finderQuery'];
548
		}
549
550
		$meta = compact(
551
			'alias', 'parent', 'target',
552
			'parentAlias', 'parentKey',
553
			'targetKey', 'aliasPath', 'propertyPath',
554
			'options', 'has', 'many', 'belong', 'external', 'finderQuery',
555
			'habtm', 'habtmAlias', 'habtmParentKey', 'habtmTargetKey'
556
		);
557
558
		if ($this->isExternal($context, $meta)) {
559
			$meta['external'] = true;
560
561
			$context['root'] = $aliasPath;
562
			$context['propertyPath'] = $alias;
563
564
			$path = $context['aliasPath'];
565
		} else {
566
			$meta['external'] = false;
567
			if ($context['root'] !== $context['aliasPath']) {
568
				$meta['eager'] = true;
569
			}
570
571
			$context['propertyPath'] = $propertyPath;
572
573
			$path = $context['root'];
574
		}
575
576
		$this->metas[$path][] = $meta;
577
578
		$context['aliasPath'] = $aliasPath;
579
		$context['forceExternal'] = !empty($finderQuery);
580
581
		foreach ($contain['contain'] as $key => $val) {
582
			$this->parseContain($target, $key, $val, $context);
583
		}
584
585
		return $this->metas;
586
	}
587
588
/**
589
 * Returns whether the target is external or not
590
 *
591
 * @param array $context Context
592
 * @param array $meta Meta data to be used for eager loading
593
 * @return bool
594
 */
595
	private function isExternal(array $context, array $meta) { // @codingStandardsIgnoreLine
596
		extract($meta);
597
598
		if ($parent->useDbConfig !== $target->useDbConfig) {
599
			return true;
600
		}
601
		if (!empty($external)) {
602
			return true;
603
		}
604
		if (!empty($many)) {
605
			return true;
606
		}
607
		if (!empty($finderQuery)) {
608
			return true;
609
		}
610
		if ($this->hasLimitOffset($options)) {
611
			return true;
612
		}
613
		if ($context['forceExternal']) {
614
			return true;
615
		}
616
617
		$metas = $this->metas($context['root']);
618
		$aliases = Hash::extract($metas, '{n}.alias');
619
		if (in_array($alias, $aliases, true)) {
620
			return true;
621
		}
622
623
		return false;
624
	}
625
626
/**
627
 * Returns where `limit` or `offset` option exists
628
 *
629
 * @param array $options Options
630
 * @return bool
631
 */
632
	private function hasLimitOffset($options) { // @codingStandardsIgnoreLine
633
		return !empty($options['limit']) || !empty($options['offset']);
634
	}
635
636
/**
637
 * Triggers afterFind() method
638
 *
639
 * @param Model $parent Model
640
 * @param string $alias Alias
641
 * @param array $results Results
642
 * @return array
643
 */
644
	private function filterResults(Model $parent, $alias, array $results) { // @codingStandardsIgnoreLine
645
		$db = $parent->getDataSource();
646
		$db->dispatchMethod('_filterResultsInclusive', array(&$results, $parent, array($alias)));
647
		return $results;
648
	}
649
}
650