Completed
Push — master ( 2d3c47...157dd8 )
by Chris
03:55
created

Relation::save()   B

Complexity

Conditions 6
Paths 10

Size

Total Lines 37
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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