1 <?php
2
3 /*
4 * This file is part of the ICanBoogie package.
5 *
6 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace ICanBoogie\ActiveRecord;
13
14 use ICanBoogie\ActiveRecord;
15 use ICanBoogie\OffsetNotWritable;
16 use ICanBoogie\PropertyNotWritable;
17
18 /**
19 * Base class for activerecord models.
20 *
21 * @method Query select() select($expression) The method is forwarded to {@link Query::select}.
22 * @method Query joins() joins($expression) The method is forwarded to {@link Query::joins}.
23 * @method Query where() where($conditions, $conditions_args=null) The method is forwarded to {@link Query::where}.
24 * @method Query group() group($group) The method is forwarded to {@link Query::group}.
25 * @method Query order() order($order) The method is forwarded to {@link Query::order}.
26 * @method Query limit() limit($limit, $offset=null) The method is forwarded to {@link Query::limit}.
27 * @method Query offset() offset($offset) The method is forwarded to {@link Query::offset}.
28 * @method bool exists() exists($key=null) The method is forwarded to {@link Query::exists}.
29 * @method mixed count() count($column=null) The method is forwarded to {@link Query::count}.
30 * @method string average() average($column) The method is forwarded to {@link Query::average}.
31 * @method string maximum() maximum($column) The method is forwarded to {@link Query::maximum}.
32 * @method string minimum() minimum($column) The method is forwarded to {@link Query::minimum}.
33 * @method int sum() sum($column) The method is forwarded to {@link Query::sum}.
34 * @method array all() all() The method is forwarded to {@link Query::all}.
35 * @method ActiveRecord one() one() The method is forwarded to {@link Query::one}.
36 *
37 * @property-read array $all Retrieve all the records from the model.
38 * @property-read string $activerecord_class Class of the active records of the model.
39 * @property-read int $count The number of records of the model.
40 * @property-read bool $exists Whether the SQL table associated with the model exists.
41 * @property-read string $id The identifier of the model.
42 * @property-read ActiveRecord Retrieve the first record from the mode.
43 * @property ActiveRecordCacheInterface $activerecord_cache The cache use to store activerecords.
44 */
45 class Model extends Table implements \ArrayAccess
46 {
47 // TODO-20130216: deprecate all T_*
48
49 const T_ACTIVERECORD_CLASS = 'activerecord_class';
50 const T_CLASS = 'class';
51 const T_ID = 'id';
52
53 const ACTIVERECORD_CLASS = 'activerecord_class';
54 const BELONGS_TO = 'belongs_to';
55 const CLASSNAME = 'class';
56 const ID = 'id';
57
58 /**
59 * Active record instances class.
60 *
61 * @var string
62 */
63 protected $activerecord_class;
64
65 /**
66 * Attributes of the model.
67 *
68 * @var array[string]mixed
69 */
70 protected $attributes;
71
72 /**
73 * Override the constructor to provide support for the {@link ACTIVERECORD_CLASS} tag and
74 * extended support for the {@link EXTENDING} tag.
75 *
76 * If {@link EXTENDING} is defined but the model has no schema ({@link SCHEMA} is empty),
77 * the name of the model and the schema are inherited from the extended model and
78 * {@link EXTENDING} is set to the parent model object. If {@link ACTIVERECORD_CLASS} is
79 * empty, its value is set to the extended model's active record class.
80 *
81 * If {@link ACTIVERECORD_CLASS} is set, its value is saved in the
82 * {@link $activerecord_class} property.
83 *
84 * @param array $tags Tags used to construct the model.
85 */
86 public function __construct(array $tags)
87 {
88 $tags += [
89
90 self::BELONGS_TO => null,
91 self::EXTENDING => null,
92 self::ID => null,
93 self::SCHEMA => null,
94 self::ACTIVERECORD_CLASS => null
95 ];
96
97 if ($tags[self::EXTENDING] && !$tags[self::SCHEMA])
98 {
99 $extends = $tags[self::EXTENDING];
100
101 $tags[self::NAME] = $extends->name_unprefixed;
102 $tags[self::SCHEMA] = $extends->schema;
103 $tags[self::EXTENDING] = $extends->parent;
104
105 if (!$tags[self::ACTIVERECORD_CLASS])
106 {
107 $tags[self::ACTIVERECORD_CLASS] = $extends->activerecord_class;
108 }
109 }
110
111 if (empty($tags[self::ID]))
112 {
113 $tags[self::ID] = $tags[self::NAME];
114 }
115
116 $this->attributes = $tags;
117
118 parent::__construct($tags);
119
120 #
121 # Resolve the active record class.
122 #
123
124 $activerecord_class = $tags[self::ACTIVERECORD_CLASS];
125
126 if (!$activerecord_class && $this->parent)
127 {
128 $activerecord_class = $this->parent->activerecord_class;
129 }
130
131 $this->activerecord_class = $activerecord_class;
132
133 # belongs_to
134
135 $belongs_to = $tags[self::BELONGS_TO];
136
137 if ($belongs_to)
138 {
139 $this->belongs_to($belongs_to);
140 }
141 }
142
143 /**
144 * Handles the _belongs to_ relationship of the model.
145 *
146 * <pre>
147 * $cars->belongs_to([ $drivers, $brands ]);
148 * # or
149 * $cars->belongs_to([ 'drivers', 'brands' ]);
150 * # or
151 * $cars->belongs_to($drivers, $brands);
152 * # or
153 * $cars->belongs_to($drivers);
154 * $cars->belongs_to($brands);
155 * </pre>
156 *
157 * @param string|array $belongs_to
158 *
159 * @return Model
160 *
161 * @throws ActiveRecordException if the class of the active record is `ICanBoogie\ActiveRecord`.
162 */
163 public function belongs_to($belongs_to)
164 {
165 if (func_num_args() > 1)
166 {
167 $belongs_to = func_get_args();
168 }
169
170 if (is_array($belongs_to))
171 {
172 foreach ($belongs_to as $b)
173 {
174 $this->belongs_to($b);
175 }
176
177 return $this;
178 }
179
180 if ($belongs_to instanceof self)
181 {
182 $belongs_to_model = $belongs_to;
183 $belongs_to_id = $belongs_to->id;
184 }
185 else
186 {
187 $belongs_to_model = null;
188 $belongs_to_id = $belongs_to;
189 }
190
191 $activerecord_class = $this->activerecord_class;
192 $getter_name = 'lazy_get_' . \ICanBoogie\singularize($belongs_to_id);
193
194 if (!$activerecord_class || $activerecord_class == 'ICanBoogie\ActiveRecord')
195 {
196 throw new ActiveRecordException('The Active Record class cannot be <code>ICanBoogie\ActiveRecord</code> for a <em>belongs to</em> relationship.');
197 }
198
199 $prototype = \ICanBoogie\Prototype::from($activerecord_class);
200
201 $prototype[$getter_name] = function(ActiveRecord $ar) use($belongs_to_model, $belongs_to_id)
202 {
203 $model = $belongs_to_model ? $belongs_to_model : get_model($belongs_to_id);
204 $primary = $model->primary;
205 $key = $ar->$primary;
206
207 return $key ? $model[$key] : null;
208 };
209
210 return $this;
211 }
212
213 /**
214 * Handles query methods, dynamic filters and scopes.
215 */
216 public function __call($method, $arguments)
217 {
218 if (is_callable([ 'ICanBoogie\ActiveRecord\Query', $method ])
219 || strpos($method, 'filter_by_') === 0
220 || method_exists($this, 'scope_' . $method))
221 {
222 $query = new Query($this);
223
224 return call_user_func_array([ $query, $method ], $arguments);
225 }
226
227 return parent::__call($method, $arguments);
228 }
229
230 /**
231 * Overrides the method to handle scopes.
232 */
233 public function __get($property)
234 {
235 $method = 'scope_' . $property;
236
237 if (method_exists($this, $method))
238 {
239 return $this->$method(new Query($this));
240 }
241
242 return parent::__get($property);
243 }
244
245 /**
246 * Returns the identifier of the model.
247 *
248 * @return string
249 */
250 protected function get_id()
251 {
252 return $this->attributes[self::ID];
253 }
254
255 /**
256 * Returns the class of the active records of the model.
257 *
258 * @return string
259 */
260 protected function get_activerecord_class()
261 {
262 return $this->activerecord_class;
263 }
264
265 /**
266 * Finds a record or a collection of records.
267 *
268 * @param mixed $key A key or an array of keys.
269 *
270 * @throws RecordNotFound when the record, or one or more records of the records
271 * set, could not be found.
272 *
273 * @return ActiveRecord|array A record or a set of records.
274 */
275 public function find($key)
276 {
277 if (func_num_args() > 1)
278 {
279 $key = func_get_args();
280 }
281
282 if (is_array($key))
283 {
284 $records = array_combine($key, array_fill(0, count($key), null));
285 $missing = $records;
286
287 foreach ($records as $key => $dummy)
288 {
289 $record = $this->retrieve($key);
290
291 if (!$record)
292 {
293 continue;
294 }
295
296 $records[$key] = $record;
297 unset($missing[$key]);
298 }
299
300 if ($missing)
301 {
302 $primary = $this->primary;
303 $query_records = $this->where([ $primary => array_keys($missing) ])->all;
304
305 foreach ($query_records as $record)
306 {
307 $key = $record->$primary;
308 $records[$key] = $record;
309 unset($missing[$key]);
310
311 $this->store($record);
312 }
313 }
314
315 if ($missing)
316 {
317 if (count($missing) > 1)
318 {
319 throw new RecordNotFound
320 (
321 "Records " . implode(', ', array_keys($missing)) . " do not exists in model <q>{$this->name_unprefixed}</q>.", $records
322 );
323 }
324 else
325 {
326 $key = array_keys($missing);
327 $key = array_shift($key);
328
329 throw new RecordNotFound
330 (
331 "Record <q>{$key}</q> does not exists in model <q>{$this->name_unprefixed}</q>.", $records
332 );
333 }
334 }
335
336 return $records;
337 }
338
339 $record = $this->retrieve($key);
340
341 if ($record === null)
342 {
343 $record = $this->where([ $this->primary => $key ])->one;
344
345 if (!$record)
346 {
347 throw new RecordNotFound
348 (
349 "Record <q>{$key}</q> does not exists in model <q>{$this->name_unprefixed}</q>.", [ $key => null ]
350 );
351 }
352
353 $this->store($record);
354 }
355
356 return $record;
357 }
358
359 /**
360 * Because records are cached, we need to remove the record from the cache when it is saved,
361 * so that loading the record again returns the updated record, not the one in the cache.
362 */
363 public function save(array $properties, $key=null, array $options=[])
364 {
365 if ($key)
366 {
367 $this->eliminate($key);
368 }
369
370 return parent::save($properties, $key, $options);
371 }
372
373 /**
374 * Eliminates the record from the cache.
375 */
376 public function delete($key)
377 {
378 $this->activerecord_cache->eliminate($key);
379
380 return parent::delete($key);
381 }
382
383 /**
384 * Stores a record in the records cache.
385 *
386 * @param ActiveRecord $record The record to store.
387 *
388 * @TODO-20140414: Remove the method and use {@link $activerecord_cache}
389 */
390 protected function store(ActiveRecord $record)
391 {
392 $this->activerecord_cache->store($record);
393 }
394
395 /**
396 * Retrieves a record from the records cache.
397 *
398 * @param int $key
399 *
400 * @return ActiveRecord|null Returns the active record found in the cache or null if it wasn't
401 * there.
402 *
403 * @TODO-20140414: Remove the method and use {@link $activerecord_cache}
404 */
405 protected function retrieve($key)
406 {
407 return $this->activerecord_cache->retrieve($key);
408 }
409
410 /**
411 * Eliminates an object from the cache.
412 *
413 * @param int $key
414 *
415 * @TODO-20140414: Remove the method and use {@link $activerecord_cache}
416 */
417 protected function eliminate($key)
418 {
419 $this->activerecord_cache->eliminate($key);
420 }
421
422 /**
423 * Checks that the SQL table associated with the model exists.
424 *
425 * @return bool
426 */
427 protected function get_exists()
428 {
429 return $this->exists();
430 }
431
432 /**
433 * Returns the number of records of the model.
434 *
435 * @return int
436 */
437 protected function get_count()
438 {
439 return $this->count();
440 }
441
442 /**
443 * Returns all the records of the model.
444 *
445 * @return array[]ActiveRecord
446 */
447 protected function get_all()
448 {
449 return $this->all();
450 }
451
452 /**
453 * Returns the first record of the model.
454 *
455 * @return ActiveRecord
456 */
457 protected function get_one()
458 {
459 return $this->one();
460 }
461
462 /**
463 * Checks if the model has a given scope.
464 *
465 * Scopes are defined using method with the "scope_" prefix. As an example, the `visible`
466 * scope can be defined by implementing the `scope_visible` method.
467 *
468 * @param string $name Scope name.
469 *
470 * @return boolean
471 */
472 public function has_scope($name)
473 {
474 return method_exists($this, 'scope_' . $name);
475 }
476
477 /**
478 * Calls a given scope on the active record query specified in the scope_args.
479 *
480 * @param string $scope_name Name of the scope to apply to the query.
481 * @param array $scope_args Arguments to forward to the scope method.
482 *
483 * @throws ScopeNotDefined when the specified scope is not defined.
484 *
485 * @return Query
486 */
487 public function scope($scope_name, $scope_args=null)
488 {
489 $callback = 'scope_' . $scope_name;
490
491 if (!method_exists($this, $callback))
492 {
493 throw new ScopeNotDefined($scope_name, $this);
494 }
495
496 return call_user_func_array([ $this, $callback ], $scope_args);
497 }
498
499 /*
500 * ArrayAccess implementation
501 */
502
503 /**
504 * Offsets are not settable.
505 *
506 * @throws OffsetNotWritable when one tries to write an offset.
507 */
508 public function offsetSet($offset, $value)
509 {
510 throw new OffsetNotWritable([ $offset, $this ]);
511 }
512
513 /**
514 * Checks if the record identified by the given key exists.
515 *
516 * The call is forwarded to {@link exists()}.
517 */
518 public function offsetExists($key)
519 {
520 return $this->exists($key);
521 }
522
523 /**
524 * Deletes the record specified by the given key.
525 *
526 * @see Model::delete();
527 */
528 public function offsetUnset($key)
529 {
530 $this->delete($key);
531 }
532
533 /**
534 * Alias for the {@link find()} method.
535 *
536 * @see Model::find()
537 */
538 public function offsetGet($key)
539 {
540 return $this->find($key);
541 }
542
543 /**
544 * Creates a new active record instance.
545 *
546 * The class of the instance is defined by the {@link $activerecord_class} property.
547 *
548 * @return ActiveRecord
549 */
550 public function new_record()
551 {
552 $class = $this->activerecord_class;
553
554 return new $class($this);
555 }
556 }
557
558 /**
559 * Exception thrown when an active record cannot be found.
560 *
561 * @property-read array[int]ActiveRecord|null $records
562 */
563 class RecordNotFound extends ActiveRecordException
564 {
565 /**
566 * A key/value array where keys are the identifier of the record, and the value is the result
567 * of finding the record. If the record was found the value is a {@link ActiveRecord}
568 * object, otherwise the `null` value.
569 *
570 * @var array[int]ActiveRecord|null
571 */
572 private $records;
573
574 /**
575 * Initializes the {@link $records} property.
576 *
577 * @param string $message
578 * @param array $records
579 * @param int $code Defaults to 404.
580 * @param \Exception $previous Previous exception.
581 */
582 public function __construct($message, array $records, $code=404, \Exception $previous=null)
583 {
584 $this->records = $records;
585
586 parent::__construct($message, $code, $previous);
587 }
588
589 public function __get($property)
590 {
591 switch ($property)
592 {
593 case 'records': return $this->records;
594 }
595 }
596 }
597
598 /**
599 * Exception thrown when a scope is not defined.
600 *
601 * @property-read string $scope_name
602 * @property-read Model $model
603 */
604 class ScopeNotDefined extends ActiveRecordException
605 {
606 /**
607 * Name of the scope.
608 *
609 * @var string
610 */
611 private $scope_name;
612
613 /**
614 * Model on which the scope was invoked.
615 *
616 * @var Model
617 */
618 private $model;
619
620 /**
621 * Initializes the {@link $scope_name} and {@link $model} properties.
622 *
623 * @param string $scope_name Name of the scope.
624 * @param Model $model Model on which the scope was invoked.
625 * @param int $code Default to 404.
626 * @param \Exception $previous Previous exception.
627 */
628 public function __construct($scope_name, Model $model, $code=500, \Exception $previous)
629 {
630 $this->scope_name = $scope_name;
631 $this->model = $model;
632
633 parent::__construct("Unknown scope <q>{$scope_name}</q> for model <q>{$model->name_unprefixed}</q>.", $code, $previous);
634 }
635
636 public function __get($property)
637 {
638 switch ($property)
639 {
640 case 'scope_name': return $this->scope_name;
641 case 'model': return $this->model;
642 }
643 }
644 }