Completed
Push — master ( e2285c...0027af )
by Chris
02:29
created

Relation::query()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
749
	}
750
	
751
	/**
752
	 * Associate the given models.
753
	 * 
754
	 * Returns the number of models successfully associated.
755
	 * 
756
	 * @param Record[]|Record $instances
757
	 * @return int
758
	 */
759
	abstract public function associate($instances);
760
	
761
	/**
762
	 * Dissociate the given models.
763
	 * 
764
	 * Returns the number of models successfully dissociated.
765
	 * 
766
	 * @param Record[]|Record $instances [optional]
767
	 * @return int
768
	 */
769
	abstract public function dissociate($instances = array());
770
	
771
	/**
772
	 * Save the relationship.
773
	 * 
774
	 * Associates related models and dissociates detached models.
775
	 * 
776
	 * Optionally accepts a set of IDs to save by. Saves all related models
777
	 * otherwise.
778
	 * 
779
	 * Returns the number of associated models.
780
	 * 
781
	 * @param int[] $ids
782
	 * @return int
783
	 */
784
	public function save(array $ids = array())
785
	{
786
		$related = $this->related;
787
		$detached = $this->detached;
788
		
789
		// Filter the IDs to associate and dissociate if any have been given
790
		if (!empty($ids)) {
791
			$filter = function ($instance) use ($ids) {
792
				return in_array($instance->id(), $ids);
793
			};
794
			
795
			$related = array_filter($related, $filter);
796
			$detached = array_filter($detached, $filter);
797
		}
798
		
799
		// Bail if we have nothing to associate or dissociate
800
		if (empty($related) && empty($detached)) {
801
			return 0;
802
		}
803
		
804
		// Dissociate, then associate
805
		if  (!empty($detached)) {
806
			$this->dissociate($detached);
807
		}
808
809
		$associated = $this->associate($related);
810
		
811
		// Update detached models to be persisted
812
		$this->detached = array();
813
		
814
		// Persist relationships on all related models
815
		foreach ($related as $instance) {
816
			$instance->saveRelations();
817
		}
818
		
819
		return $associated;
820
	}
821
	
822
	/**
823
	 * Dynamic read-only access for relation properties.
824
	 * 
825
	 * @param string $property
826
	 * @return mixed
827
	 */
828
	public function __get($property)
829
	{
830
		if (property_exists($this, $property)) {
831
			return $this->$property;
832
		}
833
	}
834
}
835