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

ICanBoogie / ActiveRecord / 4525642692

pending completion
4525642692

push

github

Olivier Laviale
Build 'belongs to' from attributes

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

1514 of 1890 relevant lines covered (80.11%)

31.88 hits per line

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

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

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

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

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

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

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

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

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

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

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

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

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

93
    /**
94
     * @return array<string, Association>
95
     */
96
    private function build_associations(): array
97
    {
98
        foreach ($this->transient_models as $id => $model) {
105✔
99
            if ($model->extends) {
105✔
100
                $parent_schema = $this->transient_models[$model->extends]->schema;
87✔
101
                $primary = $parent_schema->primary;
87✔
102

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

110
                $model->schema[$primary] = $parent_schema[$primary]->with([ 'auto_increment' => false ]);
87✔
111
            }
112
        }
113

114
        $associations = [];
105✔
115

116
        foreach ($this->association as $model_id => $association) {
105✔
117
            $belongs_to = array_map(
94✔
118
                fn(TransientBelongsToAssociation $a): BelongsToAssociation => $this->resolve_belongs_to($model_id, $a),
94✔
119
                $association->belongs_to
94✔
120
            );
94✔
121

122
            $has_many = array_map(
94✔
123
                fn(TransientHasManyAssociation $a): HasManyAssociation => $this->resolve_has_many($model_id, $a),
94✔
124
                $association->has_many
94✔
125
            );
94✔
126

127
            $associations[$model_id] = new Association(
94✔
128
                belongs_to: $belongs_to,
94✔
129
                has_many: $has_many,
94✔
130
            );
94✔
131
        }
132

133
        return $associations;
105✔
134
    }
135

136
    /**
137
     * Builds model configuration from model transient configurations and association configurations.
138
     *
139
     * @param array<string, Association> $associations
140
     *     Where _key_ is a model identifier.
141
     *
142
     * @return array<string, ModelDefinition>
143
     *     Where _key_ is a model identifier.
144
     */
145
    private function build_models(array $associations): array
146
    {
147
        $models = [];
105✔
148

149
        foreach ($this->transient_models as $id => $transient) {
105✔
150
            $models[$id] = new ModelDefinition(
105✔
151
                id: $id,
105✔
152
                connection: $transient->connection,
105✔
153
                schema: $transient->schema,
105✔
154
                activerecord_class: $transient->activerecord_class,
105✔
155
                name: $transient->name,
105✔
156
                alias: $transient->alias,
105✔
157
                extends: $transient->extends,
105✔
158
                implements: $transient->implements,
105✔
159
                model_class: $transient->model_class ?? Model::class, // @phpstan-ignore-line
105✔
160
                query_class: $transient->query_class ?? Query::class,
105✔
161
                association: $associations[$id] ?? null,
105✔
162
            );
105✔
163
        }
164

165
        return $models;
105✔
166
    }
167

168
    private function try_key(string $key, string $on): ?string
169
    {
170
        $schema = $this->transient_models[$on]->schema;
×
171

172
        return isset($schema[$key]) ? $key : null;
×
173
    }
174

175
    private function resolve_belongs_to(string $owner, TransientBelongsToAssociation $association): BelongsToAssociation
176
    {
177
        $associate = $this->resolve_model_id($association->associate);
80✔
178
        $foreign_key = $this->transient_models[$associate]->schema->primary;
80✔
179

180
        if (!is_string($foreign_key)) {
80✔
181
            throw new InvalidConfig(
×
182
                "Unable to create 'belongs to' association, primary key of model '$associate' is not a string."
×
183
            );
×
184
        }
185

186
        $local_key = $association->local_key
80✔
187
            ?? $this->try_key($foreign_key, $owner)
80✔
188
            ?? throw new LogicException(
80✔
189
                "Don't know how to resolve local key on '$owner' for association belongs_to($associate)"
80✔
190
            );
80✔
191

192
        $as = $association->as
80✔
193
            ?? singularize($associate);
79✔
194

195
        return new BelongsToAssociation(
80✔
196
            $associate,
80✔
197
            $local_key,
80✔
198
            $foreign_key,
80✔
199
            $as,
80✔
200
        );
80✔
201
    }
202

203
    private function resolve_has_many(string $owner, TransientHasManyAssociation $association): HasManyAssociation
204
    {
205
        $related = $association->model_id;
92✔
206
        $local_key = $association->local_key ?? $this->transient_models[$owner]->schema->primary;
92✔
207
        $foreign_key = $association->foreign_key;
92✔
208
        $as = $association->as ?? $related;
92✔
209

210
        if ($association->through) {
92✔
211
            $foreign_key ??= $this->transient_models[$related]->schema->primary;
7✔
212
        } else {
213
            $foreign_key ??= $this->try_key($this->transient_models[$owner]->schema->primary, $related);
92✔
214
        }
215

216
        $foreign_key or throw new InvalidConfig(
92✔
217
            "Don't know how to resolve foreign key on '$owner' for association has_many($related)"
92✔
218
        );
92✔
219

220
        if (!is_string($local_key)) {
92✔
221
            throw new InvalidConfig(
×
222
                "Unable to create 'has many' association, primary key of model '$owner' is not a string."
×
223
            );
×
224
        }
225

226
        if (!is_string($foreign_key)) {
92✔
227
            throw new InvalidConfig(
×
228
                "Unable to create 'has many' association, primary key of model '$related' is not a string."
×
229
            );
×
230
        }
231

232
        return new HasManyAssociation(
92✔
233
            $related,
92✔
234
            $local_key,
92✔
235
            $foreign_key,
92✔
236
            $as,
92✔
237
            $association->through,
92✔
238
        );
92✔
239
    }
240

241
    private function resolve_model_id(string $model_id_or_active_record_class): string
242
    {
243
        return $this->model_aliases[$model_id_or_active_record_class] ?? $model_id_or_active_record_class;
80✔
244
    }
245

246
    /**
247
     * @return $this
248
     */
249
    public function add_connection(
250
        string $id,
251
        string $dsn,
252
        string|null $username = null,
253
        string|null $password = null,
254
        string|null $table_name_prefix = null,
255
        string $charset_and_collate = ConnectionDefinition::DEFAULT_CHARSET_AND_COLLATE,
256
        string $time_zone = ConnectionDefinition::DEFAULT_TIMEZONE,
257
    ): self {
258
        $this->assert_time_zone($time_zone);
105✔
259

260
        $this->connections[$id] = new ConnectionDefinition(
105✔
261
            id: $id,
105✔
262
            dsn: $dsn,
105✔
263
            username: $username,
105✔
264
            password: $password,
105✔
265
            table_name_prefix: $table_name_prefix,
105✔
266
            charset_and_collate: $charset_and_collate,
105✔
267
            time_zone: $time_zone
105✔
268
        );
105✔
269

270
        return $this;
105✔
271
    }
272

273
    private function assert_time_zone(string $time_zone): void
274
    {
275
        $pattern = self::REGEXP_TIMEZONE;
105✔
276

277
        if (!preg_match($pattern, $time_zone)) {
105✔
278
            throw new InvalidArgumentException("Time zone doesn't match pattern '$pattern': $time_zone");
×
279
        }
280
    }
281

282
    /**
283
     * @var array<class-string, string>
284
     *     Where _key_ is an ActiveRecord class and _value_ a model identifier.
285
     */
286
    private array $model_aliases = [];
287

288
    /**
289
     * @param class-string<ActiveRecord> $activerecord_class
290
     * @param class-string<Model>|null $model_class
291
     * @param class-string<Query<ActiveRecord>>|null $query_class
292
     * @param (Closure(SchemaBuilder $schema): SchemaBuilder)|null $schema_builder
293
     */
294
    public function add_model(
295
        string $id,
296
        string $activerecord_class,
297
        string|null $model_class = null,
298
        string|null $query_class = null,
299
        string|null $name = null,
300
        string|null $alias = null,
301
        string|null $extends = null,
302
        string|null $implements = null,
303
        Closure $schema_builder = null,
304
        Closure $association_builder = null,
305
        string $connection = Config::DEFAULT_CONNECTION_ID,
306
    ): self {
307
        if ($activerecord_class === ActiveRecord::class) {
105✔
308
            throw new LogicException("\$activerecord_class must be an extension of ICanBoogie\ActiveRecord");
×
309
        }
310

311
        $this->model_aliases[$activerecord_class] = $id;
105✔
312

313
        // schema
314

315
        $schema = $this->schemas[$activerecord_class] ?? null;
105✔
316

317
        if ($schema_builder) {
105✔
318
            $inner_schema_builder = new SchemaBuilder();
103✔
319
            $schema_builder($inner_schema_builder);
103✔
320
            $schema = $inner_schema_builder->build();
103✔
321
        } elseif ($schema === null && $this->from_attributes) {
2✔
322
            throw new LogicException("expected schema builder because the config was built from attributes but there's no schema for $activerecord_class");
×
323
        } elseif ($schema === null) {
2✔
324
            throw new LogicException("expected schema builder for '$id'");
×
325
        }
326

327
        // association
328

329
        $inner_association_builder = $this->association_builders[$activerecord_class] ?? null;
105✔
330

331
        if ($association_builder) {
105✔
332
            $inner_association_builder ??= new AssociationBuilder();
92✔
333
            $association_builder($inner_association_builder);
92✔
334
        }
335
        if ($inner_association_builder) {
105✔
336
            $this->association[$id] = $inner_association_builder->build();
94✔
337
        }
338

339
        $this->transient_models[$id] = new TransientModelDefinition(
105✔
340
            id: $id,
105✔
341
            schema: $schema,
105✔
342
            activerecord_class: $activerecord_class,
105✔
343
            connection: $connection,
105✔
344
            name: $name,
105✔
345
            alias: $alias,
105✔
346
            extends: $extends,
105✔
347
            implements: $implements,
105✔
348
            model_class: $model_class,
105✔
349
            query_class: $query_class,
105✔
350
        );
105✔
351

352
        return $this;
105✔
353
    }
354

355
    /**
356
     * Schemas built from attributes.
357
     *
358
     * @var array<class-string, Schema>
359
     *     Where _key_ is an ActiveRecord class.
360
     */
361
    private array $schemas = [];
362
    private bool $from_attributes = false;
363

364
    public function from_attributes(): self
365
    {
366
        if (!class_exists(Attributes::class)) {
2✔
367
            throw new LogicException(
×
368
                sprintf(
×
369
                    "unable to load %s, is the package olvlvl/composer-attribute-collector activated?",
×
370
                    Attributes::class
×
371
                )
×
372
            );
×
373
        }
374

375
        $this->from_attributes = true;
2✔
376
        $this->build_schemas_from_attributes();
2✔
377

378
        return $this;
2✔
379
    }
380

381
    private function build_schemas_from_attributes(): void
382
    {
383
        /** @var TargetClass<SchemaAttribute>[] $target_classes */
384
        $target_classes = Attributes::filterTargetClasses(
2✔
385
            Attributes::predicateForAttributeInstanceOf(SchemaAttribute::class)
2✔
386
        );
2✔
387

388
        /** @var TargetProperty<SchemaAttribute>[] $target_properties */
389
        $target_properties = Attributes::filterTargetProperties(
2✔
390
            Attributes::predicateForAttributeInstanceOf(SchemaAttribute::class)
2✔
391
        );
2✔
392

393
        $target_classes_by_class = iterable_to_groups($target_classes, fn(TargetClass $t) => $t->name);
2✔
394
        $target_properties_by_class = iterable_to_groups($target_properties, fn(TargetProperty $t) => $t->class);
2✔
395

396
        foreach ($target_properties_by_class as $class => $target_properties) {
2✔
397
            $target_classes = $target_classes_by_class[$class] ?? [];
2✔
398

399
            $this->schemas[$class] = $this->build_schema_from_attributes($target_classes, $target_properties);
2✔
400

401
            $this->add_associations_from_attributes($class, $target_classes, $target_properties);
2✔
402
        }
403
    }
404

405
    /**
406
     * @param TargetClass<SchemaAttribute>[] $target_classes
407
     * @param TargetProperty<SchemaAttribute>[] $target_properties
408
     */
409
    private function build_schema_from_attributes(array $target_classes, array $target_properties): Schema
410
    {
411
        $ca = array_map(fn(TargetClass $t) => [ $t->attribute ], $target_classes);
2✔
412
        $pa = array_map(fn(TargetProperty $t) => [ $t->attribute, $t->name ], $target_properties);
2✔
413

414
        $builder = new SchemaBuilder();
2✔
415
        $builder->from_attributes($ca, $pa);
2✔
416

417
        return $builder->build();
2✔
418
    }
419

420
    /**
421
     * @var array<class-string, AssociationBuilder>
422
     */
423
    private array $association_builders = [];
424

425
    /**
426
     * @param class-string $class ActiveRecord class
427
     * @param TargetClass<SchemaAttribute>[] $target_classes
428
     * @param TargetProperty<SchemaAttribute>[] $target_properties
429
     */
430
    private function add_associations_from_attributes(
431
        string $class,
432
        array $target_classes,
433
        array $target_properties
434
    ): void {
435
        $this->association_builders[$class] = $b = new AssociationBuilder();
2✔
436

437
        foreach ($target_properties as $t) {
2✔
438
            $attribute = $t->attribute;
2✔
439
            $property = $t->name;
2✔
440

441
            if ($attribute instanceof BelongsTo) {
2✔
442
                $as = $attribute->as ?? $this->create_belong_to_accessor($property);
2✔
443

444
                $b->belongs_to($attribute->active_record_class, $property, $as);
2✔
445
            }
446
        }
447
    }
448

449
    private function create_belong_to_accessor(string $property): string
450
    {
451
        if (str_ends_with($property, '_id')) {
2✔
452
            $property = substr($property, 0, -3);
2✔
453
        }
454

455
        return $property;
2✔
456
    }
457
}
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