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

ICanBoogie / ActiveRecord / 11645851356

02 Nov 2024 09:09PM UTC coverage: 85.351% (+1.2%) from 84.122%
11645851356

push

github

olvlvl
Rework write interfaces

20 of 54 new or added lines in 2 files covered. (37.04%)

2 existing lines in 1 file now uncovered.

1375 of 1611 relevant lines covered (85.35%)

24.48 hits per line

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

64.15
/lib/ActiveRecord/Table.php
1
<?php
2

3
namespace ICanBoogie\ActiveRecord;
4

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

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

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

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

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

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

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

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

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

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

79
        return $join;
79✔
80
    }
81

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

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

95
    public Schema $extended_schema;
96

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

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

108
            $table = $table->parent;
56✔
109
        }
110

111
        $columns = array_reverse($columns);
56✔
112
        $columns = array_merge(...array_values($columns));
56✔
113

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

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

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

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

149
    /*
150
    **
151

152
    INSTALL
153

154
    **
155
    */
156

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

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

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

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

209
        return strtr($statement, [
45✔
210

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

217
        ]);
45✔
218
    }
219

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

232
        return $this->connection->prepare($query);
40✔
233
    }
234

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

247
        return $statement($args);
40✔
248
    }
249

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

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

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

273
        return [ $filtered, $holders, $identifiers ];
39✔
274
    }
275

276
    /**
277
     * Saves values.
278
     *
279
     * @param array<string, mixed> $values
280
     * @param array<string, mixed> $options
281
     *
282
     * @throws Throwable
283
     */
284
    public function save(array $values, int $id = null, array $options = []): int|false
285
    {
286
        // TODO: If we have a parent, we should do the changes in a transaction.
287

288
        if ($id) {
34✔
289
            $this->update($values, $id);
1✔
290

291
            return $id;
1✔
292
        }
293

294
        return $this->save_callback($values, $id, $options);
34✔
295
    }
296

297
    /**
298
     * @param array<string, mixed> $values
299
     * @param array<string, mixed> $options
300
     */
301
    private function save_callback(array $values, int $id = null, array $options = []): int
302
    {
303
        assert(count($values) > 0);
304

305
        if ($id) {
34✔
306
            $this->update($values, $id);
×
307

308
            return $id;
×
309
        }
310

311
        $parent_id = 0;
34✔
312

313
        if ($this->parent) {
34✔
314
            $parent_id = $this->parent->save_callback($values, options: $options)
30✔
NEW
315
                ?: throw new RuntimeException(
×
NEW
316
                    "Parent save failed: {$this->parent->name} returning $parent_id"
×
NEW
317
                );
×
318

319
            assert(is_string($this->primary));
320
            assert(is_numeric($parent_id));
321

322
            $values[$this->primary] = $parent_id;
30✔
323
        }
324

325
        $driver_name = $this->connection->driver_name;
34✔
326

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

329
        // FIXME: ALL THIS NEED REWRITE !
330

331
        if ($holders) {
34✔
332
            // If we have a parent, its primary key values must be used.
333

334
            if ($driver_name === 'mysql') {
34✔
NEW
335
                if ($parent_id && empty($holders[$this->primary])) {
×
NEW
336
                    $filtered[] = $parent_id;
×
NEW
337
                    $holders[] = '`{primary}` = ?';
×
338
                }
339

340
                $statement = 'INSERT INTO `{self}` SET ' . implode(', ', $holders);
×
341
                $statement = $this->prepare($statement);
×
342

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

354
            if (empty($identifiers[$this->primary])) {
×
355
                $identifiers[] = '`{primary}`';
×
356
                $filtered[] = $parent_id;
×
357
            }
358

359
            $identifiers = implode(', ', $identifiers);
×
360
            $placeholders = implode(', ', array_fill(0, count($filtered), '?'));
×
361

362
            $statement = "INSERT INTO `{self}` ($identifiers) VALUES ($placeholders)";
×
363
            $statement = $this->prepare($statement);
×
364

NEW
365
            $statement->execute($filtered);
×
366
        }
367

368
        if ($parent_id) {
34✔
369
            return $parent_id;
30✔
370
        }
371

372
        return $this->connection->last_insert_id;
34✔
373
    }
374

375
    /**
376
     * Inserts values into the table.
377
     *
378
     * @param non-empty-array<mixed> $values The values to insert.
379
     * @param bool $ignore Optional value to ignore insert errors.
380
     * @param bool $upsert Optional value to update the row if there's a matching primary key.
381
     */
382
    public function insert(array $values, bool $ignore = false, bool $upsert = false): void
383
    {
384
        [ $values, $holders, $identifiers ] = $this->filter_values($values);
39✔
385

386
        if (!$values) {
39✔
387
            throw new LogicException("No values to insert");
1✔
388
        }
389

390
        $driver_name = $this->connection->driver_name;
38✔
391

392
        if ($driver_name == 'mysql') {
38✔
UNCOV
393
            $query = 'INSERT';
×
394

NEW
395
            if ($ignore) {
×
396
                $query .= ' IGNORE ';
×
397
            }
398

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

NEW
401
            if ($upsert) {
×
402
                #
403
                # We use the same input values, but we take care of
404
                # removing the primary key and its corresponding value
405
                #
406

NEW
407
                $update_values = array_combine(array_keys($holders), $values);
×
NEW
408
                $update_holders = $holders;
×
409

NEW
410
                $primary = $this->primary;
×
411

NEW
412
                if (is_array($primary)) {
×
NEW
413
                    $flip = array_flip($primary);
×
414

NEW
415
                    $update_holders = array_diff_key($update_holders, $flip);
×
NEW
416
                    $update_values = array_diff_key($update_values, $flip);
×
417
                } else {
NEW
418
                    unset($update_holders[$primary]);
×
NEW
419
                    unset($update_values[$primary]);
×
420
                }
421

NEW
422
                $update_values = array_values($update_values);
×
423

UNCOV
424
                $query .= ' ON DUPLICATE KEY UPDATE ' . implode(', ', $update_holders);
×
425

426
                $values = array_merge($values, $update_values);
×
427
            }
428
        } elseif ($driver_name == 'sqlite') {
38✔
429
            $holders = array_fill(0, count($identifiers), '?');
38✔
430

431
            $query = 'INSERT'
38✔
432
                . ($ignore | $upsert ? ' OR' : '')
38✔
433
                . ($ignore ? ' IGNORE' : '')
38✔
434
                . ($upsert ? ' REPLACE' : '')
38✔
435
                . ' INTO `{self}` (' . implode(', ', $identifiers) . ')'
38✔
436
                . ' VALUES (' . implode(', ', $holders) . ')';
38✔
437
        } else {
438
            throw new LogicException("Unsupported drive: $driver_name.");
×
439
        }
440

441
        $this->execute($query, $values);
38✔
442
    }
443

444
    /**
445
     * Update the values of an entry.
446
     *
447
     * Even if the entry is spread over multiple tables, all the tables are updated in a single
448
     * step.
449
     *
450
     * @param array<string, mixed> $values
451
     */
452
    public function update(array $values, int|string $key): void
453
    {
454
        #
455
        # SQLite doesn't support UPDATE with INNER JOIN.
456
        #
457

458
        if ($this->connection->driver_name == 'sqlite') {
1✔
459
            $table = $this;
1✔
460

461
            while ($table) {
1✔
462
                [ $table_values, $holders ] = $table->filter_values($values);
1✔
463

464
                if ($holders) {
1✔
465
                    $query = 'UPDATE `{self}` SET ' . implode(', ', $holders) . ' WHERE `{primary}` = ?';
1✔
466
                    $table_values[] = $key;
1✔
467

468
                    $table->execute($query, $table_values);
1✔
469
                }
470

471
                $table = $table->parent;
1✔
472
            }
473

474
            return;
1✔
475
        }
476

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

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

NEW
482
        $this->execute($query, $values);
×
483
    }
484

485
    /**
486
     * Deletes a record.
487
     *
488
     * @param int|string|array<int|string> $key
489
     */
490
    public function delete(int|string|array $key): void
491
    {
NEW
492
        $this->parent?->delete($key);
×
493

NEW
494
        $where = 'WHERE ';
×
495

496
        if (is_array($this->primary)) {
×
497
            $parts = [];
×
498

499
            foreach ($this->primary as $identifier) {
×
NEW
500
                $parts[] = "`$identifier` = ?";
×
501
            }
502

NEW
503
            $where .= implode(' AND ', $parts);
×
504
        } else {
505
            $where .= '`{primary}` = ?';
×
506
        }
507

508
        $statement = $this->prepare('DELETE FROM `{self}` ' . $where);
×
509
        $statement((array)$key);
×
510
    }
511

512
    /**
513
     * Truncates table.
514
     *
515
     * @FIXME-20081223: what about extends ?
516
     */
517
    public function truncate(bool $reset_autoincrement = false): void
518
    {
519
        if ($this->connection->driver_name == 'sqlite') {
1✔
520
            $this->execute("DELETE FROM {self}");
1✔
521
            if ($reset_autoincrement) {
1✔
522
                $this->execute("DELETE FROM sqlite_sequence WHERE name = '{self}'");
1✔
523
            }
524
            $this->execute('vacuum');
1✔
525

526
            return;
1✔
527
        }
528

529
        $this->execute("TRUNCATE TABLE {self}");
×
530
        $this->execute("ALTER TABLE {self} AUTO_INCREMENT = 1");
×
531
    }
532

533
    /**
534
     * Drops table.
535
     *
536
     * @throws StatementNotValid when the table cannot be dropped.
537
     */
538
    public function drop(bool $if_exists = false): void
539
    {
540
        $query = 'DROP TABLE' . ($if_exists ? ' IF EXISTS ' : '') . ' `{self}`';
2✔
541

542
        $this->execute($query);
2✔
543
    }
544
}
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