Completed
Push — master ( 6682d4...9f12bf )
by Chris
02:48
created

Relation::separateKeys()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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