Completed
Push — master ( 480b81...de2c34 )
by Chris
02:53
created

Relation::resolveClass()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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