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

ICanBoogie / ActiveRecord / 4526552612

pending completion
4526552612

push

github

Olivier Laviale
Only load attributes for required ActiveRecords

42 of 42 new or added lines in 3 files covered. (100.0%)

1509 of 1897 relevant lines covered (79.55%)

31.9 hits per line

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

82.63
/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\singularize;
37
use function is_a;
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();
105✔
65

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

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

72
    private function validate_models(): void
73
    {
74
        foreach ($this->transient_models as $id => $config) {
105✔
75
            if (empty($this->connections[$config->connection])) {
105✔
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])) {
105✔
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])) {
105✔
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) {
105✔
95
            if ($model->extends) {
105✔
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 = [];
105✔
111

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

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

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

129
        return $associations;
105✔
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 = [];
105✔
144

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

161
        return $models;
105✔
162
    }
163

164
    private function try_key(mixed $key, string $on): ?string
165
    {
166
        if (!is_string($key)) {
×
167
            return null;
×
168
        }
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 = $this->resolve_model_id($association->associate);
93✔
206
        $local_key = $association->local_key ?? $this->transient_models[$owner]->schema->primary;
93✔
207
        $foreign_key = $association->foreign_key;
93✔
208
        $as = $association->as ?? $related;
93✔
209

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

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

220
        if (!is_string($local_key)) {
93✔
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)) {
93✔
227
            throw new InvalidConfig(
×
228
                "Unable to create 'has many' association, primary key of model '$related' is not a string."
×
229
            );
×
230
        }
231

232
        $through = $association->through;
93✔
233

234
        if ($through) {
93✔
235
            $through = $this->resolve_model_id($through);
8✔
236
        }
237

238
        return new HasManyAssociation(
93✔
239
            $related,
93✔
240
            $local_key,
93✔
241
            $foreign_key,
93✔
242
            $as,
93✔
243
            $through,
93✔
244
        );
93✔
245
    }
246

247
    private function resolve_model_id(string $model_id_or_active_record_class): string
248
    {
249
        return $this->model_aliases[$model_id_or_active_record_class] ?? $model_id_or_active_record_class;
93✔
250
    }
251

252
    /**
253
     * @return $this
254
     */
255
    public function add_connection(
256
        string $id,
257
        string $dsn,
258
        string|null $username = null,
259
        string|null $password = null,
260
        string|null $table_name_prefix = null,
261
        string $charset_and_collate = ConnectionDefinition::DEFAULT_CHARSET_AND_COLLATE,
262
        string $time_zone = ConnectionDefinition::DEFAULT_TIMEZONE,
263
    ): self {
264
        $this->assert_time_zone($time_zone);
105✔
265

266
        $this->connections[$id] = new ConnectionDefinition(
105✔
267
            id: $id,
105✔
268
            dsn: $dsn,
105✔
269
            username: $username,
105✔
270
            password: $password,
105✔
271
            table_name_prefix: $table_name_prefix,
105✔
272
            charset_and_collate: $charset_and_collate,
105✔
273
            time_zone: $time_zone
105✔
274
        );
105✔
275

276
        return $this;
105✔
277
    }
278

279
    private function assert_time_zone(string $time_zone): void
280
    {
281
        $pattern = self::REGEXP_TIMEZONE;
105✔
282

283
        if (!preg_match($pattern, $time_zone)) {
105✔
284
            throw new InvalidArgumentException("Time zone doesn't match pattern '$pattern': $time_zone");
×
285
        }
286
    }
287

288
    /**
289
     * @var array<class-string, string>
290
     *     Where _key_ is an ActiveRecord class and _value_ a model identifier.
291
     */
292
    private array $model_aliases = [];
293

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

317
        $this->model_aliases[$activerecord_class] = $id;
105✔
318

319
        //
320

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

323
        // schema
324

325
        if ($schema_builder) {
105✔
326
            $schema_builder($inner_schema_builder);
103✔
327
        } elseif ($this->use_attributes && $inner_schema_builder->is_empty()) {
2✔
328
            throw new LogicException("expected schema builder because the config was built from attributes but there's no schema for $activerecord_class");
×
329
        }
330

331
        $schema = $inner_schema_builder->build();
105✔
332

333
        // association
334

335
        if ($association_builder) {
105✔
336
            $association_builder($inner_association_builder);
92✔
337
        }
338

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

341
        // transient model
342

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

356
        return $this;
105✔
357
    }
358

359
    private bool $use_attributes = false;
360

361
    /**
362
     * Enables the use of attributes to create schemas and associations.
363
     */
364
    public function use_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->use_attributes = true;
2✔
376

377
        return $this;
2✔
378
    }
379

380
    /**
381
     * Creates a schema builder and an association builder, if attributes are enabled they are configured using them.
382
     *
383
     * @param class-string $activerecord_class
384
     *     An ActiveRecord class.
385
     *
386
     * @return array{ SchemaBuilder, AssociationBuilder }
387
     */
388
    private function create_builders(string $activerecord_class): array
389
    {
390
        $schema_builder = new SchemaBuilder();
105✔
391
        $association_builder = new AssociationBuilder();
105✔
392

393
        if ($this->use_attributes) {
105✔
394
            [ $class_targets, $target_properties ] = $this->find_attribute_targets($activerecord_class);
2✔
395

396
            $class_attributes = array_map(fn(TargetClass $t) => $t->attribute, $class_targets);
2✔
397
            $property_attributes = array_map(fn(TargetProperty $t) => [ $t->attribute, $t->name ], $target_properties);
2✔
398

399
            $schema_builder->from_attributes($class_attributes, $property_attributes);
2✔
400
            $association_builder->from_attributes($class_attributes, $property_attributes);
2✔
401
        }
402

403
        return [ $schema_builder, $association_builder ];
105✔
404
    }
405

406
    /**
407
     * @param class-string $activerecord_class
408
     *     An ActiveRecord class.
409
     *
410
     * @return array{
411
     *     TargetClass<SchemaAttribute>[],
412
     *     TargetProperty<SchemaAttribute>[],
413
     * }
414
     */
415
    private function find_attribute_targets(string $activerecord_class): array
416
    {
417
        $predicate = fn(string $attribute, string $class): bool =>
2✔
418
            is_a($attribute, SchemaAttribute::class, true)
2✔
419
            && $class === $activerecord_class;
2✔
420

421
        /** @var TargetClass<SchemaAttribute>[] $target_classes */
422
        $target_classes = Attributes::filterTargetClasses($predicate);
2✔
423

424
        /** @var TargetProperty<SchemaAttribute>[] $target_properties */
425
        $target_properties = Attributes::filterTargetProperties($predicate);
2✔
426

427
        return [ $target_classes, $target_properties ];
2✔
428
    }
429
}
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