• 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

60.64
/lib/ActiveRecord/Table.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 ICanBoogie\Prototyped;
15
use InvalidArgumentException;
16
use LogicException;
17
use Throwable;
18

19
use function array_combine;
20
use function array_diff_key;
21
use function array_fill;
22
use function array_flip;
23
use function array_keys;
24
use function array_merge;
25
use function array_values;
26
use function count;
27
use function ICanBoogie\singularize;
28
use function implode;
29
use function is_array;
30
use function is_numeric;
31
use function strrpos;
32
use function strtr;
33
use function substr;
34

35
/**
36
 * A representation of a database table.
37
 *
38
 * @property-read Schema $extended_schema The extended schema of the table.
39
 */
40
#[\AllowDynamicProperties]
41
class Table extends Prototyped
42
{
43
    /**
44
     * The parent is used when the table is in a hierarchy, which is the case if the table
45
     * extends another table.
46
     */
47
    public readonly ?self $parent;
48

49
    /**
50
     * Name of the table, without the prefix defined by the connection.
51
     */
52
    public readonly string $unprefixed_name;
53

54
    /**
55
     * Name of the table, including the prefix defined by the connection.
56
     */
57
    public readonly string $name;
58

59
    /**
60
     * Alias for the table's name, which can be defined using the {@link ALIAS} attribute
61
     * or automatically created.
62
     *
63
     * The "{primary}" placeholder used in queries is replaced by the properties value.
64
     */
65
    public readonly string $alias;
66
    public readonly Schema $schema;
67

68
    /**
69
     * Primary key of the table, retrieved from the schema defined using the {@link SCHEMA} attribute.
70
     *
71
     * @var non-empty-string|non-empty-array<non-empty-string>|null
72
     */
73
    public readonly array|string|null $primary;
74

75
    protected $implements = [];
76

77
    /**
78
     * SQL fragment for the FROM clause of the query, made of the table's name and alias and those
79
     * of the hierarchy.
80
     *
81
     * @var string
82
     */
83
    protected $update_join;
84

85
    protected function lazy_get_update_join(): string
86
    {
87
        $join = '';
80✔
88
        $parent = $this->parent;
80✔
89

90
        while ($parent) {
80✔
91
            $join .= " INNER JOIN `{$parent->name}` `{$parent->alias}` USING(`{$this->primary}`)";
72✔
92
            $parent = $parent->parent;
72✔
93
        }
94

95
        return $join;
80✔
96
    }
97

98
    /**
99
     * SQL fragment for the FROM clause of the query, made of the table's name and alias and those
100
     * of the related tables, inherited and implemented.
101
     *
102
     * The "{self_and_related}" placeholder used in queries is replaced by the properties value.
103
     *
104
     * @var string
105
     */
106
    protected $select_join;
107

108
    protected function lazy_get_select_join(): string
109
    {
110
        $join = "`{$this->alias}`" . $this->update_join;
79✔
111

112
        if (!$this->implements) {
79✔
113
            return $join;
79✔
114
        }
115

116
        foreach ($this->implements as $implement) {
×
117
            $table = $implement['table'];
×
118

119
            $join .= empty($implement['loose']) ? 'INNER' : 'LEFT';
×
120
            $join .= " JOIN `$table->name` AS {$table->alias} USING(`$table->primary`)";
×
121
        }
122

123
        return $join;
×
124
    }
125

126
    /**
127
     * Returns the extended schema.
128
     */
129
    protected function lazy_get_extended_schema(): Schema
130
    {
131
        $table = $this;
5✔
132
        $columns = [];
5✔
133

134
        while ($table) {
5✔
135
            $columns[] = $table->schema->columns;
5✔
136

137
            $table = $table->parent;
5✔
138
        }
139

140
        $columns = array_reverse($columns);
5✔
141
        $columns = array_merge(...array_values($columns));
5✔
142

143
        return new Schema($columns, primary: $this->primary);
5✔
144
    }
145

146
    public function __construct(
147
        public readonly Connection $connection,
148
        TableDefinition $definition,
149
        self $parent = null
150
    ) {
151
        $this->parent = $parent;
110✔
152
        $this->unprefixed_name = $definition->name
110✔
153
            ?? throw new LogicException("The NAME attribute is required");
×
154
        $this->name = $connection->table_name_prefix . $this->unprefixed_name;
110✔
155
        $this->alias = $definition->alias
110✔
156
            ?? $this->make_alias($this->unprefixed_name);
157
        $this->schema = $definition->schema
110✔
158
            ?? throw new LogicException("The SCHEMA attribute is required");
159
        $this->primary = $this->schema->primary;
110✔
160
        $this->implements = $definition->implements
110✔
161
            ?? null;
110✔
162

163
        unset($this->update_join);
110✔
164
        unset($this->select_join);
110✔
165

166
        $this->assert_implements_is_valid();
110✔
167

168
        if ($parent && $parent->implements) {
110✔
169
            $this->implements = array_merge($parent->implements, $this->implements);
170
        }
171
    }
172

173
    /**
174
     * Interface to the connection's query() method.
175
     *
176
     * The statement is resolved using the resolve_statement() method and prepared.
177
     *
178
     * @param string $query
179
     * @param array $args
180
     * @param array $options
181
     *
182
     * @return Statement
183
     */
184
    public function __invoke(string $query, array $args = [], array $options = []): Statement
185
    {
186
        $statement = $this->prepare($query, $options);
1✔
187

188
        return $statement($args);
1✔
189
    }
190

191
    /**
192
     * Asserts the implements definition is valid.
193
     */
194
    private function assert_implements_is_valid(): void
195
    {
196
        $implements = $this->implements;
110✔
197

198
        if (!$implements) {
110✔
199
            return;
110✔
200
        }
201

202
        if (!is_array($implements)) {
×
203
            throw new InvalidArgumentException("`IMPLEMENTING` must be an array.");
×
204
        }
205

206
        foreach ($implements as $implement) {
×
207
            if (!is_array($implement)) {
×
208
                throw new InvalidArgumentException("`IMPLEMENTING` must be an array.");
×
209
            }
210

211
            $table = $implement['table'];
×
212

213
            if (!$table instanceof Table) {
×
214
                throw new InvalidArgumentException("Implements table must be an instance of Table.");
×
215
            }
216
        }
217
    }
218

219
    /**
220
     * Makes an alias out of an unprefixed table name.
221
     */
222
    private function make_alias(string $unprefixed_name): string
223
    {
224
        $alias = $unprefixed_name;
×
225
        $pos = strrpos($alias, '_');
×
226

227
        if ($pos !== false) {
×
228
            $alias = substr($alias, $pos + 1);
×
229
        }
230

231
        return singularize($alias);
×
232
    }
233

234
    /*
235
    **
236

237
    INSTALL
238

239
    **
240
    */
241

242
    /**
243
     * Creates table.
244
     *
245
     * @throws Throwable if install fails.
246
     */
247
    public function install(): void
248
    {
249
        $this->connection->create_table($this->unprefixed_name, $this->schema);
88✔
250
    }
251

252
    /**
253
     * Drops table.
254
     *
255
     * @throws Throwable if uninstall fails.
256
     */
257
    public function uninstall(): void
258
    {
259
        $this->drop();
1✔
260
    }
261

262
    /**
263
     * Checks whether the table is installed.
264
     *
265
     * @return bool `true` if the table exists, `false` otherwise.
266
     */
267
    public function is_installed(): bool
268
    {
269
        return $this->connection->table_exists($this->unprefixed_name);
74✔
270
    }
271

272
    /**
273
     * Resolves statement placeholders.
274
     *
275
     * The following placeholder are replaced:
276
     *
277
     * - `{alias}`: The alias of the table.
278
     * - `{prefix}`: The prefix used for the tables of the connection.
279
     * - `{primary}`: The primary key of the table.
280
     * - `{self}`: The name of the table.
281
     * - `{self_and_related}`: The escaped name of the table and the possible JOIN clauses.
282
     *
283
     * Note: If the table has a multi-column primary keys `{primary}` is replaced by
284
     * `__multicolumn_primary__<concatened_columns>` where `<concatened_columns>` is a the columns
285
     * concatenated with an underscore ("_") as separator. For instance, if a table primary key is
286
     * made of columns "p1" and "p2", `{primary}` is replaced by `__multicolumn_primary__p1_p2`.
287
     * It's not very helpful, but we still have to decide what to do with this.
288
     *
289
     * @param string $statement The statement to resolve.
290
     */
291
    public function resolve_statement(string $statement): string
292
    {
293
        $primary = $this->primary;
78✔
294
        $primary = is_array($primary) ? '__multicolumn_primary__' . implode('_', $primary) : $primary;
78✔
295

296
        return strtr($statement, [
78✔
297

298
            '{alias}' => $this->alias,
78✔
299
            '{prefix}' => $this->connection->table_name_prefix,
78✔
300
            '{primary}' => $primary,
78✔
301
            '{self}' => $this->name,
78✔
302
            '{self_and_related}' => "`$this->name`" . ($this->select_join ? " $this->select_join" : '')
78✔
303

304
        ]);
78✔
305
    }
306

307
    /**
308
     * Interface to the connection's prepare method.
309
     *
310
     * The statement is resolved by the {@link resolve_statement()} method before the call is
311
     * forwarded.
312
     *
313
     * @param array<string, mixed> $options
314
     */
315
    public function prepare(string $query, array $options = []): Statement
316
    {
317
        $query = $this->resolve_statement($query);
73✔
318

319
        return $this->connection->prepare($query, $options);
73✔
320
    }
321

322
    /**
323
     * @param string $string
324
     * @param int $parameter_type
325
     *
326
     * @return string
327
     * @see Connection::quote()
328
     *
329
     */
330
    public function quote(string $string, int $parameter_type = \PDO::PARAM_STR): string
331
    {
332
        return $this->connection->quote($string, $parameter_type);
×
333
    }
334

335
    /**
336
     * Executes a statement.
337
     *
338
     * The statement is prepared by the {@link prepare()} method before it is executed.
339
     *
340
     * @param string $query
341
     * @param array<int|string, mixed> $args
342
     * @param array $options
343
     *
344
     * @return mixed
345
     */
346
    public function execute(string $query, array $args = [], array $options = [])
347
    {
348
        $statement = $this->prepare($query, $options);
73✔
349

350
        return $statement($args);
73✔
351
    }
352

353
    /**
354
     * Filters mass assignment values.
355
     *
356
     * @param array $values
357
     * @param bool|false $extended
358
     *
359
     * @return array
360
     */
361
    private function filter_values(array $values, bool $extended = false): array
362
    {
363
        $filtered = [];
72✔
364
        $holders = [];
72✔
365
        $identifiers = [];
72✔
366
        $schema = $extended ? $this->extended_schema : $this->schema;
72✔
367
        $driver = $this->connection->driver;
72✔
368

369
        foreach ($schema->filter_values($values) as $identifier => $value) {
72✔
370
            $quoted_identifier = $driver->quote_identifier($identifier);
72✔
371

372
            $filtered[] = $driver->cast_value($value);
72✔
373
            $holders[$identifier] = "$quoted_identifier = ?";
72✔
374
            $identifiers[] = $quoted_identifier;
72✔
375
        }
376

377
        return [ $filtered, $holders, $identifiers ];
72✔
378
    }
379

380
    /**
381
     * Saves values.
382
     *
383
     * @param array $values
384
     * @param mixed|null $id
385
     * @param array $options
386
     *
387
     * @return mixed
388
     *
389
     * @throws Throwable
390
     */
391
    public function save(array $values, $id = null, array $options = [])
392
    {
393
        if ($id) {
72✔
394
            return $this->update($values, $id) ? $id : false;
1✔
395
        }
396

397
        return $this->save_callback($values, $id, $options);
72✔
398
    }
399

400
    /**
401
     * @param array $values
402
     * @param null $id
403
     * @param array $options
404
     *
405
     * @return bool|int|null|string
406
     *
407
     * @throws \Exception
408
     */
409
    private function save_callback(array $values, $id = null, array $options = [])
410
    {
411
        if ($id) {
72✔
412
            $this->update($values, $id);
×
413

414
            return $id;
×
415
        }
416

417
        $parent_id = 0;
72✔
418

419
        if ($this->parent) {
72✔
420
            $parent_id = $this->parent->save_callback($values, null, $options)
69✔
421
                ?: throw new \Exception("Parent save failed: {$this->parent->name} returning {$parent_id}.");
×
422

423
            assert(is_numeric($parent_id));
424

425
            $values[$this->primary] = $parent_id;
69✔
426
        }
427

428
        $driver_name = $this->connection->driver_name;
72✔
429

430
        [ $filtered, $holders, $identifiers ] = $this->filter_values($values);
72✔
431

432
        // FIXME: ALL THIS NEED REWRITE !
433

434
        if ($holders) {
72✔
435
            // faire attention à l'id, si l'on revient du parent qui a inséré, on doit insérer aussi, avec son id
436

437
            if ($driver_name === 'mysql') {
72✔
438
//                if ($parent_id && empty($holders[$this->primary])) {
439
//                    $filtered[] = $parent_id;
440
//                    $holders[] = '`{primary}` = ?';
441
//                }
442

443
                $statement = 'INSERT INTO `{self}` SET ' . implode(', ', $holders);
×
444
                $statement = $this->prepare($statement);
×
445

446
                $rc = $statement->execute($filtered);
×
447
            } elseif ($driver_name === 'sqlite') {
72✔
448
                $rc = $this->insert($values, $options);
72✔
449
            } else {
450
                throw new LogicException("Don't know what to do with $driver_name");
72✔
451
            }
452
        } elseif ($parent_id) {
×
453
            #
454
            # a new entry has been created, but we don't have any other fields then the primary key
455
            #
456

457
            if (empty($identifiers[$this->primary])) {
×
458
                $identifiers[] = '`{primary}`';
×
459
                $filtered[] = $parent_id;
×
460
            }
461

462
            $identifiers = implode(', ', $identifiers);
×
463
            $placeholders = implode(', ', array_fill(0, count($filtered), '?'));
×
464

465
            $statement = "INSERT INTO `{self}` ($identifiers) VALUES ($placeholders)";
×
466
            $statement = $this->prepare($statement);
×
467

468
            $rc = $statement->execute($filtered);
×
469
        } else {
470
            $rc = true;
×
471
        }
472

473
        if ($parent_id) {
72✔
474
            return $parent_id;
69✔
475
        }
476

477
        if (!$rc) {
72✔
478
            return false;
×
479
        }
480

481
        return $this->connection->pdo->lastInsertId();
72✔
482
    }
483

484
    /**
485
     * Inserts values into the table.
486
     *
487
     * @param array $values The values to insert.
488
     * @param array $options The following options can be used:
489
     * - `ignore`: Ignore duplicate errors.
490
     * - `on duplicate`: specifies the column to update on duplicate, and the values to update
491
     * them. If `true` the `$values` array is used, after the primary keys has been removed.
492
     *
493
     * @return mixed
494
     */
495
    public function insert(array $values, array $options = [])
496
    {
497
        [ $values, $holders, $identifiers ] = $this->filter_values($values);
72✔
498

499
        if (!$values) {
72✔
500
            return null;
×
501
        }
502

503
        $driver_name = $this->connection->driver_name;
72✔
504

505
        $on_duplicate = $options['on duplicate'] ?? null;
72✔
506

507
        if ($driver_name == 'mysql') {
72✔
508
            $query = 'INSERT';
×
509

510
            if (!empty($options['ignore'])) {
×
511
                $query .= ' IGNORE ';
×
512
            }
513

514
            $query .= ' INTO `{self}` SET ' . implode(', ', $holders);
×
515

516
            if ($on_duplicate) {
×
517
                if ($on_duplicate === true) {
×
518
                    #
519
                    # if 'on duplicate' is true, we use the same input values, but we take care of
520
                    # removing the primary key and its corresponding value
521
                    #
522

523
                    $update_values = array_combine(array_keys($holders), $values);
×
524
                    $update_holders = $holders;
×
525

526
                    $primary = $this->primary;
×
527

528
                    if (is_array($primary)) {
×
529
                        $flip = array_flip($primary);
×
530

531
                        $update_holders = array_diff_key($update_holders, $flip);
×
532
                        $update_values = array_diff_key($update_values, $flip);
×
533
                    } else {
534
                        unset($update_holders[$primary]);
×
535
                        unset($update_values[$primary]);
×
536
                    }
537

538
                    $update_values = array_values($update_values);
×
539
                } else {
540
                    [ $update_values, $update_holders ] = $this->filter_values($on_duplicate);
×
541
                }
542

543
                $query .= ' ON DUPLICATE KEY UPDATE ' . implode(', ', $update_holders);
×
544

545
                $values = array_merge($values, $update_values);
×
546
            }
547
        } elseif ($driver_name == 'sqlite') {
72✔
548
            $holders = array_fill(0, count($identifiers), '?');
72✔
549

550
            $query = 'INSERT' . ($on_duplicate ? ' OR REPLACE' : '')
72✔
551
                . ' INTO `{self}` (' . implode(', ', $identifiers) . ')'
72✔
552
                . ' VALUES (' . implode(', ', $holders) . ')';
72✔
553
        } else {
554
            throw new LogicException("Unsupported drive: $driver_name.");
×
555
        }
556

557
        return $this->execute($query, $values);
72✔
558
    }
559

560
    /**
561
     * Update the values of an entry.
562
     *
563
     * Even if the entry is spread over multiple tables, all the tables are updated in a single
564
     * step.
565
     *
566
     * @param array $values
567
     * @param mixed $key
568
     *
569
     * @return bool
570
     */
571
    public function update(array $values, $key)
572
    {
573
        #
574
        # SQLite doesn't support UPDATE with INNER JOIN.
575
        #
576

577
        if ($this->connection->driver_name == 'sqlite') {
1✔
578
            $table = $this;
1✔
579
            $rc = true;
1✔
580

581
            while ($table) {
1✔
582
                [ $table_values, $holders ] = $table->filter_values($values);
1✔
583

584
                if ($holders) {
1✔
585
                    $query = 'UPDATE `{self}` SET ' . implode(', ', $holders) . ' WHERE `{primary}` = ?';
1✔
586
                    $table_values[] = $key;
1✔
587

588
                    $rc = $table->execute($query, $table_values);
1✔
589

590
                    if (!$rc) {
1✔
591
                        return $rc;
×
592
                    }
593
                }
594

595
                $table = $table->parent;
1✔
596
            }
597

598
            return $rc;
1✔
599
        }
600

601
        [ $values, $holders ] = $this->filter_values($values, true);
×
602

603
        $query = "UPDATE `{self}` $this->update_join  SET " . implode(', ', $holders) . ' WHERE `{primary}` = ?';
×
604
        $values[] = $key;
×
605

606
        return $this->execute($query, $values);
×
607
    }
608

609
    /**
610
     * Deletes a record.
611
     *
612
     * @param mixed $key Identifier of the record.
613
     *
614
     * @return bool
615
     */
616
    public function delete($key)
617
    {
618
        if ($this->parent) {
2✔
619
            $this->parent->delete($key);
×
620
        }
621

622
        $where = 'where ';
2✔
623

624
        if (is_array($this->primary)) {
2✔
625
            $parts = [];
×
626

627
            foreach ($this->primary as $identifier) {
×
628
                $parts[] = '`' . $identifier . '` = ?';
×
629
            }
630

631
            $where .= implode(' and ', $parts);
×
632
        } else {
633
            $where .= '`{primary}` = ?';
2✔
634
        }
635

636
        $statement = $this->prepare('DELETE FROM `{self}` ' . $where);
2✔
637
        $statement((array)$key);
2✔
638

639
        return !!$statement->pdo_statement->rowCount();
2✔
640
    }
641

642
    /**
643
     * Truncates table.
644
     *
645
     * @return mixed
646
     *
647
     * @FIXME-20081223: what about extends ?
648
     */
649
    public function truncate()
650
    {
651
        if ($this->connection->driver_name == 'sqlite') {
×
652
            $rc = $this->execute('delete from {self}');
×
653

654
            $this->execute('vacuum');
×
655

656
            return $rc;
×
657
        }
658

659
        return $this->execute('truncate table `{self}`');
×
660
    }
661

662
    /**
663
     * Drops table.
664
     *
665
     * @param array $options
666
     *
667
     * @return mixed
668
     */
669
    public function drop(array $options = [])
670
    {
671
        $query = 'DROP TABLE ';
1✔
672

673
        if (!empty($options['if exists'])) {
1✔
674
            $query .= 'if exists ';
×
675
        }
676

677
        $query .= '`{self}`';
1✔
678

679
        return $this->execute($query);
1✔
680
    }
681
}
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