Completed
Pull Request — master (#21)
by James
02:38
created

Model::is_fillable()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 19
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4.0312

Importance

Changes 0
Metric Value
dl 0
loc 19
ccs 7
cts 8
cp 0.875
rs 9.2
c 0
b 0
f 0
cc 4
eloc 8
nc 4
nop 1
crap 4.0312
1
<?php
2
namespace Intraxia\Jaxion\Axolotl;
3
4
use Exception;
5
use Intraxia\Jaxion\Contract\Axolotl\Serializes;
6
use Intraxia\Jaxion\Contract\Axolotl\UsesCustomTable;
7
use Intraxia\Jaxion\Contract\Axolotl\UsesWordPressPost;
8
use Intraxia\Jaxion\Contract\Axolotl\UsesWordPressTerm;
9
use LogicException;
10
use WP_Post;
11
use WP_Term;
12
13
/**
14
 * Class Model
15
 *
16
 * Shared model methods and properties, allowing models
17
 * to transparently map some attributes to an underlying WP_Post
18
 * object and others to postmeta or a custom table.
19
 *
20
 * @package    Intraxia\Jaxion
21
 * @subpackage Axolotl
22
 * @since      0.1.0
23
 */
24
abstract class Model implements Serializes {
25
	/**
26
	 * Table attribute key.
27
	 */
28
	const TABLE_KEY = '@@table';
29
30
	/**
31
	 * Object attribute key.
32
	 */
33
	const OBJECT_KEY = '@@object';
34
35
	/**
36
	 * Memoized values for class methods.
37
	 *
38
	 * @var array
39
	 */
40
	private static $memo = array();
41
42
	/**
43
	 * Model attributes.
44
	 *
45
	 * @var array
46
	 */
47
	private $attributes = array(
48
		self::TABLE_KEY  => array(),
49
		self::OBJECT_KEY => null,
50
	);
51
52
	/**
53
	 * Model's original attributes.
54
	 *
55
	 * @var array
56
	 */
57
	private $original = array(
58
		self::TABLE_KEY  => array(),
59
		self::OBJECT_KEY => null,
60
	);
61
62
	/**
63
	 * Default attribute values.
64
	 *
65
	 * @var array
66
	 */
67
	protected $defaults = array();
68
69
	/**
70
	 * Properties which are allowed to be set on the model.
71
	 *
72
	 * If this array is empty, any attributes can be set on the model.
73
	 *
74
	 * @var string[]
75
	 */
76
	protected $fillable = array();
77
78
	/**
79
	 * Properties which cannot be automatically filled on the model.
80
	 *
81
	 * If the model is unguarded, these properties can be filled.
82
	 *
83
	 * @var array
84
	 */
85
	protected $guarded = array();
86
87
	/**
88
	 * Properties which should not be serialized.
89
	 *
90
	 * @var array
91
	 */
92
	protected $hidden = array();
93
94
	/**
95
	 * Properties which should be serialized.
96
	 *
97
	 * @var array
98
	 */
99
	protected $visible = array();
100
101
	/**
102
	 * Whether the model's properties are guarded.
103
	 *
104
	 * When false, allows guarded properties to be filled.
105
	 *
106
	 * @var bool
107
	 */
108
	protected $is_guarded = true;
109
110
	/**
111
	 * Constructs a new model with provided attributes.
112
	 *
113
	 * If self::OBJECT_KEY is passed as one of the attributes, the underlying post
114
	 * will be overwritten.
115
	 *
116
	 * @param array <string, mixed> $attributes
117
	 */
118 96
	public function __construct( array $attributes = array() ) {
119 96
		$this->maybe_boot();
120 96
		$this->sync_original();
121
122 96
		if ( $this->uses_wp_object() ) {
123 60
			$this->create_wp_object();
124 40
		}
125
126 96
		$this->unguard();
127 96
		$this->refresh( $attributes );
128 96
		$this->reguard();
129 96
	}
130
131
	/**
132
	 * Refreshes the model's current attributes with the provided array.
133
	 *
134
	 * The model's attributes will match what was provided in the array,
135
	 * and any attributes not passed
136
	 *
137
	 * @param array $attributes
138
	 *
139
	 * @return $this
140
	 */
141 96
	public function refresh( array $attributes ) {
142 96
		$this->clear();
143
144 96
		return $this->merge( $attributes );
145
	}
146
147
	/**
148
	 * Merges the provided attributes with the provided array.
149
	 *
150
	 * @param array $attributes
151
	 *
152
	 * @return $this
153
	 */
154 96
	public function merge( array $attributes ) {
155 96
		foreach ( $attributes as $name => $value ) {
156 45
			$this->set_attribute( $name, $value );
157 64
		}
158
159 96
		return $this;
160
	}
161
162
	/**
163
	 * Get the model's table attributes.
164
	 *
165
	 * Returns the array of for the model that will either need to be
166
	 * saved in postmeta or a separate table.
167
	 *
168
	 * @return array
169
	 */
170 12
	public function get_table_attributes() {
171 12
		return $this->attributes[ self::TABLE_KEY ];
172
	}
173
174
	/**
175
	 * Get the model's original attributes.
176
	 *
177
	 * @return array
178
	 */
179 6
	public function get_original_table_attributes() {
180 6
		return $this->original[ self::TABLE_KEY ];
181
	}
182
183
	/**
184
	 * Retrieve an array of the attributes on the model
185
	 * that have changed compared to the model's
186
	 * original data.
187
	 *
188
	 * @return array
189
	 */
190 3
	public function get_changed_table_attributes() {
191 3
		$changed = array();
192
193 3
		foreach ( $this->get_table_attributes() as $key => $value ) {
194
			if ( $value !==
195 3
				 $this->get_original_attribute( $key )
196 2
			) {
197 3
				$changed[ $key ] = $value;
198 2
			}
199 2
		}
200
201 3
		return $changed;
202
	}
203
204
	/**
205
	 * Get the model's underlying post.
206
	 *
207
	 * Returns the underlying WP_Post object for the model, representing
208
	 * the data that will be save in the wp_posts table.
209
	 *
210
	 * @return false|WP_Post|WP_Term
211
	 */
212 18
	public function get_underlying_wp_object() {
213 18
		if ( isset( $this->attributes[ self::OBJECT_KEY ] ) ) {
214 15
			return $this->attributes[ self::OBJECT_KEY ];
215
		}
216
217 3
		return false;
218
	}
219
220
	/**
221
	 * Get the model's original underlying post.
222
	 *
223
	 * @return WP_Post
224
	 */
225 6
	public function get_original_underlying_wp_object() {
226 6
		return $this->original[ self::OBJECT_KEY ];
227
	}
228
229
	/**
230
	 * Get the model attributes on the WordPress object
231
	 * that have changed compared to the model's
232
	 * original attributes.
233
	 *
234
	 * @return array
235
	 */
236 3
	public function get_changed_wp_object_attributes() {
237 3
		$changed = array();
238
239 3
		foreach ( $this->get_wp_object_keys() as $key ) {
240 3
			if ( $this->get_attribute( $key ) !==
241 3
				 $this->get_original_attribute( $key )
242 2
			) {
243 3
				$changed[ $key ] = $this->get_attribute( $key );
244 2
			}
245 2
		}
246
247 3
		return $changed;
248
	}
249
250
	/**
251
	 * Magic __set method.
252
	 *
253
	 * Passes the name and value to set_attribute, which is where the magic happens.
254
	 *
255
	 * @param string $name
256
	 * @param mixed  $value
257
	 */
258 6
	public function __set( $name, $value ) {
259 6
		$this->set_attribute( $name, $value );
260 6
	}
261
262
	/**
263
	 * Sets the model attributes.
264
	 *
265
	 * Checks whether the model attribute can be set, check if it
266
	 * maps to the WP_Post property, otherwise, assigns it to the
267
	 * table attribute array.
268
	 *
269
	 * @param string $name
270
	 * @param mixed  $value
271
	 *
272
	 * @return $this
273
	 *
274
	 * @throws GuardedPropertyException
275
	 */
276 96
	public function set_attribute( $name, $value ) {
277 96
		if ( self::OBJECT_KEY === $name ) {
278 27
			return $this->override_wp_object( $value );
279
		}
280
281 96
		if ( self::TABLE_KEY === $name ) {
282 6
			return $this->override_table( $value );
283
		}
284
285 96
		if ( ! $this->is_fillable( $name ) ) {
286 9
			throw new GuardedPropertyException;
287
		}
288
289 96
		if ( $method = $this->has_map_method( $name ) ) {
290 60
			$this->attributes[ self::OBJECT_KEY ]->{$this->{$method}()} = $value;
291 40
		} else {
292 96
			$this->attributes[ self::TABLE_KEY ][ $name ] = $value;
293
		}
294
295 96
		return $this;
296
	}
297
298
	/**
299
	 * Retrieves all the attribute keys for the model.
300
	 *
301
	 * @return array
302
	 */
303 18
	public function get_attribute_keys() {
304 18
		if ( isset( self::$memo[ get_called_class() ][ __METHOD__ ] ) ) {
305 18
			return self::$memo[ get_called_class() ][ __METHOD__ ];
306
		}
307
308 9
		return self::$memo[ get_called_class() ][ __METHOD__ ]
309 9
			= array_merge(
310 9
				$this->fillable,
311 9
				$this->guarded,
312 9
				$this->get_compute_methods()
313 6
			);
314
	}
315
316
	/**
317
	 * Retrieves the attribute keys that aren't mapped to a post.
318
	 *
319
	 * @return array
320
	 */
321 96
	public function get_table_keys() {
322 96
		if ( isset( self::$memo[ get_called_class() ][ __METHOD__ ] ) ) {
323 93
			return self::$memo[ get_called_class() ][ __METHOD__ ];
324
		}
325
326 9
		$keys = array();
327
328 9
		foreach ( $this->get_attribute_keys() as $key ) {
329 9
			if ( ! $this->has_map_method( $key ) &&
330 9
				 ! $this->has_compute_method( $key )
331 6
			) {
332 9
				$keys[] = $key;
333 6
			}
334 6
		}
335
336 9
		return self::$memo[ get_called_class() ][ __METHOD__ ] = $keys;
337
	}
338
339
	/**
340
	 * Retrieves the attribute keys that are mapped to a post.
341
	 *
342
	 * @return array
343
	 */
344 96
	public function get_wp_object_keys() {
345 96
		if ( isset( self::$memo[ get_called_class() ][ __METHOD__ ] ) ) {
346 93
			return self::$memo[ get_called_class() ][ __METHOD__ ];
347
		}
348
349 9
		$keys = array();
350
351 9
		foreach ( $this->get_attribute_keys() as $key ) {
352 9
			if ( $this->has_map_method( $key ) ) {
353 7
				$keys[] = $key;
354 4
			}
355 6
		}
356
357 9
		return self::$memo[ get_called_class() ][ __METHOD__ ] = $keys;
358
	}
359
360
	/**
361
	 * Returns the model's keys that are computed at call time.
362
	 *
363
	 * @return array
364
	 */
365 3
	public function get_computed_keys() {
366 3
		if ( isset( self::$memo[ get_called_class() ][ __METHOD__ ] ) ) {
367 3
			return self::$memo[ get_called_class() ][ __METHOD__ ];
368
		}
369
370 3
		$keys = array();
371
372 3
		foreach ( $this->get_attribute_keys() as $key ) {
373 3
			if ( $this->has_compute_method( $key ) ) {
374 3
				$keys[] = $key;
375 2
			}
376 2
		}
377
378 3
		return self::$memo[ get_called_class() ][ __METHOD__ ] = $keys;
379
	}
380
381
	/**
382
	 * Serializes the model's public data into an array.
383
	 *
384
	 * @return array
385
	 */
386 12
	public function serialize() {
387 12
		$attributes = array();
388
389 12
		if ( $this->visible ) {
390
			// If visible attributes are set, we'll only reveal those.
391 6
			foreach ( $this->visible as $key ) {
392 6
				$attributes[ $key ] = $this->get_attribute( $key );
393 4
			}
394 10
		} elseif ( $this->hidden ) {
395
			// If hidden attributes are set, we'll grab everything and hide those.
396 3
			foreach ( $this->get_attribute_keys() as $key ) {
397 3
				if ( ! in_array( $key, $this->hidden ) ) {
398 3
					$attributes[ $key ] = $this->get_attribute( $key );
399 2
				}
400 2
			}
401 2
		} else {
402
			// If nothing is hidden/visible, we'll grab and reveal everything.
403 3
			foreach ( $this->get_attribute_keys() as $key ) {
404 3
				$attributes[ $key ] = $this->get_attribute( $key );
405 2
			}
406
		}
407
408 4
		return array_map( function ( $attribute ) {
409 12
			if ( $attribute instanceof Serializes ) {
410 3
				return $attribute->serialize();
411
			}
412
413 12
			return $attribute;
414 12
		}, $attributes );
415
	}
416
417
	/**
418
	 * Syncs the current attributes to the model's original.
419
	 *
420
	 * @return $this
421
	 */
422 96
	public function sync_original() {
423 96
		$this->original = $this->attributes;
424
425 96
		if ( $this->attributes[ self::OBJECT_KEY ] ) {
426 9
			$this->original[ self::OBJECT_KEY ] = clone $this->attributes[ self::OBJECT_KEY ];
427 6
		}
428
429 96
		foreach ( $this->original[ self::TABLE_KEY ] as $key => $item ) {
430 9
			if ( is_object( $item ) ) {
431 9
				$this->original[ $key ] = clone $item;
432 6
			}
433 64
		}
434
435 96
		return $this;
436
	}
437
438
	/**
439
	 * Checks if a given attribute is mass-fillable.
440
	 *
441
	 * Returns true if the attribute can be filled, false if it can't.
442
	 *
443
	 * @param string $name
444
	 *
445
	 * @return bool
446
	 */
447 96
	private function is_fillable( $name ) {
448
		// If this model isn't guarded, everything is fillable.
449 96
		if ( ! $this->is_guarded ) {
450 96
			return true;
451
		}
452
453
		// If it's in the fillable array, then it's fillable.
454 18
		if ( in_array( $name, $this->fillable ) ) {
455 12
			return true;
456
		}
457
458
		// If it's explicitly guarded, then it's not fillable.
459 9
		if ( in_array( $name, $this->guarded ) ) {
460 9
			return false;
461
		}
462
463
		// If fillable hasn't been defined, then everything else fillable.
464
		return ! $this->fillable;
465
	}
466
467
	/**
468
	 * Overrides the current WordPress object with a provided one.
469
	 *
470
	 * Resets the post's default values and stores it in the attributes.
471
	 *
472
	 * @param WP_Post|WP_Term|null $value
473
	 *
474
	 * @return $this
475
	 */
476 27
	private function override_wp_object( $value ) {
477 27
		if ( is_object( $value ) ) {
478 27
			$this->attributes[ self::OBJECT_KEY ] = $this->set_wp_object_constants( $value );
479 18
		} else {
480
			$this->attributes[ self::OBJECT_KEY ] = null;
481
482
			if ( $this->uses_wp_object() ) {
483
				$this->create_wp_object();
484
			}
485
		}
486
487 27
		return $this;
488
	}
489
490
	/**
491
	 * Overrides the current table attributes array with a provided one.
492
	 *
493
	 * @param array $value
494
	 *
495
	 * @return $this
496
	 */
497 6
	private function override_table( array $value ) {
498 6
		$this->attributes[ self::TABLE_KEY ] = $value;
499
500 6
		return $this;
501
	}
502
503
	/**
504
	 * Create and set with a new blank post.
505
	 *
506
	 * Creates a new WP_Post object, assigns it the default attributes,
507
	 * and stores it in the attributes.
508
	 *
509
	 * @throws LogicException
510
	 */
511 60
	private function create_wp_object() {
512 40
		switch ( true ) {
513 60
			case $this instanceof UsesWordPressPost:
514 60
				$object = new WP_Post( (object) array() );
515 60
				break;
516
			case $this instanceof UsesWordPressTerm:
517
				$object = new WP_Term( (object) array() );
518
				break;
519
			default:
520
				throw new LogicException;
521
				break;
522
		}
523
524 60
		$this->attributes[ self::OBJECT_KEY ] = $this->set_wp_object_constants( $object );
525 60
	}
526
527
	/**
528
	 * Enforces values on the post that can't change.
529
	 *
530
	 * Primarily, this is used to make sure the post_type always maps
531
	 * to the model's "$type" property, but this can all be overridden
532
	 * by the developer to enforce other values in the model.
533
	 *
534
	 * @param object $object
535
	 *
536
	 * @return object
537
	 */
538 60
	protected function set_wp_object_constants( $object ) {
539 60
		if ( $this instanceof UsesWordPressPost ) {
540 60
			$object->post_type = static::get_post_type();
541 40
		}
542
543 60
		if ( $this instanceof UsesWordPressTerm ) {
544
			$object->taxonomy = static::get_taxonomy();
545
		}
546
547 60
		return $object;
548
	}
549
550
	/**
551
	 * Magic __get method.
552
	 *
553
	 * Passes the name and value to get_attribute, which is where the magic happens.
554
	 *
555
	 * @param string $name
556
	 *
557
	 * @return mixed
558
	 */
559 24
	public function __get( $name ) {
560 24
		return $this->get_attribute( $name );
561
	}
562
563
	/**
564
	 * Retrieves the model attribute.
565
	 *
566
	 * @param string $name
567
	 *
568
	 * @return mixed
569
	 *
570
	 * @throws PropertyDoesNotExistException If property isn't found.
571
	 */
572 51
	public function get_attribute( $name ) {
573 51
		if ( $method = $this->has_map_method( $name ) ) {
574 24
			return $this->attributes[ self::OBJECT_KEY ]->{$this->{$method}()};
575
		}
576
577 39
		if ( $method = $this->has_compute_method( $name ) ) {
578 12
			return $this->{$method}();
579
		}
580
581 36
		if ( isset( $this->attributes[ self::TABLE_KEY ][ $name ] ) ) {
582 33
			return $this->attributes[ self::TABLE_KEY ][ $name ];
583
		}
584
585 3
		if ( isset( $this->defaults[ $name ] ) ) {
586
			return $this->defaults[ $name ];
587
		}
588
589 3
		return null;
590
	}
591
592
	/**
593
	 * Retrieve the model's original attribute value.
594
	 *
595
	 * @param string $name
596
	 *
597
	 * @return mixed
598
	 *
599
	 * @throws PropertyDoesNotExistException If property isn't found.
600
	 */
601 6
	public function get_original_attribute( $name ) {
602 6
		$original_attributes = $this->original;
603
604 6
		if ( ! is_object( $original_attributes[ static::OBJECT_KEY ] ) ) {
605
			unset( $original_attributes[ static::OBJECT_KEY ] );
606
		}
607
608 6
		$original = new static( $original_attributes );
609
610 6
		return $original->get_attribute( $name );
611
	}
612
613
	/**
614
	 * Fetches the Model's primary ID, depending on the model
615
	 * implementation.
616
	 *
617
	 * @return int
618
	 *
619
	 * @throws LogicException
620
	 */
621
	public function get_primary_id() {
622
		if ( $this instanceof UsesWordPressPost ) {
623
			return $this->get_underlying_wp_object()->ID;
624
		}
625
626
		if ( $this instanceof UsesWordPressTerm ) {
627
			return $this->get_underlying_wp_object()->term_id;
628
		}
629
630
		if ( $this instanceof UsesCustomTable ) {
631
			return $this->get_attribute( $this->get_primary_key() );
632
		}
633
634
		// Model w/o wp_object not yet supported.
635
		throw new LogicException;
636
	}
637
638
	/**
639
	 * Checks whether the attribute has a map method.
640
	 *
641
	 * This is used to determine whether the attribute maps to a
642
	 * property on the underlying WP_Post object. Returns the
643
	 * method if one exists, returns false if it doesn't.
644
	 *
645
	 * @param string $name
646
	 *
647
	 * @return false|string
648
	 */
649 96
	protected function has_map_method( $name ) {
650 96
		if ( method_exists( $this, $method = "map_{$name}" ) ) {
651 60
			return $method;
652
		}
653
654 96
		return false;
655
	}
656
657
	/**
658
	 * Checks whether the attribute has a compute method.
659
	 *
660
	 * This is used to determine if the attribute should be computed
661
	 * from other attributes.
662
	 *
663
	 * @param string $name
664
	 *
665
	 * @return false|string
666
	 */
667 51
	protected function has_compute_method( $name ) {
668 51
		if ( method_exists( $this, $method = "compute_{$name}" ) ) {
669 21
			return $method;
670
		}
671
672 48
		return false;
673
	}
674
675
	/**
676
	 * Clears all the current attributes from the model.
677
	 *
678
	 * This does not touch the model's original attributes, and will
679
	 * only clear fillable attributes, unless the model is unguarded.
680
	 *
681
	 * @return $this
682
	 */
683 96
	public function clear() {
684 96
		$keys = array_merge(
685 96
			$this->get_table_keys(),
686 96
			$this->get_wp_object_keys()
687 64
		);
688
689 96
		foreach ( $keys as $key ) {
690
			try {
691 96
				$this->set_attribute( $key, null );
692 96
			} catch ( $e ) {
0 ignored issues
show
Bug introduced by
This code did not parse for me. Apparently, there is an error somewhere around this line:

Syntax error, unexpected T_VARIABLE
Loading history...
693
				// We won't clear out guarded attributes.
694
				if ( ! ( $e instanceof GuardedPropertyException ) ) {
695 64
					throw $e;
696
				}
697 96
			}
698
		}
699
700
		return $this;
701
	}
702
703
	/**
704
	 * Unguards the model.
705
	 *
706 96
	 * Sets the model to be unguarded, allowing the filling of
707 96
	 * guarded attributes.
708 96
	 */
709
	public function unguard() {
710
		$this->is_guarded = false;
711
	}
712
713
	/**
714
	 * Reguards the model.
715
	 *
716 96
	 * Sets the model to be guarded, preventing filling of
717 96
	 * guarded attributes.
718 96
	 */
719
	public function reguard() {
720
		$this->is_guarded = true;
721
	}
722
723
	/**
724
	 * Retrieves all the compute methods on the model.
725 9
	 *
726 9
	 * @return array
727 3
	 */
728 9
	protected function get_compute_methods() {
729 9
		$methods = get_class_methods( get_called_class() );
730 9
		$methods = array_filter( $methods, function ( $method ) {
731 6
			return strrpos( $method, 'compute_', - strlen( $method ) ) !== false;
732 9
		} );
733
		$methods = array_map( function ( $method ) {
734 9
			return substr( $method, strlen( 'compute_' ) );
735
		}, $methods );
736
737
		return $methods;
738
	}
739
740 96
	/**
741 96
	 * Sets up the memo array for the creating model.
742 9
	 */
743 6
	private function maybe_boot() {
744 96
		if ( ! isset( self::$memo[ get_called_class() ] ) ) {
745
			self::$memo[ get_called_class() ] = array();
746
		}
747
	}
748
749
	/**
750
	 * Whether this Model uses an underlying WordPress object.
751 96
	 *
752 96
	 * @return bool
753 96
	 */
754
	protected function uses_wp_object() {
755
		return $this instanceof UsesWordPressPost ||
756
			$this instanceof UsesWordPressTerm;
757
	}
758
}
759