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

ICanBoogie / ActiveRecord / 4525406810

pending completion
4525406810

push

github

Olivier Laviale
Build schema from attributes

189 of 189 new or added lines in 20 files covered. (100.0%)

1491 of 1873 relevant lines covered (79.6%)

31.72 hits per line

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

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

33
use function array_map;
34
use function class_exists;
35
use function get_debug_type;
36
use function ICanBoogie\iterable_to_groups;
37
use function ICanBoogie\singularize;
38
use function is_string;
39
use function preg_match;
40
use function sprintf;
41

42
final class ConfigBuilder
43
{
44
    private const REGEXP_TIMEZONE = '/^[-+]\d{2}:\d{2}$/';
45

46
    /**
47
     * @var array<string, ConnectionDefinition>
48
     */
49
    private array $connections = [];
50

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

56
    /**
57
     * @var array<string, TransientAssociation>
58
     *     Where _key_ is a model identifier.
59
     */
60
    private array $association = [];
61

62
    public function build(): Config
63
    {
64
        $this->validate_models();
104✔
65

66
        $associations = $this->build_associations();
104✔
67
        $models = $this->build_models($associations);
104✔
68

69
        return new Config($this->connections, $models);
104✔
70
    }
71

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

79
            if ($config->extends && empty($this->transient_models[$config->extends])) {
104✔
80
                throw new InvalidConfig("Model '$id' extends '$config->extends', but it is not configured.");
×
81
            }
82

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

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

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

106
                $model->schema[$primary] = $parent_schema[$primary]->with([ 'auto_increment' => false ]);
87✔
107
            }
108
        }
109

110
        $associations = [];
104✔
111

112
        foreach ($this->association as $model_id => $association) {
104✔
113
            $belongs_to = array_map(
92✔
114
                fn(TransientBelongsToAssociation $a): BelongsToAssociation => $this->resolve_belongs_to($model_id, $a),
92✔
115
                $association->belongs_to
92✔
116
            );
92✔
117

118
            $has_many = array_map(
92✔
119
                fn(TransientHasManyAssociation $a): HasManyAssociation => $this->resolve_has_many($model_id, $a),
92✔
120
                $association->has_many
92✔
121
            );
92✔
122

123
            $associations[$model_id] = new Association(
92✔
124
                belongs_to: $belongs_to,
92✔
125
                has_many: $has_many,
92✔
126
            );
92✔
127
        }
128

129
        return $associations;
104✔
130
    }
131

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

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

161
        return $models;
104✔
162
    }
163

164
    private function try_key(string $key, string $on): ?string
165
    {
166
        $schema = $this->transient_models[$on]->schema;
×
167

168
        return isset($schema[$key]) ? $key : null;
×
169
    }
170

171
    private function resolve_belongs_to(string $owner, TransientBelongsToAssociation $association): BelongsToAssociation
172
    {
173
        $associate = $association->associate;
79✔
174
        $foreign_key = $this->transient_models[$associate]->schema->primary;
79✔
175

176
        if (!is_string($foreign_key)) {
79✔
177
            throw new InvalidConfig(
×
178
                "Unable to create 'belongs to' association, primary key of model '$associate' is not a string."
×
179
            );
×
180
        }
181

182
        $local_key = $association->local_key
79✔
183
            ?? $this->try_key($foreign_key, $owner)
79✔
184
            ?? throw new LogicException(
79✔
185
                "Don't know how to resolve local key on '$owner' for association belongs_to($associate)"
79✔
186
            );
79✔
187

188
        $as = $association->as
79✔
189
            ?? singularize($associate);
79✔
190

191
        return new BelongsToAssociation(
79✔
192
            $associate,
79✔
193
            $local_key,
79✔
194
            $foreign_key,
79✔
195
            $as,
79✔
196
        );
79✔
197
    }
198

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

206
        if ($association->through) {
92✔
207
            $foreign_key ??= $this->transient_models[$related]->schema->primary;
7✔
208
        } else {
209
            $foreign_key ??= $this->try_key($this->transient_models[$owner]->schema->primary, $related);
92✔
210
        }
211

212
        $foreign_key or throw new InvalidConfig(
92✔
213
            "Don't know how to resolve foreign key on '$owner' for association has_many($related)"
92✔
214
        );
92✔
215

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

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

228
        return new HasManyAssociation(
92✔
229
            $related,
92✔
230
            $local_key,
92✔
231
            $foreign_key,
92✔
232
            $as,
92✔
233
            $association->through,
92✔
234
        );
92✔
235
    }
236

237
    /**
238
     * @return $this
239
     */
240
    public function add_connection(
241
        string $id,
242
        string $dsn,
243
        string|null $username = null,
244
        string|null $password = null,
245
        string|null $table_name_prefix = null,
246
        string $charset_and_collate = ConnectionDefinition::DEFAULT_CHARSET_AND_COLLATE,
247
        string $time_zone = ConnectionDefinition::DEFAULT_TIMEZONE,
248
    ): self {
249
        $this->assert_time_zone($time_zone);
104✔
250

251
        $this->connections[$id] = new ConnectionDefinition(
104✔
252
            id: $id,
104✔
253
            dsn: $dsn,
104✔
254
            username: $username,
104✔
255
            password: $password,
104✔
256
            table_name_prefix: $table_name_prefix,
104✔
257
            charset_and_collate: $charset_and_collate,
104✔
258
            time_zone: $time_zone
104✔
259
        );
104✔
260

261
        return $this;
104✔
262
    }
263

264
    private function assert_time_zone(string $time_zone): void
265
    {
266
        $pattern = self::REGEXP_TIMEZONE;
104✔
267

268
        if (!preg_match($pattern, $time_zone)) {
104✔
269
            throw new InvalidArgumentException("Time zone doesn't match pattern '$pattern': $time_zone");
×
270
        }
271
    }
272

273
    /**
274
     * @param class-string<ActiveRecord> $activerecord_class
275
     * @param class-string<Model>|null $model_class
276
     * @param class-string<Query<ActiveRecord>>|null $query_class
277
     * @param (Closure(SchemaBuilder $schema): SchemaBuilder)|null $schema_builder
278
     */
279
    public function add_model(
280
        string $id,
281
        string $activerecord_class,
282
        string|null $model_class = null,
283
        string|null $query_class = null,
284
        string|null $name = null,
285
        string|null $alias = null,
286
        string|null $extends = null,
287
        string|null $implements = null,
288
        Closure $schema_builder = null,
289
        Closure $association_builder = null,
290
        string $connection = Config::DEFAULT_CONNECTION_ID,
291
    ): self {
292
        if ($activerecord_class === ActiveRecord::class) {
104✔
293
            throw new LogicException("\$activerecord_class must be an extension of ICanBoogie\ActiveRecord");
×
294
        }
295

296
        $schema = $this->schemas[$activerecord_class] ?? null;
104✔
297

298
        if ($schema_builder) {
104✔
299
            $inner_schema_builder = new SchemaBuilder();
103✔
300
            $schema_builder($inner_schema_builder);
103✔
301
            $schema = $inner_schema_builder->build();
103✔
302
        } elseif ($schema === null && $this->from_attributes) {
1✔
303
            throw new LogicException("expected schema builder because the config was built from attributes but there's no schema for $activerecord_class");
×
304
        } elseif ($schema === null) {
1✔
305
            throw new LogicException("expected schema builder for '$id'");
×
306
        }
307

308
        if ($association_builder) {
104✔
309
            $inner_association_builder = new AssociationBuilder();
92✔
310
            $association_builder($inner_association_builder);
92✔
311
            $this->association[$id] = $inner_association_builder->build();
92✔
312
        }
313

314
        $this->transient_models[$id] = new TransientModelDefinition(
104✔
315
            id: $id,
104✔
316
            schema: $schema,
104✔
317
            activerecord_class: $activerecord_class,
104✔
318
            connection: $connection,
104✔
319
            name: $name,
104✔
320
            alias: $alias,
104✔
321
            extends: $extends,
104✔
322
            implements: $implements,
104✔
323
            model_class: $model_class,
104✔
324
            query_class: $query_class,
104✔
325
        );
104✔
326

327
        return $this;
104✔
328
    }
329

330
    /**
331
     * Schemas built from attributes.
332
     *
333
     * @var array<class-string, Schema>
334
     *     Where _key_ is an ActiveRecord class.
335
     */
336
    private array $schemas = [];
337
    private bool $from_attributes = false;
338

339
    public function from_attributes(): self
340
    {
341
        if (!class_exists(Attributes::class)) {
1✔
342
            throw new LogicException(
×
343
                sprintf(
×
344
                    "unable to load %s, is the package olvlvl/composer-attribute-collector activated?",
×
345
                    Attributes::class
×
346
                )
×
347
            );
×
348
        }
349

350
        $this->from_attributes = true;
1✔
351
        $this->build_schemas_from_attributes();
1✔
352

353
        return $this;
1✔
354
    }
355

356
    private function build_schemas_from_attributes(): void
357
    {
358
        /** @var TargetClass<SchemaAttribute>[] $target_classes */
359
        $target_classes = Attributes::filterTargetClasses(
1✔
360
            Attributes::predicateForAttributeInstanceOf(SchemaAttribute::class)
1✔
361
        );
1✔
362

363
        /** @var TargetProperty<SchemaAttribute>[] $target_properties */
364
        $target_properties = Attributes::filterTargetProperties(
1✔
365
            Attributes::predicateForAttributeInstanceOf(SchemaAttribute::class)
1✔
366
        );
1✔
367

368
        $target_classes_by_class = iterable_to_groups($target_classes, fn(TargetClass $t) => $t->name);
1✔
369
        $target_properties_by_class = iterable_to_groups($target_properties, fn(TargetProperty $t) => $t->class);
1✔
370

371
        foreach ($target_properties_by_class as $class => $target_properties) {
1✔
372
            $target_classes = $target_classes_by_class[$class] ?? [];
1✔
373

374
            $this->schemas[$class] = $this->build_schema_from_attributes($target_classes, $target_properties);
1✔
375
        }
376
    }
377

378
    /**
379
     * @param TargetClass<SchemaAttribute>[] $target_classes
380
     * @param TargetProperty<SchemaAttribute>[] $target_properties
381
     */
382
    private function build_schema_from_attributes(array $target_classes, array $target_properties): Schema
383
    {
384
        $ca = array_map(fn(TargetClass $t) => [ $t->attribute ], $target_classes);
1✔
385
        $pa = array_map(fn(TargetProperty $t) => [ $t->attribute, $t->name ], $target_properties);
1✔
386

387
        $builder = new SchemaBuilder();
1✔
388
        $builder->from_attributes($ca, $pa);
1✔
389

390
        return $builder->build();
1✔
391
    }
392
}
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