Relation::attributeList()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 8
nc 4
nop 3
dl 0
loc 15
rs 10
c 0
b 0
f 0
1
<?php
2
namespace Darya\ORM;
3
4
use Darya\Storage\Queryable;
5
use Darya\Storage\Query\Builder;
6
use Exception;
7
use InvalidArgumentException;
8
use ReflectionClass;
9
use ReflectionException;
10
11
/**
12
 * Darya's abstract entity relation.
13
 *
14
 * TODO: errors() method.
15
 * TODO: Filter, order, limit, offset for load() and retrieve().
16
 * TODO: Move arrayify(), delimitClass() and prepareForeignKey() to Darya\ORM\Functions namespace as functions
17
 * TODO: Separate filters for reading and updating/deleting (readFilter(), modifyFilter()) mainly for BelongsToMany
18
 *
19
 * @property-read string    $name
20
 * @property-read Record    $parent
21
 * @property-read Record    $target
22
 * @property-read string    $foreignKey
23
 * @property-read string    $localKey
24
 * @property-read array     $constraint
25
 * @property-read Record[]  $related
26
 * @property-read bool      $loaded
27
 * @property-read Queryable $storage
28
 *
29
 * @author Chris Andrew <[email protected]>
30
 */
31
abstract class Relation
32
{
33
	const HAS             = 'has';
34
	const HAS_MANY        = 'has_many';
35
	const BELONGS_TO      = 'belongs_to';
36
	const BELONGS_TO_MANY = 'belongs_to_many';
37
38
	/**
39
	 * A map of relation type constants to their respective implementations.
40
	 *
41
	 * @var array
42
	 */
43
	protected static $classMap = array(
44
		self::HAS             => 'Darya\ORM\Relation\Has',
45
		self::HAS_MANY        => 'Darya\ORM\Relation\HasMany',
46
		self::BELONGS_TO      => 'Darya\ORM\Relation\BelongsTo',
47
		self::BELONGS_TO_MANY => 'Darya\ORM\Relation\BelongsToMany',
48
	);
49
50
	/**
51
	 * The name of the relation in the context of the parent model.
52
	 *
53
	 * @var string
54
	 */
55
	protected $name = '';
56
57
	/**
58
	 * The parent model.
59
	 *
60
	 * @var Record
61
	 */
62
	protected $parent;
63
64
	/**
65
	 * The target model.
66
	 *
67
	 * @var Record
68
	 */
69
	protected $target;
70
71
	/**
72
	 * Foreign key on the "belongs-to" model.
73
	 *
74
	 * @var string
75
	 */
76
	protected $foreignKey;
77
78
	/**
79
	 * Local key on the "has" model.
80
	 *
81
	 * @var string
82
	 */
83
	protected $localKey;
84
85
	/**
86
	 * Filter for constraining related models loaded from storage.
87
	 *
88
	 * @var array
89
	 */
90
	protected $constraint = array();
91
92
	/**
93
	 * Sort order for related models.
94
	 *
95
	 * @var array
96
	 */
97
	protected $sort = array();
98
99
	/**
100
	 * The related instances.
101
	 *
102
	 * @var Record[]
103
	 */
104
	protected $related = array();
105
106
	/**
107
	 * Detached instances that need dissociating on save.
108
	 *
109
	 * @var Record[]
110
	 */
111
	protected $detached = array();
112
113
	/**
114
	 * Determines whether related instances have been loaded.
115
	 *
116
	 * @var bool
117
	 */
118
	protected $loaded = false;
119
120
	/**
121
	 * The storage interface.
122
	 *
123
	 * @var Queryable
124
	 */
125
	protected $storage;
126
127
	/**
128
	 * Helper method for methods that accept single or multiple values, or for
129
	 * just casting to an array without losing a plain object.
130
	 *
131
	 * Returns an array with the given value as its sole element, if it is not
132
	 * an array already.
133
	 *
134
	 * This exists because casting an object to an array results in its public
135
	 * properties being set as the values.
136
	 *
137
	 * @param mixed $value
138
	 * @return array
139
	 */
140
	protected static function arrayify($value)
141
	{
142
		return !is_array($value) ? array($value) : $value;
143
	}
144
145
	/**
146
	 * Separate array elements with numeric keys from those with string keys.
147
	 *
148
	 * @param array $array
149
	 * @return array array($numeric, $strings)
150
	 */
151
	protected static function separateKeys(array $array)
152
	{
153
		$numeric = array();
154
		$strings = array();
155
156
		foreach ($array as $key => $value) {
157
			if (is_numeric($key)) {
158
				$numeric[$key] = $value;
159
			} else {
160
				$strings[$key] = $value;
161
			}
162
		}
163
164
		return array($numeric, $strings);
165
	}
166
167
	/**
168
	 * Resolve a relation class name from the given relation type constant.
169
	 *
170
	 * @param string $type
171
	 * @return string
172
	 */
173
	protected static function resolveClass($type)
174
	{
175
		if (isset(static::$classMap[$type])) {
176
			return static::$classMap[$type];
177
		}
178
179
		return static::$classMap[static::HAS];
180
	}
181
182
	/**
183
	 * Create a new relation of the given type using the given arguments.
184
	 *
185
	 * Applies numerically-keyed arguments to the constructor and string-keyed
186
	 * arguments to methods with the same name.
187
	 *
188
	 * @param string $type      The type of relation to create.
189
	 * @param array  $arguments [optional] Arguments for the relation constructor.
190
	 * @return Relation
191
	 * @throws ReflectionException
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;
0 ignored issues
show
Bug introduced by
The property parent is declared read-only in Darya\ORM\Relation.
Loading history...
232
		$this->target = !is_object($target) ? new $target : $target;
0 ignored issues
show
introduced by
The condition is_object($target) is always false.
Loading history...
Bug introduced by
The property target is declared read-only in Darya\ORM\Relation.
Loading history...
233
234
		$this->foreignKey = $foreignKey;
0 ignored issues
show
Bug introduced by
The property foreignKey is declared read-only in Darya\ORM\Relation.
Loading history...
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 [
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 = [];
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));
0 ignored issues
show
Bug introduced by
The property related is declared read-only in Darya\ORM\Relation.
Loading history...
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;
0 ignored issues
show
Bug introduced by
The property related is declared read-only in Darya\ORM\Relation.
Loading history...
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;
0 ignored issues
show
Bug introduced by
The property storage is declared read-only in Darya\ORM\Relation.
Loading history...
461
462
		return $this->storage ?: $this->target->storage() ?: $this->parent->storage();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->storage ?:...this->parent->storage() also could return the type Darya\Storage\Readable which is incompatible with the documented return type Darya\Storage\Queryable.
Loading history...
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;
0 ignored issues
show
Bug introduced by
The property name is declared read-only in Darya\ORM\Relation.
Loading history...
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;
0 ignored issues
show
Bug introduced by
The property foreignKey is declared read-only in Darya\ORM\Relation.
Loading history...
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;
0 ignored issues
show
Bug introduced by
The property localKey is declared read-only in Darya\ORM\Relation.
Loading history...
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;
0 ignored issues
show
Bug introduced by
The property constraint is declared read-only in Darya\ORM\Relation.
Loading history...
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);
0 ignored issues
show
Bug introduced by
The method read() does not exist on Darya\Storage\Queryable. It seems like you code against a sub-type of said class. However, the method does not exist in Darya\ORM\EntityManager. Are you sure you never get one of those? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

565
		return $this->storage()->/** @scrutinizer ignore-call */ read($this->target->table(), $this->filter(), $this->order(), $limit);
Loading history...
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())
0 ignored issues
show
Bug introduced by
The method filters() does not exist on Darya\Storage\Query\Builder. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

578
			->/** @scrutinizer ignore-call */ filters($this->filter())
Loading history...
579
			->orders($this->order());
0 ignored issues
show
Bug introduced by
The method orders() does not exist on Darya\Storage\Result. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

579
			->/** @scrutinizer ignore-call */ orders($this->order());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method orders() does not exist on Darya\Storage\Query\Builder. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

579
			->/** @scrutinizer ignore-call */ orders($this->order());
Loading history...
580
581
		$builder->callback(function ($result) use ($class) {
0 ignored issues
show
Bug introduced by
The method callback() does not exist on Darya\Storage\Result. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

581
		$builder->/** @scrutinizer ignore-call */ 
582
            callback(function ($result) use ($class) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
582
			return $class::hydrate($result->data);
583
		});
584
585
		return $builder;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $builder also could return the type Darya\Storage\Result which is incompatible with the documented return type Darya\Storage\Query\Builder.
Loading history...
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);
0 ignored issues
show
Bug introduced by
The property related is declared read-only in Darya\ORM\Relation.
Loading history...
601
		$this->loaded = true;
0 ignored issues
show
Bug introduced by
The property loaded is declared read-only in Darya\ORM\Relation.
Loading history...
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());
0 ignored issues
show
Bug introduced by
The method count() does not exist on Darya\Storage\Queryable. It seems like you code against a sub-type of said class. However, the method does not exist in Darya\ORM\EntityManager. Are you sure you never get one of those? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

676
			return $this->storage()->/** @scrutinizer ignore-call */ count($this->target->table(), $this->filter());
Loading history...
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);
0 ignored issues
show
Bug introduced by
The property related is declared read-only in Darya\ORM\Relation.
Loading history...
693
		$this->loaded = true;
0 ignored issues
show
Bug introduced by
The property loaded is declared read-only in Darya\ORM\Relation.
Loading history...
694
	}
695
696
	/**
697
	 * Clear the related models.
698
	 */
699
	public function clear()
700
	{
701
		$this->related = array();
0 ignored issues
show
Bug introduced by
The property related is declared read-only in Darya\ORM\Relation.
Loading history...
702
		$this->loaded = false;
0 ignored issues
show
Bug introduced by
The property loaded is declared read-only in Darya\ORM\Relation.
Loading history...
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, $this->target->key());
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);
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