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

ICanBoogie / ActiveRecord / 6362433236

30 Sep 2023 11:14AM UTC coverage: 85.731% (+5.6%) from 80.178%
6362433236

push

github

olvlvl
Rename StaticModelProvider methods

1436 of 1675 relevant lines covered (85.73%)

29.41 hits per line

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

88.54
/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\Assert;
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\ModelDefinition;
24
use ICanBoogie\ActiveRecord\Config\TableDefinition;
25
use ICanBoogie\ActiveRecord\Config\TransientAssociation;
26
use ICanBoogie\ActiveRecord\Config\TransientHasManyAssociation;
27
use ICanBoogie\ActiveRecord\Config\TransientModelDefinition;
28
use ICanBoogie\ActiveRecord\Schema\Integer;
29
use InvalidArgumentException;
30
use LogicException;
31
use Throwable;
32

33
use function assert;
34
use function get_parent_class;
35
use function ICanBoogie\pluralize;
36
use function ICanBoogie\singularize;
37
use function ICanBoogie\trim_suffix;
38
use function ICanBoogie\underscore;
39
use function is_string;
40
use function json_encode;
41
use function preg_match;
42
use function strlen;
43
use function strrpos;
44
use function substr;
45

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

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

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

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

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

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

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

77
    private function validate_models(): void
78
    {
79
        foreach ($this->model_definitions as $definition) {
87✔
80
                $this->connections[$definition->connection] ?? throw new InvalidConfig(
87✔
81
                "$definition->activerecord_class uses connection '$definition->connection', but it is not configured"
87✔
82
            );
87✔
83

84
            $this->resolve_parent_definition($definition);
87✔
85
        }
86
    }
87

88
    /**
89
     * @return array<class-string<ActiveRecord>, Association>
90
     */
91
    private function build_associations(): array
92
    {
93
        foreach ($this->model_definitions as $definition) {
87✔
94
            $parent = $this->resolve_parent_definition($definition);
87✔
95

96
            if (!$parent) {
87✔
97
                continue;
87✔
98
            }
99

100
            $parent_schema = $parent->schema;
66✔
101
            $primary = $parent_schema->primary;
66✔
102

103
            if (!is_string($primary)) {
66✔
104
                throw new InvalidConfig(
×
105
                    "$definition->activerecord_class cannot extend $parent->activerecord_class,"
×
106
                    . " the primary key is not a column, given: " . json_encode($primary)
×
107
                );
×
108
            }
109

110
            $schema = $definition->schema;
66✔
111
            $parent_column = $parent_schema->columns[$primary];
66✔
112

113
            assert($parent_column instanceof Integer);
114

115
            $definition->schema = new Schema(
66✔
116
                columns: [ $primary => new Integer(size: $parent_column->size, unique: true) ] + $schema->columns,
66✔
117
                primary: $primary,
66✔
118
                indexes: $schema->indexes,
66✔
119
            );
66✔
120
        }
121

122
        $associations = [];
87✔
123

124
        foreach ($this->association as $activerecord_class => $association) {
87✔
125
            $owner = $this->model_definitions[$activerecord_class];
87✔
126

127
            $belongs_to = [];
87✔
128

129
            foreach ($owner->schema->belongs_to_iterator() as $name => $column) {
87✔
130
                try {
131
                    $belongs_to[] = $this->resolve_belongs_to($name, $column);
75✔
132
                } catch (Throwable $e) {
×
133
                    throw new InvalidConfig(
×
134
                        "Unable to apply $owner->activerecord_class::belongs_to($column->associate::$name)",
×
135
                        previous: $e
×
136
                    );
×
137
                }
138
            }
139

140
            $has_many = [];
87✔
141

142
            foreach ($association->has_many as $item) {
87✔
143
                try {
144
                    $has_many[] = $this->resolve_has_many($owner, $item);
73✔
145
                } catch (Throwable $e) {
×
146
                    throw new InvalidConfig(
×
147
                        "Unable to apply $owner->activerecord_class::has_may($item->associate::$item->foreign_key)",
×
148
                        previous: $e
×
149
                    );
×
150
                }
151
            }
152

153
            $associations[$activerecord_class] = new Association(
87✔
154
                belongs_to: $belongs_to,
87✔
155
                has_many: $has_many,
87✔
156
            );
87✔
157
        }
158

159
        return $associations;
87✔
160
    }
161

162
    private function resolve_parent_definition(TransientModelDefinition $definition): ?TransientModelDefinition
163
    {
164
        $parent_class = get_parent_class($definition->activerecord_class);
87✔
165

166
        if ($parent_class === ActiveRecord::class) {
87✔
167
            return null;
87✔
168
        }
169

170
        return $this->model_definitions[$parent_class]
66✔
171
            ?? throw new InvalidConfig(
66✔
172
                "$definition->activerecord_class extends $parent_class but there's no definition for it"
66✔
173
            );
66✔
174
    }
175

176
    /**
177
     * Builds model configuration from model transient configurations and association configurations.
178
     *
179
     * @param array<class-string<ActiveRecord>, Association> $associations
180
     *
181
     * @return array<class-string<ActiveRecord>, ModelDefinition>
182
     */
183
    private function build_models(array $associations): array
184
    {
185
        $models = [];
87✔
186

187
        foreach ($this->model_definitions as $activerecord_class => $transient) {
87✔
188
            $models[$activerecord_class] = new ModelDefinition(
87✔
189
                table: new TableDefinition(
87✔
190
                    name: $transient->table_name,
87✔
191
                    schema: $transient->schema,
87✔
192
                    alias: $transient->alias,
87✔
193
                ),
87✔
194
                model_class: $transient->model_class,
87✔
195
                activerecord_class: $transient->activerecord_class,
87✔
196
                query_class: $transient->query_class,
87✔
197
                connection: $transient->connection,
87✔
198
                association: $associations[$activerecord_class] ?? null,
87✔
199
            );
87✔
200
        }
201

202
        return $models;
87✔
203
    }
204

205
    /**
206
     * @return non-empty-string|null
207
     */
208
    private function try_key(mixed $key, TransientModelDefinition $on): ?string
209
    {
210
        if (!is_string($key)) {
1✔
211
            return null;
×
212
        }
213

214
        assert(strlen($key) > 1);
215

216
        return $on->schema->has_column($key) ? $key : null;
1✔
217
    }
218

219
    /**
220
     * @param non-empty-string $local_key
221
     */
222
    private function resolve_belongs_to(string $local_key, Schema\BelongsTo $column): BelongsToAssociation
223
    {
224
        $associate = $this->model_definitions[$column->associate]
75✔
225
            ?? throw new InvalidConfig("$column->associate is not defined");
×
226

227
        $associate->schema->has_single_column_primary
75✔
228
            or throw new InvalidConfig(
75✔
229
                "The primary key of $associate->activerecord_class is not a single column"
75✔
230
            );
75✔
231

232
        $foreign_key = $associate->schema->primary;
75✔
233
        assert(is_string($foreign_key));
234
        $as = $column->as ?? trim_suffix($local_key, self::ID_SUFFIX);
75✔
235

236
        assert(strlen($as) > 0);
237

238
        return new BelongsToAssociation(
75✔
239
            $associate->activerecord_class,
75✔
240
            $local_key,
75✔
241
            $foreign_key,
75✔
242
            $as,
75✔
243
        );
75✔
244
    }
245

246
    private function resolve_has_many(
247
        TransientModelDefinition $owner,
248
        TransientHasManyAssociation $association
249
    ): HasManyAssociation {
250
        $owner->schema->has_single_column_primary
73✔
251
            or throw new InvalidConfig(
73✔
252
                "The primary key of $owner->activerecord_class is not a single column"
73✔
253
            );
73✔
254

255
        $related = $this->model_definitions[$association->associate];
73✔
256
        $foreign_key = $association->foreign_key;
73✔
257
        $as = $association->as ?? pluralize($related->alias);
73✔
258

259
        if ($association->through) {
73✔
260
            $foreign_key ??= $related->schema->primary;
9✔
261
        } else {
262
            $foreign_key ??= $this->try_key($owner->schema->primary, $related);
73✔
263
        }
264

265
        $foreign_key or throw new InvalidConfig("Unable to resolve the foreign key");
73✔
266
        is_string($foreign_key) or throw new InvalidConfig("The foreign key is not a single column");
73✔
267

268
        $through = null;
73✔
269

270
        if ($association->through) {
73✔
271
            $through = $this->model_definitions[$association->through];
9✔
272
        }
273

274
        assert(strlen($as) > 1);
275

276
        return new HasManyAssociation(
73✔
277
            associate: $related->activerecord_class,
73✔
278
            foreign_key: $foreign_key,
73✔
279
            as: $as,
73✔
280
            through: $through?->activerecord_class,
73✔
281
        );
73✔
282
    }
283

284
    /**
285
     * Adds a connection definition.
286
     *
287
     * @param non-empty-string $id
288
     * @param non-empty-string $dsn
289
     * @param non-empty-string|null $username
290
     * @param non-empty-string|null $password
291
     * @param non-empty-string|null $table_name_prefix
292
     * @param non-empty-string $charset_and_collate
293
     * @param non-empty-string $time_zone
294
     *
295
     * @return $this
296
     */
297
    public function add_connection(
298
        string $id,
299
        string $dsn,
300
        string|null $username = null,
301
        string|null $password = null,
302
        string|null $table_name_prefix = null,
303
        string $charset_and_collate = ConnectionDefinition::DEFAULT_CHARSET_AND_COLLATE,
304
        string $time_zone = ConnectionDefinition::DEFAULT_TIMEZONE,
305
    ): self {
306
        $this->assert_time_zone($time_zone);
87✔
307

308
        $this->connections[$id] = new ConnectionDefinition(
87✔
309
            id: $id,
87✔
310
            dsn: $dsn,
87✔
311
            username: $username,
87✔
312
            password: $password,
87✔
313
            table_name_prefix: $table_name_prefix,
87✔
314
            charset_and_collate: $charset_and_collate,
87✔
315
            time_zone: $time_zone
87✔
316
        );
87✔
317

318
        return $this;
87✔
319
    }
320

321
    private function assert_time_zone(string $time_zone): void
322
    {
323
        $pattern = self::REGEXP_TIMEZONE;
87✔
324

325
        if (!preg_match($pattern, $time_zone)) {
87✔
326
            throw new InvalidArgumentException("Time zone doesn't match pattern '$pattern': $time_zone");
×
327
        }
328
    }
329

330
    /**
331
     * Adds a record definition.
332
     *
333
     * @param class-string<ActiveRecord> $record_class
334
     * @param class-string<Model> $model_class
335
     * @param class-string<Query> $query_class
336
     * @param non-empty-string|null $table_name
337
     * @param non-empty-string|null $alias
338
     * @param (Closure(SchemaBuilder): SchemaBuilder)|null $schema_builder
339
     * @param (Closure(AssociationBuilder): AssociationBuilder)|null $association_builder
340
     * @param non-empty-string $connection
341
     */
342
    public function add_record(
343
        string $record_class,
344
        string $model_class = Model::class,
345
        string $query_class = Query::class,
346
        ?string $table_name = null,
347
        ?string $alias = null,
348
        Closure $schema_builder = null,
349
        Closure $association_builder = null,
350
        string $connection = Config::DEFAULT_CONNECTION_ID,
351
    ): self {
352
        Assert::extends_activerecord($record_class);
87✔
353

354
        [ $inner_schema_builder, $inner_association_builder ] = $this->create_builders($record_class);
87✔
355

356
        // schema
357

358
        if ($schema_builder) {
87✔
359
            $schema_builder($inner_schema_builder);
84✔
360
        } elseif ($this->use_attributes && $inner_schema_builder->is_empty()) {
3✔
361
            throw new LogicException("The Schema built from `$record_class` attributes is empty");
×
362
        }
363

364
        // association
365

366
        $schema = $inner_schema_builder->build();
87✔
367

368
        if ($association_builder) {
87✔
369
            $association_builder($inner_association_builder);
71✔
370
        }
371

372
        $this->association[$record_class] = $inner_association_builder->build();
87✔
373

374
        // transient model
375

376
        $table_name ??= self::resolve_table_name($record_class);
87✔
377

378
        $this->model_definitions[$record_class] = new TransientModelDefinition(
87✔
379
            schema: $schema,
87✔
380
            model_class: $model_class,
87✔
381
            activerecord_class: $record_class,
87✔
382
            query_class: $query_class,
87✔
383
            table_name: $table_name,
87✔
384
            alias: $alias ?? singularize($table_name), // @phpstan-ignore-line
87✔
385
            connection: $connection,
87✔
386
        );
87✔
387

388
        return $this;
87✔
389
    }
390

391
    /**
392
     * @param class-string<ActiveRecord> $activerecord_class
393
     *
394
     * @return non-empty-string
395
     */
396
    private static function resolve_table_name(string $activerecord_class): string
397
    {
398
        $pos = strrpos($activerecord_class, '\\');
87✔
399
        $base = substr($activerecord_class, $pos + 1);
87✔
400

401
        return pluralize(underscore($base)); // @phpstan-ignore-line
87✔
402
    }
403

404
    private bool $use_attributes = false;
405

406
    /**
407
     * Enables the use of attributes to create schemas and associations.
408
     */
409
    public function use_attributes(): self
410
    {
411
        $this->use_attributes = true;
3✔
412

413
        return $this;
3✔
414
    }
415

416
    /**
417
     * Creates a schema builder and an association builder.
418
     *
419
     * If attributes are enabled they are configured using the attributes on the ActiveRecord.
420
     *
421
     * @param class-string<ActiveRecord> $activerecord_class
422
     *
423
     * @return array{ SchemaBuilder, AssociationBuilder }
424
     */
425
    private function create_builders(string $activerecord_class): array
426
    {
427
        $schema_builder = new SchemaBuilder();
87✔
428
        $association_builder = new AssociationBuilder();
87✔
429

430
        if ($this->use_attributes) {
87✔
431
            $schema_builder->use_record($activerecord_class);
3✔
432
            $association_builder->use_record($activerecord_class);
3✔
433
        }
434

435
        return [ $schema_builder, $association_builder ];
87✔
436
    }
437
}
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