• 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

70.66
/lib/ActiveRecord/Query.php
1
<?php
2

3
namespace ICanBoogie\ActiveRecord;
4

5
use DateTimeInterface;
6
use ICanBoogie\ActiveRecord;
7
use ICanBoogie\PrototypeTrait;
8
use InvalidArgumentException;
9
use IteratorAggregate;
10
use LogicException;
11
use PDO;
12
use Traversable;
13

14
use function array_map;
15
use function array_merge;
16
use function array_shift;
17
use function count;
18
use function func_get_args;
19
use function func_num_args;
20
use function implode;
21
use function is_array;
22
use function is_numeric;
23
use function is_string;
24
use function preg_replace;
25
use function reset;
26
use function substr;
27

28
use const PHP_INT_MAX;
29

30
/**
31
 * @template TRecord of ActiveRecord
32
 *
33
 * @implements IteratorAggregate<TRecord>
34
 *
35
 * The class offers many features to compose model queries. Most query-related
36
 * methods of the {@see Model} class create a {@see Query} object returned for
37
 * further specification, such as filters or limits.
38
 *
39
 * @see self::get_all()
40
 * @property-read array $all An array with all the records matching the query.
41
 * @see self::get_one()
42
 * @property-read TRecord|array<string>|false $one The first record matching the query.
43
 * @see self::get_pairs()
44
 * @property-read array $pairs An array of key/value pairs.
45
 * @see self::get_rc()
46
 * @property-read int|string|false|null $rc The first column of the first row matching the query.
47
 * @see self::get_count()
48
 * @property-read int $count The number of records matching the query.
49
 * @see self::get_exists()
50
 * @property-read bool|array $exists `true` if a record matching the query exists, `false`
51
 * otherwise. If there are multiple records, the property is an array of booleans.
52
 *
53
 * @see self::get_joins()
54
 * @property-read non-empty-string[] $joins The join collection from {@see join()}.
55
 * @see self::get_joins_args()
56
 * @property-read mixed[] $joins_args The arguments to the joins.
57
 * @see self::get_conditions()
58
 * @property-read non-empty-string[] $conditions The collected conditions.
59
 * @see self::get_conditions_args()
60
 * @property-read mixed[] $conditions_args The arguments to the conditions.
61
 * @see self::get_having_args()
62
 * @property-read mixed[] $having_args The arguments to the `HAVING` clause.
63
 * @see self::get_args()
64
 * @property-read mixed[] $args Returns the arguments to the query.
65
 * @see self::get_prepared()
66
 * @property-read Query<TRecord> $prepared Return a prepared query.
67
 */
68
class Query implements IteratorAggregate
69
{
70
    use PrototypeTrait;
71

72
    public const LIMIT_MAX = PHP_INT_MAX;
73

74
    /**
75
     * Part of the `SELECT` clause.
76
     */
77
    private ?string $select = null;
78

79
    /**
80
     * `JOIN` clauses.
81
     *
82
     * @var non-empty-string[]
83
     */
84
    private array $joins = [];
85

86
    /**
87
     * @return non-empty-string[]
88
     *
89
     * @see $joins
90
     */
91
    private function get_joins(): array
92
    {
93
        return $this->joins;
2✔
94
    }
95

96
    /**
97
     * Joints arguments.
98
     *
99
     * @var mixed[]
100
     */
101
    private array $joins_args = [];
102

103
    /**
104
     * @return mixed[]
105
     *
106
     * @see $joins_args
107
     */
108
    private function get_joins_args(): array
109
    {
110
        return $this->joins_args;
1✔
111
    }
112

113
    /**
114
     * Collected conditions.
115
     *
116
     * @var non-empty-string[]
117
     */
118
    private array $conditions = [];
119

120
    /**
121
     * @return non-empty-string[]
122
     */
123
    private function get_conditions(): array
124
    {
125
        return $this->conditions;
1✔
126
    }
127

128
    /**
129
     * Arguments for the conditions.
130
     *
131
     * @var mixed[]
132
     */
133
    private array $conditions_args = [];
134

135
    /**
136
     * @return mixed[]
137
     */
138
    private function get_conditions_args(): array
139
    {
140
        return $this->conditions_args;
3✔
141
    }
142

143
    /**
144
     * Part of the `HAVING` clause.
145
     */
146
    private ?string $having = null;
147

148
    /**
149
     * Arguments to the `HAVING` clause.
150
     *
151
     * @var mixed[]
152
     */
153
    private array $having_args = [];
154

155
    /**
156
     * @return mixed[]
157
     */
158
    private function get_having_args(): array
159
    {
160
        return $this->having_args;
×
161
    }
162

163
    /**
164
     * Returns the arguments to the query, which include joins arguments, conditions arguments,
165
     * and _having_ arguments.
166
     *
167
     * @return mixed[]
168
     */
169
    private function get_args(): array
170
    {
171
        return array_merge($this->joins_args, $this->conditions_args, $this->having_args);
22✔
172
    }
173

174
    /**
175
     * Part of the `GROUP BY` clause.
176
     */
177
    private ?string $group = null;
178

179
    /**
180
     * Part of the `ORDER BY` clause.
181
     *
182
     * @var mixed[]
183
     */
184
    private array $order = [];
185

186
    /**
187
     * The number of records the skip before fetching.
188
     */
189
    private ?int $skip = null;
190

191
    /**
192
     * The maximum number of records to take when fetching.
193
     */
194
    private ?int $take = null;
195

196
    /**
197
     * Fetch mode.
198
     *
199
     * @var mixed[]
200
     */
201
    private array $mode = [];
202

203
    /**
204
     * @param Model<int|non-empty-string, TRecord> $model The model to query.
205
     */
206
    public function __construct(
207
        public readonly Model $model
208
    ) {
209
    }
36✔
210

211
    /*
212
     * Rendering
213
     */
214

215
    /**
216
     * Convert the query into a string.
217
     *
218
     * @return string
219
     */
220
    public function __toString(): string
221
    {
222
        return $this->resolve_statement(
27✔
223
            $this->render_select() . ' ' .
27✔
224
            $this->render_from() .
27✔
225
            $this->render_main()
27✔
226
        );
27✔
227
    }
228

229
    /**
230
     * Render the `SELECT` clause.
231
     *
232
     * @return string
233
     */
234
    private function render_select(): string
235
    {
236
        return 'SELECT ' . ($this->select ?? '*');
27✔
237
    }
238

239
    /**
240
     * Render the `FROM` clause.
241
     *
242
     * The rendered `FROM` clause might include some JOINS too.
243
     *
244
     * @return string
245
     */
246
    private function render_from(): string
247
    {
248
        return 'FROM {self_and_related}';
27✔
249
    }
250

251
    /**
252
     * Renders the `JOIN` clauses.
253
     *
254
     * @return string
255
     */
256
    private function render_joins(): string
257
    {
258
        return implode(' ', $this->joins);
6✔
259
    }
260

261
    /**
262
     * Render the main body of the query, without the `SELECT` and `FROM` clauses.
263
     */
264
    private function render_main(): string
265
    {
266
        $query = '';
27✔
267

268
        if ($this->joins) {
27✔
269
            $query = ' ' . $this->render_joins();
6✔
270
        }
271

272
        $conditions = $this->conditions;
27✔
273

274
        if ($conditions) {
27✔
275
            $query .= ' WHERE ' . implode(' AND ', $conditions);
18✔
276
        }
277

278
        $group = $this->group;
27✔
279

280
        if ($group) {
27✔
281
            $query .= ' GROUP BY ' . $group;
2✔
282

283
            $having = $this->having;
2✔
284

285
            if ($having) {
2✔
286
                $query .= ' HAVING ' . $having;
×
287
            }
288
        }
289

290
        $order = $this->order;
27✔
291

292
        if ($order) {
27✔
293
            $query .= ' ' . $this->render_order($order);
6✔
294
        }
295

296
        $skip = $this->skip;
27✔
297
        $take = $this->take;
27✔
298

299
        if ($skip || $take) {
27✔
300
            $query .= ' ' . $this->render_skip_and_take($skip, $take);
12✔
301
        }
302

303
        return $query;
27✔
304
    }
305

306
    /**
307
     * Render the `ORDER` clause.
308
     *
309
     * @param mixed[] $order
310
     */
311
    private function render_order(array $order): string
312
    {
313
        if (count($order) == 1) {
6✔
314
            $raw = $order[0];
5✔
315
            assert(is_string($raw));
316
            $rendered = preg_replace(
5✔
317
                '/-([a-zA-Z0-9_]+)/',
5✔
318
                '$1 DESC',
5✔
319
                $raw
5✔
320
            );
5✔
321

322
            return 'ORDER BY ' . $rendered;
5✔
323
        }
324

325
        $connection = $this->model->connection;
1✔
326

327
        $field = array_shift($order);
1✔
328
        assert(is_string($field));
329
        $field_values = is_array($order[0]) ? $order[0] : $order;
1✔
330
        $field_values = array_map(function ($v) use ($connection) {
1✔
331
            return $connection->quote($v);
1✔
332
        }, $field_values);
1✔
333

334
        return "ORDER BY FIELD($field, " . implode(', ', $field_values) . ")";
1✔
335
    }
336

337
    /**
338
     * Render the `LIMIT` and `OFFSET` clauses.
339
     */
340
    private function render_skip_and_take(?int $skip, ?int $take): string
341
    {
342
        if ($skip && $take) {
12✔
343
            return "LIMIT $skip, $take";
1✔
344
        } elseif ($skip) {
11✔
NEW
345
            return "LIMIT $skip, " . self::LIMIT_MAX;
×
346
        } elseif ($take) {
11✔
347
            return "LIMIT $take";
11✔
348
        }
349

350
        return '';
×
351
    }
352

353
    /*
354
     *
355
     */
356

357
    /**
358
     * Resolve the placeholders of a statement.
359
     *
360
     * Note: Currently, the method simply forwards the statement to the model's
361
     * resolve_statement() method.
362
     */
363
    private function resolve_statement(string $statement): string
364
    {
365
        return $this->model->resolve_statement($statement);
27✔
366
    }
367

368
    /**
369
     * Define the `SELECT` clause.
370
     *
371
     * @param non-empty-string $expression The expression of the `SELECT` clause. e.g. 'nid, title'.
372
     *
373
     * @return $this
374
     */
375
    public function select(string $expression): static
376
    {
377
        $this->select = $expression;
8✔
378

379
        return $this;
8✔
380
    }
381

382
    /**
383
     * Add a `JOIN` clause.
384
     *
385
     * @param ?non-empty-string $expression
386
     *     A raw `JOIN` clause.
387
     * @param ?Query<ActiveRecord> $query
388
     *     A {@link execute} instance, it is rendered as a string and used as a subquery of the `JOIN` clause.
389
     *     The `$options` parameter can be used to customize the output.
390
     * @param ?class-string<ActiveRecord> $with
391
     * @param non-empty-string $mode
392
     *     Join mode. Default: "INNER"
393
     * @param ?non-empty-string $as
394
     *     The alias of the subquery. Default: The query's model alias.
395
     * @param ?non-empty-string $on
396
     *     The column on which to joint is created. Default: The query's model primary key.
397
     *
398
     * @return $this
399
     *
400
     * <pre>
401
     * <?php
402
     *
403
     * # using an ActiveRecord class.
404
     *
405
     * $query->join(with: Comment::class);
406
     *
407
     * # using a subquery
408
     *
409
     * $subquery = get_model('updates')
410
     * ->select('updated_at, $subscriber_id, update_hash')
411
     * ->order('updated_at DESC')
412
     *
413
     * $query->join(query: $subquery, on: 'subscriber_id');
414
     *
415
     * # using a raw clause
416
     *
417
     * $query->join(expression: "INNER JOIN `articles` USING(`nid`)");
418
     * </pre>
419
     */
420
    public function join(
421
        string $expression = null,
422
        Query $query = null,
423
        string $with = null,
424
        string $mode = 'INNER',
425
        string $as = null,
426
        string $on = null,
427
    ): static {
428
        if ($expression) {
7✔
429
            $this->joins[] = $expression;
4✔
430

431
            return $this;
4✔
432
        }
433

434
        if ($query) {
3✔
435
            $this->join_with_query($query, mode: $mode, as: $as, on: $on);
2✔
436

437
            return $this;
2✔
438
        }
439

440
        if ($with) {
1✔
441
            $model = $this->model->models->model_for_record($with);
1✔
442

443
            $this->join_with_model($model, mode: $mode, as: $as, on: $on); // @phpstan-ignore-line
1✔
444

445
            return $this;
1✔
446
        }
447

448
        throw new LogicException("One of [ expression, query, record ] needs to be defined");
×
449
    }
450

451
    /**
452
     * Join a subquery to the query.
453
     *
454
     * @param Query<ActiveRecord> $query
455
     * @param non-empty-string $mode
456
     *     Join mode. Default: "INNER".
457
     * @param ?non-empty-string $as
458
     *     The alias of the subquery. Default: The query's model alias.
459
     * @param ?non-empty-string $on
460
     *     The column on which the joint is created. Default: The query's model primary key.
461
     */
462
    private function join_with_query(
463
        Query $query,
464
        string $mode = 'INNER',
465
        string $as = null,
466
        string $on = null,
467
    ): void {
468
        $as ??= $query->model->alias;
2✔
469
        $on ??= $query->model->primary;
2✔
470

471
        if ($on) {
2✔
472
            assert(is_string($on));
473

474
            $on = $this->render_join_on($on, $as, $query);
2✔
475
        }
476

477
        if ($on) {
2✔
478
            $on = ' ' . $on;
2✔
479
        }
480

481
        $this->joins[] = "$mode JOIN($query) `$as`$on";
2✔
482
        $this->joins_args = array_merge($this->joins_args, $query->args);
2✔
483
    }
484

485
    /**
486
     * Join a model to the query.
487
     *
488
     * @param Model<mixed, ActiveRecord> $model
489
     * @param non-empty-string $mode
490
     *     Join mode.
491
     * @param ?non-empty-string $as
492
     *     The alias of the model. Default: The model's alias.
493
     * @param ?non-empty-string $on
494
     *     The column on which the joint is created, or an _ON_ expression. Default: The model's primary key. @todo
495
     */
496
    private function join_with_model( // @phpstan-ignore-line
497
        Model $model,
498
        string $mode = 'INNER',
499
        string $as = null,
500
        string $on = null,
501
    ): void {
502
        $as ??= $model->alias;
1✔
503
        //phpcs:disable PSR2.Methods.FunctionCallSignature.SpaceBeforeOpenBracket
504
        $on ??= (function () use ($model): string {
1✔
505
            $primary = $this->model->primary;
1✔
506
            $model_schema = $model->extended_schema;
1✔
507

508
            assert(is_array($primary) || is_string($primary));
509

510
            if (is_array($primary)) {
1✔
511
                foreach ($primary as $column) {
×
512
                    if ($model_schema->has_column($column)) {
×
513
                        return $column;
×
514
                    }
515
                }
516
            } elseif (!$model_schema->has_column($primary)) {
1✔
517
                $primary = $model_schema->primary;
1✔
518

519
                if (is_array($primary)) {
1✔
520
                    $primary = reset($primary);
×
521
                }
522
            }
523

524
            assert(is_string($primary));
525

526
            return $primary;
1✔
527
        }) ();
1✔
528

529
        $this->joins[] = "$mode JOIN `$model->name` AS `$as` USING(`$on`)";
1✔
530
    }
531

532
    /**
533
     * Render the `on` join option.
534
     *
535
     * The method tries to determine the best solution between `ON` and `USING`.
536
     *
537
     * @param non-empty-string $column
538
     * @param non-empty-string $as
539
     * @param Query<ActiveRecord> $query
540
     */
541
    private function render_join_on(string $column, string $as, Query $query): string
542
    {
543
        if ($query->model->schema->has_column($column) && $this->model->schema->has_column($column)) {
2✔
544
            return "USING(`$column`)";
2✔
545
        }
546

547
        $target = $this->model;
×
548

549
        while ($target) {
×
550
            if ($target->schema->has_column($column)) {
×
551
                break;
×
552
            }
553

554
            $target = $target->parent_model;
×
555
        }
556

557
        if (!$target) {
×
558
            $model_class = $this->model::class;
×
559

NEW
560
            throw new InvalidArgumentException("Unable to resolve column `$column` from model $model_class");
×
561
        }
562

NEW
563
        return "ON `$as`.`$column` = `$target->alias`.`$column`";
×
564
    }
565

566
    /**
567
     * Parses the conditions for the {@see where()} and {@see having()} methods.
568
     *
569
     * {@see DateTimeInterface} conditions are converted to strings.
570
     *
571
     * @param mixed ...$conditions_and_args
572
     *
573
     * @return array{ non-empty-string|null, mixed[] } An array made of the condition string and its arguments.
574
     */
575
    private function deferred_parse_conditions(mixed ...$conditions_and_args): array
576
    {
577
        $conditions = array_shift($conditions_and_args);
23✔
578
        $args = $conditions_and_args;
23✔
579

580
        if (is_array($conditions)) {
23✔
581
            $c = '';
18✔
582
            $conditions_args = [];
18✔
583

584
            foreach ($conditions as $column => $arg) {
18✔
585
                if (is_array($arg) || $arg instanceof self) {
18✔
586
                    $joined = '';
3✔
587

588
                    if (is_array($arg)) {
3✔
589
                        foreach ($arg as $value) {
3✔
590
                            $joined .= ',' . (is_numeric($value) ? $value : $this->model->connection->quote($value));
3✔
591
                        }
592

593
                        $joined = substr($joined, 1);
3✔
594
                    } else {
595
                        $joined = (string)$arg;
×
596
                        $conditions_args = array_merge($conditions_args, $arg->args);
×
597
                    }
598

599
                    $c .= ' AND `' . ($column[0] == '!' ? substr($column, 1) . '` NOT' : $column . '`')
3✔
600
                        . ' IN(' . $joined . ')';
3✔
601
                } else {
602
                    $conditions_args[] = $arg;
15✔
603

604
                    $c .= ' AND `' . ($column[0] == '!' ? substr($column, 1) . '` !' : $column . '` ')
15✔
605
                        . '= ?';
15✔
606
                }
607
            }
608

609
            $conditions = substr($c, 5);
18✔
610
        } else {
611
            $conditions_args = [];
8✔
612

613
            if ($args) {
8✔
614
                if (is_array($args[0])) {
5✔
615
                    $conditions_args = $args[0];
×
616
                } else {
617
                    #
618
                    # We dereference values otherwise the caller would get a corrupted array.
619
                    #
620

621
                    foreach ($args as $key => $value) {
5✔
622
                        $conditions_args[$key] = $value;
5✔
623
                    }
624
                }
625
            }
626
        }
627

628
        $cast = $this->model->connection->driver->cast_value(...);
23✔
629
        $conditions_args = array_map($cast, $conditions_args);
23✔
630

631
        return [ $conditions ? '(' . $conditions . ')' : null, $conditions_args ];
23✔
632
    }
633

634
    /**
635
     * Add conditions to the SQL statement.
636
     *
637
     * Conditions can either be specified as string or array.
638
     *
639
     * 1. Pure string conditions
640
     *
641
     * If you'd like to add conditions to your statement, you could specify them in there,
642
     * just like `$model->where('order_count = 2');`. This will find all the entries, where the
643
     * `order_count` field's value is 2.
644
     *
645
     * 2. Array conditions
646
     *
647
     * Now what if that number could vary, say as an argument from somewhere, or perhaps from the
648
     * user’s level status somewhere? The find then becomes something like:
649
     *
650
     * `$model->where('order_count = ?', 2);`
651
     *
652
     * or
653
     *
654
     * `$model->where([ 'order_count' => 2 ]);`
655
     *
656
     * Or if you want to specify two conditions, you can do it like:
657
     *
658
     * `$model->where('order_count = ? AND locked = ?', 2, false);`
659
     *
660
     * or
661
     *
662
     * `$model->where([ 'order_count' => 2, 'locked' => false ]);`
663
     *
664
     * Or if you want to specify subset conditions:
665
     *
666
     * `$model->where([ 'order_id' => [ 123, 456, 789 ] ]);`
667
     *
668
     * This will return the orders with the `order_id` 123, 456 or 789.
669
     *
670
     * 3. Modifiers
671
     *
672
     * When using the "identifier" => "value" notation, you can switch the comparison method by
673
     * prefixing the identifier with a bang "!"
674
     *
675
     * `$model->where([ '!order_id' => [ 123, 456, 789 ]]);`
676
     *
677
     * This will return the orders with the `order_id` different from 123, 456 and 789.
678
     *
679
     * `$model->where([ '!order_count' => 2 ]);`
680
     *
681
     * This will return the orders with the `order_count` different from 2.
682
     *
683
     * @param mixed ...$conditions_and_args
684
     *
685
     * @return $this
686
     */
687
    public function where(...$conditions_and_args): static
688
    {
689
        [ $conditions, $conditions_args ] = $this->deferred_parse_conditions(...$conditions_and_args);
23✔
690

691
        if ($conditions) {
23✔
692
            $this->conditions[] = $conditions;
22✔
693

694
            if ($conditions_args) {
22✔
695
                $this->conditions_args = array_merge($this->conditions_args, $conditions_args);
17✔
696
            }
697
        }
698

699
        return $this;
23✔
700
    }
701

702
    /**
703
     * @return $this
704
     * @see self::where()
705
     *
706
     */
707
    public function and(mixed ...$conditions_and_args): static
708
    {
709
        return $this->where(...$conditions_and_args);
1✔
710
    }
711

712
    /**
713
     * Defines the `ORDER` clause.
714
     *
715
     * @param string $order_or_field_name The order for the `ORDER` clause e.g.
716
     * 'weight, date DESC', or field to order with, in which case `$field_values` is required.
717
     * @param scalar[]|null $field_values Values of the field specified by `$order_or_field_name`.
718
     *
719
     * @return $this
720
     */
721
    public function order(string $order_or_field_name, mixed $field_values = null): static
722
    {
723
        $this->order = func_get_args();
6✔
724

725
        return $this;
6✔
726
    }
727

728
    /**
729
     * Defines the `GROUP BY` clause.
730
     *
731
     * @returns $this
732
     */
733
    public function group(string $group): static
734
    {
735
        $this->group = $group;
2✔
736

737
        return $this;
2✔
738
    }
739

740
    /**
741
     * Defines the `HAVING` clause.
742
     *
743
     * @param mixed ...$conditions_and_args
744
     *
745
     * @return $this
746
     */
747
    public function having(...$conditions_and_args): static
748
    {
NEW
749
        [ $having, $having_args ] = $this->deferred_parse_conditions(...$conditions_and_args);
×
750

751
        assert($having !== null);
752

753
        $this->having = $having;
×
754
        $this->having_args = $having_args;
×
755

756
        return $this;
×
757
    }
758

759
    /**
760
     * The number of records to skip before fetching.
761
     *
762
     * @return $this
763
     */
764
    public function skip(?int $skip): static
765
    {
766
        $this->skip = $skip;
1✔
767

768
        return $this;
1✔
769
    }
770

771
    /**
772
     * The number of records to take while fetching.
773
     *
774
     * @return $this
775
     */
776
    public function take(?int $take): static
777
    {
778
        $this->take = $take;
12✔
779

780
        return $this;
12✔
781
    }
782

783
    /**
784
     * Set the fetch mode for the query.
785
     *
786
     * @param mixed ...$mode
787
     *
788
     * @return $this
789
     *
790
     * @see http://www.php.net/manual/en/pdostatement.setfetchmode.php
791
     */
792
    public function mode(...$mode): static
793
    {
794
        $this->mode = $mode;
3✔
795

796
        return $this;
3✔
797
    }
798

799
    /**
800
     * Prepare the query.
801
     *
802
     * We use the connection's prepare() method because the statement has already been resolved
803
     * during the __toString() method, and we don't want for the statement to be parsed twice.
804
     */
805
    private function prepare(): Statement
806
    {
807
        return $this->model->connection->prepare((string)$this);
16✔
808
    }
809

810
    /**
811
     * Return a prepared query.
812
     */
813
    protected function get_prepared(): Statement
814
    {
815
        return $this->prepare();
×
816
    }
817

818
    /**
819
     * Prepare and executes the query.
820
     */
821
    private function execute(): Statement
822
    {
823
        $statement = $this->prepare();
16✔
824
        $statement->execute($this->args);
16✔
825

826
        return $statement;
16✔
827
    }
828

829
    /*
830
     * FINISHER
831
     */
832

833
    /**
834
     * Resolves fetch mode.
835
     *
836
     * @return array{ 0: PDO::FETCH_*, 1?: mixed, 2?: mixed }
837
     */
838
    private function resolve_fetch_mode(mixed ...$mode): array
839
    {
840
        if ($mode) {
15✔
841
            $args = $mode;
×
842
        } elseif ($this->mode) {
15✔
843
            $args = $this->mode;
1✔
844
        } elseif ($this->select ?? null) {
15✔
845
            $args = [ PDO::FETCH_ASSOC ];
×
846
        } elseif ($this->model->activerecord_class) {
15✔
847
            $args = [ PDO::FETCH_CLASS, $this->model->activerecord_class, [ $this->model ] ];
15✔
848
        } else {
849
            $args = [ PDO::FETCH_CLASS, ActiveRecord::class, [ $this->model ] ];
×
850
        }
851

852
        // @phpstan-ignore-next-line
853
        return $args;
15✔
854
    }
855

856
    /**
857
     * Execute the query and returns an array of records.
858
     *
859
     * @param mixed ...$mode Fetch mode.
860
     *
861
     * @return array<mixed>
862
     */
863
    public function all(...$mode): array
864
    {
865
        return $this->execute()->all(...$this->resolve_fetch_mode(...$mode));
8✔
866
    }
867

868
    /**
869
     * Getter for the {@see $all} magic property.
870
     *
871
     * @return TRecord[]|mixed[]
872
     */
873
    protected function get_all(): array
874
    {
875
        return $this->all();
6✔
876
    }
877

878
    /**
879
     * Return the first result of the query and close the cursor.
880
     *
881
     * @param mixed ...$mode Fetch node.
882
     *
883
     * @return mixed The return value of this function on success depends on the fetch mode. In
884
     * all cases, FALSE is returned on failure.
885
     */
886
    public function one(...$mode): mixed
887
    {
888
        $query = (clone $this)->take(1);
9✔
889

890
        return $query
9✔
891
            ->execute()
9✔
892
            ->mode(...$this->resolve_fetch_mode(...$mode))
9✔
893
            ->one;
9✔
894
    }
895

896
    /**
897
     * @see $one
898
     */
899
    protected function get_one(): mixed
900
    {
901
        return $this->one();
9✔
902
    }
903

904
    /**
905
     * Execute the query and return an array of key/value pairs, where _key_ is the value of
906
     * the first column and _value_ the value of the second column.
907
     *
908
     * @return array<string, string>
909
     *
910
     * @see $pairs
911
     */
912
    protected function get_pairs(): array
913
    {
914
        // @phpstan-ignore-next-line
UNCOV
915
        return $this->all(PDO::FETCH_KEY_PAIR);
×
916
    }
917

918
    /**
919
     * Returns the first column of the first row.
920
     */
921
    protected function get_rc(): int|string|false|null
922
    {
923
        return (clone $this)->take(1)->execute()->rc;
2✔
924
    }
925

926
    /**
927
     * Check the existence of records in the model.
928
     *
929
     * $model->exists;
930
     * $model->where('name = "max"')->exists;
931
     * $model->exists(1);
932
     * $model->exists(1, 2);
933
     * $model->exists([ 1, 2 ]);
934
     *
935
     * @param mixed $key
936
     *
937
     * @return bool|array
938
     */
939
    public function exists($key = null) // @phpstan-ignore-line
940
    {
941
        if ($key !== null && func_num_args() > 1) {
1✔
942
            $key = func_get_args();
×
943
        }
944

945
        $query = clone $this;
1✔
946

947
        #
948
        # Checking if the query matches any record.
949
        #
950

951
        if ($key === null) {
1✔
952
            return !!$query
1✔
953
                ->select('1')
1✔
954
                ->take(1)
1✔
955
                ->rc;
1✔
956
        }
957

958
        #
959
        # Checking if the query matches the specified record keys.
960
        #
961

962
        $rc = $query
×
963
            ->select('`{primary}`')
×
964
            ->and([ '{primary}' => $key ])
×
NEW
965
            ->skip(null)
×
NEW
966
            ->take(null)
×
UNCOV
967
            ->all(PDO::FETCH_COLUMN);
×
968

969
        if ($rc && is_array($key)) {
×
NEW
970
            $exists = array_fill_keys($key, false);
×
971

972
            foreach ($rc as $key) {
×
973
                $exists[$key] = true;
×
974
            }
975

976
            foreach ($exists as $v) {
×
977
                if (!$v) {
×
978
                    return $exists;
×
979
                }
980
            }
981

982
            # all true
983

984
            return true;
×
985
        }
986

987
        return !empty($rc);
×
988
    }
989

990
    protected function get_exists(): bool
991
    {
992
        // @phpstan-ignore-next-line
993
        return $this->exists();
1✔
994
    }
995

996
    /**
997
     * Handle all the computations.
998
     *
999
     * @param non-empty-string $method
1000
     * @param non-empty-string|null $column
1001
     *
1002
     * @return string|array<string, string>
1003
     */
1004
    private function compute(string $method, string $column = null): string|array
1005
    {
1006
        $query = 'SELECT ';
×
1007

1008
        if ($column) {
×
1009
            if ($method == 'COUNT') {
×
1010
                $query .= "`$column`, $method(`$column`)";
×
1011

1012
                $this->group($column);
×
1013
            } else {
1014
                $query .= "$method(`$column`)";
×
1015
            }
1016
        } else {
1017
            $query .= $method . '(*)';
×
1018
        }
1019

1020
        $query .= ' AS count ' . $this->render_from() . $this->render_main();
×
1021
        $statement = ($this->model)($query, $this->args);
×
1022

1023
        if ($method == 'COUNT' && $column) {
×
1024
            return $statement->pairs;
×
1025
        }
1026

1027
        // @phpstan-ignore-next-line
NEW
1028
        return $statement->rc;
×
1029
    }
1030

1031
    /**
1032
     * Implement the 'COUNT' computation.
1033
     *
1034
     * @param non-empty-string|null $column The name of the column to count.
1035
     *
1036
     * @return int|array<non-empty-string, int>
1037
     */
1038
    public function count(string $column = null): int|array
1039
    {
1040
        // @phpstan-ignore-next-line
UNCOV
1041
        return $this->compute('COUNT', $column);
×
1042
    }
1043

1044
    /**
1045
     * @return int|array<non-empty-string, int>
1046
     *
1047
     * @see $count
1048
     */
1049
    protected function get_count(): int|array
1050
    {
1051
        return $this->count();
×
1052
    }
1053

1054
    /**
1055
     * Implement the 'AVG' computation.
1056
     *
1057
     * @param non-empty-string $column
1058
     */
1059
    public function average(string $column): int
1060
    {
1061
        // @phpstan-ignore-next-line
UNCOV
1062
        return $this->compute('AVG', $column);
×
1063
    }
1064

1065
    /**
1066
     * Implement the 'MIN' computation.
1067
     *
1068
     * @param non-empty-string $column
1069
     */
1070
    public function minimum(string $column): int|string
1071
    {
1072
        // @phpstan-ignore-next-line
UNCOV
1073
        return $this->compute('MIN', $column);
×
1074
    }
1075

1076
    /**
1077
     * Implement the 'MAX' computation.
1078
     *
1079
     * @param non-empty-string $column
1080
     */
1081
    public function maximum(string $column): int|string
1082
    {
1083
        // @phpstan-ignore-next-line
UNCOV
1084
        return $this->compute('MAX', $column);
×
1085
    }
1086

1087
    /**
1088
     * Implement the 'SUM' computation.
1089
     *
1090
     * @param non-empty-string $column
1091
     */
1092
    public function sum(string $column): int
1093
    {
1094
        // @phpstan-ignore-next-line
UNCOV
1095
        return $this->compute('SUM', $column);
×
1096
    }
1097

1098
    /**
1099
     * Delete the records matching the conditions and range of the query.
1100
     *
1101
     * @param ?string $tables When using a JOIN, `$tables` is used to specify the tables in which
1102
     * records should be deleted. Default: The alias of queried model, only if at least one join
1103
     * clause has been defined using the {@link join()} method.
1104
     *
1105
     * @todo-20140901: reflect on join to add the required tables by default, discarding tables
1106
     * joined with the LEFT mode.
1107
     */
1108
    public function delete(string $tables = null): Statement
1109
    {
NEW
1110
        if (!$tables && $this->joins) {
×
1111
            $tables = "`{alias}`";
×
1112
        }
1113

1114
        if ($tables) {
×
NEW
1115
            $query = "DELETE $tables FROM {self} AS `{alias}`";
×
1116
        } else {
1117
            $query = "DELETE FROM {self}";
×
1118
        }
1119

1120
        $query .= $this->render_main();
×
1121

1122
        return $this->model->execute($query, $this->args);
×
1123
    }
1124

1125
    #
1126
    # Batches
1127
    #
1128

1129
    public const DEFAULT_BATCH_SIZE = 1000;
1130

1131
    private int $batch_size = self::DEFAULT_BATCH_SIZE;
1132

1133
    public function batch_size(int $batch_size): static
1134
    {
1135
        $this->batch_size = $batch_size;
1✔
1136

1137
        return $this;
1✔
1138
    }
1139

1140
    /**
1141
     * Return an iterator for the query.
1142
     */
1143
    public function getIterator(): Traversable
1144
    {
1145
        $skip = $this->skip;
1✔
1146
        $take = $this->batch_size;
1✔
1147
        $query = (clone $this)->take($take);
1✔
1148

1149
        do {
1150
            $all = $query->all();
1✔
1151

1152
            foreach ($all as $one) {
1✔
1153
                // @phpstan-ignore-next-line
1154
                yield $one;
1✔
1155
            }
1156

1157
            if (count($all) < $take) {
1✔
1158
                return;
1✔
1159
            }
1160

1161
            $skip += $take;
1✔
1162
            $query->skip($skip);
1✔
1163
        } while (true);
1✔
1164
    }
1165
}
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

© 2025 Coveralls, Inc