Completed
Push — master ( 3a99ef...85bced )
by Kurita
02:34
created

EagerLoader::normalizeField()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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