Completed
Push — master ( dec0fe...94a512 )
by Chris
02:44
created

Relation::persist()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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