Completed
Push — master ( 6ac80e...ea27d1 )
by Chris
03:18
created

Relation   F

Complexity

Total Complexity 85

Size/Duplication

Total Lines 803
Duplicated Lines 0 %

Coupling/Cohesion

Components 3
Dependencies 2

Importance

Changes 0
Metric Value
dl 0
loc 803
rs 1.797
c 0
b 0
f 0
wmc 85
lcom 3
cbo 2

43 Methods

Rating   Name   Duplication   Size   Complexity  
setDefaultKeys() 0 1 ?
eager() 0 1 ?
retrieve() 0 1 ?
associate() 0 1 ?
dissociate() 0 1 ?
A arrayify() 0 4 2
A separateKeys() 0 15 3
A resolveClass() 0 8 2
A factory() 0 22 3
A __construct() 0 13 3
A delimitClass() 0 9 1
A prepareForeignKey() 0 4 1
A defaultConstraint() 0 6 1
A attributeList() 0 16 4
A adjacencyList() 0 12 3
A reduce() 0 16 4
A replace() 0 20 5
A persist() 0 6 2
A verify() 0 4 1
A verifyModels() 0 12 4
A verifyParents() 0 4 1
A storage() 0 6 4
A name() 0 6 2
A foreignKey() 0 6 2
A localKey() 0 6 2
A constrain() 0 4 1
A constraint() 0 4 1
A filter() 0 4 1
A sort() 0 4 1
A order() 0 4 1
A read() 0 4 1
A query() 0 14 1
A load() 0 9 1
A loaded() 0 4 1
A one() 0 10 4
A all() 0 10 3
A count() 0 8 3
A set() 0 6 1
A clear() 0 5 1
A attach() 0 8 2
A detach() 0 24 4
B save() 0 37 6
A __get() 0 6 2

How to fix   Complexity   

Complex Class

Complex classes like Relation often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Relation, and based on these observations, apply Extract Interface, too.

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
10
/**
11
 * Darya's abstract entity relation.
12
 *
13
 * TODO: errors() method.
14
 * TODO: Filter, order, limit, offset for load() and retrieve().
15
 * TODO: Shouldn't delimitClass() and prepareForeignKey() be static?
16
 * TODO: Separate filters for reading and updating/deleting (readFilter(), modifyFilter()) mainly for BelongsToMany
17
 *
18
 * @property-read string    $name
19
 * @property-read Record    $parent
20
 * @property-read Record    $target
21
 * @property-read string    $foreignKey
22
 * @property-read string    $localKey
23
 * @property-read array     $constraint
24
 * @property-read Record[]  $related
25
 * @property-read bool      $loaded
26
 * @property-read Queryable $storage
27
 *
28
 * @author Chris Andrew <[email protected]>
29
 */
30
abstract class Relation
31
{
32
	const HAS             = 'has';
33
	const HAS_MANY        = 'has_many';
34
	const BELONGS_TO      = 'belongs_to';
35
	const BELONGS_TO_MANY = 'belongs_to_many';
36
37
	/**
38
	 * A map of relation type constants to their respective implementations.
39
	 *
40
	 * @var array
41
	 */
42
	protected static $classMap = array(
43
		self::HAS             => 'Darya\ORM\Relation\Has',
44
		self::HAS_MANY        => 'Darya\ORM\Relation\HasMany',
45
		self::BELONGS_TO      => 'Darya\ORM\Relation\BelongsTo',
46
		self::BELONGS_TO_MANY => 'Darya\ORM\Relation\BelongsToMany',
47
	);
48
49
	/**
50
	 * The name of the relation in the context of the parent model.
51
	 *
52
	 * @var string
53
	 */
54
	protected $name = '';
55
56
	/**
57
	 * The parent model.
58
	 *
59
	 * @var Record
60
	 */
61
	protected $parent;
62
63
	/**
64
	 * The target model.
65
	 *
66
	 * @var Record
67
	 */
68
	protected $target;
69
70
	/**
71
	 * Foreign key on the "belongs-to" model.
72
	 *
73
	 * @var string
74
	 */
75
	protected $foreignKey;
76
77
	/**
78
	 * Local key on the "has" model.
79
	 *
80
	 * @var string
81
	 */
82
	protected $localKey;
83
84
	/**
85
	 * Filter for constraining related models loaded from storage.
86
	 *
87
	 * @var array
88
	 */
89
	protected $constraint = array();
90
91
	/**
92
	 * Sort order for related models.
93
	 *
94
	 * @var array
95
	 */
96
	protected $sort = array();
97
98
	/**
99
	 * The related instances.
100
	 *
101
	 * @var Record[]
102
	 */
103
	protected $related = array();
104
105
	/**
106
	 * Detached instances that need dissociating on save.
107
	 *
108
	 * @var Record[]
109
	 */
110
	protected $detached = array();
111
112
	/**
113
	 * Determines whether related instances have been loaded.
114
	 *
115
	 * @var bool
116
	 */
117
	protected $loaded = false;
118
119
	/**
120
	 * The storage interface.
121
	 *
122
	 * @var Queryable
123
	 */
124
	protected $storage;
125
126
	/**
127
	 * Helper method for methods that accept single or multiple values, or for
128
	 * just casting to an array without losing a plain object.
129
	 *
130
	 * Returns an array with the given value as its sole element, if it is not
131
	 * an array already.
132
	 *
133
	 * This exists because casting an object to an array results in its public
134
	 * properties being set as the values.
135
	 *
136
	 * @param mixed $value
137
	 * @return array
138
	 */
139
	protected static function arrayify($value)
140
	{
141
		return !is_array($value) ? array($value) : $value;
142
	}
143
144
	/**
145
	 * Separate array elements with numeric keys from those with string keys.
146
	 *
147
	 * @param array $array
148
	 * @return array array($numeric, $strings)
149
	 */
150
	protected static function separateKeys(array $array)
151
	{
152
		$numeric = array();
153
		$strings = array();
154
155
		foreach ($array as $key => $value) {
156
			if (is_numeric($key)) {
157
				$numeric[$key] = $value;
158
			} else {
159
				$strings[$key] = $value;
160
			}
161
		}
162
163
		return array($numeric, $strings);
164
	}
165
166
	/**
167
	 * Resolve a relation class name from the given relation type constant.
168
	 *
169
	 * @param string $type
170
	 * @return string
171
	 */
172
	protected static function resolveClass($type)
173
	{
174
		if (isset(static::$classMap[$type])) {
175
			return static::$classMap[$type];
176
		}
177
178
		return static::$classMap[static::HAS];
179
	}
180
181
	/**
182
	 * Create a new relation of the given type using the given arguments.
183
	 *
184
	 * Applies numerically-keyed arguments to the constructor and string-keyed
185
	 * arguments to methods with the same name.
186
	 *
187
	 * @param string $type
188
	 * @param array  $arguments
189
	 * @return Relation
190
	 */
191
	public static function factory($type = self::HAS, array $arguments)
192
	{
193
		$class = static::resolveClass($type);
194
195
		$reflection = new ReflectionClass($class);
196
197
		list($arguments, $named) = static::separateKeys($arguments);
198
199
		/**
200
		 * @var Relation $instance
201
		 */
202
		$instance = $reflection->newInstanceArgs($arguments);
203
204
		foreach ($named as $method => $argument) {
205
			if (method_exists($instance, $method)) {
206
				$argument = static::arrayify($argument);
207
				call_user_func_array(array($instance, $method), $argument);
208
			}
209
		}
210
211
		return $instance;
212
	}
213
214
	/**
215
	 * Instantiate a new relation.
216
	 *
217
	 * @param Record $parent     Parent class
218
	 * @param string $target     Related class that extends \Darya\ORM\Record
219
	 * @param string $foreignKey [optional] Custom foreign key
220
	 * @param array  $constraint [optional] Constraint filter for related models
221
	 * @throws InvalidArgumentException
222
	 */
223
	public function __construct(Record $parent, $target, $foreignKey = null, array $constraint = array())
224
	{
225
		if (!is_subclass_of($target, 'Darya\ORM\Record')) {
226
			throw new InvalidArgumentException('Target class not does not extend Darya\ORM\Record');
227
		}
228
229
		$this->parent = $parent;
230
		$this->target = !is_object($target) ? new $target : $target;
231
232
		$this->foreignKey = $foreignKey;
233
		$this->setDefaultKeys();
234
		$this->constrain($constraint);
235
	}
236
237
	/**
238
	 * Lowercase and delimit the given PascalCase class name.
239
	 *
240
	 * @param string $class
241
	 * @return string
242
	 */
243
	protected function delimitClass($class)
244
	{
245
		$split = explode('\\', $class);
246
		$class = end($split);
247
248
		return preg_replace_callback('/([A-Z])/', function ($matches) {
249
			return '_' . strtolower($matches[1]);
250
		}, lcfirst($class));
251
	}
252
253
	/**
254
	 * Prepare a foreign key from the given class name.
255
	 *
256
	 * @param string $class
257
	 * @return string
258
	 */
259
	protected function prepareForeignKey($class)
260
	{
261
		return $this->delimitClass($class) . '_id';
262
	}
263
264
	/**
265
	 * Retrieve the default filter for the related models.
266
	 *
267
	 * @return array
268
	 */
269
	protected function defaultConstraint()
270
	{
271
		return array(
272
			$this->foreignKey => $this->parent->id()
273
		);
274
	}
275
276
	/**
277
	 * Set the default keys for the relation if they haven't already been set.
278
	 */
279
	abstract protected function setDefaultKeys();
280
281
	/**
282
	 * Retrieve the values of the given attribute of the given instances.
283
	 *
284
	 * Works similarly to array_column(), but doesn't return data from any rows
285
	 * without the given attribute set.
286
	 *
287
	 * Optionally accepts a second attribute to index by.
288
	 *
289
	 * @param Record[]|Record|array $instances
290
	 * @param string                $attribute
291
	 * @param string                $index     [optional]
292
	 * @return array
293
	 */
294
	protected static function attributeList($instances, $attribute, $index = null)
295
	{
296
		$values = array();
297
298
		foreach (static::arrayify($instances) as $instance) {
299
			if (isset($instance[$attribute])) {
300
				if ($index !== null) {
301
					$values[$instance[$index]] = $instance[$attribute];
302
				} else {
303
					$values[] = $instance[$attribute];
304
				}
305
			}
306
		}
307
308
		return $values;
309
	}
310
311
	/**
312
	 * Build an adjacency list of related models, indexed by their foreign keys.
313
	 *
314
	 * Optionally accepts a different attribute to index the models by.
315
	 *
316
	 * @param Record[] $instances
317
	 * @param string   $index     [optional]
318
	 * @return array
319
	 */
320
	protected function adjacencyList(array $instances, $index = null)
321
	{
322
		$index = $index ?: $this->foreignKey;
323
324
		$related = array();
325
326
		foreach ($instances as $instance) {
327
			$related[$instance->get($index)][] = $instance;
328
		}
329
330
		return $related;
331
	}
332
333
	/**
334
	 * Reduce the cached related models to those with the given IDs.
335
	 *
336
	 * If no IDs are given then all of the in-memory models will be removed.
337
	 *
338
	 * @param int[] $ids
339
	 */
340
	protected function reduce(array $ids = array())
341
	{
342
		if (empty($this->related)) {
343
			return;
344
		}
345
346
		$keys = array();
347
348
		foreach ($this->related as $key => $instance) {
349
			if (!in_array($instance->id(), $ids)) {
350
				$keys[$key] = null;
351
			}
352
		}
353
354
		$this->related = array_values(array_diff_key($this->related, $keys));
355
	}
356
357
	/**
358
	 * Replace a cached related model.
359
	 *
360
	 * If the related model does not have an ID or it is not found, it is simply
361
	 * appended.
362
	 *
363
	 * TODO: Remove from $this->detached if found?
364
	 *
365
	 * @param Record $instance
366
	 */
367
	protected function replace(Record $instance)
368
	{
369
		$this->verify($instance);
370
371
		if (!$instance->id()) {
372
			$this->related[] = $instance;
373
374
			return;
375
		}
376
377
		foreach ($this->related as $key => $related) {
378
			if ($related->id() === $instance->id() || $related === $instance) {
379
				$this->related[$key] = $instance;
380
381
				return;
382
			}
383
		}
384
385
		$this->related[] = $instance;
386
	}
387
388
	/**
389
	 * Save the given record to storage if it hasn't got an ID.
390
	 *
391
	 * @param Record $instance
392
	 */
393
	protected function persist(Record $instance)
394
	{
395
		if (!$instance->id()) {
396
			$instance->save();
397
		}
398
	}
399
400
	/**
401
	 * Verify that the given models are instances of the relation's target
402
	 * class.
403
	 *
404
	 * Throws an exception if any of them aren't.
405
	 *
406
	 * @param Record[]|Record $instances
407
	 * @throws Exception
408
	 */
409
	protected function verify($instances)
410
	{
411
		static::verifyModels($instances, get_class($this->target));
412
	}
413
414
	/**
415
	 * Verify that the given objects are instances of the given class.
416
	 *
417
	 * @param object[]|object $instances
418
	 * @param string          $class
419
	 * @throws Exception
420
	 */
421
	protected static function verifyModels($instances, $class)
422
	{
423
		if (!class_exists($class)) {
424
			return;
425
		}
426
427
		foreach (static::arrayify($instances) as $instance) {
428
			if (!$instance instanceof $class) {
429
				throw new Exception('Related models must be an instance of ' . $class);
430
			}
431
		}
432
	}
433
434
	/**
435
	 * Verify that the given models are instances of the relation's parent
436
	 * class.
437
	 *
438
	 * Throws an exception if any of them aren't.
439
	 *
440
	 * @param Record[]|Record $instances
441
	 * @throws Exception
442
	 */
443
	protected function verifyParents($instances)
444
	{
445
		static::verifyModels($instances, get_class($this->parent));
446
	}
447
448
	/**
449
	 * Retrieve and optionally set the storage used for the target model.
450
	 *
451
	 * Falls back to target model storage, then parent model storage.
452
	 *
453
	 * @param Queryable $storage
454
	 * @return Queryable
455
	 */
456
	public function storage(Queryable $storage = null)
457
	{
458
		$this->storage = $storage ?: $this->storage;
459
460
		return $this->storage ?: $this->target->storage() ?: $this->parent->storage();
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->storage ?: $this-...his->parent->storage(); of type Darya\Storage\Queryable|Darya\Storage\Readable adds the type Darya\Storage\Readable to the return on line 460 which is incompatible with the return type documented by Darya\ORM\Relation::storage of type Darya\Storage\Queryable.
Loading history...
461
	}
462
463
	/**
464
	 * Retrieve and optionally set the name of the relation on the parent model.
465
	 *
466
	 * @param string $name [optional]
467
	 * @return string
468
	 */
469
	public function name($name = '')
470
	{
471
		$this->name = (string) $name ?: $this->name;
472
473
		return $this->name;
474
	}
475
476
	/**
477
	 * Retrieve and optionally set the foreign key for the "belongs-to" model.
478
	 *
479
	 * @param string $foreignKey [optional]
480
	 * @return string
481
	 */
482
	public function foreignKey($foreignKey = '')
483
	{
484
		$this->foreignKey = (string) $foreignKey ?: $this->foreignKey;
485
486
		return $this->foreignKey;
487
	}
488
489
	/**
490
	 * Retrieve and optionally set the local key for the "has" model.
491
	 *
492
	 * @param string $localKey [optional]
493
	 * @return string
494
	 */
495
	public function localKey($localKey = '')
496
	{
497
		$this->localKey = (string) $localKey ?: $this->localKey;
498
499
		return $this->localKey;
500
	}
501
502
	/**
503
	 * Set a filter to constrain which models are considered related.
504
	 *
505
	 * @param array $filter
506
	 */
507
	public function constrain(array $filter)
508
	{
509
		$this->constraint = $filter;
510
	}
511
512
	/**
513
	 * Retrieve the custom filter used to constrain related models.
514
	 *
515
	 * @return array
516
	 */
517
	public function constraint()
518
	{
519
		return $this->constraint;
520
	}
521
522
	/**
523
	 * Retrieve the filter for this relation.
524
	 *
525
	 * @return array
526
	 */
527
	public function filter()
528
	{
529
		return array_merge($this->defaultConstraint(), $this->constraint());
530
	}
531
532
	/**
533
	 * Set the sorting order for this relation.
534
	 *
535
	 * @param array|string $order
536
	 * @return array|string
537
	 */
538
	public function sort($order)
539
	{
540
		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...
541
	}
542
543
	/**
544
	 * Retrieve the order for this relation.
545
	 *
546
	 * @return array|string
547
	 */
548
	public function order()
549
	{
550
		return $this->sort;
551
	}
552
553
	/**
554
	 * Read related model data from storage.
555
	 *
556
	 * TODO: $filter, $order, $offset
557
	 *
558
	 * @param int $limit [optional]
559
	 * @return array
560
	 */
561
	public function read($limit = 0)
562
	{
563
		return $this->storage()->read($this->target->table(), $this->filter(), $this->order(), $limit);
564
	}
565
566
	/**
567
	 * Query related model data from storage.
568
	 *
569
	 * @return Builder
570
	 */
571
	public function query()
572
	{
573
		$class = get_class($this->target);
574
575
		$builder = $this->storage()->query($this->target->table())
0 ignored issues
show
Documentation Bug introduced by
The method filters does not exist on object<Darya\Storage\Query\Builder>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
576
			->filters($this->filter())
577
			->orders($this->order());
578
579
		$builder->callback(function ($result) use ($class) {
580
			return $class::hydrate($result->data);
581
		});
582
583
		return $builder;
584
	}
585
586
	/**
587
	 * Read, generate and set cached related models from storage.
588
	 *
589
	 * This will completely replace any cached related models.
590
	 *
591
	 * @param int $limit [optional]
592
	 * @return Record[]
593
	 */
594
	public function load($limit = 0)
595
	{
596
		$data = $this->read($limit);
597
		$class = get_class($this->target);
598
		$this->related = $class::generate($data);
599
		$this->loaded = true;
600
601
		return $this->related;
602
	}
603
604
	/**
605
	 * Determine whether cached related models have been loaded from storage.
606
	 *
607
	 * @return bool
608
	 */
609
	public function loaded()
610
	{
611
		return $this->loaded;
612
	}
613
614
	/**
615
	 * Eagerly load the related models for the given parent instances.
616
	 *
617
	 * Returns the given instances with their related models loaded.
618
	 *
619
	 * @param array $instances
620
	 * @return array
621
	 */
622
	abstract public function eager(array $instances);
623
624
	/**
625
	 * Retrieve one or many related model instances, depending on the relation.
626
	 *
627
	 * @return Record[]|Record|null
628
	 */
629
	abstract public function retrieve();
630
631
	/**
632
	 * Retrieve one related model instance.
633
	 *
634
	 * @return Record|null
635
	 */
636
	public function one()
637
	{
638
		if (!$this->loaded() && empty($this->related)) {
639
			$this->load(1);
640
		}
641
642
		// TODO: Load and merge with cached?
643
644
		return !empty($this->related) ? $this->related[0] : null;
645
	}
646
647
	/**
648
	 * Retrieve all related model instances.
649
	 *
650
	 * @return Record[]|null
651
	 */
652
	public function all()
653
	{
654
		if (!$this->loaded() && empty($this->related)) {
655
			$this->load();
656
		}
657
658
		// TODO: Load and merge with cached?
659
660
		return $this->related;
661
	}
662
663
	/**
664
	 * Count the number of related model instances.
665
	 *
666
	 * Counts loaded or attached instances if they are present, queries storage
667
	 * otherwise.
668
	 *
669
	 * @return int
670
	 */
671
	public function count()
672
	{
673
		if (!$this->loaded() && empty($this->related)) {
674
			return $this->storage()->count($this->target->table(), $this->filter());
675
		}
676
677
		return count($this->related);
678
	}
679
680
	/**
681
	 * Set the related models.
682
	 *
683
	 * Overwrites any currently set related models.
684
	 *
685
	 * @param Record[] $instances
686
	 */
687
	public function set($instances)
688
	{
689
		$this->verify($instances);
690
		$this->related = static::arrayify($instances);
691
		$this->loaded = true;
692
	}
693
694
	/**
695
	 * Clear the related models.
696
	 */
697
	public function clear()
698
	{
699
		$this->related = array();
700
		$this->loaded = false;
701
	}
702
703
	/**
704
	 * Attach the given models.
705
	 *
706
	 * @param Record[]|Record $instances
707
	 */
708
	public function attach($instances)
709
	{
710
		$this->verify($instances);
711
712
		foreach (static::arrayify($instances) as $instance) {
713
			$this->replace($instance);
714
		}
715
	}
716
717
	/**
718
	 * Detach the given models.
719
	 *
720
	 * Detaches all attached models if none are given.
721
	 *
722
	 * @param Record[]|Record $instances [optional]
723
	 */
724
	public function detach($instances = array())
725
	{
726
		$this->verify($instances);
727
728
		$instances = static::arrayify($instances) ?: $this->related;
729
730
		$relatedIds = static::attributeList($this->related, 'id');
731
		$detached = array();
732
		$ids = array();
733
734
		// Collect the IDs and instances of the models to be detached
735
		foreach ($instances as $instance) {
736
			if (in_array($instance->id(), $relatedIds)) {
737
				$ids[] = $instance->id();
738
				$detached[] = $instance;
739
			}
740
		}
741
742
		// Reduce related models to those that haven't been detached
743
		$this->reduce(array_diff($relatedIds, $ids));
744
745
		// Merge the newly detached models in with the existing ones
746
		$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...
747
	}
748
749
	/**
750
	 * Associate the given models.
751
	 *
752
	 * Returns the number of models successfully associated.
753
	 *
754
	 * @param Record[]|Record $instances
755
	 * @return int
756
	 */
757
	abstract public function associate($instances);
758
759
	/**
760
	 * Dissociate the given models.
761
	 *
762
	 * Returns the number of models successfully dissociated.
763
	 *
764
	 * @param Record[]|Record $instances [optional]
765
	 * @return int
766
	 */
767
	abstract public function dissociate($instances = array());
768
769
	/**
770
	 * Save the relationship.
771
	 *
772
	 * Associates related models and dissociates detached models.
773
	 *
774
	 * Optionally accepts a set of IDs to save by. Saves all related models
775
	 * otherwise.
776
	 *
777
	 * Returns the number of associated models.
778
	 *
779
	 * @param int[] $ids
780
	 * @return int
781
	 */
782
	public function save(array $ids = array())
783
	{
784
		$related = $this->related;
785
		$detached = $this->detached;
786
787
		// Filter the IDs to associate and dissociate if any have been given
788
		if (!empty($ids)) {
789
			$filter = function ($instance) use ($ids) {
790
				return in_array($instance->id(), $ids);
791
			};
792
793
			$related = array_filter($related, $filter);
794
			$detached = array_filter($detached, $filter);
795
		}
796
797
		// Bail if we have nothing to associate or dissociate
798
		if (empty($related) && empty($detached)) {
799
			return 0;
800
		}
801
802
		// Dissociate, then associate
803
		if  (!empty($detached)) {
804
			$this->dissociate($detached);
805
		}
806
807
		$associated = $this->associate($related);
808
809
		// Update detached models to be persisted
810
		$this->detached = array();
811
812
		// Persist relationships on all related models
813
		foreach ($related as $instance) {
814
			$instance->saveRelations();
815
		}
816
817
		return $associated;
818
	}
819
820
	/**
821
	 * Dynamic read-only access for relation properties.
822
	 *
823
	 * @param string $property
824
	 * @return mixed
825
	 */
826
	public function __get($property)
827
	{
828
		if (property_exists($this, $property)) {
829
			return $this->$property;
830
		}
831
	}
832
}
833