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

ICanBoogie / ActiveRecord / 4437457574

pending completion
4437457574

push

github

Olivier Laviale
Case of a nullable belong_to

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

1356 of 1686 relevant lines covered (80.43%)

34.68 hits per line

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

60.85
/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
     * Alias of the table.
45
     */
46
    public const ALIAS = 'alias';
47

48
    /**
49
     * Connection.
50
     */
51
    public const CONNECTION = 'connection';
52

53
    /**
54
     * Extended model.
55
     */
56
    public const EXTENDING = 'extends';
57
    public const IMPLEMENTING = 'implements';
58

59
    /**
60
     * Unprefixed Name of the table.
61
     */
62
    public const NAME = 'name';
63

64
    /**
65
     * Schema of the table.
66
     */
67
    public const SCHEMA = 'schema';
68

69
    /**
70
     * The parent is used when the table is in a hierarchy, which is the case if the table
71
     * extends another table.
72
     */
73
    public readonly ?self $parent;
74

75
    /**
76
     * Name of the table, without the prefix defined by the connection.
77
     */
78
    public readonly string $unprefixed_name;
79

80
    /**
81
     * Name of the table, including the prefix defined by the connection.
82
     */
83
    public readonly string $name;
84

85
    /**
86
     * Alias for the table's name, which can be defined using the {@link ALIAS} attribute
87
     * or automatically created.
88
     *
89
     * The "{primary}" placeholder used in queries is replaced by the properties value.
90
     */
91
    public readonly string $alias;
92
    public readonly Schema $schema;
93

94
    /**
95
     * Primary key of the table, retrieved from the schema defined using the {@link SCHEMA} attribute.
96
     *
97
     * @var string[]|string|null
98
     */
99
    public readonly array|string|null $primary;
100

101
    protected $implements = [];
102

103
    /**
104
     * SQL fragment for the FROM clause of the query, made of the table's name and alias and those
105
     * of the hierarchy.
106
     *
107
     * @var string
108
     */
109
    protected $update_join;
110

111
    protected function lazy_get_update_join(): string
112
    {
113
        $join = '';
79✔
114
        $parent = $this->parent;
79✔
115

116
        while ($parent) {
79✔
117
            $join .= " INNER JOIN `{$parent->name}` `{$parent->alias}` USING(`{$this->primary}`)";
71✔
118
            $parent = $parent->parent;
71✔
119
        }
120

121
        return $join;
79✔
122
    }
123

124
    /**
125
     * SQL fragment for the FROM clause of the query, made of the table's name and alias and those
126
     * of the related tables, inherited and implemented.
127
     *
128
     * The "{self_and_related}" placeholder used in queries is replaced by the properties value.
129
     *
130
     * @var string
131
     */
132
    protected $select_join;
133

134
    protected function lazy_get_select_join(): string
135
    {
136
        $join = "`{$this->alias}`" . $this->update_join;
78✔
137

138
        if (!$this->implements) {
78✔
139
            return $join;
78✔
140
        }
141

142
        foreach ($this->implements as $implement) {
×
143
            $table = $implement['table'];
×
144

145
            $join .= empty($implement['loose']) ? 'INNER' : 'LEFT';
×
146
            $join .= " JOIN `$table->name` AS {$table->alias} USING(`$table->primary`)";
×
147
        }
148

149
        return $join;
×
150
    }
151

152
    /**
153
     * Returns the extended schema.
154
     */
155
    protected function lazy_get_extended_schema(): Schema
156
    {
157
        $table = $this;
5✔
158
        $columns = [];
5✔
159

160
        while ($table) {
5✔
161
            $columns[] = $table->schema->columns;
5✔
162

163
            $table = $table->parent;
5✔
164
        }
165

166
        $columns = array_reverse($columns);
5✔
167
        $columns = array_merge(...array_values($columns));
5✔
168

169
        return new Schema($columns);
5✔
170
    }
171

172
    public function __construct(
173
        public readonly Connection $connection,
174
        TableDefinition $definition,
175
        self $parent = null
176
    ) {
177
        $this->parent = $parent;
98✔
178
        $this->unprefixed_name = $definition->name
98✔
179
            ?? throw new LogicException("The NAME attribute is required");
×
180
        $this->name = $connection->table_name_prefix . $this->unprefixed_name;
98✔
181
        $this->alias = $definition->alias
98✔
182
            ?? $this->make_alias($this->unprefixed_name);
183
        $this->schema = $definition->schema
98✔
184
            ?? throw new LogicException("The SCHEMA attribute is required");
185
        $this->primary = $this->schema->primary;
98✔
186
        $this->implements = $definition->implements
98✔
187
            ?? null;
98✔
188

189
        unset($this->update_join);
98✔
190
        unset($this->select_join);
98✔
191

192
        $this->assert_implements_is_valid();
98✔
193

194
        if ($parent && $parent->implements) {
98✔
195
            $this->implements = array_merge($parent->implements, $this->implements);
196
        }
197
    }
198

199
    /**
200
     * Interface to the connection's query() method.
201
     *
202
     * The statement is resolved using the resolve_statement() method and prepared.
203
     *
204
     * @param string $query
205
     * @param array $args
206
     * @param array $options
207
     *
208
     * @return Statement
209
     */
210
    public function __invoke(string $query, array $args = [], array $options = []): Statement
211
    {
212
        $statement = $this->prepare($query, $options);
1✔
213

214
        return $statement($args);
1✔
215
    }
216

217
    /**
218
     * Asserts the implements definition is valid.
219
     */
220
    private function assert_implements_is_valid(): void
221
    {
222
        $implements = $this->implements;
98✔
223

224
        if (!$implements) {
98✔
225
            return;
98✔
226
        }
227

228
        if (!is_array($implements)) {
×
229
            throw new InvalidArgumentException("`IMPLEMENTING` must be an array.");
×
230
        }
231

232
        foreach ($implements as $implement) {
×
233
            if (!is_array($implement)) {
×
234
                throw new InvalidArgumentException("`IMPLEMENTING` must be an array.");
×
235
            }
236

237
            $table = $implement['table'];
×
238

239
            if (!$table instanceof Table) {
×
240
                throw new InvalidArgumentException("Implements table must be an instance of Table.");
×
241
            }
242
        }
243
    }
244

245
    /**
246
     * Makes an alias out of an unprefixed table name.
247
     */
248
    private function make_alias(string $unprefixed_name): string
249
    {
250
        $alias = $unprefixed_name;
×
251
        $pos = strrpos($alias, '_');
×
252

253
        if ($pos !== false) {
×
254
            $alias = substr($alias, $pos + 1);
×
255
        }
256

257
        return singularize($alias);
×
258
    }
259

260
    /*
261
    **
262

263
    INSTALL
264

265
    **
266
    */
267

268
    /**
269
     * Creates table.
270
     *
271
     * @throws Throwable if install fails.
272
     */
273
    public function install(): void
274
    {
275
        $this->connection->create_table($this->unprefixed_name, $this->schema);
74✔
276
        $this->connection->create_indexes($this->unprefixed_name, $this->schema);
74✔
277
    }
278

279
    /**
280
     * Drops table.
281
     *
282
     * @throws Throwable if uninstall fails.
283
     */
284
    public function uninstall(): void
285
    {
286
        $this->drop();
1✔
287
    }
288

289
    /**
290
     * Checks whether the table is installed.
291
     *
292
     * @return bool `true` if the table exists, `false` otherwise.
293
     */
294
    public function is_installed(): bool
295
    {
296
        return $this->connection->table_exists($this->unprefixed_name);
73✔
297
    }
298

299
    /**
300
     * Resolves statement placeholders.
301
     *
302
     * The following placeholder are replaced:
303
     *
304
     * - `{alias}`: The alias of the table.
305
     * - `{prefix}`: The prefix used for the tables of the connection.
306
     * - `{primary}`: The primary key of the table.
307
     * - `{self}`: The name of the table.
308
     * - `{self_and_related}`: The escaped name of the table and the possible JOIN clauses.
309
     *
310
     * Note: If the table has a multi-column primary keys `{primary}` is replaced by
311
     * `__multicolumn_primary__<concatened_columns>` where `<concatened_columns>` is a the columns
312
     * concatenated with an underscore ("_") as separator. For instance, if a table primary key is
313
     * made of columns "p1" and "p2", `{primary}` is replaced by `__multicolumn_primary__p1_p2`.
314
     * It's not very helpful, but we still have to decide what to do with this.
315
     *
316
     * @param string $statement The statement to resolve.
317
     */
318
    public function resolve_statement(string $statement): string
319
    {
320
        $primary = $this->primary;
77✔
321
        $primary = is_array($primary) ? '__multicolumn_primary__' . implode('_', $primary) : $primary;
77✔
322

323
        return strtr($statement, [
77✔
324

325
            '{alias}' => $this->alias,
77✔
326
            '{prefix}' => $this->connection->table_name_prefix,
77✔
327
            '{primary}' => $primary,
77✔
328
            '{self}' => $this->name,
77✔
329
            '{self_and_related}' => "`$this->name`" . ($this->select_join ? " $this->select_join" : '')
77✔
330

331
        ]);
77✔
332
    }
333

334
    /**
335
     * Interface to the connection's prepare method.
336
     *
337
     * The statement is resolved by the {@link resolve_statement()} method before the call is
338
     * forwarded.
339
     *
340
     * @param array<string, mixed> $options
341
     */
342
    public function prepare(string $query, array $options = []): Statement
343
    {
344
        $query = $this->resolve_statement($query);
72✔
345

346
        return $this->connection->prepare($query, $options);
72✔
347
    }
348

349
    /**
350
     * @param string $string
351
     * @param int $parameter_type
352
     *
353
     * @return string
354
     * @see Connection::quote()
355
     *
356
     */
357
    public function quote(string $string, int $parameter_type = \PDO::PARAM_STR): string
358
    {
359
        return $this->connection->quote($string, $parameter_type);
×
360
    }
361

362
    /**
363
     * Executes a statement.
364
     *
365
     * The statement is prepared by the {@link prepare()} method before it is executed.
366
     *
367
     * @param string $query
368
     * @param array $args
369
     * @param array $options
370
     *
371
     * @return mixed
372
     */
373
    public function execute(string $query, array $args = [], array $options = [])
374
    {
375
        $statement = $this->prepare($query, $options);
72✔
376

377
        return $statement($args);
72✔
378
    }
379

380
    /**
381
     * Filters mass assignment values.
382
     *
383
     * @param array $values
384
     * @param bool|false $extended
385
     *
386
     * @return array
387
     */
388
    private function filter_values(array $values, bool $extended = false): array
389
    {
390
        $filtered = [];
71✔
391
        $holders = [];
71✔
392
        $identifiers = [];
71✔
393
        $schema = $extended ? $this->extended_schema : $this->schema;
71✔
394
        $driver = $this->connection->driver;
71✔
395

396
        foreach ($schema->filter_values($values) as $identifier => $value) {
71✔
397
            $quoted_identifier = $driver->quote_identifier($identifier);
71✔
398

399
            $filtered[] = $driver->cast_value($value);
71✔
400
            $holders[$identifier] = "$quoted_identifier = ?";
71✔
401
            $identifiers[] = $quoted_identifier;
71✔
402
        }
403

404
        return [ $filtered, $holders, $identifiers ];
71✔
405
    }
406

407
    /**
408
     * Saves values.
409
     *
410
     * @param array $values
411
     * @param mixed|null $id
412
     * @param array $options
413
     *
414
     * @return mixed
415
     *
416
     * @throws Throwable
417
     */
418
    public function save(array $values, $id = null, array $options = [])
419
    {
420
        if ($id) {
71✔
421
            return $this->update($values, $id) ? $id : false;
1✔
422
        }
423

424
        return $this->save_callback($values, $id, $options);
71✔
425
    }
426

427
    /**
428
     * @param array $values
429
     * @param null $id
430
     * @param array $options
431
     *
432
     * @return bool|int|null|string
433
     *
434
     * @throws \Exception
435
     */
436
    private function save_callback(array $values, $id = null, array $options = [])
437
    {
438
        if ($id) {
71✔
439
            $this->update($values, $id);
×
440

441
            return $id;
×
442
        }
443

444
        $parent_id = 0;
71✔
445

446
        if ($this->parent) {
71✔
447
            $parent_id = $this->parent->save_callback($values, null, $options)
68✔
448
                ?: throw new \Exception("Parent save failed: {$this->parent->name} returning {$parent_id}.");
×
449

450
            assert(is_numeric($parent_id));
451

452
            $values[$this->primary] = $parent_id;
68✔
453
        }
454

455
        $driver_name = $this->connection->driver_name;
71✔
456

457
        [ $filtered, $holders, $identifiers ] = $this->filter_values($values);
71✔
458

459
        // FIXME: ALL THIS NEED REWRITE !
460

461
        if ($holders) {
71✔
462
            // faire attention à l'id, si l'on revient du parent qui a inséré, on doit insérer aussi, avec son id
463

464
            if ($driver_name === 'mysql') {
71✔
465
//                if ($parent_id && empty($holders[$this->primary])) {
466
//                    $filtered[] = $parent_id;
467
//                    $holders[] = '`{primary}` = ?';
468
//                }
469

470
                $statement = 'INSERT INTO `{self}` SET ' . implode(', ', $holders);
×
471
                $statement = $this->prepare($statement);
×
472

473
                $rc = $statement->execute($filtered);
×
474
            } elseif ($driver_name === 'sqlite') {
71✔
475
                $rc = $this->insert($values, $options);
71✔
476
            } else {
477
                throw new LogicException("Don't know what to do with $driver_name");
71✔
478
            }
479
        } elseif ($parent_id) {
×
480
            #
481
            # a new entry has been created, but we don't have any other fields then the primary key
482
            #
483

484
            if (empty($identifiers[$this->primary])) {
×
485
                $identifiers[] = '`{primary}`';
×
486
                $filtered[] = $parent_id;
×
487
            }
488

489
            $identifiers = implode(', ', $identifiers);
×
490
            $placeholders = implode(', ', array_fill(0, count($filtered), '?'));
×
491

492
            $statement = "INSERT INTO `{self}` ($identifiers) VALUES ($placeholders)";
×
493
            $statement = $this->prepare($statement);
×
494

495
            $rc = $statement->execute($filtered);
×
496
        } else {
497
            $rc = true;
×
498
        }
499

500
        if ($parent_id) {
71✔
501
            return $parent_id;
68✔
502
        }
503

504
        if (!$rc) {
71✔
505
            return false;
×
506
        }
507

508
        return $this->connection->pdo->lastInsertId();
71✔
509
    }
510

511
    /**
512
     * Inserts values into the table.
513
     *
514
     * @param array $values The values to insert.
515
     * @param array $options The following options can be used:
516
     * - `ignore`: Ignore duplicate errors.
517
     * - `on duplicate`: specifies the column to update on duplicate, and the values to update
518
     * them. If `true` the `$values` array is used, after the primary keys has been removed.
519
     *
520
     * @return mixed
521
     */
522
    public function insert(array $values, array $options = [])
523
    {
524
        [ $values, $holders, $identifiers ] = $this->filter_values($values);
71✔
525

526
        if (!$values) {
71✔
527
            return null;
×
528
        }
529

530
        $driver_name = $this->connection->driver_name;
71✔
531

532
        $on_duplicate = $options['on duplicate'] ?? null;
71✔
533

534
        if ($driver_name == 'mysql') {
71✔
535
            $query = 'INSERT';
×
536

537
            if (!empty($options['ignore'])) {
×
538
                $query .= ' IGNORE ';
×
539
            }
540

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

543
            if ($on_duplicate) {
×
544
                if ($on_duplicate === true) {
×
545
                    #
546
                    # if 'on duplicate' is true, we use the same input values, but we take care of
547
                    # removing the primary key and its corresponding value
548
                    #
549

550
                    $update_values = array_combine(array_keys($holders), $values);
×
551
                    $update_holders = $holders;
×
552

553
                    $primary = $this->primary;
×
554

555
                    if (is_array($primary)) {
×
556
                        $flip = array_flip($primary);
×
557

558
                        $update_holders = array_diff_key($update_holders, $flip);
×
559
                        $update_values = array_diff_key($update_values, $flip);
×
560
                    } else {
561
                        unset($update_holders[$primary]);
×
562
                        unset($update_values[$primary]);
×
563
                    }
564

565
                    $update_values = array_values($update_values);
×
566
                } else {
567
                    [ $update_values, $update_holders ] = $this->filter_values($on_duplicate);
×
568
                }
569

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

572
                $values = array_merge($values, $update_values);
×
573
            }
574
        } elseif ($driver_name == 'sqlite') {
71✔
575
            $holders = array_fill(0, count($identifiers), '?');
71✔
576

577
            $query = 'INSERT' . ($on_duplicate ? ' OR REPLACE' : '')
71✔
578
                . ' INTO `{self}` (' . implode(', ', $identifiers) . ')'
71✔
579
                . ' VALUES (' . implode(', ', $holders) . ')';
71✔
580
        } else {
581
            throw new LogicException("Unsupported drive: $driver_name.");
×
582
        }
583

584
        return $this->execute($query, $values);
71✔
585
    }
586

587
    /**
588
     * Update the values of an entry.
589
     *
590
     * Even if the entry is spread over multiple tables, all the tables are updated in a single
591
     * step.
592
     *
593
     * @param array $values
594
     * @param mixed $key
595
     *
596
     * @return bool
597
     */
598
    public function update(array $values, $key)
599
    {
600
        #
601
        # SQLite doesn't support UPDATE with INNER JOIN.
602
        #
603

604
        if ($this->connection->driver_name == 'sqlite') {
1✔
605
            $table = $this;
1✔
606
            $rc = true;
1✔
607

608
            while ($table) {
1✔
609
                [ $table_values, $holders ] = $table->filter_values($values);
1✔
610

611
                if ($holders) {
1✔
612
                    $query = 'UPDATE `{self}` SET ' . implode(', ', $holders) . ' WHERE `{primary}` = ?';
1✔
613
                    $table_values[] = $key;
1✔
614

615
                    $rc = $table->execute($query, $table_values);
1✔
616

617
                    if (!$rc) {
1✔
618
                        return $rc;
×
619
                    }
620
                }
621

622
                $table = $table->parent;
1✔
623
            }
624

625
            return $rc;
1✔
626
        }
627

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

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

633
        return $this->execute($query, $values);
×
634
    }
635

636
    /**
637
     * Deletes a record.
638
     *
639
     * @param mixed $key Identifier of the record.
640
     *
641
     * @return bool
642
     */
643
    public function delete($key)
644
    {
645
        if ($this->parent) {
2✔
646
            $this->parent->delete($key);
×
647
        }
648

649
        $where = 'where ';
2✔
650

651
        if (is_array($this->primary)) {
2✔
652
            $parts = [];
×
653

654
            foreach ($this->primary as $identifier) {
×
655
                $parts[] = '`' . $identifier . '` = ?';
×
656
            }
657

658
            $where .= implode(' and ', $parts);
×
659
        } else {
660
            $where .= '`{primary}` = ?';
2✔
661
        }
662

663
        $statement = $this->prepare('DELETE FROM `{self}` ' . $where);
2✔
664
        $statement((array)$key);
2✔
665

666
        return !!$statement->pdo_statement->rowCount();
2✔
667
    }
668

669
    /**
670
     * Truncates table.
671
     *
672
     * @return mixed
673
     *
674
     * @FIXME-20081223: what about extends ?
675
     */
676
    public function truncate()
677
    {
678
        if ($this->connection->driver_name == 'sqlite') {
×
679
            $rc = $this->execute('delete from {self}');
×
680

681
            $this->execute('vacuum');
×
682

683
            return $rc;
×
684
        }
685

686
        return $this->execute('truncate table `{self}`');
×
687
    }
688

689
    /**
690
     * Drops table.
691
     *
692
     * @param array $options
693
     *
694
     * @return mixed
695
     */
696
    public function drop(array $options = [])
697
    {
698
        $query = 'DROP TABLE ';
1✔
699

700
        if (!empty($options['if exists'])) {
1✔
701
            $query .= 'if exists ';
×
702
        }
703

704
        $query .= '`{self}`';
1✔
705

706
        return $this->execute($query);
1✔
707
    }
708
}
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