• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ICanBoogie / ActiveRecord / 4437457574

pending completion
4437457574

push

github

Olivier Laviale
Case of a nullable belong_to

7 of 7 new or added lines in 1 file covered. (100.0%)

1356 of 1686 relevant lines covered (80.43%)

34.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.76
/lib/ActiveRecord/Model.php
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 AllowDynamicProperties;
15
use ArrayAccess;
16
use ICanBoogie\ActiveRecord;
17
use ICanBoogie\OffsetNotWritable;
18
use ICanBoogie\Prototype\MethodNotDefined;
19
use LogicException;
20

21
use function array_combine;
22
use function array_fill;
23
use function array_keys;
24
use function count;
25
use function func_get_args;
26
use function implode;
27
use function is_array;
28
use function is_callable;
29
use function method_exists;
30

31
/**
32
 * Base class for activerecord models.
33
 *
34
 * @template TKey of int|string|string[]
35
 * @template TValue of ActiveRecord
36
 *
37
 * @implements ArrayAccess<TKey, TValue>
38
 *
39
 * @method Query select($expression) The method is forwarded to Query::select().
40
 * @method Query join($expression) The method is forwarded to Query::join().
41
 * @method Query where($conditions, $conditions_args = null, $_ = null)
42
 *     The method is forwarded to {@link Query::where}.
43
 * @method Query group($group) The method is forwarded to Query::group().
44
 * @method Query order(...$order) The method is forwarded to @link Query::order().
45
 * @method Query limit($limit, $offset = null) The method is forwarded to Query::limit().
46
 * @method Query offset($offset) The method is forwarded to Query::offset().
47
 * @method bool exists($key = null) The method is forwarded to Query::exists().
48
 * @method mixed count($column = null) The method is forwarded to Query::count().
49
 * @method string average($column) The method is forwarded to Query::average().
50
 * @method string maximum($column) The method is forwarded to Query::maximum().
51
 * @method string minimum($column) The method is forwarded to Query::minimum().
52
 * @method int sum($column) The method is forwarded to Query::sum().
53
 * @method array all() The method is forwarded to Query::all().
54
 * @method ActiveRecord one() The method is forwarded to Query::one().
55
 * @method ActiveRecord new(array $properties = []) Instantiate a new record.
56
 *
57
 * @property-read Model|null $parent Parent model.
58
 * @property-read array $all Retrieve all the records from the model.
59
 * @property-read int $count The number of records of the model.
60
 * @property-read bool $exists Whether the SQL table associated with the model exists.
61
 * @property-read ActiveRecord $one Retrieve the first record from the mode.
62
 * @property ActiveRecordCache $activerecord_cache The cache use to store activerecords.
63
 */
64
#[AllowDynamicProperties]
65
class Model extends Table implements ArrayAccess
66
{
67
    /**
68
     * Active record instances class.
69
     *
70
     * @var class-string<TValue>
71
     */
72
    public readonly string $activerecord_class;
73

74
    /**
75
     * @var class-string<Query<TValue>>
76
     */
77
    private readonly string $query_class;
78

79
    /**
80
     * The identifier of the model.
81
     */
82
    public readonly string $id;
83

84
    /**
85
     * The relations of this model to other models.
86
     */
87
    public readonly RelationCollection $relations;
88

89
    /**
90
     * Returns the records cache.
91
     *
92
     * **Note:** The method needs to be implemented through prototype bindings.
93
     */
94
    protected function lazy_get_activerecord_cache(): ActiveRecordCache
95
    {
96
        /** @phpstan-ignore-next-line */
97
        return parent::lazy_get_activerecord_cache();
15✔
98
    }
99

100
    public function __construct(
101
        Connection $connection,
102
        public readonly ModelProvider $models,
103
        private readonly ModelDefinition $definition
104
    ) {
105
        $this->id = $definition->id;
97✔
106
        $this->relations = new RelationCollection($this);
97✔
107

108
        $parent = null;
97✔
109

110
        if ($definition->extends) {
97✔
111
            $parent = $models->model_for_id($definition->extends);
79✔
112
        }
113

114
        parent::__construct($connection, $definition, $parent);
97✔
115

116
        /** @phpstan-ignore-next-line */
117
        $this->activerecord_class = $definition->activerecord_class
97✔
118
            ?? throw new LogicException("Missing ActiveRecord class for model '$this->id'");
×
119
        /** @phpstan-ignore-next-line */
120
        $this->query_class = $definition->query_class
97✔
121
            ?? Query::class;
122

123
        $this->resolve_relations();
97✔
124
    }
125

126
//    // @codeCoverageIgnoreStart
127
//    /**
128
//     * @return array<string, mixed>
129
//     */
130
//    public function __debugInfo(): array
131
//    {
132
//        return [
133
//
134
//            'id' => $this->id,
135
//            'name' => "$this->name ($this->unprefixed_name)",
136
//            'parent' => $this->parent ? $this->parent->id . " of " . get_class($this->parent) : null,
137
//            'parent_model' => $this->parent_model
138
//                ? $this->parent_model->id . " of " . get_class($this->parent_model)
139
//                : null,
140
//            'relations' => $this->relations
141
//
142
//        ];
143
//    }
144
//    // @codeCoverageIgnoreEnd
145

146
    /**
147
     * Resolves relations with other models.
148
     */
149
    private function resolve_relations(): void
150
    {
151
        $association = $this->definition->association;
97✔
152

153
        if (!$association) {
97✔
154
            return;
91✔
155
        }
156

157
        # belongs_to
158

159
        $belongs_to = $association->belongs_to;
85✔
160

161
        if ($belongs_to) {
85✔
162
            foreach ($belongs_to as $r) {
69✔
163
                $this->belongs_to(
69✔
164
                    related: $r->associate,
69✔
165
                    local_key: $r->local_key,
69✔
166
                    foreign_key: $r->foreign_key,
69✔
167
                    as: $r->as,
69✔
168
                );
69✔
169
            }
170
        }
171

172
        # has_many
173

174
        $has_many = $association->has_many;
85✔
175

176
        if ($has_many) {
85✔
177
            foreach ($has_many as $r) {
85✔
178
                $this->has_many(
85✔
179
                    related: $r->model_id,
85✔
180
                    foreign_key: $r->foreign_key,
85✔
181
                    as: $r->as,
85✔
182
                    through: $r->through,
85✔
183
                );
85✔
184
            }
185
        }
186
    }
187

188
    /**
189
     * @param string $related A module identifier.
190
     */
191
    public function belongs_to(
192
        string $related,
193
        string $local_key,
194
        string $foreign_key,
195
        string $as,
196
    ): self {
197
        $this->relations->belongs_to(
70✔
198
            related: $related,
70✔
199
            local_key: $local_key,
70✔
200
            foreign_key: $foreign_key,
70✔
201
            as: $as,
70✔
202
        );
70✔
203

204
        return $this;
70✔
205
    }
206

207
    /**
208
     * @param string $related A module identifier.
209
     */
210
    public function has_many(
211
        string $related,
212
        string $foreign_key,
213
        string $as,
214
        ?string $through = null,
215
    ): self {
216
        $this->relations->has_many(
85✔
217
            related: $related,
85✔
218
            local_key: $this->primary,
85✔
219
            foreign_key: $foreign_key,
85✔
220
            as: $as,
85✔
221
            through: $through,
85✔
222
        );
85✔
223

224
        return $this;
85✔
225
    }
226

227
    /**
228
     * Handles query methods, dynamic filters, scopes, and relations.
229
     *
230
     * @inheritdoc
231
     */
232
    public function __call($method, $arguments)
233
    {
234
        if ($method == 'new') {
57✔
235
            return $this->new_record(...$arguments);
2✔
236
        }
237

238
        if (
239
            method_exists($this->query_class, $method)
56✔
240
            || str_starts_with($method, 'filter_by_')
19✔
241
            || method_exists($this, 'scope_' . $method)
56✔
242
        ) {
243
            return $this->query()->$method(...$arguments);
54✔
244
        }
245

246
        if (is_callable([ $this->relations, $method ])) {
17✔
247
            return $this->relations->$method(...$arguments);
×
248
        }
249

250
        return parent::__call($method, $arguments);
17✔
251
    }
252

253
    /**
254
     * Overrides the method to handle scopes.
255
     */
256
    public function __get($property)
257
    {
258
        $method = 'scope_' . $property;
76✔
259

260
        if (method_exists($this, $method)) {
76✔
261
            return $this->$method($this->query());
2✔
262
        }
263

264
        return parent::__get($property);
76✔
265
    }
266

267
    /**
268
     * Finds a record or a collection of records.
269
     *
270
     * @param mixed $key A key, multiple keys, or an array of keys.
271
     *
272
     * @return TValue|TValue[] A record or a set of records.
273
     * @throws RecordNotFound when the record, or one or more records of the records
274
     * set, could not be found.
275
     *
276
     */
277
    public function find(mixed $key)
278
    {
279
        $args = func_get_args();
14✔
280
        $n = count($args);
14✔
281

282
        if (!$n) {
14✔
283
            throw new \BadMethodCallException("Expected at least one argument.");
×
284
        }
285

286
        if (count($args) == 1) {
14✔
287
            $key = $args[0];
11✔
288

289
            if (!is_array($key)) {
11✔
290
                return $this->find_one($key);
10✔
291
            }
292

293
            $args = $key;
1✔
294
        }
295

296
        return $this->find_many($args);
4✔
297
    }
298

299
    /**
300
     * Finds one records.
301
     *
302
     * @param TKey $key
303
     *
304
     * @return TValue
305
     */
306
    private function find_one($key): ActiveRecord
307
    {
308
        $record = $this->activerecord_cache->retrieve($key);
10✔
309

310
        if ($record) {
10✔
311
            return $record;
2✔
312
        }
313

314
        $record = $this->where([ $this->primary => $key ])->one;
10✔
315

316
        if (!$record) {
10✔
317
            throw new RecordNotFound(
2✔
318
                "Record <q>{$key}</q> does not exists in model <q>{$this->id}</q>.",
2✔
319
                [ $key => null ]
2✔
320
            );
2✔
321
        }
322

323
        $this->activerecord_cache->store($record);
9✔
324

325
        return $record;
9✔
326
    }
327

328
    /**
329
     * Finds many records.
330
     *
331
     * @param array<TKey> $keys
332
     *
333
     * @return TValue[]
334
     */
335
    private function find_many(array $keys): array
336
    {
337
        $records = array_combine($keys, array_fill(0, count($keys), null));
4✔
338
        $missing = $records;
4✔
339

340
        foreach ($records as $key => $dummy) {
4✔
341
            $record = $this->activerecord_cache->retrieve($key);
4✔
342

343
            if (!$record) {
4✔
344
                continue;
4✔
345
            }
346

347
            $records[$key] = $record;
2✔
348
            unset($missing[$key]);
2✔
349
        }
350

351
        if ($missing) {
4✔
352
            $primary = $this->primary;
4✔
353
            $query_records = $this->where([ $primary => array_keys($missing) ])->all;
4✔
354

355
            foreach ($query_records as $record) {
4✔
356
                $key = $record->$primary;
3✔
357
                $records[$key] = $record;
3✔
358
                unset($missing[$key]);
3✔
359

360
                $this->activerecord_cache->store($record);
3✔
361
            }
362
        }
363

364
        if ($missing) {
4✔
365
            if (count($missing) > 1) {
2✔
366
                throw new RecordNotFound(
1✔
367
                    "Records " . implode(', ', array_keys($missing)) . " do not exists in model <q>{$this->id}</q>.",
1✔
368
                    $records
1✔
369
                );
1✔
370
            }
371

372
            $key = array_keys($missing);
1✔
373
            $key = \array_shift($key);
1✔
374

375
            throw new RecordNotFound(
1✔
376
                "Record <q>{$key}</q> does not exists in model <q>{$this->id}</q>.",
1✔
377
                $records
1✔
378
            );
1✔
379
        }
380

381
        return $records;
2✔
382
    }
383

384
    /**
385
     * @param mixed ...$conditions_and_args
386
     *
387
     * @return Query<TValue>
388
     */
389
    public function query(...$conditions_and_args): Query
390
    {
391
        $class = $this->query_class;
57✔
392
        $query = new $class($this);
57✔
393

394
        if ($conditions_and_args) {
57✔
395
            $query->where(...$conditions_and_args);
1✔
396
        }
397

398
        return $query;
57✔
399
    }
400

401
    /**
402
     * Because records are cached, we need to remove the record from the cache when it is saved,
403
     * so that loading the record again returns the updated record, not the one in the cache.
404
     *
405
     * @inheritdoc
406
     */
407
    public function save(array $properties, $key = null, array $options = [])
408
    {
409
        if ($key) {
71✔
410
            $this->activerecord_cache->eliminate($key);
1✔
411
        }
412

413
        return parent::save($properties, $key, $options);
71✔
414
    }
415

416
    /**
417
     * Eliminates the record from the cache.
418
     *
419
     * @inheritdoc
420
     */
421
    public function delete($key)
422
    {
423
        $this->activerecord_cache->eliminate($key);
2✔
424

425
        return parent::delete($key);
2✔
426
    }
427

428
    /**
429
     * Checks that the SQL table associated with the model exists.
430
     */
431
    protected function get_exists(): bool
432
    {
433
        return $this->exists();
1✔
434
    }
435

436
    /**
437
     * Returns the number of records of the model.
438
     */
439
    protected function get_count(): int
440
    {
441
        return $this->count();
1✔
442
    }
443

444
    /**
445
     * Returns all the records of the model.
446
     *
447
     * @return TValue[]
448
     */
449
    protected function get_all(): array
450
    {
451
        return $this->all();
2✔
452
    }
453

454
    /**
455
     * Returns the first record of the model.
456
     *
457
     * @return TValue
458
     */
459
    protected function get_one(): ActiveRecord
460
    {
461
        return $this->one();
2✔
462
    }
463

464
    /**
465
     * Checks if the model has a given scope.
466
     *
467
     * Scopes are defined using method with the "scope_" prefix. As an example, the `visible`
468
     * scope can be defined by implementing the `scope_visible` method.
469
     *
470
     * @param string $name Scope name.
471
     *
472
     * @return bool
473
     */
474
    public function has_scope(string $name): bool
475
    {
476
        return method_exists($this, 'scope_' . $name);
1✔
477
    }
478

479
    /**
480
     * Invokes a given scope.
481
     *
482
     * @param string $scope_name Name of the scope to apply to the query.
483
     * @param array $scope_args Arguments to forward to the scope method. The first argument must
484
     * be a {@link Query} instance.
485
     *
486
     * @return Query<TValue>
487
     * @throws ScopeNotDefined when the specified scope is not defined.
488
     *
489
     */
490
    public function scope(string $scope_name, array $scope_args = []): Query
491
    {
492
        try {
493
            return $this->{'scope_' . $scope_name}(...$scope_args);
2✔
494
        } catch (MethodNotDefined $e) {
1✔
495
            throw new ScopeNotDefined($scope_name, $this);
1✔
496
        }
497
    }
498

499
    /**
500
     * @inheritdoc
501
     *
502
     * @throws OffsetNotWritable when one tries to write an offset.
503
     */
504
    public function offsetSet(mixed $offset, mixed $value): void
505
    {
506
        throw new OffsetNotWritable([ $offset, $this ]);
1✔
507
    }
508

509
    /**
510
     * Alias to {@link exists()}.
511
     *
512
     * @param int $key ActiveRecord identifier.
513
     *
514
     * @return bool
515
     */
516
    public function offsetExists(mixed $key): bool
517
    {
518
        return $this->exists($key);
1✔
519
    }
520

521
    /**
522
     * Alias to {@link delete()}.
523
     *
524
     * @param int $key ActiveRecord identifier.
525
     */
526
    public function offsetUnset(mixed $key): void
527
    {
528
        $this->delete($key);
1✔
529
    }
530

531
    /**
532
     * Alias to {@link find()}.
533
     *
534
     * @param int $key ActiveRecord identifier.
535
     *
536
     * @return TValue
537
     */
538
    public function offsetGet(mixed $key): ActiveRecord
539
    {
540
        return $this->find($key);
8✔
541
    }
542

543
    /**
544
     * Creates a new ActiveRecord instance.
545
     *
546
     * The class of the instance is defined by the {@link $activerecord_class} property.
547
     *
548
     * @param array<string, mixed> $properties Optional properties to instantiate the record with.
549
     *
550
     * @retrun TValue
551
     */
552
    protected function new_record(array $properties = []): ActiveRecord
553
    {
554
        $class = $this->activerecord_class;
2✔
555

556
        return $properties ? $class::from($properties, [ $this ]) : new $class($this);
2✔
557
    }
558
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc