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

ICanBoogie / ActiveRecord / 37804871854

08 Oct 2026 03:56PM UTC coverage: 84.583%. Remained the same
37804871854

push

github

olvlvl
Added support for PostgreSQL

60 of 107 new or added lines in 5 files covered. (56.07%)

95 existing lines in 4 files now uncovered.

1399 of 1654 relevant lines covered (84.58%)

25.11 hits per line

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

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

3
namespace ICanBoogie\ActiveRecord;
4

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

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

27
use const PHP_INT_MAX;
28

29
/**
30
 * @template-covariant TRecord of ActiveRecord
31
 *
32
 * @implements IteratorAggregate<TRecord>
33
 *
34
 * The class offers many features to compose model queries. Most query-related
35
 * methods of the {@see Model} class create a {@see Query} object returned for
36
 * further specification, such as filters or limits.
37
 */
38
class Query implements IteratorAggregate
39
{
40
    public const int LIMIT_MAX = PHP_INT_MAX;
41

42
    /**
43
     * Part of the `SELECT` clause.
44
     */
45
    private ?string $select = null;
46

47
    /**
48
     * `JOIN` clauses.
49
     *
50
     * @var non-empty-string[]
51
     */
52
    private array $joins_ = [];
53

54
    /**
55
     * @var non-empty-string[]
56
     */
57
    public array $joins
58
    {
59
        get => $this->joins_;
60
    }
61

62
    /**
63
     * Joints arguments.
64
     *
65
     * @var mixed[]
66
     */
67
    private array $joins_args_ = [];
68

69
    /**
70
     * @var mixed[]
71
     */
72
    public array $joins_args
73
    {
74
        get => $this->joins_args_;
75
    }
76

77
    /**
78
     * Collected conditions.
79
     *
80
     * @var non-empty-string[]
81
     */
82
    private array $conditions_ = [];
83

84
    /**
85
     * @var non-empty-string[]
86
     */
87
    public array $conditions
88
    {
89
        get => $this->conditions_;
90
    }
91

92
    /**
93
     * Arguments for the conditions.
94
     *
95
     * @var mixed[]
96
     */
97
    private array $conditions_args_ = [];
98

99
    /**
100
     * @var mixed[]
101
     */
102
    public array $conditions_args
103
    {
104
        get => $this->conditions_args_;
105
    }
106

107
    /**
108
     * Part of the `HAVING` clause.
109
     */
110
    private ?string $having = null;
111

112
    /**
113
     * Arguments to the `HAVING` clause.
114
     *
115
     * @var mixed[]
116
     */
117
    private array $having_args_ = [];
118

119
    /**
120
     * @var mixed[]
121
     */
122
    public array $having_args
123
    {
124
        get => $this->having_args_;
125
    }
126

127
    /**
128
     * Returns the arguments to the query, which include joins arguments, conditions arguments,
129
     * and _having_ arguments.
130
     *
131
     * @var mixed[]
132
     */
133
    public array $args
134
    {
135
        get => array_merge($this->joins_args_, $this->conditions_args_, $this->having_args_);
136
    }
137

138
    /**
139
     * Part of the `GROUP BY` clause.
140
     */
141
    private ?string $group = null;
142

143
    /**
144
     * Part of the `ORDER BY` clause.
145
     *
146
     * @var mixed[]
147
     */
148
    private array $order = [];
149

150
    /**
151
     * The number of records the skip before fetching.
152
     */
153
    private ?int $skip = null;
154

155
    /**
156
     * The maximum number of records to take when fetching.
157
     */
158
    private ?int $take = null;
159

160
    /**
161
     * Fetch mode.
162
     *
163
     * @var mixed[]
164
     */
165
    private array $mode = [];
166

167
    /**
168
     * @param Model<TRecord> $model The model to query.
169
     */
170
    public function __construct(
171
        public readonly Model $model // @phpstan-ignore generics.variance
172
    ) {
173
    }
39 ✔
174

175
    /**
176
     * Quotes an identifier using the model's connection driver.
177
     */
178
    private function quote_identifier(string $identifier): string
179
    {
180
        return $this->model->connection->quote_identifier($identifier);
23 ✔
181
    }
182

183
    /*
184
     * Rendering
185
     */
186

187
    /**
188
     * Convert the query into a string.
189
     *
190
     * @return string
191
     */
192
    public function __toString(): string
193
    {
194
        return $this->resolve_statement(
30 ✔
195
            $this->render_select() . ' ' .
30 ✔
196
            $this->render_from() .
30 ✔
197
            $this->render_main()
30 ✔
198
        );
30 ✔
199
    }
200

201
    /**
202
     * Render the `SELECT` clause.
203
     *
204
     * @return string
205
     */
206
    private function render_select(): string
207
    {
208
        return 'SELECT ' . ($this->select ?? '*');
30 ✔
209
    }
210

211
    /**
212
     * Render the `FROM` clause.
213
     *
214
     * The rendered `FROM` clause might include some JOINS too.
215
     *
216
     * @return string
217
     */
218
    private function render_from(): string
219
    {
220
        return 'FROM {self_and_related}';
30 ✔
221
    }
222

223
    /**
224
     * Renders the `JOIN` clauses.
225
     *
226
     * @return string
227
     */
228
    private function render_joins(): string
229
    {
230
        return implode(' ', $this->joins_);
6 ✔
231
    }
232

233
    /**
234
     * Render the main body of the query, without the `SELECT` and `FROM` clauses.
235
     */
236
    private function render_main(): string
237
    {
238
        $query = '';
30 ✔
239

240
        if ($this->joins_) {
30 ✔
241
            $query = ' ' . $this->render_joins();
6 ✔
242
        }
243

244
        $conditions = $this->conditions_;
30 ✔
245

246
        if ($conditions) {
30 ✔
247
            $query .= ' WHERE ' . implode(' AND ', $conditions);
21 ✔
248
        }
249

250
        $group = $this->group;
30 ✔
251

252
        if ($group) {
30 ✔
253
            $query .= ' GROUP BY ' . $group;
2 ✔
254

255
            $having = $this->having;
2 ✔
256

257
            if ($having) {
2 ✔
UNCOV
258
                $query .= ' HAVING ' . $having;
×
259
            }
260
        }
261

262
        $order = $this->order;
30 ✔
263

264
        if ($order) {
30 ✔
265
            $query .= ' ' . $this->render_order($order);
6 ✔
266
        }
267

268
        $skip = $this->skip;
30 ✔
269
        $take = $this->take;
30 ✔
270

271
        if ($skip || $take) {
30 ✔
272
            $query .= ' ' . $this->render_skip_and_take($skip, $take);
14 ✔
273
        }
274

275
        return $query;
30 ✔
276
    }
277

278
    /**
279
     * Render the `ORDER` clause.
280
     *
281
     * @param mixed[] $order
282
     */
283
    private function render_order(array $order): string
284
    {
285
        if (count($order) == 1) {
6 ✔
286
            $raw = $order[0];
5 ✔
UNCOV
287
            assert(is_string($raw));
×
288
            $rendered = preg_replace(
5 ✔
289
                '/-([a-zA-Z0-9_]+)/',
5 ✔
290
                '$1 DESC',
5 ✔
291
                $raw
5 ✔
292
            );
5 ✔
293

294
            return 'ORDER BY ' . $rendered;
5 ✔
295
        }
296

297
        $connection = $this->model->connection;
1 ✔
298

299
        $field = array_shift($order);
1 ✔
UNCOV
300
        assert(is_string($field));
×
301
        $field_values = is_array($order[0]) ? $order[0] : $order;
1 ✔
302
        // @phpstan-ignore-next-line
303
        $field_values = array_map(fn($v) => $connection->quote($v), $field_values);
1 ✔
304

305
        return "ORDER BY FIELD($field, " . implode(', ', $field_values) . ")";
1 ✔
306
    }
307

308
    /**
309
     * Render the `LIMIT` and `OFFSET` clauses.
310
     */
311
    private function render_skip_and_take(?int $skip, ?int $take): string
312
    {
313
        if ($skip && $take) {
14 ✔
314
            return "LIMIT $skip, $take";
1 ✔
315
        } elseif ($skip) {
13 ✔
UNCOV
316
            return "LIMIT $skip, " . self::LIMIT_MAX;
×
317
        } elseif ($take) {
13 ✔
318
            return "LIMIT $take";
13 ✔
319
        }
320

UNCOV
321
        return '';
×
322
    }
323

324
    /*
325
     *
326
     */
327

328
    /**
329
     * Resolve the placeholders of a statement.
330
     *
331
     * Note: Currently, the method forwards the statement to the model's resolve_statement() method.
332
     */
333
    private function resolve_statement(string $statement): string
334
    {
335
        return $this->model->resolve_statement($statement);
30 ✔
336
    }
337

338
    /**
339
     * Define the `SELECT` clause.
340
     *
341
     * @param non-empty-string $expression The expression of the `SELECT` clause. e.g. 'nid, title'.
342
     *
343
     * @return $this
344
     */
345
    public function select(string $expression): static
346
    {
347
        $this->select = $expression;
8 ✔
348

349
        return $this;
8 ✔
350
    }
351

352
    /**
353
     * Add a `JOIN` clause.
354
     *
355
     * @param ?non-empty-string $expression
356
     *     A raw `JOIN` clause.
357
     * @param ?Query<ActiveRecord> $query
358
     *     A {@link execute} instance, it is rendered as a string and used as a subquery of the `JOIN` clause.
359
     *     The `$options` parameter can be used to customize the output.
360
     * @param ?class-string<ActiveRecord> $with
361
     * @param non-empty-string $mode
362
     *     Join mode. Default: "INNER"
363
     * @param ?non-empty-string $as
364
     *     The alias of the subquery. Default: The query's model alias.
365
     * @param ?non-empty-string $on
366
     *     The column on which to joint is created. Default: The query's model primary key.
367
     *
368
     * @return $this
369
     *
370
     * <pre>
371
     * <?php
372
     *
373
     * # using an ActiveRecord class.
374
     *
375
     * $query->join(with: Comment::class);
376
     *
377
     * # using a subquery
378
     *
379
     * $subquery = get_model('updates')
380
     * ->select('updated_at, $subscriber_id, update_hash')
381
     * ->order('updated_at DESC')
382
     *
383
     * $query->join(query: $subquery, on: 'subscriber_id');
384
     *
385
     * # using a raw clause
386
     *
387
     * $query->join(expression: "INNER JOIN `articles` USING(`nid`)");
388
     * </pre>
389
     */
390
    public function join(
391
        ?string $expression = null,
392
        ?Query $query = null,
393
        ?string $with = null,
394
        string $mode = 'INNER',
395
        ?string $as = null,
396
        ?string $on = null,
397
    ): static {
398
        if ($expression) {
7 ✔
399
            $this->joins_[] = $expression;
4 ✔
400

401
            return $this;
4 ✔
402
        }
403

404
        if ($query) {
3 ✔
405
            $this->join_with_query($query, mode: $mode, as: $as, on: $on);
2 ✔
406

407
            return $this;
2 ✔
408
        }
409

410
        if ($with) {
1 ✔
411
            $model = $this->model->models->model_for_record($with);
1 ✔
412

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

415
            return $this;
1 ✔
416
        }
417

UNCOV
418
        throw new LogicException("One of [ expression, query, record ] needs to be defined");
×
419
    }
420

421
    /**
422
     * Join a subquery to the query.
423
     *
424
     * @param Query<ActiveRecord> $query
425
     * @param non-empty-string $mode
426
     *     Join mode. Default: "INNER".
427
     * @param ?non-empty-string $as
428
     *     The alias of the subquery. Default: The query's model alias.
429
     * @param ?non-empty-string $on
430
     *     The column on which the joint is created. Default: The query's model primary key.
431
     */
432
    private function join_with_query(
433
        Query $query,
434
        string $mode = 'INNER',
435
        ?string $as = null,
436
        ?string $on = null,
437
    ): void {
438
        $as ??= $query->model->alias;
2 ✔
439
        $on ??= $query->model->primary;
2 ✔
440

441
        if ($on) {
2 ✔
UNCOV
442
            assert(is_string($on));
×
443

444
            $on = $this->render_join_on($on, $as, $query);
2 ✔
445
        }
446

447
        if ($on) {
2 ✔
448
            $on = ' ' . $on;
2 ✔
449
        }
450

451
        $this->joins_[] = "$mode JOIN($query) " . $this->quote_identifier($as) . "$on";
2 ✔
452
        $this->joins_args_ = array_merge($this->joins_args_, $query->args);
2 ✔
453
    }
454

455
    /**
456
     * Join a model to the query.
457
     *
458
     * @param Model<mixed, ActiveRecord> $model
459
     * @param non-empty-string $mode
460
     *     Join mode.
461
     * @param ?non-empty-string $as
462
     *     The alias of the model. Default: The model's alias.
463
     * @param ?non-empty-string $on
464
     *     The column on which the joint is created, or an _ON_ expression. Default: The model's primary key. @todo
465
     */
466
    private function join_with_model( // @phpstan-ignore-line
467
        Model $model,
468
        string $mode = 'INNER',
469
        ?string $as = null,
470
        ?string $on = null,
471
    ): void {
472
        $as ??= $model->alias;
1 ✔
473
        //phpcs:disable PSR2.Methods.FunctionCallSignature.SpaceBeforeOpenBracket
474
        $on ??= (function () use ($model): string {
1 ✔
475
            $primary = $this->model->primary;
1 ✔
476
            $model_schema = $model->extended_schema;
1 ✔
477

UNCOV
478
            assert(is_array($primary) || is_string($primary));
×
479

480
            if (is_array($primary)) {
1 ✔
UNCOV
481
                foreach ($primary as $column) {
×
UNCOV
482
                    if ($model_schema->has_column($column)) {
×
UNCOV
483
                        return $column;
×
484
                    }
485
                }
486
            } elseif (!$model_schema->has_column($primary)) {
1 ✔
487
                $primary = $model_schema->primary;
1 ✔
488

489
                if (is_array($primary)) {
1 ✔
490
                    $primary = reset($primary);
×
491
                }
492
            }
493

UNCOV
494
            assert(is_string($primary));
×
495

496
            return $primary;
1 ✔
497
        }) ();
1 ✔
498

499
        $this->joins_[] = "$mode JOIN " . $this->quote_identifier($model->name)
1 ✔
500
            . ' AS ' . $this->quote_identifier($as)
1 ✔
501
            . ' USING(' . $this->quote_identifier($on) . ')';
1 ✔
502
    }
503

504
    /**
505
     * Render the `on` join option.
506
     *
507
     * The method tries to determine the best solution between `ON` and `USING`.
508
     *
509
     * @param non-empty-string $column
510
     * @param non-empty-string $as
511
     * @param Query<ActiveRecord> $query
512
     */
513
    private function render_join_on(string $column, string $as, Query $query): string
514
    {
515
        if ($query->model->schema->has_column($column) && $this->model->schema->has_column($column)) {
2 ✔
516
            return 'USING(' . $this->quote_identifier($column) . ')';
2 ✔
517
        }
518

UNCOV
519
        $target = $this->model;
×
520

UNCOV
521
        while ($target instanceof Model) {
×
UNCOV
522
            if ($target->schema->has_column($column)) {
×
UNCOV
523
                break;
×
524
            }
525

UNCOV
526
            $target = $target->parent;
×
527
        }
528

529
        if (!$target) {
×
UNCOV
530
            $model_class = $this->model::class;
×
531

532
            throw new InvalidArgumentException("Unable to resolve column `$column` from model $model_class");
×
533
        }
534

NEW
535
        return 'ON ' . $this->quote_identifier($as) . '.' . $this->quote_identifier($column)
×
NEW
536
            . ' = ' . $this->quote_identifier($target->alias) . '.' . $this->quote_identifier($column);
×
537
    }
538

539
    /**
540
     * Parses the conditions for the {@see where()} and {@see having()} methods.
541
     *
542
     * {@see DateTimeInterface} conditions are converted to strings.
543
     *
544
     * @param mixed ...$conditions_and_args
545
     *
546
     * @return array{ non-empty-string|null, mixed[] } An array made of the condition string and its arguments.
547
     */
548
    private function deferred_parse_conditions(mixed ...$conditions_and_args): array
549
    {
550
        $conditions = array_shift($conditions_and_args);
26 ✔
551
        $args = $conditions_and_args;
26 ✔
552

553
        if (is_array($conditions)) {
26 ✔
554
            $c = '';
21 ✔
555
            $conditions_args = [];
21 ✔
556

557
            foreach ($conditions as $column => $arg) {
21 ✔
558
                if (is_array($arg) || $arg instanceof self) {
21 ✔
559
                    $joined = '';
3 ✔
560

561
                    if (is_array($arg)) {
3 ✔
562
                        foreach ($arg as $value) {
3 ✔
563
                            // @phpstan-ignore-next-line argument.type
564
                            $joined .= ',' . (is_numeric($value) ? $value : $this->model->connection->quote($value));
3 ✔
565
                        }
566

567
                        $joined = substr($joined, 1);
3 ✔
568
                    } else {
UNCOV
569
                        $joined = (string)$arg;
×
UNCOV
570
                        $conditions_args = array_merge($conditions_args, $arg->args);
×
571
                    }
572

573
                    $negated = $column[0] == '!';
3 ✔
574
                    $identifier = $this->quote_identifier($negated ? substr($column, 1) : $column);
3 ✔
575

576
                    $c .= ' AND ' . $identifier . ($negated ? ' NOT IN(' : ' IN(') . $joined . ')';
3 ✔
577
                } else {
578
                    $conditions_args[] = $arg;
18 ✔
579

580
                    $negated = $column[0] == '!';
18 ✔
581
                    $identifier = $this->quote_identifier($negated ? substr($column, 1) : $column);
18 ✔
582

583
                    $c .= ' AND ' . $identifier . ($negated ? ' != ' : ' = ') . '?';
18 ✔
584
                }
585
            }
586

587
            $conditions = substr($c, 5);
21 ✔
588
        } else {
UNCOV
589
            assert(is_null($conditions) || is_string($conditions));
×
590

591
            $conditions_args = [];
8 ✔
592

593
            if ($args) {
8 ✔
594
                if (is_array($args[0])) {
5 ✔
UNCOV
595
                    $conditions_args = $args[0];
×
596
                } else {
597
                    #
598
                    # We dereference values, otherwise the caller would get a corrupted array.
599
                    #
600
                    foreach ($args as $key => $value) {
5 ✔
601
                        $conditions_args[$key] = $value;
5 ✔
602
                    }
603
                }
604
            }
605
        }
606

607
        $cast = $this->model->connection->driver->cast_value(...);
26 ✔
608
        $conditions_args = array_map($cast, $conditions_args);
26 ✔
609

610
        return [ $conditions ? '(' . $conditions . ')' : null, $conditions_args ];
26 ✔
611
    }
612

613
    /**
614
     * Add conditions to the SQL statement.
615
     *
616
     * Conditions can either be specified as string or array.
617
     *
618
     * 1. Pure string conditions
619
     *
620
     * If you would like to add conditions to your statement, you could specify them in there,
621
     * like `$model->where('order_count = 2');`. This will find all the entries where the
622
     * `order_count` field's value is 2.
623
     *
624
     * 2. Array conditions
625
     *
626
     * Now what if that number could vary, say as an argument from somewhere, or perhaps from the
627
     * user’s level status somewhere? The find then becomes something like:
628
     *
629
     * `$model->where('order_count = ?', 2);`
630
     *
631
     * or
632
     *
633
     * `$model->where([ 'order_count' => 2 ]);`
634
     *
635
     * Or if you want to specify two conditions, you can do it like:
636
     *
637
     * `$model->where('order_count = ? AND locked = ?', 2, false);`
638
     *
639
     * or
640
     *
641
     * `$model->where([ 'order_count' => 2, 'locked' => false ]);`
642
     *
643
     * Or if you want to specify subset conditions:
644
     *
645
     * `$model->where([ 'order_id' => [ 123, 456, 789 ] ]);`
646
     *
647
     * This will return the orders with the `order_id` 123, 456, or 789.
648
     *
649
     * 3. Modifiers
650
     *
651
     * When using the "identifier" => "value" notation, you can switch the comparison method by
652
     * prefixing the identifier with a bang "!"
653
     *
654
     * `$model->where([ '!order_id' => [ 123, 456, 789 ]]);`
655
     *
656
     * This will return the orders with the `order_id` different from 123, 456, and 789.
657
     *
658
     * `$model->where([ '!order_count' => 2 ]);`
659
     *
660
     * This will return the orders with the `order_count` different from 2.
661
     *
662
     * @param mixed ...$conditions_and_args
663
     *
664
     * @return $this
665
     */
666
    public function where(...$conditions_and_args): static
667
    {
668
        [ $conditions, $conditions_args ] = $this->deferred_parse_conditions(...$conditions_and_args);
26 ✔
669

670
        if ($conditions) {
26 ✔
671
            $this->conditions_[] = $conditions;
25 ✔
672

673
            if ($conditions_args) {
25 ✔
674
                $this->conditions_args_ = array_merge($this->conditions_args_, $conditions_args);
20 ✔
675
            }
676
        }
677

678
        return $this;
26 ✔
679
    }
680

681
    /**
682
     * @return $this
683
     * @see self::where()
684
     *
685
     */
686
    public function and(mixed ...$conditions_and_args): static
687
    {
688
        return $this->where(...$conditions_and_args);
1 ✔
689
    }
690

691
    /**
692
     * Defines the `ORDER` clause.
693
     *
694
     * @param string $order_or_field_name The order for the `ORDER` clause e.g.
695
     * 'weight, date DESC', or field to order with, in which case `$field_values` is required.
696
     * @param scalar[]|null $field_values Values of the field specified by `$order_or_field_name`.
697
     *
698
     * @return $this
699
     */
700
    public function order(string $order_or_field_name, mixed $field_values = null): static
701
    {
702
        $this->order = func_get_args();
6 ✔
703

704
        return $this;
6 ✔
705
    }
706

707
    /**
708
     * Defines the `GROUP BY` clause.
709
     *
710
     * @returns $this
711
     */
712
    public function group(string $group): static
713
    {
714
        $this->group = $group;
2 ✔
715

716
        return $this;
2 ✔
717
    }
718

719
    /**
720
     * Defines the `HAVING` clause.
721
     *
722
     * @param mixed ...$conditions_and_args
723
     *
724
     * @return $this
725
     */
726
    public function having(...$conditions_and_args): static
727
    {
UNCOV
728
        [ $having, $having_args ] = $this->deferred_parse_conditions(...$conditions_and_args);
×
729

UNCOV
730
        assert($having !== null);
×
731

UNCOV
732
        $this->having = $having;
×
UNCOV
733
        $this->having_args_ = $having_args;
×
734

UNCOV
735
        return $this;
×
736
    }
737

738
    /**
739
     * The number of records to skip before fetching.
740
     *
741
     * @return $this
742
     */
743
    public function skip(?int $skip): static
744
    {
745
        $this->skip = $skip;
1 ✔
746

747
        return $this;
1 ✔
748
    }
749

750
    /**
751
     * The number of records to take while fetching.
752
     *
753
     * @return $this
754
     */
755
    public function take(?int $take): static
756
    {
757
        $this->take = $take;
14 ✔
758

759
        return $this;
14 ✔
760
    }
761

762
    /**
763
     * Set the fetch mode for the query.
764
     *
765
     * @param mixed ...$mode
766
     *
767
     * @return $this
768
     *
769
     * @see http://www.php.net/manual/en/pdostatement.setfetchmode.php
770
     */
771
    public function mode(...$mode): static
772
    {
773
        $this->mode = $mode;
3 ✔
774

775
        return $this;
3 ✔
776
    }
777

778
    /**
779
     * Prepare the query.
780
     *
781
     * We use the connection's prepare() method because the statement has already been resolved
782
     * during the __toString() method, and we don't want for the statement to be parsed twice.
783
     */
784
    private function prepare(): Statement
785
    {
786
        return $this->model->connection->prepare((string)$this);
19 ✔
787
    }
788

789
    /**
790
     * Return a prepared query.
791
     *
792
     * @deprecated Use {@see self::prepare()}
793
     */
794
    public Statement $prepared
795
    {
796
        get => $this->prepare();
797
    }
798

799
    /**
800
     * Prepare and executes the query.
801
     */
802
    private function execute(): Statement
803
    {
804
        $statement = $this->prepare();
19 ✔
805
        // @phpstan-ignore-next-line
806
        $statement->execute($this->args);
19 ✔
807

808
        return $statement;
19 ✔
809
    }
810

811
    /*
812
     * FINISHER
813
     */
814

815
    /**
816
     * Resolves fetch mode.
817
     *
818
     * @return array{ 0: PDO::FETCH_*, 1?: mixed, 2?: mixed }
819
     */
820
    private function resolve_fetch_mode(mixed ...$mode): array
821
    {
822
        if ($mode) {
18 ✔
UNCOV
823
            $args = $mode;
×
824
        } elseif ($this->mode) {
18 ✔
825
            $args = $this->mode;
1 ✔
826
        } elseif ($this->select ?? null) {
18 ✔
UNCOV
827
            $args = [ PDO::FETCH_ASSOC ];
×
828
        } elseif ($this->model->activerecord_class) {
18 ✔
829
            $args = [ PDO::FETCH_CLASS, $this->model->activerecord_class, [ $this->model ] ];
18 ✔
830
        } else {
UNCOV
831
            $args = [ PDO::FETCH_CLASS, ActiveRecord::class, [ $this->model ] ];
×
832
        }
833

834
        // @phpstan-ignore-next-line
835
        return $args;
18 ✔
836
    }
837

838
    /**
839
     * Execute the query and returns an array of records.
840
     *
841
     * @param mixed ...$mode Fetch mode.
842
     *
843
     * @return array<mixed>
844
     */
845
    public function all(...$mode): array
846
    {
847
        return $this->execute()->all(...$this->resolve_fetch_mode(...$mode));
9 ✔
848
    }
849

850
    /**
851
     * @var TRecord[]|mixed[]
852
     *
853
     * @see self::all()
854
     */
855
    public array $all
856
    {
857
        get => $this->all();
858
    }
859

860
    /**
861
     * Return the first result of the query and close the cursor.
862
     *
863
     * @param mixed ...$mode Fetch node.
864
     *
865
     * @return mixed The return value of this function on success depends on the fetch mode. In
866
     * all cases, FALSE is returned on failure.
867
     */
868
    public function one(...$mode): mixed
869
    {
870
        $query = (clone $this)->take(1);
11 ✔
871

872
        return $query
11 ✔
873
            ->execute()
11 ✔
874
            ->mode(...$this->resolve_fetch_mode(...$mode))
11 ✔
875
            ->one;
11 ✔
876
    }
877

878
    /**
879
     * @see self::one()
880
     */
881
    public mixed $one
882
    {
883
        get => $this->one();
884
    }
885

886
    /**
887
     * Execute the query and return an array of key/value pairs, where _key_ is the value of
888
     * the first column and _value_ the value of the second column.
889
     *
890
     * @var array<string, string>
891
     *
892
     * @see self::pairs()
893
     */
894
    public array $pairs
895
    {
896
        // @phpstan-ignore-next-line
897
        get => $this->all(PDO::FETCH_KEY_PAIR);
898
    }
899

900
    /**
901
     * Returns the first column of the first row.
902
     */
903
    public int|string|false|null $rc
904
    {
905
        get => (clone $this)->take(1)->execute()->rc;
906
    }
907

908
    /**
909
     * Check the existence of records in the model.
910
     *
911
     * $model->exists;
912
     * $model->where('name = "max"')->exists;
913
     * $model->exists(1);
914
     * $model->exists(1, 2);
915
     * $model->exists([ 1, 2 ]);
916
     *
917
     * @return bool|array
918
     */
919
    public function exists(mixed $key = null) // @phpstan-ignore-line
920
    {
921
        if ($key !== null && func_num_args() > 1) {
1 ✔
UNCOV
922
            $key = func_get_args();
×
923
        }
924

925
        $query = clone $this;
1 ✔
926

927
        #
928
        # Checking if the query matches any record.
929
        #
930

931
        if ($key === null) {
1 ✔
932
            return !!$query
1 ✔
933
                ->select('1')
1 ✔
934
                ->take(1)
1 ✔
935
                ->rc;
1 ✔
936
        }
937

938
        #
939
        # Checking if the query matches the specified record keys.
940
        #
941

NEW
942
        $primary = $this->model->primary;
×
NEW
943
        assert(is_string($primary));
×
944

UNCOV
945
        $rc = $query
×
NEW
946
            ->select($this->quote_identifier($primary)) // @phpstan-ignore argument.type
×
NEW
947
            ->and([ $primary => $key ])
×
UNCOV
948
            ->skip(null)
×
UNCOV
949
            ->take(null)
×
UNCOV
950
            ->all(PDO::FETCH_COLUMN);
×
951

UNCOV
952
        if ($rc && is_array($key)) {
×
953
            /** @var array<string> $key */
UNCOV
954
            $exists = array_fill_keys($key, false);
×
955

UNCOV
956
            foreach ($rc as $key) {
×
957
                // @phpstan-ignore-next-line
UNCOV
958
                $exists[$key] = true;
×
959
            }
960

961
            if (array_any($exists, fn($v) => !$v)) {
×
962
                return $exists;
×
963
            }
964

965
            # all true
966

967
            return true;
×
968
        }
969

UNCOV
970
        return !empty($rc);
×
971
    }
972

973
    public bool $exists
974
    {
975
        // @phpstan-ignore-next-line
976
        get => $this->exists();
977
    }
978

979
    /**
980
     * Handle all the computations.
981
     *
982
     * @param non-empty-string $method
983
     * @param non-empty-string|null $column
984
     *
985
     * @return string|array<string, string>
986
     */
987
    private function compute(string $method, ?string $column = null): string|array
988
    {
989
        $query = 'SELECT ';
×
990

UNCOV
991
        if ($column) {
×
UNCOV
992
            if ($method == 'COUNT') {
×
NEW
993
                $query .= $this->quote_identifier($column) . ", $method(" . $this->quote_identifier($column) . ")";
×
994

UNCOV
995
                $this->group($column);
×
996
            } else {
NEW
997
                $query .= "$method(" . $this->quote_identifier($column) . ")";
×
998
            }
999
        } else {
UNCOV
1000
            $query .= $method . '(*)';
×
1001
        }
1002

UNCOV
1003
        $query .= ' AS count ' . $this->render_from() . $this->render_main();
×
UNCOV
1004
        $statement = ($this->model)($query, $this->args);
×
1005

UNCOV
1006
        if ($method == 'COUNT' && $column) {
×
1007
            // @phpstan-ignore-next-line
1008
            return $statement->pairs;
×
1009
        }
1010

1011
        // @phpstan-ignore-next-line
1012
        return $statement->rc;
×
1013
    }
1014

1015
    /**
1016
     * Implement the 'COUNT' computation.
1017
     *
1018
     * @param non-empty-string|null $column The name of the column to count.
1019
     *
1020
     * @return int|array<non-empty-string, int>
1021
     */
1022
    public function count(?string $column = null): int|array
1023
    {
1024
        // @phpstan-ignore-next-line
1025
        return $this->compute('COUNT', $column);
×
1026
    }
1027

1028
    /**
1029
     * @var int|array<non-empty-string, int>
1030
     *
1031
     * @see self::count()
1032
     */
1033
    public int|array $count
1034
    {
1035
        get => $this->count();
1036
    }
1037

1038
    /**
1039
     * Implement the 'AVG' computation.
1040
     *
1041
     * @param non-empty-string $column
1042
     */
1043
    public function average(string $column): int
1044
    {
1045
        // @phpstan-ignore-next-line
UNCOV
1046
        return $this->compute('AVG', $column);
×
1047
    }
1048

1049
    /**
1050
     * Implement the 'MIN' computation.
1051
     *
1052
     * @param non-empty-string $column
1053
     */
1054
    public function minimum(string $column): int|string
1055
    {
1056
        // @phpstan-ignore-next-line
UNCOV
1057
        return $this->compute('MIN', $column);
×
1058
    }
1059

1060
    /**
1061
     * Implement the 'MAX' computation.
1062
     *
1063
     * @param non-empty-string $column
1064
     */
1065
    public function maximum(string $column): int|string
1066
    {
1067
        // @phpstan-ignore-next-line
UNCOV
1068
        return $this->compute('MAX', $column);
×
1069
    }
1070

1071
    /**
1072
     * Implement the 'SUM' computation.
1073
     *
1074
     * @param non-empty-string $column
1075
     */
1076
    public function sum(string $column): int
1077
    {
1078
        // @phpstan-ignore-next-line
UNCOV
1079
        return $this->compute('SUM', $column);
×
1080
    }
1081

1082
    /**
1083
     * Delete the records matching the conditions and range of the query.
1084
     *
1085
     * @param ?string $tables When using a JOIN, `$tables` is used to specify the tables in which
1086
     * records should be deleted. Default: The alias of queried model, only if at least one join
1087
     * clause has been defined using the {@link join()} method.
1088
     *
1089
     * @todo-20140901: reflect on join to add the required tables by default, discarding tables
1090
     * joined with the LEFT mode.
1091
     */
1092
    public function delete(?string $tables = null): Statement
1093
    {
UNCOV
1094
        if (!$tables && $this->joins_) {
×
NEW
1095
            $tables = $this->quote_identifier($this->model->alias);
×
1096
        }
1097

1098
        if ($tables) {
×
NEW
1099
            $query = "DELETE $tables FROM " . $this->quote_identifier($this->model->name)
×
NEW
1100
                . ' AS ' . $this->quote_identifier($this->model->alias);
×
1101
        } else {
NEW
1102
            $query = "DELETE FROM " . $this->quote_identifier($this->model->name);
×
1103
        }
1104

UNCOV
1105
        $query .= $this->render_main();
×
1106

UNCOV
1107
        return $this->model->execute($query, $this->args);
×
1108
    }
1109

1110
    #
1111
    # Batches
1112
    #
1113

1114
    public const int DEFAULT_BATCH_SIZE = 1000;
1115

1116
    private int $batch_size = self::DEFAULT_BATCH_SIZE;
1117

1118
    public function batch_size(int $batch_size): static
1119
    {
1120
        $this->batch_size = $batch_size;
1 ✔
1121

1122
        return $this;
1 ✔
1123
    }
1124

1125
    /**
1126
     * Return an iterator for the query.
1127
     */
1128
    public function getIterator(): Traversable
1129
    {
1130
        $skip = $this->skip;
1 ✔
1131
        $take = $this->batch_size;
1 ✔
1132
        $query = (clone $this)->take($take);
1 ✔
1133

1134
        do {
1135
            $all = $query->all();
1 ✔
1136

1137
            foreach ($all as $one) {
1 ✔
1138
                // @phpstan-ignore-next-line
1139
                yield $one;
1 ✔
1140
            }
1141

1142
            if (count($all) < $take) {
1 ✔
1143
                return;
1 ✔
1144
            }
1145

1146
            $skip += $take;
1 ✔
1147
            $query->skip($skip);
1 ✔
1148
        } while (true);
1 ✔
1149
    }
1150
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc