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

ICanBoogie / ActiveRecord / 11645641585

02 Nov 2024 08:34PM UTC coverage: 84.102% (+0.05%) from 84.054%
11645641585

push

github

olvlvl
Rework query interfaces

75 of 88 new or added lines in 3 files covered. (85.23%)

27 existing lines in 2 files now uncovered.

1349 of 1604 relevant lines covered (84.1%)

22.32 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 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
class Table extends Prototyped
40
{
41
    /**
42
     * Name of the table, without the prefix defined by the connection.
43
     *
44
     * @var non-empty-string
45
     */
46
    public readonly string $unprefixed_name;
47

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

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

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

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

79
    public function make_update_join(): string
80
    {
81
        $join = '';
70✔
82
        $parent = $this->parent;
70✔
83

84
        while ($parent) {
70✔
85
            assert(is_string($this->primary));
86

87
            $join .= " INNER JOIN `$parent->name` `$parent->alias` USING(`$this->primary`)";
51✔
88
            $parent = $parent->parent;
51✔
89
        }
90

91
        return $join;
70✔
92
    }
93

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

102
    private function make_select_join(): string
103
    {
104
        return "`$this->alias`" . $this->update_join;
70✔
105
    }
106

107
    public Schema $extended_schema;
108

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

117
        while ($table) {
51✔
118
            $columns[] = $table->schema->columns;
51✔
119

120
            $table = $table->parent;
51✔
121
        }
122

123
        $columns = array_reverse($columns);
51✔
124
        $columns = array_merge(...array_values($columns));
51✔
125

126
        return new Schema($columns, primary: $this->primary);
51✔
127
    }
128

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

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

158
        return $statement($args);
×
159
    }
160

161
    /*
162
    **
163

164
    INSTALL
165

166
    **
167
    */
168

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

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

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

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

221
        return strtr($statement, [
40✔
222

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

229
        ]);
40✔
230
    }
231

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

244
        return $this->connection->prepare($query);
35✔
245
    }
246

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

259
        return $statement($args);
35✔
260
    }
261

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

277
        foreach ($schema->filter_values($values) as $identifier => $value) {
33✔
278
            $quoted_identifier = $driver->quote_identifier($identifier);
33✔
279

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

285
        return [ $filtered, $holders, $identifiers ];
33✔
286
    }
287

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

302
        return $this->save_callback($values, $id, $options);
33✔
303
    }
304

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

316
            return $id;
×
317
        }
318

319
        $parent_id = 0;
33✔
320

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

325
            assert(is_string($this->primary));
326
            assert(is_numeric($parent_id));
327

328
            $values[$this->primary] = $parent_id;
30✔
329
        }
330

331
        $driver_name = $this->connection->driver_name;
33✔
332

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

335
        // FIXME: ALL THIS NEED REWRITE !
336

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

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

346
                $statement = 'INSERT INTO `{self}` SET ' . implode(', ', $holders);
×
347
                $statement = $this->prepare($statement);
×
348

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

360
            if (empty($identifiers[$this->primary])) {
×
361
                $identifiers[] = '`{primary}`';
×
362
                $filtered[] = $parent_id;
×
363
            }
364

365
            $identifiers = implode(', ', $identifiers);
×
366
            $placeholders = implode(', ', array_fill(0, count($filtered), '?'));
×
367

368
            $statement = "INSERT INTO `{self}` ($identifiers) VALUES ($placeholders)";
×
369
            $statement = $this->prepare($statement);
×
370

371
            $rc = $statement->execute($filtered);
×
372
        } else {
373
            $rc = true;
×
374
        }
375

376
        if ($parent_id) {
33✔
377
            return $parent_id;
30✔
378
        }
379

380
        if (!$rc) {
33✔
381
            return false;
×
382
        }
383

384
        return $this->connection->pdo->lastInsertId();
33✔
385
    }
386

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

402
        if (!$values) {
33✔
403
            return null;
×
404
        }
405

406
        $driver_name = $this->connection->driver_name;
33✔
407

408
        $on_duplicate = $options['on duplicate'] ?? null;
33✔
409

410
        if ($driver_name == 'mysql') {
33✔
411
            $query = 'INSERT';
×
412

413
            if (!empty($options['ignore'])) {
×
414
                $query .= ' IGNORE ';
×
415
            }
416

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

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

426
                    $update_values = array_combine(array_keys($holders), $values);
×
427
                    $update_holders = $holders;
×
428

429
                    $primary = $this->primary;
×
430

431
                    if (is_array($primary)) {
×
432
                        $flip = array_flip($primary);
×
433

434
                        $update_holders = array_diff_key($update_holders, $flip);
×
435
                        $update_values = array_diff_key($update_values, $flip);
×
436
                    } else {
437
                        unset($update_holders[$primary]);
×
438
                        unset($update_values[$primary]);
×
439
                    }
440

441
                    $update_values = array_values($update_values);
×
442
                } else {
443
                    [ $update_values, $update_holders ] = $this->filter_values($on_duplicate);
×
444
                }
445

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

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

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

460
        return $this->execute($query, $values);
33✔
461
    }
462

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

UNCOV
480
        if ($this->connection->driver_name == 'sqlite') {
×
UNCOV
481
            $table = $this;
×
UNCOV
482
            $rc = true;
×
483

UNCOV
484
            while ($table) {
×
UNCOV
485
                [ $table_values, $holders ] = $table->filter_values($values);
×
486

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

UNCOV
491
                    $rc = $table->execute($query, $table_values);
×
492

UNCOV
493
                    if (!$rc) {
×
494
                        return $rc;
×
495
                    }
496
                }
497

UNCOV
498
                $table = $table->parent;
×
499
            }
500

UNCOV
501
            return $rc;
×
502
        }
503

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

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

509
        return $this->execute($query, $values);
×
510
    }
511

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

UNCOV
525
        $where = 'where ';
×
526

UNCOV
527
        if (is_array($this->primary)) {
×
528
            $parts = [];
×
529

530
            foreach ($this->primary as $identifier) {
×
531
                $parts[] = '`' . $identifier . '` = ?';
×
532
            }
533

534
            $where .= implode(' and ', $parts);
×
535
        } else {
UNCOV
536
            $where .= '`{primary}` = ?';
×
537
        }
538

UNCOV
539
        $statement = $this->prepare('DELETE FROM `{self}` ' . $where);
×
UNCOV
540
        $statement((array)$key);
×
541

UNCOV
542
        return !!$statement->pdo_statement->rowCount();
×
543
    }
544

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

559
            return;
1✔
560
        }
561

NEW
562
        $this->execute("TRUNCATE TABLE {self}");
×
NEW
563
        $this->execute("ALTER TABLE {self} AUTO_INCREMENT = 1");
×
564
    }
565

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

575
        $this->execute($query);
2✔
576
    }
577
}
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