Completed
Push — master ( a0e404...7251f8 )
by Chris
02:39
created

Relation::__construct()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 1
Metric Value
c 2
b 1
f 1
dl 0
loc 12
rs 9.4285
cc 3
eloc 8
nc 3
nop 4
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
 * TODO: Use a $loaded property instead of setting relations to null.
16
 * 
17
 * @author Chris Andrew <[email protected]>
18
 */
19
abstract class Relation {
20
	
21
	const HAS             = 'has';
22
	const HAS_MANY        = 'has_many';
23
	const BELONGS_TO      = 'belongs_to';
24
	const BELONGS_TO_MANY = 'belongs_to_many';
25
	
26
	/**
27
	 * @var string The name of the relation in the context of the parent model
28
	 */
29
	protected $name = '';
30
	
31
	/**
32
	 * @var Record Parent model
33
	 */
34
	protected $parent;
35
	
36
	/**
37
	 * @var Record Target model
38
	 */
39
	protected $target;
40
	
41
	/**
42
	 * @var string Foreign key on the "belongs-to" model
43
	 */
44
	protected $foreignKey;
45
	
46
	/**
47
	 * @var string Local key on the "has" model
48
	 */
49
	protected $localKey;
50
	
51
	/**
52
	 * @var array Filter for constraining related models loaded from storage
53
	 */
54
	protected $constraint = array();
55
	
56
	/**
57
	 * @var Record[]|null The related instances
58
	 */
59
	protected $related = null;
60
	
61
	/**
62
	 * @var Readable Storage interface
63
	 */
64
	protected $storage;
65
	
66
	/**
67
	 * Separate array elements numeric keys from those with string keys.
68
	 * 
69
	 * @param array $array
70
	 * @return array array($numeric, $strings)
71
	 */
72
	public static function separateKeys(array $array) {
73
		$numeric = array();
74
		$strings = array();
75
		
76
		
77
		foreach ($array as $key => $value) {
78
			if (is_numeric($key)) {
79
				$numeric[$key] = $value;
80
			} else {
81
				$strings[$key] = $value;
82
			}
83
		}
84
		
85
		return array($numeric, $strings);
86
	}
87
	
88
	/**
89
	 * Create a new relation of the given type using the given arguments.
90
	 * 
91
	 * Applies numeric-ley arguments to the constructor and string-key arguments
92
	 * to methods with the same name as the key.
93
	 * 
94
	 * @param string $type
95
	 * @param array  $arguments
96
	 * @return Relation
97
	 */
98
	public static function factory($type = self::HAS, array $arguments) {
99
		switch ($type) {
100
			case static::HAS_MANY:
101
				$class = 'Darya\ORM\Relation\HasMany';
102
				break;
103
			case static::BELONGS_TO:
104
				$class = 'Darya\ORM\Relation\BelongsTo';
105
				break;
106
			case static::BELONGS_TO_MANY:
107
				$class = 'Darya\ORM\Relation\BelongsToMany';
108
				break;
109
			default:
110
				$class = 'Darya\ORM\Relation\Has';
111
		}
112
		
113
		$reflection = new ReflectionClass($class);
114
		
115
		list($arguments, $named) = static::separateKeys($arguments);
116
		
117
		$instance = $reflection->newInstanceArgs($arguments);
118
		
119
		foreach ($named as $method => $argument) {
120
			if (method_exists($instance, $method)) {
121
				$argument = static::arrayify($argument);
122
				call_user_func_array(array($instance, $method), $argument);
123
			}
124
		}
125
		
126
		return $instance;
127
	}
128
	
129
	/**
130
	 * Instantiate a new relation.
131
	 * 
132
	 * @param Record $parent     Parent class
133
	 * @param string $target     Related class that extends \Darya\ORM\Record
134
	 * @param string $foreignKey [optional] Custom foreign key
135
	 * @param array  $constraint [optional] Constraint filter for related models
136
	 */
137
	public function __construct(Record $parent, $target, $foreignKey = null, array $constraint = array()) {
138
		if (!is_subclass_of($target, 'Darya\ORM\Record')) {
139
			throw new Exception('Target class not does not extend Darya\ORM\Record');
140
		}
141
		
142
		$this->parent = $parent;
143
		$this->target = !is_object($target) ? new $target : $target;
144
		
145
		$this->foreignKey = $foreignKey;
146
		$this->setDefaultKeys();
147
		$this->constrain($constraint);
148
	}
149
	
150
	/**
151
	 * Lowercase and delimit the given PascalCase class name.
152
	 * 
153
	 * @param string $class
154
	 * @return string
155
	 */
156
	protected function delimitClass($class) {
157
		return preg_replace_callback('/([A-Z])/', function ($matches) {
158
			return '_' . strtolower($matches[1]);
159
		}, lcfirst(basename($class)));
160
	}
161
	
162
	/**
163
	 * Prepare a foreign key from the given class name.
164
	 * 
165
	 * @param string $class
166
	 * @return string
167
	 */
168
	protected function prepareForeignKey($class) {
169
		return $this->delimitClass($class) . '_id';
170
	}
171
	
172
	/**
173
	 * Retrieve the default filter for the related models.
174
	 * 
175
	 * @return array
176
	 */
177
	protected function defaultConstraint() {
178
		return array(
179
			$this->foreignKey => $this->parent->id()
180
		);
181
	}
182
	
183
	/**
184
	 * Set the default keys for the relation if they haven't already been set.
185
	 */
186
	abstract protected function setDefaultKeys();
187
	
188
	/**
189
	 * Helper method for methods that accept single or multiple values.
190
	 * 
191
	 * Returns a array with the given value as its sole element, if it is not an
192
	 * array already.
193
	 * 
194
	 * @param mixed $value
195
	 * @return array
196
	 */
197
	protected static function arrayify($value) {
198
		return !is_array($value) ? array($value) : $value;
199
	}
200
	
201
	/**
202
	 * Retrieve the values of the given attribute of the given instances.
203
	 * 
204
	 * Works similarly to array_column(), but doesn't return data from any rows
205
	 * without the given attribute set.
206
	 * 
207
	 * @param Record[]|Record|array $instances
208
	 * @param string                $attribute
209
	 * @param string                $index     [optional]
210
	 * @return array
211
	 */
212
	protected static function attributeList($instances, $attribute, $index = null) {
213
		$values = array();
214
		
215
		foreach (static::arrayify($instances) as $instance) {
216
			if (isset($instance[$attribute])) {
217
				if ($index !== null) {
218
					$values[$instance[$index]] = $instance[$attribute];
219
				} else {
220
					$values[] = $instance[$attribute];
221
				}
222
			}
223
		}
224
		
225
		return $values;
226
	}
227
	
228
	/**
229
	 * Reduce the cached related models to those with the given IDs.
230
	 * 
231
	 * If no IDs are given then all of the in-memory models will be removed.
232
	 * 
233
	 * @param int[] $ids
234
	 */
235
	protected function reduce(array $ids = array()) {
236
		if (empty($this->related)) {
237
			return;
238
		}
239
		
240
		$keys = array();
241
		
242
		foreach ($this->related as $key => $instance) {
243
			if (!in_array($instance->id(), $ids)) {
244
				$keys[$key] = null;
245
			}
246
		}
247
		
248
		$this->related = array_values(array_diff_key($this->related, $keys));
249
	}
250
	
251
	/**
252
	 * Replace a cached related model.
253
	 * 
254
	 * If the related model does not have an ID or it is not found, it is simply
255
	 * appended.
256
	 * 
257
	 * Retrieves related models if none have been loaded yet.
258
	 * 
259
	 * @param Record $instance
260
	 */
261
	protected function replace(Record $instance) {
262
		$this->verify($instance);
263
		
264
		$this->retrieve();
265
		
266
		if (!$instance->id()) {
267
			$this->related[] = $instance;
268
			
269
			return;
270
		}
271
		
272
		foreach ($this->related as $key => $related) {
0 ignored issues
show
Bug introduced by
The expression $this->related of type array<integer,object<Darya\ORM\Record>>|null is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
273
			if ($related->id() === $instance->id()) {
274
				$this->related[$key] = $instance;
275
				
276
				return;
277
			}
278
		}
279
		
280
		$this->related[] = $instance;
281
	}
282
	
283
	/**
284
	 * Save the given record to storage if it hasn't got an ID.
285
	 * 
286
	 * @param Record $instance
287
	 */
288
	protected function persist(Record $instance) {
289
		if (!$instance->id()) {
290
			$instance->save();
291
		}
292
	}
293
	
294
	/**
295
	 * Verify that the given models are instances of the relation's target
296
	 * class.
297
	 * 
298
	 * Throws an exception if any of them aren't.
299
	 * 
300
	 * @param Record[]|Record $instances
301
	 * @throws Exception
302
	 */
303
	protected function verify($instances) {
304
		static::verifyModels($instances, get_class($this->target));
305
	}
306
	
307
	/**
308
	 * Verify that the given objects are instances of the given class.
309
	 * 
310
	 * @param object[]|object $instances
311
	 * @param string          $class
312
	 * @throws Exception
313
	 */
314
	protected static function verifyModels($instances, $class) {
315
		if (!class_exists($class)) {
316
			return;
317
		}
318
		
319
		foreach (static::arrayify($instances) as $instance) {
320
			if (!$instance instanceof $class) {
321
				throw new Exception('Related models must be an instance of ' . $class);
322
			}
323
		}
324
	}
325
	
326
	/**
327
	 * Verify that the given models are instances of the relation's parent
328
	 * class.
329
	 * 
330
	 * Throws an exception if any of them aren't.
331
	 * 
332
	 * @param Record[]|Record $instances
333
	 * @throws Exception
334
	 */
335
	protected function verifyParents($instances) {
336
		static::verifyModels($instances, get_class($this->parent));
337
	}
338
	
339
	/**
340
	 * Retrieve and optionally set the name of the relation on the parent model.
341
	 * 
342
	 * @param string $name [optional]
343
	 * @return string
344
	 */
345
	public function name($name = '') {
346
		$this->name = ((string) $name) ?: $this->name;
347
		
348
		return $this->name;
349
	}
350
	
351
	/**
352
	 * Retrieve and optionally set the storage used for the target model.
353
	 * 
354
	 * Falls back to target model storage, then parent model storage.
355
	 * 
356
	 * @param Readable $storage
357
	 */
358
	public function storage(Readable $storage = null) {
359
		$this->storage = $storage ?: $this->storage;
360
		
361
		return $this->storage ?: $this->target->storage() ?: $this->parent->storage();
362
	}
363
	
364
	/**
365
	 * Set a filter to constrain which models are considered related.
366
	 * 
367
	 * @param array $filter
368
	 */
369
	public function constrain(array $filter) {
370
		$this->constraint = $filter;
371
	}
372
	
373
	/**
374
	 * Retrieve the custom filter used to constrain related models.
375
	 * 
376
	 * @return array
377
	 */
378
	public function constraint() {
379
		return $this->constraint;
380
	}
381
	
382
	/**
383
	 * Retrieve the filter for this relation.
384
	 * 
385
	 * @return array
386
	 */
387
	public function filter() {
388
		return array_merge($this->defaultConstraint(), $this->constraint());
389
	}
390
	
391
	/**
392
	 * Read related model data from storage.
393
	 * 
394
	 * TODO: $filter, $order, $offset
395
	 * 
396
	 * @param int $limit [optional]
397
	 * @return array
398
	 */
399
	public function read($limit = 0) {
400
		return $this->storage()->read($this->target->table(), $this->filter(), null, $limit);
401
	}
402
	
403
	/**
404
	 * Read, generate and set cached related models from storage.
405
	 * 
406
	 * @param int $limit [optional]
407
	 * @return Record[]
408
	 */
409
	public function load($limit = 0) {
410
		$data = $this->read($limit);
411
		$class = get_class($this->target);
412
		$this->related = $class::generate($data);
413
		
414
		return $this->related;
415
	}
416
	
417
	/**
418
	 * Determine whether cached related models have been attempted to be loaded.
419
	 * 
420
	 * @return bool
421
	 */
422
	public function loaded() {
423
		return $this->related !== null;
424
	}
425
	
426
	/**
427
	 * Eagerly load the related models for the given parent instances.
428
	 * 
429
	 * Returns the given instances with their related models loaded.
430
	 * 
431
	 * @param array $instances
432
	 * @return array
433
	 */
434
	abstract public function eager(array $instances);
435
	
436
	/**
437
	 * Retrieve one or many related model instances, depending on the relation.
438
	 * 
439
	 * @return Record[]|Record|null
440
	 */
441
	abstract public function retrieve();
442
	
443
	/**
444
	 * Retrieve one related model instance.
445
	 * 
446
	 * @return Record|null
447
	 */
448
	public function one() {
449
		if (!$this->loaded()) {
450
			$this->load(1);
451
		}
452
		
453
		return !empty($this->related) ? $this->related[0] : null;
454
	}
455
	
456
	/**
457
	 * Retrieve all related model instances.
458
	 * 
459
	 * @return Record[]|null
460
	 */
461
	public function all() {
462
		if (!$this->loaded()) {
463
			$this->load();
464
		}
465
		
466
		return $this->related;
467
	}
468
	
469
	/**
470
	 * Count the number of related model instances.
471
	 * 
472
	 * Counts loaded instances if they are present, queries storage otherwise.
473
	 * 
474
	 * @return int
475
	 */
476
	public function count() {
477
		if (!$this->loaded()) {
478
			return $this->storage()->count($this->target->table(), $this->filter());
479
		}
480
		
481
		return count($this->related);
482
	}
483
	
484
	/**
485
	 * Set the related models.
486
	 * 
487
	 * @param Record[] $instances
488
	 */
489
	public function set($instances) {
490
		$this->verify($instances);
491
		$this->related = static::arrayify($instances);
492
	}
493
	
494
	/**
495
	 * Clear the related models.
496
	 */
497
	public function clear() {
498
		$this->related = null;
499
	}
500
	
501
	/**
502
	 * Read-only access for relation properties.
503
	 * 
504
	 * @param string $property
505
	 * @return mixed
506
	 */
507
	public function __get($property) {
508
		if (property_exists($this, $property)) {
509
			return $this->$property;
510
		}
511
	}
512
	
513
}
514