Completed
Push — master ( 6fc22f...b216a6 )
by Chris
03:21
created

Relation::__get()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 5
rs 9.4285
cc 2
eloc 3
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
 * 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 Filters for constraining related models loaded from storage
53
	 */
54
	protected $constraints = 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
	 * Create a new relation of the given type using the given arguments.
68
	 * 
69
	 * @param string $type
70
	 * @param array  $arguments
71
	 * @return Relation
72
	 */
73
	public static function factory($type = self::HAS, array $arguments) {
74
		switch ($type) {
75
			case static::HAS_MANY:
76
				$class = 'Darya\ORM\Relation\HasMany';
77
				break;
78
			case static::BELONGS_TO:
79
				$class = 'Darya\ORM\Relation\BelongsTo';
80
				break;
81
			case static::BELONGS_TO_MANY:
82
				$class = 'Darya\ORM\Relation\BelongsToMany';
83
				break;
84
			default:
85
				$class = 'Darya\ORM\Relation\Has';
86
		}
87
		
88
		$reflection = new ReflectionClass($class);
89
		
90
		return $reflection->newInstanceArgs($arguments);
91
	}
92
	
93
	/**
94
	 * Instantiate a new relation.
95
	 * 
96
	 * @param Record $parent     Parent class
97
	 * @param string $target     Related class that extends \Darya\ORM\Record
98
	 * @param string $foreignKey Custom foreign key
99
	 * @param array  $constraint Constraint filter for related models
100
	 */
101
	public function __construct(Record $parent, $target, $foreignKey = null, $constraint = null) {
102
		if (!is_subclass_of($target, 'Darya\ORM\Record')) {
103
			throw new Exception('Target class not does not extend Darya\ORM\Record');
104
		}
105
		
106
		$this->parent = $parent;
107
		$this->target = !is_object($target) ? new $target : $target;
108
		
109
		$this->foreignKey = $foreignKey;
110
		$this->setDefaultKeys();
111
		$this->constrain($constraint ?: array());
112
	}
113
	
114
	/**
115
	 * Lowercase and delimit the given PascalCase class name.
116
	 * 
117
	 * @param string $class
118
	 * @return string
119
	 */
120
	protected function delimitClass($class) {
121
		return preg_replace_callback('/([A-Z])/', function ($matches) {
122
			return '_' . strtolower($matches[1]);
123
		}, lcfirst(basename($class)));
124
	}
125
	
126
	/**
127
	 * Prepare a foreign key from the given class name.
128
	 * 
129
	 * @param string $class
130
	 * @return string
131
	 */
132
	protected function prepareForeignKey($class) {
133
		return $this->delimitClass($class) . '_id';
134
	}
135
	
136
	/**
137
	 * Retrieve the default filter for this relation.
138
	 * 
139
	 * @return array
140
	 */
141
	protected function defaultConstraints() {
142
		return array(
143
			$this->foreignKey => $this->parent->id()
144
		);
145
	}
146
	
147
	/**
148
	 * Set the default keys for the relation if they haven't already been set.
149
	 */
150
	abstract protected function setDefaultKeys();
151
	
152
	/**
153
	 * Helper method for methods that accept single or multiple values.
154
	 * 
155
	 * Returns a array with the given value as its sole element, if it is not an
156
	 * array already.
157
	 * 
158
	 * @param mixed $value
159
	 * @return array
160
	 */
161
	protected static function arrayify($value) {
162
		return !is_array($value) ? array($value) : $value;
163
	}
164
	
165
	/**
166
	 * Retrieve the values of the given attribute of the given instances.
167
	 * 
168
	 * Works similarly to array_column(), but doesn't return data from any rows
169
	 * without the given attribute set.
170
	 * 
171
	 * @param Record[]|Record $instances
172
	 * @param string $attribute
173
	 * @return array
174
	 */
175
	protected static function attributeList($instances, $attribute, $index = null) {
176
		$values = array();
177
		
178
		foreach (static::arrayify($instances) as $instance) {
179
			if (isset($instance[$attribute])) {
180
				if ($index !== null) {
181
					$values[$instance[$index]] = $instance[$attribute];
182
				} else {
183
					$values[] = $instance[$attribute];
184
				}
185
			}
186
		}
187
		
188
		return $values;
189
	}
190
	
191
	/**
192
	 * Reduce the cached related models to those with the given IDs.
193
	 * 
194
	 * If no IDs are given then all of the in-memory models will be removed.
195
	 * 
196
	 * @param int[] $ids
197
	 */
198
	protected function reduce(array $ids = array()) {
199
		if (empty($this->related)) {
200
			return;
201
		}
202
		
203
		$keys = array();
204
		
205
		foreach ($this->related as $key => $instance) {
206
			if (!in_array($instance->id(), $ids)) {
207
				$keys[$key] = null;
208
			}
209
		}
210
		
211
		$this->related = array_values(array_diff_key($this->related, $keys));
212
	}
213
	
214
	/**
215
	 * Replace a cached related model.
216
	 * 
217
	 * If the related model does not have an ID or it is not found, it is simply
218
	 * appended.
219
	 * 
220
	 * Retrieves related models if none have been loaded yet.
221
	 * 
222
	 * @param Record $instance
223
	 */
224
	protected function replace(Record $instance) {
225
		$this->verify($instance);
226
		
227
		$this->retrieve();
228
		
229
		if (!$instance->id()) {
230
			$this->related[] = $instance;
231
			
232
			return;
233
		}
234
		
235
		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...
236
			if ($related->id() === $instance->id()) {
237
				$this->related[$key] = $instance;
238
				
239
				return;
240
			}
241
		}
242
		
243
		$this->related[] = $instance;
244
	}
245
	
246
	/**
247
	 * Save the given record to storage if it hasn't got an ID.
248
	 * 
249
	 * @param Record $instance
250
	 */
251
	protected function persist(Record $instance) {
252
		if (!$instance->id()) {
253
			$instance->save();
254
		}
255
	}
256
	
257
	/**
258
	 * Verify that the given models are instances of the relation's target
259
	 * class.
260
	 * 
261
	 * Throws an exception if any of them aren't.
262
	 * 
263
	 * @param Record[]|Record $instances
264
	 * @throws Exception
265
	 */
266
	protected function verify($instances) {
267
		static::verifyModels($instances, get_class($this->target));
268
	}
269
	
270
	/**
271
	 * Verify that the given objects are instances of the given class.
272
	 * 
273
	 * @param object[]|object $instances
274
	 * @param string          $class
275
	 * @throws Exception
276
	 */
277
	protected static function verifyModels($instances, $class) {
278
		if (!class_exists($class)) {
279
			return;
280
		}
281
		
282
		foreach (static::arrayify($instances) as $instance) {
283
			if (!$instance instanceof $class) {
284
				throw new Exception('Related models must be an instance of ' . $class);
285
			}
286
		}
287
	}
288
	
289
	/**
290
	 * Verify that the given models are instances of the relation's parent
291
	 * class.
292
	 * 
293
	 * Throws an exception if any of them aren't.
294
	 * 
295
	 * @param Record[]|Record $instances
296
	 * @throws Exception
297
	 */
298
	protected function verifyParents($instances) {
299
		static::verifyModels($instances, get_class($this->parent));
300
	}
301
	
302
	/**
303
	 * Retrieve and optionally set the storage used for the target model.
304
	 * 
305
	 * Falls back to target model storage, then parent model storage.
306
	 * 
307
	 * @param Readable $storage
308
	 */
309
	public function storage(Readable $storage = null) {
310
		$this->storage = $storage ?: $this->storage;
311
		
312
		return $this->storage ?: $this->target->storage() ?: $this->parent->storage();
313
	}
314
	
315
	/**
316
	 * Set a filter to constrain which models are considered related.
317
	 * 
318
	 * @param array $filter
319
	 */
320
	public function constrain(array $filter) {
321
		$this->constraints = $filter;
322
	}
323
	
324
	/**
325
	 * Retrieve the custom filters used to constrain related models.
326
	 * 
327
	 * @return array
328
	 */
329
	public function constraints() {
330
		return $this->constraints;
331
	}
332
	
333
	/**
334
	 * Retrieve the filter for this relation.
335
	 * 
336
	 * @return array
337
	 */
338
	public function filter() {
339
		return array_merge($this->defaultConstraints(), $this->constraints());
340
	}
341
	
342
	/**
343
	 * Read related model data from storage.
344
	 * 
345
	 * TODO: $filter, $order, $offset
346
	 * 
347
	 * @param int $limit [optional]
348
	 * @return array
349
	 */
350
	public function read($limit = 0) {
351
		return $this->storage()->read($this->target->table(), $this->filter(), null, $limit);
352
	}
353
	
354
	/**
355
	 * Read, generate and set cached related models from storage.
356
	 * 
357
	 * @param int $limit [optional]
358
	 * @return Record[]
359
	 */
360
	public function load($limit = 0) {
361
		$data = $this->read($limit);
362
		$class = get_class($this->target);
363
		$this->related = $class::generate($data);
364
		
365
		return $this->related;
366
	}
367
	
368
	/**
369
	 * Determine whether cached related models have been attempted to be loaded.
370
	 * 
371
	 * @return bool
372
	 */
373
	public function loaded() {
374
		return $this->related !== null;
375
	}
376
	
377
	/**
378
	 * Eagerly load the related models for the given parent instances.
379
	 * 
380
	 * Returns the given instances with their related models loaded.
381
	 * 
382
	 * @param array $instances
383
	 * @param string $name TODO: Remove this and store as a property
384
	 * @return array
385
	 */
386
	abstract public function eager(array $instances, $name);
387
	
388
	/**
389
	 * Retrieve one or many related model instances, depending on the relation.
390
	 * 
391
	 * @return Record[]|Record|null
392
	 */
393
	abstract public function retrieve();
394
	
395
	/**
396
	 * Retrieve one related model instance.
397
	 * 
398
	 * @return Record|null
399
	 */
400
	public function one() {
401
		if (!$this->loaded()) {
402
			$this->load(1);
403
		}
404
		
405
		return !empty($this->related) ? $this->related[0] : null;
406
	}
407
	
408
	/**
409
	 * Retrieve all related model instances.
410
	 * 
411
	 * @return Record[]|null
412
	 */
413
	public function all() {
414
		if (!$this->loaded()) {
415
			$this->load();
416
		}
417
		
418
		return $this->related;
419
	}
420
	
421
	/**
422
	 * Count the number of related model instances.
423
	 * 
424
	 * Counts loaded instances if they are present, queries storage otherwise.
425
	 * 
426
	 * @return int
427
	 */
428
	public function count() {
429
		if (!$this->loaded()) {
430
			return $this->storage()->count($this->target->table(), $this->filter());
431
		}
432
		
433
		return count($this->related);
434
	}
435
	
436
	/**
437
	 * Set the related models.
438
	 * 
439
	 * @param Record[] $instances
440
	 */
441
	public function set($instances) {
442
		$this->verify($instances);
443
		$this->related = static::arrayify($instances);
444
	}
445
	
446
	/**
447
	 * Clear the related models.
448
	 */
449
	public function clear() {
450
		$this->related = null;
451
	}
452
	
453
	/**
454
	 * Read-only access for relation properties.
455
	 * 
456
	 * @param string $property
457
	 * @return mixed
458
	 */
459
	public function __get($property) {
460
		if (property_exists($this, $property)) {
461
			return $this->$property;
462
		}
463
	}
464
	
465
}
466