Completed
Push — master ( 2fef44...399242 )
by Chris
02:54
created

Relation::replace()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 22
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 1 Features 0
Metric Value
cc 4
eloc 11
c 4
b 1
f 0
nc 4
nop 1
dl 0
loc 22
rs 8.9197
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
	/**
36
	 * A map of relation type constants to their respective implementations.
37
	 * 
38
	 * @var array
39
	 */
40
	protected static $classMap = array(
41
		self::HAS             => 'Darya\ORM\Relation\Has',
42
		self::HAS_MANY        => 'Darya\ORM\Relation\HasMany',
43
		self::BELONGS_TO      => 'Darya\ORM\Relation\BelongsTo',
44
		self::BELONGS_TO_MANY => 'Darya\ORM\Relation\BelongsToMany',
45
	);
46
	
47
	/**
48
	 * The name of the relation in the context of the parent model.
49
	 * 
50
	 * @var string
51
	 */
52
	protected $name = '';
53
	
54
	/**
55
	 * The parent model.
56
	 * 
57
	 * @var Record
58
	 */
59
	protected $parent;
60
	
61
	/**
62
	 * The target model.
63
	 * 
64
	 * @var Record
65
	 */
66
	protected $target;
67
	
68
	/**
69
	 * Foreign key on the "belongs-to" model.
70
	 * 
71
	 * @var string
72
	 */
73
	protected $foreignKey;
74
	
75
	/**
76
	 * Local key on the "has" model.
77
	 * 
78
	 * @var string
79
	 */
80
	protected $localKey;
81
	
82
	/**
83
	 * Filter for constraining related models loaded from storage.
84
	 * 
85
	 * @var array
86
	 */
87
	protected $constraint = array();
88
	
89
	/**
90
	 * Sort order for related models.
91
	 * 
92
	 * @var array
93
	 */
94
	protected $sort = array();
95
	
96
	/**
97
	 * The related instances.
98
	 * 
99
	 * @var Record[]
100
	 */
101
	protected $related = array();
102
	
103
	/**
104
	 * Determines whether related instances have been loaded.
105
	 * 
106
	 * @var bool
107
	 */
108
	protected $loaded = false;
109
	
110
	/**
111
	 * The storage interface.
112
	 * 
113
	 * @var Readable
114
	 */
115
	protected $storage;
116
	
117
	/**
118
	 * Helper method for methods that accept single or multiple values, or for
119
	 * just casting to an array without losing a plain object.
120
	 * 
121
	 * Returns a array with the given value as its sole element, if it is not an
122
	 * array already.
123
	 * 
124
	 * @param mixed $value
125
	 * @return array
126
	 */
127
	protected static function arrayify($value)
128
	{
129
		return !is_array($value) ? array($value) : $value;
130
	}
131
	
132
	/**
133
	 * Separate array elements with numeric keys from those with string keys.
134
	 * 
135
	 * @param array $array
136
	 * @return array array($numeric, $strings)
137
	 */
138
	protected static function separateKeys(array $array)
139
	{
140
		$numeric = array();
141
		$strings = array();
142
		
143
		
144
		foreach ($array as $key => $value) {
145
			if (is_numeric($key)) {
146
				$numeric[$key] = $value;
147
			} else {
148
				$strings[$key] = $value;
149
			}
150
		}
151
		
152
		return array($numeric, $strings);
153
	}
154
	
155
	/**
156
	 * Resolve a relation class name from the given relation type constant.
157
	 * 
158
	 * @param string $type
159
	 * @return string
160
	 */
161
	protected static function resolveClass($type)
162
	{
163
		if (isset(static::$classMap[$type])) {
164
			return static::$classMap[$type];
165
		}
166
		
167
		return static::$classMap[static::HAS];
168
	}
169
	
170
	/**
171
	 * Create a new relation of the given type using the given arguments.
172
	 * 
173
	 * Applies numerically-keyed arguments to the constructor and string-keyed
174
	 * arguments to methods with the same name.
175
	 * 
176
	 * @param string $type
177
	 * @param array  $arguments
178
	 * @return Relation
179
	 */
180
	public static function factory($type = self::HAS, array $arguments)
181
	{
182
		$class = static::resolveClass($type);
183
		
184
		$reflection = new ReflectionClass($class);
185
		
186
		list($arguments, $named) = static::separateKeys($arguments);
187
		
188
		$instance = $reflection->newInstanceArgs($arguments);
189
		
190
		foreach ($named as $method => $argument) {
191
			if (method_exists($instance, $method)) {
192
				$argument = static::arrayify($argument);
193
				call_user_func_array(array($instance, $method), $argument);
194
			}
195
		}
196
		
197
		return $instance;
198
	}
199
	
200
	/**
201
	 * Instantiate a new relation.
202
	 * 
203
	 * @param Record $parent     Parent class
204
	 * @param string $target     Related class that extends \Darya\ORM\Record
205
	 * @param string $foreignKey [optional] Custom foreign key
206
	 * @param array  $constraint [optional] Constraint filter for related models
207
	 */
208
	public function __construct(Record $parent, $target, $foreignKey = null, array $constraint = array())
209
	{
210
		if (!is_subclass_of($target, 'Darya\ORM\Record')) {
211
			throw new Exception('Target class not does not extend Darya\ORM\Record');
212
		}
213
		
214
		$this->parent = $parent;
215
		$this->target = !is_object($target) ? new $target : $target;
216
		
217
		$this->foreignKey = $foreignKey;
218
		$this->setDefaultKeys();
219
		$this->constrain($constraint);
220
	}
221
	
222
	/**
223
	 * Lowercase and delimit the given PascalCase class name.
224
	 * 
225
	 * @param string $class
226
	 * @return string
227
	 */
228
	protected function delimitClass($class)
229
	{
230
		$split = explode('\\', $class);
231
		$class = end($split);
232
		
233
		return preg_replace_callback('/([A-Z])/', function ($matches) {
234
			return '_' . strtolower($matches[1]);
235
		}, lcfirst($class));
236
	}
237
	
238
	/**
239
	 * Prepare a foreign key from the given class name.
240
	 * 
241
	 * @param string $class
242
	 * @return string
243
	 */
244
	protected function prepareForeignKey($class)
245
	{
246
		return $this->delimitClass($class) . '_id';
247
	}
248
	
249
	/**
250
	 * Retrieve the default filter for the related models.
251
	 * 
252
	 * @return array
253
	 */
254
	protected function defaultConstraint()
255
	{
256
		return array(
257
			$this->foreignKey => $this->parent->id()
258
		);
259
	}
260
	
261
	/**
262
	 * Set the default keys for the relation if they haven't already been set.
263
	 */
264
	abstract protected function setDefaultKeys();
265
	
266
	/**
267
	 * Retrieve the values of the given attribute of the given instances.
268
	 * 
269
	 * Works similarly to array_column(), but doesn't return data from any rows
270
	 * without the given attribute set.
271
	 * 
272
	 * Optionally accepts a second attribute to index by.
273
	 * 
274
	 * @param Record[]|Record|array $instances
275
	 * @param string                $attribute
276
	 * @param string                $index     [optional]
277
	 * @return array
278
	 */
279
	protected static function attributeList($instances, $attribute, $index = null)
280
	{
281
		$values = array();
282
		
283
		foreach (static::arrayify($instances) as $instance) {
284
			if (isset($instance[$attribute])) {
285
				if ($index !== null) {
286
					$values[$instance[$index]] = $instance[$attribute];
287
				} else {
288
					$values[] = $instance[$attribute];
289
				}
290
			}
291
		}
292
		
293
		return $values;
294
	}
295
	
296
	/**
297
	 * Build an adjacency list of related models, indexed by their foreign keys.
298
	 * 
299
	 * Optionally accepts a different attribute to index the models by.
300
	 * 
301
	 * @param Record[] $instances
302
	 * @param string   $index     [optional]
303
	 * @return array
304
	 */
305
	protected function adjacencyList(array $instances, $index = null)
306
	{
307
		$index = $index ?: $this->foreignKey;
308
		
309
		$related = array();
310
		
311
		foreach ($instances as $instance) {
312
			$related[$instance->get($index)][] = $instance;
313
		}
314
		
315
		return $related;
316
	}
317
	
318
	/**
319
	 * Reduce the cached related models to those with the given IDs.
320
	 * 
321
	 * If no IDs are given then all of the in-memory models will be removed.
322
	 * 
323
	 * @param int[] $ids
324
	 */
325
	protected function reduce(array $ids = array())
326
	{
327
		if (empty($this->related)) {
328
			return;
329
		}
330
		
331
		$keys = array();
332
		
333
		foreach ($this->related as $key => $instance) {
334
			if (!in_array($instance->id(), $ids)) {
335
				$keys[$key] = null;
336
			}
337
		}
338
		
339
		$this->related = array_values(array_diff_key($this->related, $keys));
340
	}
341
	
342
	/**
343
	 * Replace a cached related model.
344
	 * 
345
	 * If the related model does not have an ID or it is not found, it is simply
346
	 * appended.
347
	 * 
348
	 * Retrieves related models if none have been loaded yet.
349
	 * 
350
	 * @param Record $instance
351
	 */
352
	protected function replace(Record $instance)
353
	{
354
		$this->verify($instance);
355
		
356
		$this->retrieve();
357
		
358
		if (!$instance->id()) {
359
			$this->related[] = $instance;
360
			
361
			return;
362
		}
363
		
364
		foreach ($this->related as $key => $related) {
365
			if ($related->id() === $instance->id()) {
366
				$this->related[$key] = $instance;
367
				
368
				return;
369
			}
370
		}
371
		
372
		$this->related[] = $instance;
373
	}
374
	
375
	/**
376
	 * Save the given record to storage if it hasn't got an ID.
377
	 * 
378
	 * @param Record $instance
379
	 */
380
	protected function persist(Record $instance)
381
	{
382
		if (!$instance->id()) {
383
			$instance->save();
384
		}
385
	}
386
	
387
	/**
388
	 * Verify that the given models are instances of the relation's target
389
	 * class.
390
	 * 
391
	 * Throws an exception if any of them aren't.
392
	 * 
393
	 * @param Record[]|Record $instances
394
	 * @throws Exception
395
	 */
396
	protected function verify($instances)
397
	{
398
		static::verifyModels($instances, get_class($this->target));
399
	}
400
	
401
	/**
402
	 * Verify that the given objects are instances of the given class.
403
	 * 
404
	 * @param object[]|object $instances
405
	 * @param string          $class
406
	 * @throws Exception
407
	 */
408
	protected static function verifyModels($instances, $class)
409
	{
410
		if (!class_exists($class)) {
411
			return;
412
		}
413
		
414
		foreach (static::arrayify($instances) as $instance) {
415
			if (!$instance instanceof $class) {
416
				throw new Exception('Related models must be an instance of ' . $class);
417
			}
418
		}
419
	}
420
	
421
	/**
422
	 * Verify that the given models are instances of the relation's parent
423
	 * class.
424
	 * 
425
	 * Throws an exception if any of them aren't.
426
	 * 
427
	 * @param Record[]|Record $instances
428
	 * @throws Exception
429
	 */
430
	protected function verifyParents($instances)
431
	{
432
		static::verifyModels($instances, get_class($this->parent));
433
	}
434
	
435
	/**
436
	 * Retrieve and optionally set the storage used for the target model.
437
	 * 
438
	 * Falls back to target model storage, then parent model storage.
439
	 * 
440
	 * @param Readable $storage
441
	 */
442
	public function storage(Readable $storage = null)
443
	{
444
		$this->storage = $storage ?: $this->storage;
445
		
446
		return $this->storage ?: $this->target->storage() ?: $this->parent->storage();
447
	}
448
	
449
	/**
450
	 * Retrieve and optionally set the name of the relation on the parent model.
451
	 * 
452
	 * @param string $name [optional]
453
	 * @return string
454
	 */
455
	public function name($name = '')
456
	{
457
		$this->name = (string) $name ?: $this->name;
458
		
459
		return $this->name;
460
	}
461
	
462
	/**
463
	 * Retrieve and optionally set the foreign key for the "belongs-to" model.
464
	 * 
465
	 * @param string $foreignKey [optional]
466
	 * @return string
467
	 */
468
	public function foreignKey($foreignKey = '')
469
	{
470
		$this->foreignKey = (string) $foreignKey ?: $this->foreignKey;
471
		
472
		return $this->foreignKey;
473
	}
474
	
475
	/**
476
	 * Retrieve and optionally set the local key for the "has" model.
477
	 * 
478
	 * @param string $localKey [optional]
479
	 * @return string
480
	 */
481
	public function localKey($localKey = '')
482
	{
483
		$this->localKey = (string) $localKey ?: $this->localKey;
484
		
485
		return $this->localKey;
486
	}
487
	
488
	/**
489
	 * Set a filter to constrain which models are considered related.
490
	 * 
491
	 * @param array $filter
492
	 */
493
	public function constrain(array $filter)
494
	{
495
		$this->constraint = $filter;
496
	}
497
	
498
	/**
499
	 * Retrieve the custom filter used to constrain related models.
500
	 * 
501
	 * @return array
502
	 */
503
	public function constraint()
504
	{
505
		return $this->constraint;
506
	}
507
	
508
	/**
509
	 * Retrieve the filter for this relation.
510
	 * 
511
	 * @return array
512
	 */
513
	public function filter()
514
	{
515
		return array_merge($this->defaultConstraint(), $this->constraint());
516
	}
517
	
518
	/**
519
	 * Set the sorting order for this relation.
520
	 * 
521
	 * @param array|string $order
522
	 */
523
	public function sort($order)
524
	{
525
		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...
526
	}
527
	
528
	/**
529
	 * Retrieve the order for this relation.
530
	 * 
531
	 * @return array|string
532
	 */
533
	public function order()
534
	{
535
		return $this->sort;
536
	}
537
	
538
	/**
539
	 * Read related model data from storage.
540
	 * 
541
	 * TODO: $filter, $order, $offset
542
	 * 
543
	 * @param int $limit [optional]
544
	 * @return array
545
	 */
546
	public function read($limit = 0)
547
	{
548
		return $this->storage()->read($this->target->table(), $this->filter(), $this->order(), $limit);
549
	}
550
	
551
	/**
552
	 * Read, generate and set cached related models from storage.
553
	 * 
554
	 * @param int $limit [optional]
555
	 * @return Record[]
556
	 */
557
	public function load($limit = 0)
558
	{
559
		$data = $this->read($limit);
560
		$class = get_class($this->target);
561
		$this->related = $class::generate($data);
562
		$this->loaded = true;
563
		
564
		return $this->related;
565
	}
566
	
567
	/**
568
	 * Determine whether cached related models have been loaded.
569
	 * 
570
	 * @return bool
571
	 */
572
	public function loaded()
573
	{
574
		return $this->loaded;
575
	}
576
	
577
	/**
578
	 * Eagerly load the related models for the given parent instances.
579
	 * 
580
	 * Returns the given instances with their related models loaded.
581
	 * 
582
	 * @param array $instances
583
	 * @return array
584
	 */
585
	abstract public function eager(array $instances);
586
	
587
	/**
588
	 * Retrieve one or many related model instances, depending on the relation.
589
	 * 
590
	 * @return Record[]|Record|null
591
	 */
592
	abstract public function retrieve();
593
	
594
	/**
595
	 * Retrieve one related model instance.
596
	 * 
597
	 * @return Record|null
598
	 */
599
	public function one()
600
	{
601
		if (!$this->loaded()) {
602
			$this->load(1);
603
		}
604
		
605
		return !empty($this->related) ? $this->related[0] : null;
606
	}
607
	
608
	/**
609
	 * Retrieve all related model instances.
610
	 * 
611
	 * @return Record[]|null
612
	 */
613
	public function all()
614
	{
615
		if (!$this->loaded()) {
616
			$this->load();
617
		}
618
		
619
		return $this->related;
620
	}
621
	
622
	/**
623
	 * Count the number of related model instances.
624
	 * 
625
	 * Counts loaded instances if they are present, queries storage otherwise.
626
	 * 
627
	 * @return int
628
	 */
629
	public function count()
630
	{
631
		if (!$this->loaded()) {
632
			return $this->storage()->count($this->target->table(), $this->filter());
633
		}
634
		
635
		return count($this->related);
636
	}
637
	
638
	/**
639
	 * Set the related models.
640
	 * 
641
	 * @param Record[] $instances
642
	 */
643
	public function set($instances)
644
	{
645
		$this->verify($instances);
646
		$this->related = static::arrayify($instances);
647
		$this->loaded = true;
648
	}
649
	
650
	/**
651
	 * Clear the related models.
652
	 */
653
	public function clear()
654
	{
655
		$this->related = array();
656
		$this->loaded = false;
657
	}
658
	
659
	/**
660
	 * Read-only access for relation properties.
661
	 * 
662
	 * @param string $property
663
	 * @return mixed
664
	 */
665
	public function __get($property)
666
	{
667
		if (property_exists($this, $property)) {
668
			return $this->$property;
669
		}
670
	}
671
}
672