Completed
Push — master ( ad0a2c...3d1a35 )
by Chris
02:24
created

Relation::defaultConstraints()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 5
rs 9.4285
cc 1
eloc 3
nc 1
nop 0
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
	 * 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 [optional] Custom foreign key
99
	 * @param array  $constraint [optional] Constraint filter for related models
100
	 */
101
	public function __construct(Record $parent, $target, $foreignKey = null, array $constraint = array()) {
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);
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 the related models.
138
	 * 
139
	 * @return array
140
	 */
141
	protected function defaultConstraint() {
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|array $instances
172
	 * @param string                $attribute
173
	 * @param string                $index     [optional]
174
	 * @return array
175
	 */
176
	protected static function attributeList($instances, $attribute, $index = null) {
177
		$values = array();
178
		
179
		foreach (static::arrayify($instances) as $instance) {
180
			if (isset($instance[$attribute])) {
181
				if ($index !== null) {
182
					$values[$instance[$index]] = $instance[$attribute];
183
				} else {
184
					$values[] = $instance[$attribute];
185
				}
186
			}
187
		}
188
		
189
		return $values;
190
	}
191
	
192
	/**
193
	 * Reduce the cached related models to those with the given IDs.
194
	 * 
195
	 * If no IDs are given then all of the in-memory models will be removed.
196
	 * 
197
	 * @param int[] $ids
198
	 */
199
	protected function reduce(array $ids = array()) {
200
		if (empty($this->related)) {
201
			return;
202
		}
203
		
204
		$keys = array();
205
		
206
		foreach ($this->related as $key => $instance) {
207
			if (!in_array($instance->id(), $ids)) {
208
				$keys[$key] = null;
209
			}
210
		}
211
		
212
		$this->related = array_values(array_diff_key($this->related, $keys));
213
	}
214
	
215
	/**
216
	 * Replace a cached related model.
217
	 * 
218
	 * If the related model does not have an ID or it is not found, it is simply
219
	 * appended.
220
	 * 
221
	 * Retrieves related models if none have been loaded yet.
222
	 * 
223
	 * @param Record $instance
224
	 */
225
	protected function replace(Record $instance) {
226
		$this->verify($instance);
227
		
228
		$this->retrieve();
229
		
230
		if (!$instance->id()) {
231
			$this->related[] = $instance;
232
			
233
			return;
234
		}
235
		
236
		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...
237
			if ($related->id() === $instance->id()) {
238
				$this->related[$key] = $instance;
239
				
240
				return;
241
			}
242
		}
243
		
244
		$this->related[] = $instance;
245
	}
246
	
247
	/**
248
	 * Save the given record to storage if it hasn't got an ID.
249
	 * 
250
	 * @param Record $instance
251
	 */
252
	protected function persist(Record $instance) {
253
		if (!$instance->id()) {
254
			$instance->save();
255
		}
256
	}
257
	
258
	/**
259
	 * Verify that the given models are instances of the relation's target
260
	 * class.
261
	 * 
262
	 * Throws an exception if any of them aren't.
263
	 * 
264
	 * @param Record[]|Record $instances
265
	 * @throws Exception
266
	 */
267
	protected function verify($instances) {
268
		static::verifyModels($instances, get_class($this->target));
269
	}
270
	
271
	/**
272
	 * Verify that the given objects are instances of the given class.
273
	 * 
274
	 * @param object[]|object $instances
275
	 * @param string          $class
276
	 * @throws Exception
277
	 */
278
	protected static function verifyModels($instances, $class) {
279
		if (!class_exists($class)) {
280
			return;
281
		}
282
		
283
		foreach (static::arrayify($instances) as $instance) {
284
			if (!$instance instanceof $class) {
285
				throw new Exception('Related models must be an instance of ' . $class);
286
			}
287
		}
288
	}
289
	
290
	/**
291
	 * Verify that the given models are instances of the relation's parent
292
	 * class.
293
	 * 
294
	 * Throws an exception if any of them aren't.
295
	 * 
296
	 * @param Record[]|Record $instances
297
	 * @throws Exception
298
	 */
299
	protected function verifyParents($instances) {
300
		static::verifyModels($instances, get_class($this->parent));
301
	}
302
	
303
	/**
304
	 * Retrieve and optionally set the storage used for the target model.
305
	 * 
306
	 * Falls back to target model storage, then parent model storage.
307
	 * 
308
	 * @param Readable $storage
309
	 */
310
	public function storage(Readable $storage = null) {
311
		$this->storage = $storage ?: $this->storage;
312
		
313
		return $this->storage ?: $this->target->storage() ?: $this->parent->storage();
314
	}
315
	
316
	/**
317
	 * Set a filter to constrain which models are considered related.
318
	 * 
319
	 * @param array $filter
320
	 */
321
	public function constrain(array $filter) {
322
		$this->constraint = $filter;
323
	}
324
	
325
	/**
326
	 * Retrieve the custom filter used to constrain related models.
327
	 * 
328
	 * @return array
329
	 */
330
	public function constraint() {
331
		return $this->constraint;
332
	}
333
	
334
	/**
335
	 * Retrieve the filter for this relation.
336
	 * 
337
	 * @return array
338
	 */
339
	public function filter() {
340
		return array_merge($this->defaultConstraint(), $this->constraint());
341
	}
342
	
343
	/**
344
	 * Read related model data from storage.
345
	 * 
346
	 * TODO: $filter, $order, $offset
347
	 * 
348
	 * @param int $limit [optional]
349
	 * @return array
350
	 */
351
	public function read($limit = 0) {
352
		return $this->storage()->read($this->target->table(), $this->filter(), null, $limit);
353
	}
354
	
355
	/**
356
	 * Read, generate and set cached related models from storage.
357
	 * 
358
	 * @param int $limit [optional]
359
	 * @return Record[]
360
	 */
361
	public function load($limit = 0) {
362
		$data = $this->read($limit);
363
		$class = get_class($this->target);
364
		$this->related = $class::generate($data);
365
		
366
		return $this->related;
367
	}
368
	
369
	/**
370
	 * Determine whether cached related models have been attempted to be loaded.
371
	 * 
372
	 * @return bool
373
	 */
374
	public function loaded() {
375
		return $this->related !== null;
376
	}
377
	
378
	/**
379
	 * Eagerly load the related models for the given parent instances.
380
	 * 
381
	 * Returns the given instances with their related models loaded.
382
	 * 
383
	 * @param array $instances
384
	 * @param string $name TODO: Remove this and store as a property
385
	 * @return array
386
	 */
387
	abstract public function eager(array $instances, $name);
388
	
389
	/**
390
	 * Retrieve one or many related model instances, depending on the relation.
391
	 * 
392
	 * @return Record[]|Record|null
393
	 */
394
	abstract public function retrieve();
395
	
396
	/**
397
	 * Retrieve one related model instance.
398
	 * 
399
	 * @return Record|null
400
	 */
401
	public function one() {
402
		if (!$this->loaded()) {
403
			$this->load(1);
404
		}
405
		
406
		return !empty($this->related) ? $this->related[0] : null;
407
	}
408
	
409
	/**
410
	 * Retrieve all related model instances.
411
	 * 
412
	 * @return Record[]|null
413
	 */
414
	public function all() {
415
		if (!$this->loaded()) {
416
			$this->load();
417
		}
418
		
419
		return $this->related;
420
	}
421
	
422
	/**
423
	 * Count the number of related model instances.
424
	 * 
425
	 * Counts loaded instances if they are present, queries storage otherwise.
426
	 * 
427
	 * @return int
428
	 */
429
	public function count() {
430
		if (!$this->loaded()) {
431
			return $this->storage()->count($this->target->table(), $this->filter());
432
		}
433
		
434
		return count($this->related);
435
	}
436
	
437
	/**
438
	 * Set the related models.
439
	 * 
440
	 * @param Record[] $instances
441
	 */
442
	public function set($instances) {
443
		$this->verify($instances);
444
		$this->related = static::arrayify($instances);
445
	}
446
	
447
	/**
448
	 * Clear the related models.
449
	 */
450
	public function clear() {
451
		$this->related = null;
452
	}
453
	
454
	/**
455
	 * Read-only access for relation properties.
456
	 * 
457
	 * @param string $property
458
	 * @return mixed
459
	 */
460
	public function __get($property) {
461
		if (property_exists($this, $property)) {
462
			return $this->$property;
463
		}
464
	}
465
	
466
}
467