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

ICanBoogie / ActiveRecord / 4542546258

pending completion
4542546258

push

github

Olivier Laviale
Add 'belongs_to' to the SchemaBuilder

29 of 29 new or added lines in 4 files covered. (100.0%)

1356 of 1726 relevant lines covered (78.56%)

36.14 hits per line

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

84.24
/lib/ActiveRecord/ConfigBuilder.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 Closure;
15
use ICanBoogie\ActiveRecord;
16
use ICanBoogie\ActiveRecord\Config\Association;
17
use ICanBoogie\ActiveRecord\Config\AssociationBuilder;
18
use ICanBoogie\ActiveRecord\Config\BelongsToAssociation;
19
use ICanBoogie\ActiveRecord\Config\ConnectionDefinition;
20
use ICanBoogie\ActiveRecord\Config\HasManyAssociation;
21
use ICanBoogie\ActiveRecord\Config\InvalidConfig;
22
use ICanBoogie\ActiveRecord\Config\TransientAssociation;
23
use ICanBoogie\ActiveRecord\Config\TransientBelongsToAssociation;
24
use ICanBoogie\ActiveRecord\Config\TransientHasManyAssociation;
25
use ICanBoogie\ActiveRecord\Config\TransientModelDefinition;
26
use ICanBoogie\ActiveRecord\Schema\BelongsTo;
27
use ICanBoogie\ActiveRecord\Schema\Integer;
28
use ICanBoogie\ActiveRecord\Schema\SchemaAttribute;
29
use InvalidArgumentException;
30
use LogicException;
31
use olvlvl\ComposerAttributeCollector\Attributes;
32
use olvlvl\ComposerAttributeCollector\TargetClass;
33
use olvlvl\ComposerAttributeCollector\TargetProperty;
34

35
use function array_map;
36
use function assert;
37
use function class_exists;
38
use function get_debug_type;
39
use function ICanBoogie\singularize;
40
use function is_a;
41
use function is_string;
42
use function preg_match;
43
use function sprintf;
44
use function str_ends_with;
45
use function substr;
46

47
final class ConfigBuilder
48
{
49
    private const REGEXP_TIMEZONE = '/^[-+]\d{2}:\d{2}$/';
50

51
    /**
52
     * @var array<string, ConnectionDefinition>
53
     */
54
    private array $connections = [];
55

56
    /**
57
     * @var array<string, TransientModelDefinition>
58
     */
59
    private array $transient_models = [];
60

61
    /**
62
     * @var array<string, TransientAssociation>
63
     *     Where _key_ is a model identifier.
64
     */
65
    private array $association = [];
66

67
    public function build(): Config
68
    {
69
        $this->validate_models();
105✔
70

71
        $associations = $this->build_associations();
105✔
72
        $models = $this->build_models($associations);
105✔
73

74
        return new Config($this->connections, $models);
105✔
75
    }
76

77
    private function validate_models(): void
78
    {
79
        foreach ($this->transient_models as $id => $config) {
105✔
80
            if (empty($this->connections[$config->connection])) {
105✔
81
                throw new InvalidConfig("Model '$id' uses connection '$config->connection', but it is not configured.");
×
82
            }
83

84
            if ($config->extends && empty($this->transient_models[$config->extends])) {
105✔
85
                throw new InvalidConfig("Model '$id' extends '$config->extends', but it is not configured.");
×
86
            }
87

88
            if ($config->implements && empty($this->transient_models[$config->implements])) {
105✔
89
                throw new InvalidConfig("Model '$id' implements '$config->implements', but it is not configured.");
×
90
            }
91
        }
92
    }
93

94
    /**
95
     * @return array<string, Association>
96
     */
97
    private function build_associations(): array
98
    {
99
        foreach ($this->transient_models as $id => $model) {
105✔
100
            if (!$model->extends) {
105✔
101
                continue;
105✔
102
            }
103

104
            $parent_schema = $this->transient_models[$model->extends]->schema;
87✔
105
            $primary = $parent_schema->primary;
87✔
106

107
            if (!is_string($primary)) {
87✔
108
                throw new InvalidConfig(
×
109
                    "Model '$id' cannot extend '$model->extends',"
×
110
                    . " the primary key is not a string, given: " . get_debug_type($primary)
×
111
                );
×
112
            }
113

114
            $schema = $model->schema;
87✔
115
            $parent_column = $parent_schema->columns[$primary];
87✔
116

117
            assert($parent_column instanceof Integer);
118

119
            $model->schema = new Schema(
87✔
120
                columns: [ $primary => new Integer(size: $parent_column->size, unique: true) ] + $schema->columns,
87✔
121
                primary: $primary,
87✔
122
                indexes: $schema->indexes,
87✔
123
            );
87✔
124
        }
125

126
        $associations = [];
105✔
127

128
        foreach ($this->association as $model_id => $association) {
105✔
129
            $belongs_to = array_map(
105✔
130
                fn(TransientBelongsToAssociation $a): BelongsToAssociation => $this->resolve_belongs_to($model_id, $a),
105✔
131
                $association->belongs_to
105✔
132
            );
105✔
133

134
            $has_many = array_map(
105✔
135
                fn(TransientHasManyAssociation $a): HasManyAssociation => $this->resolve_has_many($model_id, $a),
105✔
136
                $association->has_many
105✔
137
            );
105✔
138

139
            $associations[$model_id] = new Association(
105✔
140
                belongs_to: $belongs_to,
105✔
141
                has_many: $has_many,
105✔
142
            );
105✔
143
        }
144

145
        return $associations;
105✔
146
    }
147

148
    /**
149
     * Builds model configuration from model transient configurations and association configurations.
150
     *
151
     * @param array<string, Association> $associations
152
     *     Where _key_ is a model identifier.
153
     *
154
     * @return array<string, ModelDefinition>
155
     *     Where _key_ is a model identifier.
156
     */
157
    private function build_models(array $associations): array
158
    {
159
        $models = [];
105✔
160

161
        foreach ($this->transient_models as $id => $transient) {
105✔
162
            $models[$id] = new ModelDefinition(
105✔
163
                id: $id,
105✔
164
                connection: $transient->connection,
105✔
165
                schema: $transient->schema,
105✔
166
                activerecord_class: $transient->activerecord_class,
105✔
167
                name: $transient->name,
105✔
168
                alias: $transient->alias,
105✔
169
                extends: $transient->extends,
105✔
170
                implements: $transient->implements,
105✔
171
                model_class: $transient->model_class ?? Model::class,
105✔
172
                query_class: $transient->query_class ?? Query::class,
105✔
173
                association: $associations[$id] ?? null,
105✔
174
            );
105✔
175
        }
176

177
        return $models;
105✔
178
    }
179

180
    private function try_key(mixed $key, string $on): ?string
181
    {
182
        if (!is_string($key)) {
×
183
            return null;
×
184
        }
185

186
        $schema = $this->transient_models[$on]->schema;
×
187

188
        return $schema->has_column($key) ? $key : null;
×
189
    }
190

191
    private function resolve_belongs_to(string $owner, TransientBelongsToAssociation $association): BelongsToAssociation
192
    {
193
        $associate = $this->resolve_model_id($association->associate);
80✔
194
        $foreign_key = $this->transient_models[$associate]->schema->primary;
80✔
195

196
        if (!is_string($foreign_key)) {
80✔
197
            throw new InvalidConfig(
×
198
                "Unable to create 'belongs to' association, primary key of model '$associate' is not a string."
×
199
            );
×
200
        }
201

202
        $local_key = $association->local_key
80✔
203
            ?? $this->try_key($foreign_key, $owner)
80✔
204
            ?? throw new LogicException(
80✔
205
                "Don't know how to resolve local key on '$owner' for association belongs_to($associate)"
80✔
206
            );
80✔
207

208
        $as = $association->as
80✔
209
            ?? singularize($associate);
210

211
        return new BelongsToAssociation(
80✔
212
            $associate,
80✔
213
            $local_key,
80✔
214
            $foreign_key,
80✔
215
            $as,
80✔
216
        );
80✔
217
    }
218

219
    private function resolve_has_many(string $owner, TransientHasManyAssociation $association): HasManyAssociation
220
    {
221
        $related = $this->resolve_model_id($association->associate);
93✔
222
        $local_key = $association->local_key ?? $this->transient_models[$owner]->schema->primary;
93✔
223
        $foreign_key = $association->foreign_key;
93✔
224
        $as = $association->as ?? $related;
93✔
225

226
        if ($association->through) {
93✔
227
            $foreign_key ??= $this->transient_models[$related]->schema->primary;
8✔
228
        } else {
229
            $foreign_key ??= $this->try_key($this->transient_models[$owner]->schema->primary, $related);
93✔
230
        }
231

232
        $foreign_key or throw new InvalidConfig(
93✔
233
            "Don't know how to resolve foreign key on '$owner' for association has_many($related)"
93✔
234
        );
93✔
235

236
        if (!is_string($local_key)) {
93✔
237
            throw new InvalidConfig(
×
238
                "Unable to create 'has many' association, primary key of model '$owner' is not a string."
×
239
            );
×
240
        }
241

242
        if (!is_string($foreign_key)) {
93✔
243
            throw new InvalidConfig(
×
244
                "Unable to create 'has many' association, primary key of model '$related' is not a string."
×
245
            );
×
246
        }
247

248
        $through = $association->through;
93✔
249

250
        if ($through) {
93✔
251
            $through = $this->resolve_model_id($through);
8✔
252
        }
253

254
        return new HasManyAssociation(
93✔
255
            $related,
93✔
256
            $local_key,
93✔
257
            $foreign_key,
93✔
258
            $as,
93✔
259
            $through,
93✔
260
        );
93✔
261
    }
262

263
    private function resolve_model_id(string $model_id_or_active_record_class): string
264
    {
265
        return $this->model_aliases[$model_id_or_active_record_class] ?? $model_id_or_active_record_class;
93✔
266
    }
267

268
    /**
269
     * @return $this
270
     */
271
    public function add_connection(
272
        string $id,
273
        string $dsn,
274
        string|null $username = null,
275
        string|null $password = null,
276
        string|null $table_name_prefix = null,
277
        string $charset_and_collate = ConnectionDefinition::DEFAULT_CHARSET_AND_COLLATE,
278
        string $time_zone = ConnectionDefinition::DEFAULT_TIMEZONE,
279
    ): self {
280
        $this->assert_time_zone($time_zone);
105✔
281

282
        $this->connections[$id] = new ConnectionDefinition(
105✔
283
            id: $id,
105✔
284
            dsn: $dsn,
105✔
285
            username: $username,
105✔
286
            password: $password,
105✔
287
            table_name_prefix: $table_name_prefix,
105✔
288
            charset_and_collate: $charset_and_collate,
105✔
289
            time_zone: $time_zone
105✔
290
        );
105✔
291

292
        return $this;
105✔
293
    }
294

295
    private function assert_time_zone(string $time_zone): void
296
    {
297
        $pattern = self::REGEXP_TIMEZONE;
105✔
298

299
        if (!preg_match($pattern, $time_zone)) {
105✔
300
            throw new InvalidArgumentException("Time zone doesn't match pattern '$pattern': $time_zone");
×
301
        }
302
    }
303

304
    /**
305
     * @var array<class-string, string>
306
     *     Where _key_ is an ActiveRecord class and _value_ a model identifier.
307
     */
308
    private array $model_aliases = [];
309

310
    /**
311
     * @param class-string<ActiveRecord> $activerecord_class
312
     * @param class-string<Model>|null $model_class
313
     * @param class-string<Query>|null $query_class
314
     * @param (Closure(SchemaBuilder $schema): SchemaBuilder)|null $schema_builder
315
     */
316
    public function add_model( // @phpstan-ignore-line
317
        string $id,
318
        string $activerecord_class,
319
        string|null $model_class = null,
320
        string|null $query_class = null,
321
        string|null $name = null,
322
        string|null $alias = null,
323
        string|null $extends = null,
324
        string|null $implements = null,
325
        Closure $schema_builder = null,
326
        Closure $association_builder = null,
327
        string $connection = Config::DEFAULT_CONNECTION_ID,
328
    ): self {
329
        if ($activerecord_class === ActiveRecord::class) {
105✔
330
            throw new LogicException("\$activerecord_class must be an extension of ICanBoogie\ActiveRecord");
×
331
        }
332

333
        $this->model_aliases[$activerecord_class] = $id;
105✔
334

335
        //
336

337
        [ $inner_schema_builder, $inner_association_builder ] = $this->create_builders($activerecord_class);
105✔
338

339
        // schema
340

341
        if ($schema_builder) {
105✔
342
            $schema_builder($inner_schema_builder);
103✔
343
        } elseif ($this->use_attributes && $inner_schema_builder->is_empty()) {
2✔
344
            throw new LogicException("expected schema builder because the config was built from attributes but there's no schema for $activerecord_class");
×
345
        }
346

347
        $schema = $inner_schema_builder->build();
105✔
348

349
        // association
350

351
        foreach ($schema->columns as $local_key => $column) {
105✔
352
            if (!$column instanceof BelongsTo) {
105✔
353
                continue;
105✔
354
            }
355

356
            $inner_association_builder->belongs_to(
80✔
357
                associate: $column->associate,
80✔
358
                local_key: $local_key,
80✔
359
                as: $column->as ?? $this->resolve_belong_to_accessor($local_key),
80✔
360
            );
80✔
361
        }
362

363
        if ($association_builder) {
105✔
364
            $association_builder($inner_association_builder);
92✔
365
        }
366

367
        $this->association[$id] = $inner_association_builder->build();
105✔
368

369
        // transient model
370

371
        $this->transient_models[$id] = new TransientModelDefinition(
105✔
372
            id: $id,
105✔
373
            schema: $schema,
105✔
374
            activerecord_class: $activerecord_class,
105✔
375
            connection: $connection,
105✔
376
            name: $name,
105✔
377
            alias: $alias,
105✔
378
            extends: $extends,
105✔
379
            implements: $implements,
105✔
380
            model_class: $model_class,
105✔
381
            query_class: $query_class,
105✔
382
        );
105✔
383

384
        return $this;
105✔
385
    }
386

387
    /**
388
     * @param non-empty-string $local_key
389
     *
390
     * @return non-empty-string
391
     */
392
    private function resolve_belong_to_accessor(string $local_key): string
393
    {
394
        if (str_ends_with($local_key, '_id')) {
80✔
395
            $local_key = substr($local_key, 0, -3);
8✔
396
        }
397

398
        assert($local_key !== '');
399

400
        return $local_key;
80✔
401
    }
402

403
    private bool $use_attributes = false;
404

405
    /**
406
     * Enables the use of attributes to create schemas and associations.
407
     */
408
    public function use_attributes(): self
409
    {
410
        if (!class_exists(Attributes::class)) {
2✔
411
            throw new LogicException(
×
412
                sprintf(
×
413
                    "unable to load %s, is the package olvlvl/composer-attribute-collector activated?",
×
414
                    Attributes::class
×
415
                )
×
416
            );
×
417
        }
418

419
        $this->use_attributes = true;
2✔
420

421
        return $this;
2✔
422
    }
423

424
    /**
425
     * Creates a schema builder and an association builder, if attributes are enabled they are configured using them.
426
     *
427
     * @param class-string $activerecord_class
428
     *     An ActiveRecord class.
429
     *
430
     * @return array{ SchemaBuilder, AssociationBuilder }
431
     */
432
    private function create_builders(string $activerecord_class): array
433
    {
434
        $schema_builder = new SchemaBuilder();
105✔
435
        $association_builder = new AssociationBuilder();
105✔
436

437
        if ($this->use_attributes) {
105✔
438
            [ $class_targets, $target_properties ] = $this->find_attribute_targets($activerecord_class);
2✔
439

440
            $class_attributes = array_map(fn(TargetClass $t) => $t->attribute, $class_targets);
2✔
441
            $property_attributes = array_map(fn(TargetProperty $t) => [ $t->attribute, $t->name ], $target_properties);
2✔
442

443
            $schema_builder->from_attributes($class_attributes, $property_attributes);
2✔
444
            $association_builder->from_attributes($class_attributes);
2✔
445
        }
446

447
        return [ $schema_builder, $association_builder ];
105✔
448
    }
449

450
    /**
451
     * @param class-string $activerecord_class
452
     *     An ActiveRecord class.
453
     *
454
     * @return array{
455
     *     TargetClass<SchemaAttribute>[],
456
     *     TargetProperty<SchemaAttribute>[],
457
     * }
458
     */
459
    private function find_attribute_targets(string $activerecord_class): array
460
    {
461
        $predicate = fn(string $attribute, string $class): bool =>
2✔
462
            is_a($attribute, SchemaAttribute::class, true)
2✔
463
            && $class === $activerecord_class;
2✔
464

465
        /** @var TargetClass<SchemaAttribute>[] $target_classes */
466
        $target_classes = Attributes::filterTargetClasses($predicate);
2✔
467

468
        /** @var TargetProperty<SchemaAttribute>[] $target_properties */
469
        $target_properties = Attributes::filterTargetProperties($predicate);
2✔
470

471
        return [ $target_classes, $target_properties ];
2✔
472
    }
473
}
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