Completed
Push — master ( ee3e85...0843f6 )
by Chris
02:40
created

Relation::eager()

Size

Total Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 1
nc 1
1
<?php
2
namespace Darya\ORM;
3
4
use Exception;
5
use ReflectionClass;
6
use Darya\ORM\Record;
7
use Darya\Storage\Readable;
8
9
/**
10
 * Darya's abstract entity relation.
11
 * 
12
 * TODO: errors() method.
13
 * TODO: Filter, order, limit, offset for load() and retrieve().
14
 * TODO: Shouldn't delimitClass() and prepareForeignKey() be static?
15
 * 
16
 * @property-read string   $name
17
 * @property-read Record   $parent
18
 * @property-read Record   $target
19
 * @property-read string   $foreignKey
20
 * @property-read string   $localKey
21
 * @property-read array    $constraint
22
 * @property-read Record[] $related
23
 * @property-read bool     $loaded
24
 * @property-read Readable $storage
25
 * 
26
 * @author Chris Andrew <[email protected]>
27
 */
28
abstract class Relation {
29
	
30
	const HAS             = 'has';
31
	const HAS_MANY        = 'has_many';
32
	const BELONGS_TO      = 'belongs_to';
33
	const BELONGS_TO_MANY = 'belongs_to_many';
34
	
35
	protected static $classMap = array(
36
		self::HAS             => 'Darya\ORM\Relation\Has',
37
		self::HAS_MANY        => 'Darya\ORM\Relation\HasMany',
38
		self::BELONGS_TO      => 'Darya\ORM\Relation\BelongsTo',
39
		self::BELONGS_TO_MANY => 'Darya\ORM\Relation\BelongsToMany',
40
	);
41
	
42
	/**
43
	 * @var string The name of the relation in the context of the parent model
44
	 */
45
	protected $name = '';
46
	
47
	/**
48
	 * @var Record Parent model
49
	 */
50
	protected $parent;
51
	
52
	/**
53
	 * @var Record Target model
54
	 */
55
	protected $target;
56
	
57
	/**
58
	 * @var string Foreign key on the "belongs-to" model
59
	 */
60
	protected $foreignKey;
61
	
62
	/**
63
	 * @var string Local key on the "has" model
64
	 */
65
	protected $localKey;
66
	
67
	/**
68
	 * @var array Filter for constraining related models loaded from storage
69
	 */
70
	protected $constraint = array();
71
	
72
	/**
73
	 * @var array Sort order for related models
74
	 */
75
	protected $sort = array();
76
	
77
	/**
78
	 * @var Record[] The related instances
79
	 */
80
	protected $related = array();
81
	
82
	/**
83
	 * @var bool Determines whether related instances have been loaded
84
	 */
85
	protected $loaded = false;
86
	
87
	/**
88
	 * @var Readable Storage interface
89
	 */
90
	protected $storage;
91
	
92
	/**
93
	 * Helper method for methods that accept single or multiple values, or for
94
	 * just casting to an array without losing a plain object.
95
	 * 
96
	 * Returns a array with the given value as its sole element, if it is not an
97
	 * array already.
98
	 * 
99
	 * @param mixed $value
100
	 * @return array
101
	 */
102
	protected static function arrayify($value) {
103
		return !is_array($value) ? array($value) : $value;
104
	}
105
	
106
	/**
107
	 * Separate array elements with numeric keys from those with string keys.
108
	 * 
109
	 * @param array $array
110
	 * @return array array($numeric, $strings)
111
	 */
112
	protected static function separateKeys(array $array) {
113
		$numeric = array();
114
		$strings = array();
115
		
116
		
117
		foreach ($array as $key => $value) {
118
			if (is_numeric($key)) {
119
				$numeric[$key] = $value;
120
			} else {
121
				$strings[$key] = $value;
122
			}
123
		}
124
		
125
		return array($numeric, $strings);
126
	}
127
	
128
	/**
129
	 * Resolve a relation class name from the given relation type constant.
130
	 * 
131
	 * @param string $type
132
	 * @return string
133
	 */
134
	protected static function resolveClass($type) {
135
		if (isset(static::$classMap[$type])) {
136
			return static::$classMap[$type];
137
		}
138
		
139
		return static::$classMap[static::HAS];
140
	}
141
	
142
	/**
143
	 * Create a new relation of the given type using the given arguments.
144
	 * 
145
	 * Applies numerically-keyed arguments to the constructor and string-keyed
146
	 * arguments to methods with the same name.
147
	 * 
148
	 * @param string $type
149
	 * @param array  $arguments
150
	 * @return Relation
151
	 */
152
	public static function factory($type = self::HAS, array $arguments) {
153
		$class = static::resolveClass($type);
154
		
155
		$reflection = new ReflectionClass($class);
156
		
157
		list($arguments, $named) = static::separateKeys($arguments);
158
		
159
		$instance = $reflection->newInstanceArgs($arguments);
160
		
161
		foreach ($named as $method => $argument) {
162
			if (method_exists($instance, $method)) {
163
				$argument = static::arrayify($argument);
164
				call_user_func_array(array($instance, $method), $argument);
165
			}
166
		}
167
		
168
		return $instance;
169
	}
170
	
171
	/**
172
	 * Instantiate a new relation.
173
	 * 
174
	 * @param Record $parent     Parent class
175
	 * @param string $target     Related class that extends \Darya\ORM\Record
176
	 * @param string $foreignKey [optional] Custom foreign key
177
	 * @param array  $constraint [optional] Constraint filter for related models
178
	 */
179
	public function __construct(Record $parent, $target, $foreignKey = null, array $constraint = array()) {
180
		if (!is_subclass_of($target, 'Darya\ORM\Record')) {
181
			throw new Exception('Target class not does not extend Darya\ORM\Record');
182
		}
183
		
184
		$this->parent = $parent;
185
		$this->target = !is_object($target) ? new $target : $target;
186
		
187
		$this->foreignKey = $foreignKey;
188
		$this->setDefaultKeys();
189
		$this->constrain($constraint);
190
	}
191
	
192
	/**
193
	 * Lowercase and delimit the given PascalCase class name.
194
	 * 
195
	 * @param string $class
196
	 * @return string
197
	 */
198
	protected function delimitClass($class) {
199
		$split = explode('\\', $class);
200
		$class = end($split);
201
		
202
		return preg_replace_callback('/([A-Z])/', function ($matches) {
203
			return '_' . strtolower($matches[1]);
204
		}, lcfirst($class));
205
	}
206
	
207
	/**
208
	 * Prepare a foreign key from the given class name.
209
	 * 
210
	 * @param string $class
211
	 * @return string
212
	 */
213
	protected function prepareForeignKey($class) {
214
		return $this->delimitClass($class) . '_id';
215
	}
216
	
217
	/**
218
	 * Retrieve the default filter for the related models.
219
	 * 
220
	 * @return array
221
	 */
222
	protected function defaultConstraint() {
223
		return array(
224
			$this->foreignKey => $this->parent->id()
225
		);
226
	}
227
	
228
	/**
229
	 * Set the default keys for the relation if they haven't already been set.
230
	 */
231
	abstract protected function setDefaultKeys();
232
	
233
	/**
234
	 * Retrieve the values of the given attribute of the given instances.
235
	 * 
236
	 * Works similarly to array_column(), but doesn't return data from any rows
237
	 * without the given attribute set.
238
	 * 
239
	 * Optionally accepts a second attribute to index by.
240
	 * 
241
	 * @param Record[]|Record|array $instances
242
	 * @param string                $attribute
243
	 * @param string                $index     [optional]
244
	 * @return array
245
	 */
246
	protected static function attributeList($instances, $attribute, $index = null) {
247
		$values = array();
248
		
249
		foreach (static::arrayify($instances) as $instance) {
250
			if (isset($instance[$attribute])) {
251
				if ($index !== null) {
252
					$values[$instance[$index]] = $instance[$attribute];
253
				} else {
254
					$values[] = $instance[$attribute];
255
				}
256
			}
257
		}
258
		
259
		return $values;
260
	}
261
	
262
	/**
263
	 * Build an adjacency list of related models using the foreign key.
264
	 * 
265
	 * @param Record[] $instances
266
	 * @return array
267
	 */
268
	protected function adjacencyList(array $instances) {
269
		$related = array();
270
		
271
		foreach ($instances as $instance) {
272
			$related[$instance->get($this->foreignKey)][] = $instance;
273
		}
274
		
275
		return $related;
276
	}
277
	
278
	/**
279
	 * Reduce the cached related models to those with the given IDs.
280
	 * 
281
	 * If no IDs are given then all of the in-memory models will be removed.
282
	 * 
283
	 * @param int[] $ids
284
	 */
285
	protected function reduce(array $ids = array()) {
286
		if (empty($this->related)) {
287
			return;
288
		}
289
		
290
		$keys = array();
291
		
292
		foreach ($this->related as $key => $instance) {
293
			if (!in_array($instance->id(), $ids)) {
294
				$keys[$key] = null;
295
			}
296
		}
297
		
298
		$this->related = array_values(array_diff_key($this->related, $keys));
299
	}
300
	
301
	/**
302
	 * Replace a cached related model.
303
	 * 
304
	 * If the related model does not have an ID or it is not found, it is simply
305
	 * appended.
306
	 * 
307
	 * Retrieves related models if none have been loaded yet.
308
	 * 
309
	 * @param Record $instance
310
	 */
311
	protected function replace(Record $instance) {
312
		$this->verify($instance);
313
		
314
		$this->retrieve();
315
		
316
		if (!$instance->id()) {
317
			$this->related[] = $instance;
318
			
319
			return;
320
		}
321
		
322
		foreach ($this->related as $key => $related) {
323
			if ($related->id() === $instance->id()) {
324
				$this->related[$key] = $instance;
325
				
326
				return;
327
			}
328
		}
329
		
330
		$this->related[] = $instance;
331
	}
332
	
333
	/**
334
	 * Save the given record to storage if it hasn't got an ID.
335
	 * 
336
	 * @param Record $instance
337
	 */
338
	protected function persist(Record $instance) {
339
		if (!$instance->id()) {
340
			$instance->save();
341
		}
342
	}
343
	
344
	/**
345
	 * Verify that the given models are instances of the relation's target
346
	 * class.
347
	 * 
348
	 * Throws an exception if any of them aren't.
349
	 * 
350
	 * @param Record[]|Record $instances
351
	 * @throws Exception
352
	 */
353
	protected function verify($instances) {
354
		static::verifyModels($instances, get_class($this->target));
355
	}
356
	
357
	/**
358
	 * Verify that the given objects are instances of the given class.
359
	 * 
360
	 * @param object[]|object $instances
361
	 * @param string          $class
362
	 * @throws Exception
363
	 */
364
	protected static function verifyModels($instances, $class) {
365
		if (!class_exists($class)) {
366
			return;
367
		}
368
		
369
		foreach (static::arrayify($instances) as $instance) {
370
			if (!$instance instanceof $class) {
371
				throw new Exception('Related models must be an instance of ' . $class);
372
			}
373
		}
374
	}
375
	
376
	/**
377
	 * Verify that the given models are instances of the relation's parent
378
	 * class.
379
	 * 
380
	 * Throws an exception if any of them aren't.
381
	 * 
382
	 * @param Record[]|Record $instances
383
	 * @throws Exception
384
	 */
385
	protected function verifyParents($instances) {
386
		static::verifyModels($instances, get_class($this->parent));
387
	}
388
	
389
	/**
390
	 * Retrieve and optionally set the storage used for the target model.
391
	 * 
392
	 * Falls back to target model storage, then parent model storage.
393
	 * 
394
	 * @param Readable $storage
395
	 */
396
	public function storage(Readable $storage = null) {
397
		$this->storage = $storage ?: $this->storage;
398
		
399
		return $this->storage ?: $this->target->storage() ?: $this->parent->storage();
400
	}
401
	
402
	/**
403
	 * Retrieve and optionally set the name of the relation on the parent model.
404
	 * 
405
	 * @param string $name [optional]
406
	 * @return string
407
	 */
408
	public function name($name = '') {
409
		$this->name = (string) $name ?: $this->name;
410
		
411
		return $this->name;
412
	}
413
	
414
	/**
415
	 * Retrieve and optionally set the foreign key for the "belongs-to" model.
416
	 * 
417
	 * @param string $foreignKey [optional]
418
	 * @return string
419
	 */
420
	public function foreignKey($foreignKey = '') {
421
		$this->foreignKey = (string) $foreignKey ?: $this->foreignKey;
422
		
423
		return $this->foreignKey;
424
	}
425
	
426
	/**
427
	 * Retrieve and optionally set the local key for the "has" model.
428
	 * 
429
	 * @param string $localKey [optional]
430
	 * @return string
431
	 */
432
	public function localKey($localKey = '') {
433
		$this->localKey = (string) $localKey ?: $this->localKey;
434
		
435
		return $this->localKey;
436
	}
437
	
438
	/**
439
	 * Set a filter to constrain which models are considered related.
440
	 * 
441
	 * @param array $filter
442
	 */
443
	public function constrain(array $filter) {
444
		$this->constraint = $filter;
445
	}
446
	
447
	/**
448
	 * Retrieve the custom filter used to constrain related models.
449
	 * 
450
	 * @return array
451
	 */
452
	public function constraint() {
453
		return $this->constraint;
454
	}
455
	
456
	/**
457
	 * Retrieve the filter for this relation.
458
	 * 
459
	 * @return array
460
	 */
461
	public function filter() {
462
		return array_merge($this->defaultConstraint(), $this->constraint());
463
	}
464
	
465
	/**
466
	 * Set the sorting order for this relation.
467
	 * 
468
	 * @param array|string $order
469
	 */
470
	public function sort($order) {
471
		return $this->sort = $order;
0 ignored issues
show
Documentation Bug introduced by
It seems like $order can also be of type string. However, the property $sort is declared as type array. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
472
	}
473
	
474
	/**
475
	 * Retrieve the order for this relation.
476
	 * 
477
	 * @return array|string
478
	 */
479
	public function order() {
480
		return $this->sort;
481
	}
482
	
483
	/**
484
	 * Read related model data from storage.
485
	 * 
486
	 * TODO: $filter, $order, $offset
487
	 * 
488
	 * @param int $limit [optional]
489
	 * @return array
490
	 */
491
	public function read($limit = 0) {
492
		return $this->storage()->read($this->target->table(), $this->filter(), $this->order(), $limit);
493
	}
494
	
495
	/**
496
	 * Read, generate and set cached related models from storage.
497
	 * 
498
	 * @param int $limit [optional]
499
	 * @return Record[]
500
	 */
501
	public function load($limit = 0) {
502
		$data = $this->read($limit);
503
		$class = get_class($this->target);
504
		$this->related = $class::generate($data);
505
		$this->loaded = true;
506
		
507
		return $this->related;
508
	}
509
	
510
	/**
511
	 * Determine whether cached related models have been loaded.
512
	 * 
513
	 * @return bool
514
	 */
515
	public function loaded() {
516
		return $this->loaded;
517
	}
518
	
519
	/**
520
	 * Eagerly load the related models for the given parent instances.
521
	 * 
522
	 * Returns the given instances with their related models loaded.
523
	 * 
524
	 * @param array $instances
525
	 * @return array
526
	 */
527
	abstract public function eager(array $instances);
528
	
529
	/**
530
	 * Retrieve one or many related model instances, depending on the relation.
531
	 * 
532
	 * @return Record[]|Record|null
533
	 */
534
	abstract public function retrieve();
535
	
536
	/**
537
	 * Retrieve one related model instance.
538
	 * 
539
	 * @return Record|null
540
	 */
541
	public function one() {
542
		if (!$this->loaded()) {
543
			$this->load(1);
544
		}
545
		
546
		return !empty($this->related) ? $this->related[0] : null;
547
	}
548
	
549
	/**
550
	 * Retrieve all related model instances.
551
	 * 
552
	 * @return Record[]|null
553
	 */
554
	public function all() {
555
		if (!$this->loaded()) {
556
			$this->load();
557
		}
558
		
559
		return $this->related;
560
	}
561
	
562
	/**
563
	 * Count the number of related model instances.
564
	 * 
565
	 * Counts loaded instances if they are present, queries storage otherwise.
566
	 * 
567
	 * @return int
568
	 */
569
	public function count() {
570
		if (!$this->loaded()) {
571
			return $this->storage()->count($this->target->table(), $this->filter());
572
		}
573
		
574
		return count($this->related);
575
	}
576
	
577
	/**
578
	 * Set the related models.
579
	 * 
580
	 * @param Record[] $instances
581
	 */
582
	public function set($instances) {
583
		$this->verify($instances);
584
		$this->related = static::arrayify($instances);
585
		$this->loaded = true;
586
	}
587
	
588
	/**
589
	 * Clear the related models.
590
	 */
591
	public function clear() {
592
		$this->related = array();
593
		$this->loaded = false;
594
	}
595
	
596
	/**
597
	 * Read-only access for relation properties.
598
	 * 
599
	 * @param string $property
600
	 * @return mixed
601
	 */
602
	public function __get($property) {
603
		if (property_exists($this, $property)) {
604
			return $this->$property;
605
		}
606
	}
607
	
608
}
609