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

ICanBoogie / ActiveRecord / 6130187394

10 Apr 2023 11:18PM UTC coverage: 80.178% (-0.1%) from 80.32%
6130187394

push

github

olvlvl
Table::quote() uses PDO::quote()

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

1355 of 1690 relevant lines covered (80.18%)

34.81 hits per line

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

59.9
/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 PDO;
18
use Throwable;
19

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

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

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

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

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

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

76
    protected $implements = [];
77

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

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

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

96
        return $join;
80✔
97
    }
98

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

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

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

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

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

124
        return $join;
×
125
    }
126

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

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

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

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

144
        return new Schema($columns);
5✔
145
    }
146

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

164
        unset($this->update_join);
98✔
165
        unset($this->select_join);
98✔
166

167
        $this->assert_implements_is_valid();
98✔
168

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

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

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

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

199
        if (!$implements) {
98✔
200
            return;
98✔
201
        }
202

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

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

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

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

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

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

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

235
    /*
236
    **
237

238
    INSTALL
239

240
    **
241
    */
242

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

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

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

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

298
        return strtr($statement, [
78✔
299

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

306
        ]);
78✔
307
    }
308

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

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

324
    /**
325
     * @see PDO::quote()
326
     */
327
    public function quote(string $string, int $type = PDO::PARAM_STR): string
328
    {
329
        $quoted = $this->connection->pdo->quote($string, $type);
×
330

331
        if ($quoted === false) {
×
332
            throw new InvalidArgumentException("Unsupported quote type: $type");
×
333
        }
334

335
        return $quoted;
×
336
    }
337

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

353
        return $statement($args);
73✔
354
    }
355

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

372
        foreach ($schema->filter_values($values) as $identifier => $value) {
72✔
373
            $quoted_identifier = $driver->quote_identifier($identifier);
72✔
374

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

380
        return [ $filtered, $holders, $identifiers ];
72✔
381
    }
382

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

400
        return $this->save_callback($values, $id, $options);
72✔
401
    }
402

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

417
            return $id;
×
418
        }
419

420
        $parent_id = 0;
72✔
421

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

426
            assert(is_numeric($parent_id));
427

428
            $values[$this->primary] = $parent_id;
69✔
429
        }
430

431
        $driver_name = $this->connection->driver_name;
72✔
432

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

435
        // FIXME: ALL THIS NEED REWRITE !
436

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

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

446
                $statement = 'INSERT INTO `{self}` SET ' . implode(', ', $holders);
×
447
                $statement = $this->prepare($statement);
×
448

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

460
            if (empty($identifiers[$this->primary])) {
×
461
                $identifiers[] = '`{primary}`';
×
462
                $filtered[] = $parent_id;
×
463
            }
464

465
            $identifiers = implode(', ', $identifiers);
×
466
            $placeholders = implode(', ', array_fill(0, count($filtered), '?'));
×
467

468
            $statement = "INSERT INTO `{self}` ($identifiers) VALUES ($placeholders)";
×
469
            $statement = $this->prepare($statement);
×
470

471
            $rc = $statement->execute($filtered);
×
472
        } else {
473
            $rc = true;
×
474
        }
475

476
        if ($parent_id) {
72✔
477
            return $parent_id;
69✔
478
        }
479

480
        if (!$rc) {
72✔
481
            return false;
×
482
        }
483

484
        return $this->connection->pdo->lastInsertId();
72✔
485
    }
486

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

502
        if (!$values) {
72✔
503
            return null;
×
504
        }
505

506
        $driver_name = $this->connection->driver_name;
72✔
507

508
        $on_duplicate = $options['on duplicate'] ?? null;
72✔
509

510
        if ($driver_name == 'mysql') {
72✔
511
            $query = 'INSERT';
×
512

513
            if (!empty($options['ignore'])) {
×
514
                $query .= ' IGNORE ';
×
515
            }
516

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

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

526
                    $update_values = array_combine(array_keys($holders), $values);
×
527
                    $update_holders = $holders;
×
528

529
                    $primary = $this->primary;
×
530

531
                    if (is_array($primary)) {
×
532
                        $flip = array_flip($primary);
×
533

534
                        $update_holders = array_diff_key($update_holders, $flip);
×
535
                        $update_values = array_diff_key($update_values, $flip);
×
536
                    } else {
537
                        unset($update_holders[$primary]);
×
538
                        unset($update_values[$primary]);
×
539
                    }
540

541
                    $update_values = array_values($update_values);
×
542
                } else {
543
                    [ $update_values, $update_holders ] = $this->filter_values($on_duplicate);
×
544
                }
545

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

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

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

560
        return $this->execute($query, $values);
72✔
561
    }
562

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

580
        if ($this->connection->driver_name == 'sqlite') {
1✔
581
            $table = $this;
1✔
582
            $rc = true;
1✔
583

584
            while ($table) {
1✔
585
                [ $table_values, $holders ] = $table->filter_values($values);
1✔
586

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

591
                    $rc = $table->execute($query, $table_values);
1✔
592

593
                    if (!$rc) {
1✔
594
                        return $rc;
×
595
                    }
596
                }
597

598
                $table = $table->parent;
1✔
599
            }
600

601
            return $rc;
1✔
602
        }
603

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

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

609
        return $this->execute($query, $values);
×
610
    }
611

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

625
        $where = 'where ';
2✔
626

627
        if (is_array($this->primary)) {
2✔
628
            $parts = [];
×
629

630
            foreach ($this->primary as $identifier) {
×
631
                $parts[] = '`' . $identifier . '` = ?';
×
632
            }
633

634
            $where .= implode(' and ', $parts);
×
635
        } else {
636
            $where .= '`{primary}` = ?';
2✔
637
        }
638

639
        $statement = $this->prepare('DELETE FROM `{self}` ' . $where);
2✔
640
        $statement((array)$key);
2✔
641

642
        return !!$statement->pdo_statement->rowCount();
2✔
643
    }
644

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

657
            $this->execute('vacuum');
×
658

659
            return $rc;
×
660
        }
661

662
        return $this->execute('truncate table `{self}`');
×
663
    }
664

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

676
        if (!empty($options['if exists'])) {
1✔
677
            $query .= 'if exists ';
×
678
        }
679

680
        $query .= '`{self}`';
1✔
681

682
        return $this->execute($query);
1✔
683
    }
684
}
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