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

ICanBoogie / ActiveRecord / 8366801652

20 Mar 2024 10:35PM UTC coverage: 84.036% (-1.7%) from 85.731%
8366801652

push

github

olvlvl
Remove query method forwarding and ArrayAccessor implementation

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

93 existing lines in 5 files now uncovered.

1395 of 1660 relevant lines covered (84.04%)

21.2 hits per line

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

62.26
/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 AllowDynamicProperties;
15
use ICanBoogie\ActiveRecord\Config\TableDefinition;
16
use ICanBoogie\Prototyped;
17
use InvalidArgumentException;
18
use LogicException;
19
use PDO;
20
use Throwable;
21

22
use function array_combine;
23
use function array_diff_key;
24
use function array_fill;
25
use function array_flip;
26
use function array_keys;
27
use function array_merge;
28
use function array_values;
29
use function count;
30
use function implode;
31
use function is_array;
32
use function is_numeric;
33
use function is_string;
34
use function strtr;
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
     * Name of the table, without the prefix defined by the connection.
46
     *
47
     * @var non-empty-string
48
     */
49
    public readonly string $unprefixed_name;
50

51
    /**
52
     * Name of the table, including the prefix defined by the connection.
53
     *
54
     * @var non-empty-string
55
     */
56
    public readonly string $name;
57

58
    /**
59
     * Alias for the table's name, which can be defined using the {@link ALIAS} attribute
60
     * or automatically created.
61
     *
62
     * The "{primary}" placeholder used in queries is replaced by the properties value.
63
     *
64
     * @var non-empty-string
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 non-empty-string|non-empty-array<non-empty-string>|null
73
     */
74
    public readonly array|string|null $primary;
75

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

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

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

94
        return $join;
43✔
95
    }
96

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

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

112
    /**
113
     * Returns the extended schema.
114
     */
115
    protected function lazy_get_extended_schema(): Schema
116
    {
117
        $table = $this;
5✔
118
        $columns = [];
5✔
119

120
        while ($table) {
5✔
121
            $columns[] = $table->schema->columns;
5✔
122

123
            $table = $table->parent;
5✔
124
        }
125

126
        $columns = array_reverse($columns);
5✔
127
        $columns = array_merge(...array_values($columns));
5✔
128

129
        return new Schema($columns, primary: $this->primary);
5✔
130
    }
131

132
    public function __construct(
133
        public readonly Connection $connection,
134
        TableDefinition $definition,
135
        public readonly ?self $parent = null,
136
    ) {
137
        $this->unprefixed_name = $definition->name;
72✔
138
        $this->name = $connection->table_name_prefix . $this->unprefixed_name;
72✔
139
        $this->alias = $definition->alias;
72✔
140
        $this->schema = $definition->schema;
72✔
141
        $this->primary = $this->schema->primary;
72✔
142

143
        unset($this->update_join);
72✔
144
        unset($this->select_join);
72✔
145
}
146

147
    /**
148
     * Interface to the connection's query() method.
149
     *
150
     * The statement is resolved using the resolve_statement() method and prepared.
151
     *
152
     * @param non-empty-string $query
153
     * @param array<mixed> $args
154
     * @param array<non-empty-string, mixed> $options
155
     */
156
    public function __invoke(string $query, array $args = [], array $options = []): Statement
157
    {
UNCOV
158
        $statement = $this->prepare($query, $options);
×
159

UNCOV
160
        return $statement($args);
×
161
    }
162

163
    /*
164
    **
165

166
    INSTALL
167

168
    **
169
    */
170

171
    /**
172
     * Creates table.
173
     *
174
     * @throws Throwable if install fails.
175
     */
176
    public function install(): void
177
    {
178
        $this->connection->create_table($this->unprefixed_name, $this->schema);
51✔
179
    }
180

181
    /**
182
     * Drops table.
183
     *
184
     * @throws Throwable if uninstall fails.
185
     */
186
    public function uninstall(): void
187
    {
188
        $this->drop();
1✔
189
    }
190

191
    /**
192
     * Checks whether the table is installed.
193
     */
194
    public function is_installed(): bool
195
    {
196
        return $this->connection->table_exists($this->unprefixed_name);
37✔
197
    }
198

199
    /**
200
     * Resolves statement placeholders.
201
     *
202
     * The following placeholder are replaced:
203
     *
204
     * - `{alias}`: The alias of the table.
205
     * - `{prefix}`: The prefix used for the tables of the connection.
206
     * - `{primary}`: The primary key of the table.
207
     * - `{self}`: The name of the table.
208
     * - `{self_and_related}`: The escaped name of the table and the possible JOIN clauses.
209
     *
210
     * Note: If the table has a multi-column primary keys `{primary}` is replaced by
211
     * `__multicolumn_primary__<concatenated_columns>` where `<concatenated_columns>` is a the columns
212
     * concatenated with an underscore ("_") as separator. For instance, if a table primary key is
213
     * made of columns "p1" and "p2", `{primary}` is replaced by `__multicolumn_primary__p1_p2`.
214
     * It's not very helpful, but we still have to decide what to do with this.
215
     *
216
     * @param string $statement The statement to resolve.
217
     */
218
    public function resolve_statement(string $statement): string
219
    {
220
        $primary = $this->primary;
41✔
221
        $primary = is_array($primary) ? '__multicolumn_primary__' . implode('_', $primary) : $primary;
41✔
222

223
        return strtr($statement, [
41✔
224

225
            '{alias}' => $this->alias,
41✔
226
            '{prefix}' => $this->connection->table_name_prefix,
41✔
227
            '{primary}' => $primary,
41✔
228
            '{self}' => $this->name,
41✔
229
            '{self_and_related}' => "`$this->name`" . ($this->select_join ? " $this->select_join" : '')
41✔
230

231
        ]);
41✔
232
    }
233

234
    /**
235
     * Interface to the connection's prepare method.
236
     *
237
     * The statement is resolved by the {@link resolve_statement()} method before the call is
238
     * forwarded.
239
     *
240
     * @param array<string, mixed> $options
241
     */
242
    public function prepare(string $query, array $options = []): Statement
243
    {
244
        $query = $this->resolve_statement($query);
36✔
245

246
        return $this->connection->prepare($query, $options);
36✔
247
    }
248

249
    /**
250
     * @see PDO::quote()
251
     */
252
    public function quote(string $string, int $type = PDO::PARAM_STR): string
253
    {
254
        $quoted = $this->connection->pdo->quote($string, $type);
×
255

256
        if ($quoted === false) {
×
257
            throw new InvalidArgumentException("Unsupported quote type: $type");
×
258
        }
259

260
        return $quoted;
×
261
    }
262

263
    /**
264
     * Executes a statement.
265
     *
266
     * The statement is prepared by the {@link prepare()} method before it is executed.
267
     *
268
     * @param non-empty-string $query
269
     * @param array<int|string, mixed> $args
270
     * @param array<string, mixed> $options
271
     */
272
    public function execute(string $query, array $args = [], array $options = []): Statement
273
    {
274
        $statement = $this->prepare($query, $options);
36✔
275

276
        return $statement($args);
36✔
277
    }
278

279
    /**
280
     * Filters mass assignment values.
281
     *
282
     * @param array<non-empty-string, mixed> $values
283
     *
284
     * @return array{ mixed[], array<non-empty-string, non-empty-string>, non-empty-string[] }
285
     */
286
    private function filter_values(array $values, bool $extended = false): array
287
    {
288
        $filtered = [];
34✔
289
        $holders = [];
34✔
290
        $identifiers = [];
34✔
291
        $schema = $extended ? $this->extended_schema : $this->schema;
34✔
292
        $driver = $this->connection->driver;
34✔
293

294
        foreach ($schema->filter_values($values) as $identifier => $value) {
34✔
295
            $quoted_identifier = $driver->quote_identifier($identifier);
34✔
296

297
            $filtered[] = $driver->cast_value($value);
34✔
298
            $holders[$identifier] = "$quoted_identifier = ?";
34✔
299
            $identifiers[] = $quoted_identifier;
34✔
300
        }
301

302
        return [ $filtered, $holders, $identifiers ];
34✔
303
    }
304

305
    /**
306
     * Saves values.
307
     *
308
     * @param array<string, mixed> $values
309
     * @param array<string, mixed> $options
310
     *
311
     * @throws Throwable
312
     */
313
    public function save(array $values, mixed $id = null, array $options = []): mixed
314
    {
315
        if ($id) {
34✔
316
            return $this->update($values, $id) ? $id : false;
1✔
317
        }
318

319
        return $this->save_callback($values, $id, $options);
34✔
320
    }
321

322
    /**
323
     * @param array<string, mixed> $values
324
     * @param array<string, mixed> $options
325
     *
326
     * @return bool|int|null|string
327
     */
328
    private function save_callback(array $values, mixed $id = null, array $options = []): mixed
329
    {
330
        if ($id) {
34✔
331
            $this->update($values, $id);
×
332

333
            return $id;
×
334
        }
335

336
        $parent_id = 0;
34✔
337

338
        if ($this->parent) {
34✔
339
            $parent_id = $this->parent->save_callback($values, null, $options)
31✔
340
                ?: throw new \Exception("Parent save failed: {$this->parent->name} returning {$parent_id}.");
×
341

342
            assert(is_string($this->primary));
343
            assert(is_numeric($parent_id));
344

345
            $values[$this->primary] = $parent_id;
31✔
346
        }
347

348
        $driver_name = $this->connection->driver_name;
34✔
349

350
        [ $filtered, $holders, $identifiers ] = $this->filter_values($values);
34✔
351

352
        // FIXME: ALL THIS NEED REWRITE !
353

354
        if ($holders) {
34✔
355
            // faire attention à l'id, si l'on revient du parent qui a inséré, on doit insérer aussi, avec son id
356

357
            if ($driver_name === 'mysql') {
34✔
358
//                if ($parent_id && empty($holders[$this->primary])) {
359
//                    $filtered[] = $parent_id;
360
//                    $holders[] = '`{primary}` = ?';
361
//                }
362

363
                $statement = 'INSERT INTO `{self}` SET ' . implode(', ', $holders);
×
364
                $statement = $this->prepare($statement);
×
365

366
                $rc = $statement->execute($filtered);
×
367
            } elseif ($driver_name === 'sqlite') {
34✔
368
                $rc = $this->insert($values, $options);
34✔
369
            } else {
UNCOV
370
                throw new LogicException("Don't know what to do with $driver_name");
×
371
            }
372
        } elseif ($parent_id) {
×
373
            #
374
            # a new entry has been created, but we don't have any other fields then the primary key
375
            #
376

377
            if (empty($identifiers[$this->primary])) {
×
378
                $identifiers[] = '`{primary}`';
×
379
                $filtered[] = $parent_id;
×
380
            }
381

382
            $identifiers = implode(', ', $identifiers);
×
383
            $placeholders = implode(', ', array_fill(0, count($filtered), '?'));
×
384

385
            $statement = "INSERT INTO `{self}` ($identifiers) VALUES ($placeholders)";
×
386
            $statement = $this->prepare($statement);
×
387

388
            $rc = $statement->execute($filtered);
×
389
        } else {
390
            $rc = true;
×
391
        }
392

393
        if ($parent_id) {
34✔
394
            return $parent_id;
31✔
395
        }
396

397
        if (!$rc) {
34✔
398
            return false;
×
399
        }
400

401
        return $this->connection->pdo->lastInsertId();
34✔
402
    }
403

404
    /**
405
     * Inserts values into the table.
406
     *
407
     * @param array $values The values to insert.
408
     * @param array $options The following options can be used:
409
     * - `ignore`: Ignore duplicate errors.
410
     * - `on duplicate`: specifies the column to update on duplicate, and the values to update
411
     * them. If `true` the `$values` array is used, after the primary keys has been removed.
412
     *
413
     * @return mixed
414
     */
415
    public function insert(array $values, array $options = [])
416
    {
417
        [ $values, $holders, $identifiers ] = $this->filter_values($values);
34✔
418

419
        if (!$values) {
34✔
420
            return null;
×
421
        }
422

423
        $driver_name = $this->connection->driver_name;
34✔
424

425
        $on_duplicate = $options['on duplicate'] ?? null;
34✔
426

427
        if ($driver_name == 'mysql') {
34✔
428
            $query = 'INSERT';
×
429

430
            if (!empty($options['ignore'])) {
×
431
                $query .= ' IGNORE ';
×
432
            }
433

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

436
            if ($on_duplicate) {
×
437
                if ($on_duplicate === true) {
×
438
                    #
439
                    # if 'on duplicate' is true, we use the same input values, but we take care of
440
                    # removing the primary key and its corresponding value
441
                    #
442

443
                    $update_values = array_combine(array_keys($holders), $values);
×
444
                    $update_holders = $holders;
×
445

446
                    $primary = $this->primary;
×
447

448
                    if (is_array($primary)) {
×
449
                        $flip = array_flip($primary);
×
450

451
                        $update_holders = array_diff_key($update_holders, $flip);
×
452
                        $update_values = array_diff_key($update_values, $flip);
×
453
                    } else {
454
                        unset($update_holders[$primary]);
×
455
                        unset($update_values[$primary]);
×
456
                    }
457

458
                    $update_values = array_values($update_values);
×
459
                } else {
460
                    [ $update_values, $update_holders ] = $this->filter_values($on_duplicate);
×
461
                }
462

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

465
                $values = array_merge($values, $update_values);
×
466
            }
467
        } elseif ($driver_name == 'sqlite') {
34✔
468
            $holders = array_fill(0, count($identifiers), '?');
34✔
469

470
            $query = 'INSERT' . ($on_duplicate ? ' OR REPLACE' : '')
34✔
471
                . ' INTO `{self}` (' . implode(', ', $identifiers) . ')'
34✔
472
                . ' VALUES (' . implode(', ', $holders) . ')';
34✔
473
        } else {
474
            throw new LogicException("Unsupported drive: $driver_name.");
×
475
        }
476

477
        return $this->execute($query, $values);
34✔
478
    }
479

480
    /**
481
     * Update the values of an entry.
482
     *
483
     * Even if the entry is spread over multiple tables, all the tables are updated in a single
484
     * step.
485
     *
486
     * @param array $values
487
     * @param mixed $key
488
     *
489
     * @return bool
490
     */
491
    public function update(array $values, $key)
492
    {
493
        #
494
        # SQLite doesn't support UPDATE with INNER JOIN.
495
        #
496

497
        if ($this->connection->driver_name == 'sqlite') {
1✔
498
            $table = $this;
1✔
499
            $rc = true;
1✔
500

501
            while ($table) {
1✔
502
                [ $table_values, $holders ] = $table->filter_values($values);
1✔
503

504
                if ($holders) {
1✔
505
                    $query = 'UPDATE `{self}` SET ' . implode(', ', $holders) . ' WHERE `{primary}` = ?';
1✔
506
                    $table_values[] = $key;
1✔
507

508
                    $rc = $table->execute($query, $table_values);
1✔
509

510
                    if (!$rc) {
1✔
511
                        return $rc;
×
512
                    }
513
                }
514

515
                $table = $table->parent;
1✔
516
            }
517

518
            return $rc;
1✔
519
        }
520

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

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

526
        return $this->execute($query, $values);
×
527
    }
528

529
    /**
530
     * Deletes a record.
531
     *
532
     * @param mixed $key Identifier of the record.
533
     *
534
     * @return bool
535
     */
536
    public function delete($key)
537
    {
538
        if ($this->parent) {
1✔
539
            $this->parent->delete($key);
×
540
        }
541

542
        $where = 'where ';
1✔
543

544
        if (is_array($this->primary)) {
1✔
545
            $parts = [];
×
546

547
            foreach ($this->primary as $identifier) {
×
548
                $parts[] = '`' . $identifier . '` = ?';
×
549
            }
550

551
            $where .= implode(' and ', $parts);
×
552
        } else {
553
            $where .= '`{primary}` = ?';
1✔
554
        }
555

556
        $statement = $this->prepare('DELETE FROM `{self}` ' . $where);
1✔
557
        $statement((array)$key);
1✔
558

559
        return !!$statement->pdo_statement->rowCount();
1✔
560
    }
561

562
    /**
563
     * Truncates table.
564
     *
565
     * @return mixed
566
     *
567
     * @FIXME-20081223: what about extends ?
568
     */
569
    public function truncate()
570
    {
571
        if ($this->connection->driver_name == 'sqlite') {
×
572
            $rc = $this->execute('DELETE FROM `{self}`');
×
573

574
            $this->execute('vacuum');
×
575

576
            return $rc;
×
577
        }
578

579
        return $this->execute('TRUNCATE TABLE `{self}`');
×
580
    }
581

582
    /**
583
     * Drops table.
584
     *
585
     * @throws StatementNotValid when the table cannot be dropped.
586
     */
587
    public function drop(bool $if_exists = false): void
588
    {
589
        $query = 'DROP TABLE' . ($if_exists ? ' IF EXISTS ' : '') . ' `{self}`';
2✔
590

591
        $this->execute($query);
2✔
592
    }
593
}
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