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

ICanBoogie / ActiveRecord / 11645744527

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

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
namespace ICanBoogie\ActiveRecord;
4

5
use ICanBoogie\ActiveRecord\Config\TableDefinition;
6
use LogicException;
7
use Throwable;
8

9
use function array_combine;
10
use function array_diff_key;
11
use function array_fill;
12
use function array_flip;
13
use function array_keys;
14
use function array_merge;
15
use function array_values;
16
use function count;
17
use function implode;
18
use function is_array;
19
use function is_numeric;
20
use function is_string;
21
use function strtr;
22

23
/**
24
 * A representation of a database table.
25
 */
26
class Table
27
{
28
    /**
29
     * Name of the table, without the prefix defined by the connection.
30
     *
31
     * @var non-empty-string
32
     */
33
    public readonly string $unprefixed_name;
34

35
    /**
36
     * Name of the table, including the prefix defined by the connection.
37
     *
38
     * @var non-empty-string
39
     */
40
    public readonly string $name;
41

42
    /**
43
     * Alias for the table's name, which can be defined using the {@link ALIAS} attribute
44
     * or automatically created.
45
     *
46
     * This is the value for the "{primary}" placeholder.
47
     *
48
     * @var non-empty-string
49
     */
50
    public readonly string $alias;
51
    public readonly Schema $schema;
52

53
    /**
54
     * Primary key of the table, retrieved from the schema defined using the {@link SCHEMA} attribute.
55
     *
56
     * @var non-empty-string|non-empty-array<non-empty-string>|null
57
     */
58
    public readonly array|string|null $primary;
59

60
    /**
61
     * SQL fragment for the FROM clause of the query, made of the table's name and alias and those
62
     * of the hierarchy.
63
     */
64
    public string $update_join;
65

66
    public function make_update_join(): string
67
    {
68
        $join = '';
73✔
69
        $parent = $this->parent;
73✔
70

71
        while ($parent) {
73✔
72
            assert(is_string($this->primary));
73

74
            $join .= " INNER JOIN `$parent->name` `$parent->alias` USING(`$this->primary`)";
51✔
75
            $parent = $parent->parent;
51✔
76
        }
77

78
        return $join;
73✔
79
    }
80

81
    /**
82
     * SQL fragment for the FROM clause of the query, made of the table's name and alias and those
83
     * of the related tables, inherited and implemented.
84
     *
85
     * This is the value for the `{self_and_related}` placeholder.
86
     */
87
    public string $select_join;
88

89
    private function make_select_join(): string
90
    {
91
        return "`$this->alias`" . $this->update_join;
73✔
92
    }
93

94
    public Schema $extended_schema;
95

96
    /**
97
     * Returns the extended schema.
98
     */
99
    private function make_extended_schema(): Schema
100
    {
101
        $table = $this;
51✔
102
        $columns = [];
51✔
103

104
        while ($table) {
51✔
105
            $columns[] = $table->schema->columns;
51✔
106

107
            $table = $table->parent;
51✔
108
        }
109

110
        $columns = array_reverse($columns);
51✔
111
        $columns = array_merge(...array_values($columns));
51✔
112

113
        return new Schema($columns, primary: $this->primary);
51✔
114
    }
115

116
    public function __construct(
117
        public readonly Connection $connection,
118
        TableDefinition $definition,
119
        public readonly ?self $parent = null,
120
    ) {
121
        $this->unprefixed_name = $definition->name;
73✔
122
        $this->name = $connection->table_name_prefix . $this->unprefixed_name;
73✔
123
        $this->alias = $definition->alias;
73✔
124
        $this->schema = $definition->schema;
73✔
125
        $this->primary = $this->schema->primary;
73✔
126
        $this->extended_schema = $this->parent
73✔
127
            ? $this->make_extended_schema()
51✔
128
            : $this->schema;
73✔
129
        $this->update_join = $this->make_update_join();
73✔
130
        $this->select_join = $this->make_select_join();
73✔
131
    }
132

133
    /**
134
     * Interface to the connection's query() method.
135
     *
136
     * The statement is resolved using the resolve_statement() method and prepared.
137
     *
138
     * @param non-empty-string $query
139
     * @param mixed[] $args
140
     */
141
    public function __invoke(string $query, array $args = []): Statement
142
    {
NEW
143
        $statement = $this->prepare($query);
×
144

145
        return $statement($args);
×
146
    }
147

148
    /*
149
    **
150

151
    INSTALL
152

153
    **
154
    */
155

156
    /**
157
     * Creates table.
158
     *
159
     * @throws Throwable if install fails.
160
     */
161
    public function install(): void
162
    {
163
        $this->connection->create_table($this->unprefixed_name, $this->schema);
60✔
164
    }
165

166
    /**
167
     * Drops table.
168
     *
169
     * @throws Throwable if uninstall fails.
170
     */
171
    public function uninstall(): void
172
    {
173
        $this->drop();
1✔
174
    }
175

176
    /**
177
     * Checks whether the table is installed.
178
     */
179
    public function is_installed(): bool
180
    {
181
        return $this->connection->table_exists($this->unprefixed_name);
36✔
182
    }
183

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

208
        return strtr($statement, [
40✔
209

210
            '{alias}' => $this->alias,
40✔
211
            '{prefix}' => $this->connection->table_name_prefix,
40✔
212
            '{primary}' => $primary,
40✔
213
            '{self}' => $this->name,
40✔
214
            '{self_and_related}' => "`$this->name`" . ($this->select_join ? " $this->select_join" : '')
40✔
215

216
        ]);
40✔
217
    }
218

219
    /**
220
     * Interface to the connection's prepare method.
221
     *
222
     * The statement is resolved by the {@link resolve_statement()} method before the call is
223
     * forwarded.
224
     *
225
     * @param non-empty-string $query
226
     */
227
    public function prepare(string $query): Statement
228
    {
229
        $query = $this->resolve_statement($query);
35✔
230

231
        return $this->connection->prepare($query);
35✔
232
    }
233

234
    /**
235
     * Executes a statement.
236
     *
237
     * The statement is prepared by the {@link prepare()} method before it is executed.
238
     *
239
     * @param non-empty-string $query
240
     * @param array<int|string, mixed> $args
241
     */
242
    public function execute(string $query, array $args = []): Statement
243
    {
244
        $statement = $this->prepare($query);
35✔
245

246
        return $statement($args);
35✔
247
    }
248

249
    /**
250
     * Filters mass assignment values.
251
     *
252
     * @param array<non-empty-string, mixed> $values
253
     *
254
     * @return array{ mixed[], array<non-empty-string, non-empty-string>, non-empty-string[] }
255
     */
256
    private function filter_values(array $values, bool $extended = false): array
257
    {
258
        $filtered = [];
33✔
259
        $holders = [];
33✔
260
        $identifiers = [];
33✔
261
        $schema = $extended ? $this->extended_schema : $this->schema;
33✔
262
        $driver = $this->connection->driver;
33✔
263

264
        foreach ($schema->filter_values($values) as $identifier => $value) {
33✔
265
            $quoted_identifier = $driver->quote_identifier($identifier);
33✔
266

267
            $filtered[] = $driver->cast_value($value);
33✔
268
            $holders[$identifier] = "$quoted_identifier = ?";
33✔
269
            $identifiers[] = $quoted_identifier;
33✔
270
        }
271

272
        return [ $filtered, $holders, $identifiers ];
33✔
273
    }
274

275
    /**
276
     * Saves values.
277
     *
278
     * @param array<string, mixed> $values
279
     * @param array<string, mixed> $options
280
     *
281
     * @throws Throwable
282
     */
283
    public function save(array $values, mixed $id = null, array $options = []): mixed
284
    {
285
        if ($id) {
33✔
UNCOV
286
            return $this->update($values, $id) ? $id : false;
×
287
        }
288

289
        return $this->save_callback($values, $id, $options);
33✔
290
    }
291

292
    /**
293
     * @param array<string, mixed> $values
294
     * @param array<string, mixed> $options
295
     *
296
     * @return bool|int|null|string
297
     */
298
    private function save_callback(array $values, mixed $id = null, array $options = []): mixed
299
    {
300
        if ($id) {
33✔
301
            $this->update($values, $id);
×
302

303
            return $id;
×
304
        }
305

306
        $parent_id = 0;
33✔
307

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

312
            assert(is_string($this->primary));
313
            assert(is_numeric($parent_id));
314

315
            $values[$this->primary] = $parent_id;
30✔
316
        }
317

318
        $driver_name = $this->connection->driver_name;
33✔
319

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

322
        // FIXME: ALL THIS NEED REWRITE !
323

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

327
            if ($driver_name === 'mysql') {
33✔
328
//                if ($parent_id && empty($holders[$this->primary])) {
329
//                    $filtered[] = $parent_id;
330
//                    $holders[] = '`{primary}` = ?';
331
//                }
332

333
                $statement = 'INSERT INTO `{self}` SET ' . implode(', ', $holders);
×
334
                $statement = $this->prepare($statement);
×
335

336
                $rc = $statement->execute($filtered);
×
337
            } elseif ($driver_name === 'sqlite') {
33✔
338
                $rc = $this->insert($values, $options);
33✔
339
            } else {
340
                throw new LogicException("Don't know what to do with $driver_name");
×
341
            }
342
        } elseif ($parent_id) {
×
343
            #
344
            # a new entry has been created, but we don't have any other fields then the primary key
345
            #
346

347
            if (empty($identifiers[$this->primary])) {
×
348
                $identifiers[] = '`{primary}`';
×
349
                $filtered[] = $parent_id;
×
350
            }
351

352
            $identifiers = implode(', ', $identifiers);
×
353
            $placeholders = implode(', ', array_fill(0, count($filtered), '?'));
×
354

355
            $statement = "INSERT INTO `{self}` ($identifiers) VALUES ($placeholders)";
×
356
            $statement = $this->prepare($statement);
×
357

358
            $rc = $statement->execute($filtered);
×
359
        } else {
360
            $rc = true;
×
361
        }
362

363
        if ($parent_id) {
33✔
364
            return $parent_id;
30✔
365
        }
366

367
        if (!$rc) {
33✔
368
            return false;
×
369
        }
370

371
        return $this->connection->pdo->lastInsertId();
33✔
372
    }
373

374
    /**
375
     * Inserts values into the table.
376
     *
377
     * @param array $values The values to insert.
378
     * @param array $options The following options can be used:
379
     * - `ignore`: Ignore duplicate errors.
380
     * - `on duplicate`: specifies the column to update on duplicate, and the values to update
381
     * them. If `true` the `$values` array is used, after the primary keys has been removed.
382
     *
383
     * @return mixed
384
     */
385
    public function insert(array $values, array $options = [])
386
    {
387
        [ $values, $holders, $identifiers ] = $this->filter_values($values);
33✔
388

389
        if (!$values) {
33✔
390
            return null;
×
391
        }
392

393
        $driver_name = $this->connection->driver_name;
33✔
394

395
        $on_duplicate = $options['on duplicate'] ?? null;
33✔
396

397
        if ($driver_name == 'mysql') {
33✔
398
            $query = 'INSERT';
×
399

400
            if (!empty($options['ignore'])) {
×
401
                $query .= ' IGNORE ';
×
402
            }
403

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

406
            if ($on_duplicate) {
×
407
                if ($on_duplicate === true) {
×
408
                    #
409
                    # if 'on duplicate' is true, we use the same input values, but we take care of
410
                    # removing the primary key and its corresponding value
411
                    #
412

413
                    $update_values = array_combine(array_keys($holders), $values);
×
414
                    $update_holders = $holders;
×
415

416
                    $primary = $this->primary;
×
417

418
                    if (is_array($primary)) {
×
419
                        $flip = array_flip($primary);
×
420

421
                        $update_holders = array_diff_key($update_holders, $flip);
×
422
                        $update_values = array_diff_key($update_values, $flip);
×
423
                    } else {
424
                        unset($update_holders[$primary]);
×
425
                        unset($update_values[$primary]);
×
426
                    }
427

428
                    $update_values = array_values($update_values);
×
429
                } else {
430
                    [ $update_values, $update_holders ] = $this->filter_values($on_duplicate);
×
431
                }
432

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

435
                $values = array_merge($values, $update_values);
×
436
            }
437
        } elseif ($driver_name == 'sqlite') {
33✔
438
            $holders = array_fill(0, count($identifiers), '?');
33✔
439

440
            $query = 'INSERT' . ($on_duplicate ? ' OR REPLACE' : '')
33✔
441
                . ' INTO `{self}` (' . implode(', ', $identifiers) . ')'
33✔
442
                . ' VALUES (' . implode(', ', $holders) . ')';
33✔
443
        } else {
444
            throw new LogicException("Unsupported drive: $driver_name.");
×
445
        }
446

447
        return $this->execute($query, $values);
33✔
448
    }
449

450
    /**
451
     * Update the values of an entry.
452
     *
453
     * Even if the entry is spread over multiple tables, all the tables are updated in a single
454
     * step.
455
     *
456
     * @param array $values
457
     * @param mixed $key
458
     *
459
     * @return bool
460
     */
461
    public function update(array $values, $key)
462
    {
463
        #
464
        # SQLite doesn't support UPDATE with INNER JOIN.
465
        #
466

UNCOV
467
        if ($this->connection->driver_name == 'sqlite') {
×
UNCOV
468
            $table = $this;
×
UNCOV
469
            $rc = true;
×
470

UNCOV
471
            while ($table) {
×
UNCOV
472
                [ $table_values, $holders ] = $table->filter_values($values);
×
473

UNCOV
474
                if ($holders) {
×
UNCOV
475
                    $query = 'UPDATE `{self}` SET ' . implode(', ', $holders) . ' WHERE `{primary}` = ?';
×
UNCOV
476
                    $table_values[] = $key;
×
477

UNCOV
478
                    $rc = $table->execute($query, $table_values);
×
479

UNCOV
480
                    if (!$rc) {
×
481
                        return $rc;
×
482
                    }
483
                }
484

UNCOV
485
                $table = $table->parent;
×
486
            }
487

UNCOV
488
            return $rc;
×
489
        }
490

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

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

496
        return $this->execute($query, $values);
×
497
    }
498

499
    /**
500
     * Deletes a record.
501
     *
502
     * @param mixed $key Identifier of the record.
503
     *
504
     * @return bool
505
     */
506
    public function delete($key)
507
    {
UNCOV
508
        if ($this->parent) {
×
509
            $this->parent->delete($key);
×
510
        }
511

UNCOV
512
        $where = 'where ';
×
513

UNCOV
514
        if (is_array($this->primary)) {
×
515
            $parts = [];
×
516

517
            foreach ($this->primary as $identifier) {
×
518
                $parts[] = '`' . $identifier . '` = ?';
×
519
            }
520

521
            $where .= implode(' and ', $parts);
×
522
        } else {
UNCOV
523
            $where .= '`{primary}` = ?';
×
524
        }
525

UNCOV
526
        $statement = $this->prepare('DELETE FROM `{self}` ' . $where);
×
UNCOV
527
        $statement((array)$key);
×
528

UNCOV
529
        return !!$statement->pdo_statement->rowCount();
×
530
    }
531

532
    /**
533
     * Truncates table.
534
     *
535
     * @FIXME-20081223: what about extends ?
536
     */
537
    public function truncate(bool $reset_autoincrement = false): void
538
    {
539
        if ($this->connection->driver_name == 'sqlite') {
1✔
540
            $this->execute("DELETE FROM {self}");
1✔
541
            if ($reset_autoincrement) {
1✔
542
                $this->execute("DELETE FROM sqlite_sequence WHERE name = '{self}'");
1✔
543
            }
544
            $this->execute('vacuum');
1✔
545

546
            return;
1✔
547
        }
548

NEW
549
        $this->execute("TRUNCATE TABLE {self}");
×
NEW
550
        $this->execute("ALTER TABLE {self} AUTO_INCREMENT = 1");
×
551
    }
552

553
    /**
554
     * Drops table.
555
     *
556
     * @throws StatementNotValid when the table cannot be dropped.
557
     */
558
    public function drop(bool $if_exists = false): void
559
    {
560
        $query = 'DROP TABLE' . ($if_exists ? ' IF EXISTS ' : '') . ' `{self}`';
2✔
561

562
        $this->execute($query);
2✔
563
    }
564
}
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