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

ICanBoogie / ActiveRecord / 11645727069

02 Nov 2024 08:50PM UTC coverage: 84.122% (+0.07%) from 84.054%
11645727069

push

github

olvlvl
Rework query interfaces

77 of 90 new or added lines in 4 files covered. (85.56%)

27 existing lines in 2 files now uncovered.

1351 of 1606 relevant lines covered (84.12%)

23.24 hits per line

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

54.66
/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\ActiveRecord\Config\TableDefinition;
15
use ICanBoogie\Prototyped;
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 implode;
28
use function is_array;
29
use function is_numeric;
30
use function is_string;
31
use function strtr;
32

33
/**
34
 * A representation of a database table.
35
 */
36
class Table extends Prototyped
37
{
38
    /**
39
     * Name of the table, without the prefix defined by the connection.
40
     *
41
     * @var non-empty-string
42
     */
43
    public readonly string $unprefixed_name;
44

45
    /**
46
     * Name of the table, including the prefix defined by the connection.
47
     *
48
     * @var non-empty-string
49
     */
50
    public readonly string $name;
51

52
    /**
53
     * Alias for the table's name, which can be defined using the {@link ALIAS} attribute
54
     * or automatically created.
55
     *
56
     * This is the value for the "{primary}" placeholder.
57
     *
58
     * @var non-empty-string
59
     */
60
    public readonly string $alias;
61
    public readonly Schema $schema;
62

63
    /**
64
     * Primary key of the table, retrieved from the schema defined using the {@link SCHEMA} attribute.
65
     *
66
     * @var non-empty-string|non-empty-array<non-empty-string>|null
67
     */
68
    public readonly array|string|null $primary;
69

70
    /**
71
     * SQL fragment for the FROM clause of the query, made of the table's name and alias and those
72
     * of the hierarchy.
73
     */
74
    public string $update_join;
75

76
    public function make_update_join(): string
77
    {
78
        $join = '';
73✔
79
        $parent = $this->parent;
73✔
80

81
        while ($parent) {
73✔
82
            assert(is_string($this->primary));
83

84
            $join .= " INNER JOIN `$parent->name` `$parent->alias` USING(`$this->primary`)";
51✔
85
            $parent = $parent->parent;
51✔
86
        }
87

88
        return $join;
73✔
89
    }
90

91
    /**
92
     * SQL fragment for the FROM clause of the query, made of the table's name and alias and those
93
     * of the related tables, inherited and implemented.
94
     *
95
     * This is the value for the `{self_and_related}` placeholder.
96
     */
97
    public string $select_join;
98

99
    private function make_select_join(): string
100
    {
101
        return "`$this->alias`" . $this->update_join;
73✔
102
    }
103

104
    public Schema $extended_schema;
105

106
    /**
107
     * Returns the extended schema.
108
     */
109
    private function make_extended_schema(): Schema
110
    {
111
        $table = $this;
51✔
112
        $columns = [];
51✔
113

114
        while ($table) {
51✔
115
            $columns[] = $table->schema->columns;
51✔
116

117
            $table = $table->parent;
51✔
118
        }
119

120
        $columns = array_reverse($columns);
51✔
121
        $columns = array_merge(...array_values($columns));
51✔
122

123
        return new Schema($columns, primary: $this->primary);
51✔
124
    }
125

126
    public function __construct(
127
        public readonly Connection $connection,
128
        TableDefinition $definition,
129
        public readonly ?self $parent = null,
130
    ) {
131
        $this->unprefixed_name = $definition->name;
73✔
132
        $this->name = $connection->table_name_prefix . $this->unprefixed_name;
73✔
133
        $this->alias = $definition->alias;
73✔
134
        $this->schema = $definition->schema;
73✔
135
        $this->primary = $this->schema->primary;
73✔
136
        $this->extended_schema = $this->parent
73✔
137
            ? $this->make_extended_schema()
51✔
138
            : $this->schema;
73✔
139
        $this->update_join = $this->make_update_join();
73✔
140
        $this->select_join = $this->make_select_join();
73✔
141
    }
142

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

155
        return $statement($args);
×
156
    }
157

158
    /*
159
    **
160

161
    INSTALL
162

163
    **
164
    */
165

166
    /**
167
     * Creates table.
168
     *
169
     * @throws Throwable if install fails.
170
     */
171
    public function install(): void
172
    {
173
        $this->connection->create_table($this->unprefixed_name, $this->schema);
60✔
174
    }
175

176
    /**
177
     * Drops table.
178
     *
179
     * @throws Throwable if uninstall fails.
180
     */
181
    public function uninstall(): void
182
    {
183
        $this->drop();
1✔
184
    }
185

186
    /**
187
     * Checks whether the table is installed.
188
     */
189
    public function is_installed(): bool
190
    {
191
        return $this->connection->table_exists($this->unprefixed_name);
36✔
192
    }
193

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

218
        return strtr($statement, [
40✔
219

220
            '{alias}' => $this->alias,
40✔
221
            '{prefix}' => $this->connection->table_name_prefix,
40✔
222
            '{primary}' => $primary,
40✔
223
            '{self}' => $this->name,
40✔
224
            '{self_and_related}' => "`$this->name`" . ($this->select_join ? " $this->select_join" : '')
40✔
225

226
        ]);
40✔
227
    }
228

229
    /**
230
     * Interface to the connection's prepare method.
231
     *
232
     * The statement is resolved by the {@link resolve_statement()} method before the call is
233
     * forwarded.
234
     *
235
     * @param non-empty-string $query
236
     */
237
    public function prepare(string $query): Statement
238
    {
239
        $query = $this->resolve_statement($query);
35✔
240

241
        return $this->connection->prepare($query);
35✔
242
    }
243

244
    /**
245
     * Executes a statement.
246
     *
247
     * The statement is prepared by the {@link prepare()} method before it is executed.
248
     *
249
     * @param non-empty-string $query
250
     * @param array<int|string, mixed> $args
251
     */
252
    public function execute(string $query, array $args = []): Statement
253
    {
254
        $statement = $this->prepare($query);
35✔
255

256
        return $statement($args);
35✔
257
    }
258

259
    /**
260
     * Filters mass assignment values.
261
     *
262
     * @param array<non-empty-string, mixed> $values
263
     *
264
     * @return array{ mixed[], array<non-empty-string, non-empty-string>, non-empty-string[] }
265
     */
266
    private function filter_values(array $values, bool $extended = false): array
267
    {
268
        $filtered = [];
33✔
269
        $holders = [];
33✔
270
        $identifiers = [];
33✔
271
        $schema = $extended ? $this->extended_schema : $this->schema;
33✔
272
        $driver = $this->connection->driver;
33✔
273

274
        foreach ($schema->filter_values($values) as $identifier => $value) {
33✔
275
            $quoted_identifier = $driver->quote_identifier($identifier);
33✔
276

277
            $filtered[] = $driver->cast_value($value);
33✔
278
            $holders[$identifier] = "$quoted_identifier = ?";
33✔
279
            $identifiers[] = $quoted_identifier;
33✔
280
        }
281

282
        return [ $filtered, $holders, $identifiers ];
33✔
283
    }
284

285
    /**
286
     * Saves values.
287
     *
288
     * @param array<string, mixed> $values
289
     * @param array<string, mixed> $options
290
     *
291
     * @throws Throwable
292
     */
293
    public function save(array $values, mixed $id = null, array $options = []): mixed
294
    {
295
        if ($id) {
33✔
UNCOV
296
            return $this->update($values, $id) ? $id : false;
×
297
        }
298

299
        return $this->save_callback($values, $id, $options);
33✔
300
    }
301

302
    /**
303
     * @param array<string, mixed> $values
304
     * @param array<string, mixed> $options
305
     *
306
     * @return bool|int|null|string
307
     */
308
    private function save_callback(array $values, mixed $id = null, array $options = []): mixed
309
    {
310
        if ($id) {
33✔
311
            $this->update($values, $id);
×
312

313
            return $id;
×
314
        }
315

316
        $parent_id = 0;
33✔
317

318
        if ($this->parent) {
33✔
319
            $parent_id = $this->parent->save_callback($values, null, $options)
30✔
320
                ?: throw new \Exception("Parent save failed: {$this->parent->name} returning {$parent_id}.");
×
321

322
            assert(is_string($this->primary));
323
            assert(is_numeric($parent_id));
324

325
            $values[$this->primary] = $parent_id;
30✔
326
        }
327

328
        $driver_name = $this->connection->driver_name;
33✔
329

330
        [ $filtered, $holders, $identifiers ] = $this->filter_values($values);
33✔
331

332
        // FIXME: ALL THIS NEED REWRITE !
333

334
        if ($holders) {
33✔
335
            // faire attention à l'id, si l'on revient du parent qui a inséré, on doit insérer aussi, avec son id
336

337
            if ($driver_name === 'mysql') {
33✔
338
//                if ($parent_id && empty($holders[$this->primary])) {
339
//                    $filtered[] = $parent_id;
340
//                    $holders[] = '`{primary}` = ?';
341
//                }
342

343
                $statement = 'INSERT INTO `{self}` SET ' . implode(', ', $holders);
×
344
                $statement = $this->prepare($statement);
×
345

346
                $rc = $statement->execute($filtered);
×
347
            } elseif ($driver_name === 'sqlite') {
33✔
348
                $rc = $this->insert($values, $options);
33✔
349
            } else {
350
                throw new LogicException("Don't know what to do with $driver_name");
×
351
            }
352
        } elseif ($parent_id) {
×
353
            #
354
            # a new entry has been created, but we don't have any other fields then the primary key
355
            #
356

357
            if (empty($identifiers[$this->primary])) {
×
358
                $identifiers[] = '`{primary}`';
×
359
                $filtered[] = $parent_id;
×
360
            }
361

362
            $identifiers = implode(', ', $identifiers);
×
363
            $placeholders = implode(', ', array_fill(0, count($filtered), '?'));
×
364

365
            $statement = "INSERT INTO `{self}` ($identifiers) VALUES ($placeholders)";
×
366
            $statement = $this->prepare($statement);
×
367

368
            $rc = $statement->execute($filtered);
×
369
        } else {
370
            $rc = true;
×
371
        }
372

373
        if ($parent_id) {
33✔
374
            return $parent_id;
30✔
375
        }
376

377
        if (!$rc) {
33✔
378
            return false;
×
379
        }
380

381
        return $this->connection->pdo->lastInsertId();
33✔
382
    }
383

384
    /**
385
     * Inserts values into the table.
386
     *
387
     * @param array $values The values to insert.
388
     * @param array $options The following options can be used:
389
     * - `ignore`: Ignore duplicate errors.
390
     * - `on duplicate`: specifies the column to update on duplicate, and the values to update
391
     * them. If `true` the `$values` array is used, after the primary keys has been removed.
392
     *
393
     * @return mixed
394
     */
395
    public function insert(array $values, array $options = [])
396
    {
397
        [ $values, $holders, $identifiers ] = $this->filter_values($values);
33✔
398

399
        if (!$values) {
33✔
400
            return null;
×
401
        }
402

403
        $driver_name = $this->connection->driver_name;
33✔
404

405
        $on_duplicate = $options['on duplicate'] ?? null;
33✔
406

407
        if ($driver_name == 'mysql') {
33✔
408
            $query = 'INSERT';
×
409

410
            if (!empty($options['ignore'])) {
×
411
                $query .= ' IGNORE ';
×
412
            }
413

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

416
            if ($on_duplicate) {
×
417
                if ($on_duplicate === true) {
×
418
                    #
419
                    # if 'on duplicate' is true, we use the same input values, but we take care of
420
                    # removing the primary key and its corresponding value
421
                    #
422

423
                    $update_values = array_combine(array_keys($holders), $values);
×
424
                    $update_holders = $holders;
×
425

426
                    $primary = $this->primary;
×
427

428
                    if (is_array($primary)) {
×
429
                        $flip = array_flip($primary);
×
430

431
                        $update_holders = array_diff_key($update_holders, $flip);
×
432
                        $update_values = array_diff_key($update_values, $flip);
×
433
                    } else {
434
                        unset($update_holders[$primary]);
×
435
                        unset($update_values[$primary]);
×
436
                    }
437

438
                    $update_values = array_values($update_values);
×
439
                } else {
440
                    [ $update_values, $update_holders ] = $this->filter_values($on_duplicate);
×
441
                }
442

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

445
                $values = array_merge($values, $update_values);
×
446
            }
447
        } elseif ($driver_name == 'sqlite') {
33✔
448
            $holders = array_fill(0, count($identifiers), '?');
33✔
449

450
            $query = 'INSERT' . ($on_duplicate ? ' OR REPLACE' : '')
33✔
451
                . ' INTO `{self}` (' . implode(', ', $identifiers) . ')'
33✔
452
                . ' VALUES (' . implode(', ', $holders) . ')';
33✔
453
        } else {
454
            throw new LogicException("Unsupported drive: $driver_name.");
×
455
        }
456

457
        return $this->execute($query, $values);
33✔
458
    }
459

460
    /**
461
     * Update the values of an entry.
462
     *
463
     * Even if the entry is spread over multiple tables, all the tables are updated in a single
464
     * step.
465
     *
466
     * @param array $values
467
     * @param mixed $key
468
     *
469
     * @return bool
470
     */
471
    public function update(array $values, $key)
472
    {
473
        #
474
        # SQLite doesn't support UPDATE with INNER JOIN.
475
        #
476

UNCOV
477
        if ($this->connection->driver_name == 'sqlite') {
×
UNCOV
478
            $table = $this;
×
UNCOV
479
            $rc = true;
×
480

UNCOV
481
            while ($table) {
×
UNCOV
482
                [ $table_values, $holders ] = $table->filter_values($values);
×
483

UNCOV
484
                if ($holders) {
×
UNCOV
485
                    $query = 'UPDATE `{self}` SET ' . implode(', ', $holders) . ' WHERE `{primary}` = ?';
×
UNCOV
486
                    $table_values[] = $key;
×
487

UNCOV
488
                    $rc = $table->execute($query, $table_values);
×
489

UNCOV
490
                    if (!$rc) {
×
491
                        return $rc;
×
492
                    }
493
                }
494

UNCOV
495
                $table = $table->parent;
×
496
            }
497

UNCOV
498
            return $rc;
×
499
        }
500

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

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

506
        return $this->execute($query, $values);
×
507
    }
508

509
    /**
510
     * Deletes a record.
511
     *
512
     * @param mixed $key Identifier of the record.
513
     *
514
     * @return bool
515
     */
516
    public function delete($key)
517
    {
UNCOV
518
        if ($this->parent) {
×
519
            $this->parent->delete($key);
×
520
        }
521

UNCOV
522
        $where = 'where ';
×
523

UNCOV
524
        if (is_array($this->primary)) {
×
525
            $parts = [];
×
526

527
            foreach ($this->primary as $identifier) {
×
528
                $parts[] = '`' . $identifier . '` = ?';
×
529
            }
530

531
            $where .= implode(' and ', $parts);
×
532
        } else {
UNCOV
533
            $where .= '`{primary}` = ?';
×
534
        }
535

UNCOV
536
        $statement = $this->prepare('DELETE FROM `{self}` ' . $where);
×
UNCOV
537
        $statement((array)$key);
×
538

UNCOV
539
        return !!$statement->pdo_statement->rowCount();
×
540
    }
541

542
    /**
543
     * Truncates table.
544
     *
545
     * @FIXME-20081223: what about extends ?
546
     */
547
    public function truncate(bool $reset_autoincrement = false): void
548
    {
549
        if ($this->connection->driver_name == 'sqlite') {
1✔
550
            $this->execute("DELETE FROM {self}");
1✔
551
            if ($reset_autoincrement) {
1✔
552
                $this->execute("DELETE FROM sqlite_sequence WHERE name = '{self}'");
1✔
553
            }
554
            $this->execute('vacuum');
1✔
555

556
            return;
1✔
557
        }
558

NEW
559
        $this->execute("TRUNCATE TABLE {self}");
×
NEW
560
        $this->execute("ALTER TABLE {self} AUTO_INCREMENT = 1");
×
561
    }
562

563
    /**
564
     * Drops table.
565
     *
566
     * @throws StatementNotValid when the table cannot be dropped.
567
     */
568
    public function drop(bool $if_exists = false): void
569
    {
570
        $query = 'DROP TABLE' . ($if_exists ? ' IF EXISTS ' : '') . ' `{self}`';
2✔
571

572
        $this->execute($query);
2✔
573
    }
574
}
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