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

ICanBoogie / ActiveRecord / 11645353913

02 Nov 2024 07:44PM UTC coverage: 86.318%. Remained the same
11645353913

push

github

olvlvl
Tidy Query

9 of 18 new or added lines in 2 files covered. (50.0%)

98 existing lines in 6 files now uncovered.

1369 of 1586 relevant lines covered (86.32%)

24.43 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
/*
4
 * This file is part of the ICanBoogie package.
5
 *
6
 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
namespace ICanBoogie\ActiveRecord;
13

14
use DateTimeInterface;
15
use ICanBoogie\ActiveRecord;
16
use ICanBoogie\DateTime;
17
use ICanBoogie\PrototypeTrait;
18
use InvalidArgumentException;
19
use IteratorAggregate;
20
use LogicException;
21
use PDO;
22
use Traversable;
23

24
use function array_map;
25
use function array_merge;
26
use function array_shift;
27
use function count;
28
use function func_get_args;
29
use function func_num_args;
30
use function implode;
31
use function is_array;
32
use function is_numeric;
33
use function is_string;
34
use function preg_replace;
35
use function reset;
36
use function substr;
37

38
use const PHP_INT_MAX;
39

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

82
    public const int LIMIT_MAX = PHP_INT_MAX;
83

84
    /**
85
     * Part of the `SELECT` clause.
86
     */
87
    private ?string $select = null;
88

89
    /**
90
     * `JOIN` clauses.
91
     *
92
     * @var non-empty-string[]
93
     */
94
    private array $joins = [];
95

96
    /**
97
     * @return non-empty-string[]
98
     *
99
     * @see $joins
100
     */
101
    private function get_joins(): array
102
    {
103
        return $this->joins;
2✔
104
    }
105

106
    /**
107
     * Joints arguments.
108
     *
109
     * @var mixed[]
110
     */
111
    private array $joins_args = [];
112

113
    /**
114
     * @return mixed[]
115
     *
116
     * @see $joins_args
117
     */
118
    private function get_joins_args(): array
119
    {
120
        return $this->joins_args;
1✔
121
    }
122

123
    /**
124
     * Collected conditions.
125
     *
126
     * @var non-empty-string[]
127
     */
128
    private array $conditions = [];
129

130
    /**
131
     * @return non-empty-string[]
132
     */
133
    private function get_conditions(): array
134
    {
135
        return $this->conditions;
1✔
136
    }
137

138
    /**
139
     * Arguments for the conditions.
140
     *
141
     * @var mixed[]
142
     */
143
    private array $conditions_args = [];
144

145
    /**
146
     * @return mixed[]
147
     */
148
    private function get_conditions_args(): array
149
    {
150
        return $this->conditions_args;
3✔
151
    }
152

153
    /**
154
     * Part of the `HAVING` clause.
155
     */
156
    private ?string $having = null;
157

158
    /**
159
     * Arguments to the `HAVING` clause.
160
     *
161
     * @var mixed[]
162
     */
163
    private array $having_args = [];
164

165
    /**
166
     * @return mixed[]
167
     */
168
    private function get_having_args(): array
169
    {
UNCOV
170
        return $this->having_args;
×
171
    }
172

173
    /**
174
     * Returns the arguments to the query, which include joins arguments, conditions arguments,
175
     * and _having_ arguments.
176
     *
177
     * @return mixed[]
178
     */
179
    private function get_args(): array
180
    {
181
        return array_merge($this->joins_args, $this->conditions_args, $this->having_args);
22✔
182
    }
183

184
    /**
185
     * Part of the `GROUP BY` clause.
186
     */
187
    private ?string $group = null;
188

189
    /**
190
     * Part of the `ORDER BY` clause.
191
     *
192
     * @var mixed[]
193
     */
194
    private array $order = [];
195

196
    /**
197
     * The number of records the skip before fetching.
198
     */
199
    private ?int $skip = null;
200

201
    /**
202
     * The maximum number of records to take when fetching.
203
     */
204
    private ?int $take = null;
205

206
    /**
207
     * Fetch mode.
208
     *
209
     * @var mixed[]
210
     */
211
    private array $mode = [];
212

213
    /**
214
     * @param Model<int|non-empty-string, TRecord> $model The model to query.
215
     */
216
    public function __construct(
217
        public readonly Model $model
218
    ) {
219
    }
36✔
220

221
    /*
222
     * Rendering
223
     */
224

225
    /**
226
     * Convert the query into a string.
227
     *
228
     * @return string
229
     */
230
    public function __toString(): string
231
    {
232
        return $this->resolve_statement(
27✔
233
            $this->render_select() . ' ' .
27✔
234
            $this->render_from() .
27✔
235
            $this->render_main()
27✔
236
        );
27✔
237
    }
238

239
    /**
240
     * Render the `SELECT` clause.
241
     *
242
     * @return string
243
     */
244
    private function render_select(): string
245
    {
246
        return 'SELECT ' . ($this->select ?? '*');
27✔
247
    }
248

249
    /**
250
     * Render the `FROM` clause.
251
     *
252
     * The rendered `FROM` clause might include some JOINS too.
253
     *
254
     * @return string
255
     */
256
    private function render_from(): string
257
    {
258
        return 'FROM {self_and_related}';
27✔
259
    }
260

261
    /**
262
     * Renders the `JOIN` clauses.
263
     *
264
     * @return string
265
     */
266
    private function render_joins(): string
267
    {
268
        return implode(' ', $this->joins);
6✔
269
    }
270

271
    /**
272
     * Render the main body of the query, without the `SELECT` and `FROM` clauses.
273
     */
274
    private function render_main(): string
275
    {
276
        $query = '';
27✔
277

278
        if ($this->joins) {
27✔
279
            $query = ' ' . $this->render_joins();
6✔
280
        }
281

282
        $conditions = $this->conditions;
27✔
283

284
        if ($conditions) {
27✔
285
            $query .= ' WHERE ' . implode(' AND ', $conditions);
18✔
286
        }
287

288
        $group = $this->group;
27✔
289

290
        if ($group) {
27✔
291
            $query .= ' GROUP BY ' . $group;
2✔
292

293
            $having = $this->having;
2✔
294

295
            if ($having) {
2✔
UNCOV
296
                $query .= ' HAVING ' . $having;
×
297
            }
298
        }
299

300
        $order = $this->order;
27✔
301

302
        if ($order) {
27✔
303
            $query .= ' ' . $this->render_order($order);
6✔
304
        }
305

306
        $skip = $this->skip;
27✔
307
        $take = $this->take;
27✔
308

309
        if ($skip || $take) {
27✔
310
            $query .= ' ' . $this->render_skip_and_take($skip, $take);
12✔
311
        }
312

313
        return $query;
27✔
314
    }
315

316
    /**
317
     * Render the `ORDER` clause.
318
     *
319
     * @param mixed[] $order
320
     */
321
    private function render_order(array $order): string
322
    {
323
        if (count($order) == 1) {
6✔
324
            $raw = $order[0];
5✔
325
            assert(is_string($raw));
326
            $rendered = preg_replace(
5✔
327
                '/-([a-zA-Z0-9_]+)/',
5✔
328
                '$1 DESC',
5✔
329
                $raw
5✔
330
            );
5✔
331

332
            return 'ORDER BY ' . $rendered;
5✔
333
        }
334

335
        $connection = $this->model->connection;
1✔
336

337
        $field = array_shift($order);
1✔
338
        assert(is_string($field));
339
        $field_values = is_array($order[0]) ? $order[0] : $order;
1✔
340
        $field_values = array_map(function ($v) use ($connection) {
1✔
341
            return $connection->quote($v);
1✔
342
        }, $field_values);
1✔
343

344
        return "ORDER BY FIELD($field, " . implode(', ', $field_values) . ")";
1✔
345
    }
346

347
    /**
348
     * Render the `LIMIT` and `OFFSET` clauses.
349
     */
350
    private function render_skip_and_take(?int $skip, ?int $take): string
351
    {
352
        if ($skip && $take) {
12✔
353
            return "LIMIT $skip, $take";
1✔
354
        } elseif ($skip) {
11✔
UNCOV
355
            return "LIMIT $skip, " . self::LIMIT_MAX;
×
356
        } elseif ($take) {
11✔
357
            return "LIMIT $take";
11✔
358
        }
359

UNCOV
360
        return '';
×
361
    }
362

363
    /*
364
     *
365
     */
366

367
    /**
368
     * Resolve the placeholders of a statement.
369
     *
370
     * Note: Currently, the method simply forwards the statement to the model's
371
     * resolve_statement() method.
372
     */
373
    private function resolve_statement(string $statement): string
374
    {
375
        return $this->model->resolve_statement($statement);
27✔
376
    }
377

378
    /**
379
     * Define the `SELECT` clause.
380
     *
381
     * @param non-empty-string $expression The expression of the `SELECT` clause. e.g. 'nid, title'.
382
     *
383
     * @return $this
384
     */
385
    public function select(string $expression): static
386
    {
387
        $this->select = $expression;
8✔
388

389
        return $this;
8✔
390
    }
391

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

441
            return $this;
4✔
442
        }
443

444
        if ($query) {
3✔
445
            $this->join_with_query($query, mode: $mode, as: $as, on: $on);
2✔
446

447
            return $this;
2✔
448
        }
449

450
        if ($with) {
1✔
451
            $model = $this->model->models->model_for_record($with);
1✔
452

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

455
            return $this;
1✔
456
        }
457

UNCOV
458
        throw new LogicException("One of [ expression, query, record ] needs to be defined");
×
459
    }
460

461
    /**
462
     * Join a subquery to the query.
463
     *
464
     * @param Query<ActiveRecord> $query
465
     * @param non-empty-string $mode
466
     *     Join mode. Default: "INNER".
467
     * @param ?non-empty-string $as
468
     *     The alias of the subquery. Default: The query's model alias.
469
     * @param ?non-empty-string $on
470
     *     The column on which the joint is created. Default: The query's model primary key.
471
     */
472
    private function join_with_query(
473
        Query $query,
474
        string $mode = 'INNER',
475
        string $as = null,
476
        string $on = null,
477
    ): void {
478
        $as ??= $query->model->alias;
2✔
479
        $on ??= $query->model->primary;
2✔
480

481
        if ($on) {
2✔
482
            assert(is_string($on));
483

484
            $on = $this->render_join_on($on, $as, $query);
2✔
485
        }
486

487
        if ($on) {
2✔
488
            $on = ' ' . $on;
2✔
489
        }
490

491
        $this->joins[] = "$mode JOIN($query) `$as`$on";
2✔
492
        $this->joins_args = array_merge($this->joins_args, $query->args);
2✔
493
    }
494

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

518
            assert(is_array($primary) || is_string($primary));
519

520
            if (is_array($primary)) {
1✔
UNCOV
521
                foreach ($primary as $column) {
×
522
                    if ($model_schema->has_column($column)) {
×
523
                        return $column;
×
524
                    }
525
                }
526
            } elseif (!$model_schema->has_column($primary)) {
1✔
527
                $primary = $model_schema->primary;
1✔
528

529
                if (is_array($primary)) {
1✔
UNCOV
530
                    $primary = reset($primary);
×
531
                }
532
            }
533

534
            assert(is_string($primary));
535

536
            return $primary;
1✔
537
        }) ();
1✔
538

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

542
    /**
543
     * Render the `on` join option.
544
     *
545
     * The method tries to determine the best solution between `ON` and `USING`.
546
     *
547
     * @param non-empty-string $column
548
     * @param non-empty-string $as
549
     * @param Query<ActiveRecord> $query
550
     */
551
    private function render_join_on(string $column, string $as, Query $query): string
552
    {
553
        if ($query->model->schema->has_column($column) && $this->model->schema->has_column($column)) {
2✔
554
            return "USING(`$column`)";
2✔
555
        }
556

UNCOV
557
        $target = $this->model;
×
558

UNCOV
559
        while ($target) {
×
560
            if ($target->schema->has_column($column)) {
×
561
                break;
×
562
            }
563

UNCOV
564
            $target = $target->parent_model;
×
565
        }
566

UNCOV
567
        if (!$target) {
×
568
            $model_class = $this->model::class;
×
569

NEW
570
            throw new InvalidArgumentException("Unable to resolve column `$column` from model $model_class");
×
571
        }
572

NEW
573
        return "ON `$as`.`$column` = `$target->alias`.`$column`";
×
574
    }
575

576
    /**
577
     * Parses the conditions for the {@see where()} and {@see having()} methods.
578
     *
579
     * {@see DateTimeInterface} conditions are converted to strings.
580
     *
581
     * @param mixed ...$conditions_and_args
582
     *
583
     * @return array{ non-empty-string|null, mixed[] } An array made of the condition string and its arguments.
584
     */
585
    private function deferred_parse_conditions(mixed ...$conditions_and_args): array
586
    {
587
        $conditions = array_shift($conditions_and_args);
23✔
588
        $args = $conditions_and_args;
23✔
589

590
        if (is_array($conditions)) {
23✔
591
            $c = '';
18✔
592
            $conditions_args = [];
18✔
593

594
            foreach ($conditions as $column => $arg) {
18✔
595
                if (is_array($arg) || $arg instanceof self) {
18✔
596
                    $joined = '';
3✔
597

598
                    if (is_array($arg)) {
3✔
599
                        foreach ($arg as $value) {
3✔
600
                            $joined .= ',' . (is_numeric($value) ? $value : $this->model->connection->quote($value));
3✔
601
                        }
602

603
                        $joined = substr($joined, 1);
3✔
604
                    } else {
UNCOV
605
                        $joined = (string)$arg;
×
606
                        $conditions_args = array_merge($conditions_args, $arg->args);
×
607
                    }
608

609
                    $c .= ' AND `' . ($column[0] == '!' ? substr($column, 1) . '` NOT' : $column . '`')
3✔
610
                        . ' IN(' . $joined . ')';
3✔
611
                } else {
612
                    $conditions_args[] = $arg;
15✔
613

614
                    $c .= ' AND `' . ($column[0] == '!' ? substr($column, 1) . '` !' : $column . '` ')
15✔
615
                        . '= ?';
15✔
616
                }
617
            }
618

619
            $conditions = substr($c, 5);
18✔
620
        } else {
621
            $conditions_args = [];
8✔
622

623
            if ($args) {
8✔
624
                if (is_array($args[0])) {
5✔
UNCOV
625
                    $conditions_args = $args[0];
×
626
                } else {
627
                    #
628
                    # We dereference values otherwise the caller would get a corrupted array.
629
                    #
630

631
                    foreach ($args as $key => $value) {
5✔
632
                        $conditions_args[$key] = $value;
5✔
633
                    }
634
                }
635
            }
636
        }
637

638
        $cast = $this->model->connection->driver->cast_value(...);
23✔
639
        $conditions_args = array_map($cast, $conditions_args);
23✔
640

641
        return [ $conditions ? '(' . $conditions . ')' : null, $conditions_args ];
23✔
642
    }
643

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

701
        if ($conditions) {
23✔
702
            $this->conditions[] = $conditions;
22✔
703

704
            if ($conditions_args) {
22✔
705
                $this->conditions_args = array_merge($this->conditions_args, $conditions_args);
17✔
706
            }
707
        }
708

709
        return $this;
23✔
710
    }
711

712
    /**
713
     * @return $this
714
     * @see self::where()
715
     *
716
     */
717
    public function and(mixed ...$conditions_and_args): static
718
    {
719
        return $this->where(...$conditions_and_args);
1✔
720
    }
721

722
    /**
723
     * Defines the `ORDER` clause.
724
     *
725
     * @param string $order_or_field_name The order for the `ORDER` clause e.g.
726
     * 'weight, date DESC', or field to order with, in which case `$field_values` is required.
727
     * @param scalar[]|null $field_values Values of the field specified by `$order_or_field_name`.
728
     *
729
     * @return $this
730
     */
731
    public function order(string $order_or_field_name, mixed $field_values = null): static
732
    {
733
        $this->order = func_get_args();
6✔
734

735
        return $this;
6✔
736
    }
737

738
    /**
739
     * Defines the `GROUP BY` clause.
740
     *
741
     * @returns $this
742
     */
743
    public function group(string $group): static
744
    {
745
        $this->group = $group;
2✔
746

747
        return $this;
2✔
748
    }
749

750
    /**
751
     * Defines the `HAVING` clause.
752
     *
753
     * @param mixed ...$conditions_and_args
754
     *
755
     * @return $this
756
     */
757
    public function having(...$conditions_and_args): static
758
    {
UNCOV
759
        [ $having, $having_args ] = $this->deferred_parse_conditions(...$conditions_and_args);
×
760

761
        assert($having !== null);
762

UNCOV
763
        $this->having = $having;
×
764
        $this->having_args = $having_args;
×
765

UNCOV
766
        return $this;
×
767
    }
768

769
    /**
770
     * The number of records to skip before fetching.
771
     *
772
     * @return $this
773
     */
774
    public function skip(?int $skip): static
775
    {
776
        $this->skip = $skip;
1✔
777

778
        return $this;
1✔
779
    }
780

781
    /**
782
     * The number of records to take while fetching.
783
     *
784
     * @return $this
785
     */
786
    public function take(?int $take): static
787
    {
788
        $this->take = $take;
12✔
789

790
        return $this;
12✔
791
    }
792

793
    /**
794
     * Set the fetch mode for the query.
795
     *
796
     * @param mixed ...$mode
797
     *
798
     * @return $this
799
     *
800
     * @see http://www.php.net/manual/en/pdostatement.setfetchmode.php
801
     */
802
    public function mode(...$mode): static
803
    {
804
        $this->mode = $mode;
3✔
805

806
        return $this;
3✔
807
    }
808

809
    /**
810
     * Prepare the query.
811
     *
812
     * We use the connection's prepare() method because the statement has already been resolved
813
     * during the __toString() method, and we don't want for the statement to be parsed twice.
814
     */
815
    private function prepare(): Statement
816
    {
817
        return $this->model->connection->prepare((string)$this);
16✔
818
    }
819

820
    /**
821
     * Return a prepared query.
822
     */
823
    protected function get_prepared(): Statement
824
    {
UNCOV
825
        return $this->prepare();
×
826
    }
827

828
    /**
829
     * Prepare and executes the query.
830
     */
831
    private function execute(): Statement
832
    {
833
        $statement = $this->prepare();
16✔
834
        $statement->execute($this->args);
16✔
835

836
        return $statement;
16✔
837
    }
838

839
    /*
840
     * FINISHER
841
     */
842

843
    /**
844
     * Resolves fetch mode.
845
     *
846
     * @return array{ 0: PDO::FETCH_*, 1?: mixed, 2?: mixed }
847
     */
848
    private function resolve_fetch_mode(mixed ...$mode): array
849
    {
850
        if ($mode) {
15✔
UNCOV
851
            $args = $mode;
×
852
        } elseif ($this->mode) {
15✔
853
            $args = $this->mode;
1✔
854
        } elseif ($this->select ?? null) {
15✔
UNCOV
855
            $args = [ PDO::FETCH_ASSOC ];
×
856
        } elseif ($this->model->activerecord_class) {
15✔
857
            $args = [ PDO::FETCH_CLASS, $this->model->activerecord_class, [ $this->model ] ];
15✔
858
        } else {
UNCOV
859
            $args = [ PDO::FETCH_CLASS, ActiveRecord::class, [ $this->model ] ];
×
860
        }
861

862
        // @phpstan-ignore-next-line
863
        return $args;
15✔
864
    }
865

866
    /**
867
     * Execute the query and returns an array of records.
868
     *
869
     * @param mixed ...$mode Fetch mode.
870
     *
871
     * @return array<mixed>
872
     */
873
    public function all(...$mode): array
874
    {
875
        return $this->execute()->all(...$this->resolve_fetch_mode(...$mode));
8✔
876
    }
877

878
    /**
879
     * Getter for the {@see $all} magic property.
880
     *
881
     * @return TRecord[]|mixed[]
882
     */
883
    protected function get_all(): array
884
    {
885
        return $this->all();
6✔
886
    }
887

888
    /**
889
     * Return the first result of the query and close the cursor.
890
     *
891
     * @param mixed ...$mode Fetch node.
892
     *
893
     * @return mixed The return value of this function on success depends on the fetch mode. In
894
     * all cases, FALSE is returned on failure.
895
     */
896
    public function one(...$mode): mixed
897
    {
898
        $query = (clone $this)->take(1);
9✔
899

900
        return $query
9✔
901
            ->execute()
9✔
902
            ->mode(...$this->resolve_fetch_mode(...$mode))
9✔
903
            ->one;
9✔
904
    }
905

906
    /**
907
     * @see $one
908
     */
909
    protected function get_one(): mixed
910
    {
911
        return $this->one();
9✔
912
    }
913

914
    /**
915
     * Execute the query and return an array of key/value pairs, where _key_ is the value of
916
     * the first column and _value_ the value of the second column.
917
     *
918
     * @return array<string, string>
919
     *
920
     * @see $pairs
921
     */
922
    protected function get_pairs(): array
923
    {
924
        // @phpstan-ignore-next-line
UNCOV
925
        return $this->all(PDO::FETCH_KEY_PAIR);
×
926
    }
927

928
    /**
929
     * Returns the first column of the first row.
930
     */
931
    protected function get_rc(): int|string|false|null
932
    {
933
        return (clone $this)->take(1)->execute()->rc;
2✔
934
    }
935

936
    /**
937
     * Check the existence of records in the model.
938
     *
939
     * $model->exists;
940
     * $model->where('name = "max"')->exists;
941
     * $model->exists(1);
942
     * $model->exists(1, 2);
943
     * $model->exists([ 1, 2 ]);
944
     *
945
     * @param mixed $key
946
     *
947
     * @return bool|array
948
     */
949
    public function exists($key = null) // @phpstan-ignore-line
950
    {
951
        if ($key !== null && func_num_args() > 1) {
1✔
UNCOV
952
            $key = func_get_args();
×
953
        }
954

955
        $query = clone $this;
1✔
956

957
        #
958
        # Checking if the query matches any record.
959
        #
960

961
        if ($key === null) {
1✔
962
            return !!$query
1✔
963
                ->select('1')
1✔
964
                ->take(1)
1✔
965
                ->rc;
1✔
966
        }
967

968
        #
969
        # Checking if the query matches the specified record keys.
970
        #
971

972
        $rc = $query
×
973
            ->select('`{primary}`')
×
UNCOV
974
            ->and([ '{primary}' => $key ])
×
UNCOV
975
            ->skip(null)
×
976
            ->take(null)
×
977
            ->all(PDO::FETCH_COLUMN);
×
978

UNCOV
979
        if ($rc && is_array($key)) {
×
UNCOV
980
            $exists = array_fill_keys($key, false);
×
981

UNCOV
982
            foreach ($rc as $key) {
×
UNCOV
983
                $exists[$key] = true;
×
984
            }
985

UNCOV
986
            foreach ($exists as $v) {
×
987
                if (!$v) {
×
UNCOV
988
                    return $exists;
×
989
                }
990
            }
991

992
            # all true
993

UNCOV
994
            return true;
×
995
        }
996

UNCOV
997
        return !empty($rc);
×
998
    }
999

1000
    protected function get_exists(): bool
1001
    {
1002
        // @phpstan-ignore-next-line
1003
        return $this->exists();
1✔
1004
    }
1005

1006
    /**
1007
     * Handle all the computations.
1008
     *
1009
     * @param non-empty-string $method
1010
     * @param non-empty-string|null $column
1011
     *
1012
     * @return string|array<string, string>
1013
     */
1014
    private function compute(string $method, string $column = null): string|array
1015
    {
UNCOV
1016
        $query = 'SELECT ';
×
1017

UNCOV
1018
        if ($column) {
×
UNCOV
1019
            if ($method == 'COUNT') {
×
1020
                $query .= "`$column`, $method(`$column`)";
×
1021

UNCOV
1022
                $this->group($column);
×
1023
            } else {
1024
                $query .= "$method(`$column`)";
×
1025
            }
1026
        } else {
UNCOV
1027
            $query .= $method . '(*)';
×
1028
        }
1029

UNCOV
1030
        $query .= ' AS count ' . $this->render_from() . $this->render_main();
×
UNCOV
1031
        $statement = ($this->model)($query, $this->args);
×
1032

UNCOV
1033
        if ($method == 'COUNT' && $column) {
×
UNCOV
1034
            return $statement->pairs;
×
1035
        }
1036

1037
        // @phpstan-ignore-next-line
UNCOV
1038
        return $statement->rc;
×
1039
    }
1040

1041
    /**
1042
     * Implement the 'COUNT' computation.
1043
     *
1044
     * @param non-empty-string|null $column The name of the column to count.
1045
     *
1046
     * @return int|array<non-empty-string, int>
1047
     */
1048
    public function count(string $column = null): int|array
1049
    {
1050
        // @phpstan-ignore-next-line
UNCOV
1051
        return $this->compute('COUNT', $column);
×
1052
    }
1053

1054
    /**
1055
     * @return int|array<non-empty-string, int>
1056
     *
1057
     * @see $count
1058
     */
1059
    protected function get_count(): int|array
1060
    {
UNCOV
1061
        return $this->count();
×
1062
    }
1063

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

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

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

1097
    /**
1098
     * Implement the 'SUM' computation.
1099
     *
1100
     * @param non-empty-string $column
1101
     */
1102
    public function sum(string $column): int
1103
    {
1104
        // @phpstan-ignore-next-line
UNCOV
1105
        return $this->compute('SUM', $column);
×
1106
    }
1107

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

UNCOV
1124
        if ($tables) {
×
NEW
1125
            $query = "DELETE $tables FROM {self} AS `{alias}`";
×
1126
        } else {
UNCOV
1127
            $query = "DELETE FROM {self}";
×
1128
        }
1129

UNCOV
1130
        $query .= $this->render_main();
×
1131

UNCOV
1132
        return $this->model->execute($query, $this->args);
×
1133
    }
1134

1135
    #
1136
    # Batches
1137
    #
1138

1139
    public const int DEFAULT_BATCH_SIZE = 1000;
1140

1141
    private int $batch_size = self::DEFAULT_BATCH_SIZE;
1142

1143
    public function batch_size(int $batch_size): static
1144
    {
1145
        $this->batch_size = $batch_size;
1✔
1146

1147
        return $this;
1✔
1148
    }
1149

1150
    /**
1151
     * Return an iterator for the query.
1152
     */
1153
    public function getIterator(): Traversable
1154
    {
1155
        $skip = $this->skip;
1✔
1156
        $take = $this->batch_size;
1✔
1157
        $query = (clone $this)->take($take);
1✔
1158

1159
        do {
1160
            $all = $query->all();
1✔
1161

1162
            foreach ($all as $one) {
1✔
1163
                // @phpstan-ignore-next-line
1164
                yield $one;
1✔
1165
            }
1166

1167
            if (count($all) < $take) {
1✔
1168
                return;
1✔
1169
            }
1170

1171
            $skip += $take;
1✔
1172
            $query->skip($skip);
1✔
1173
        } while (true);
1✔
1174
    }
1175
}
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