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

ICanBoogie / ActiveRecord / 11645748547

02 Nov 2024 08:54PM UTC coverage: 86.318%. Remained the same
11645748547

push

github

olvlvl
Tidy Query

3 of 9 new or added lines in 1 file covered. (33.33%)

93 existing lines in 3 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
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 ActiveRecord|array<string>|false $one The first record 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
 * @see self::get_exists()
52
 * otherwise. If there are multiple records, the property is an array of booleans.
53
 *
54
 * @see self::get_joins()
55
 * @property-read non-empty-string[] $joins The join collection from {@see join()}.
56
 * @see self::get_joins_args()
57
 * @property-read mixed[] $joins_args The arguments to the joins.
58
 * @see self::get_conditions()
59
 * @property-read non-empty-string[] $conditions The collected conditions.
60
 * @see self::get_conditions_args()
61
 * @property-read mixed[] $conditions_args The arguments to the conditions.
62
 * @see self::get_having_args()
63
 * @property-read mixed[] $having_args The arguments to the `HAVING` clause.
64
 * @see self::get_args()
65
 * @property-read mixed[] $args Returns the arguments to the query.
66
 * @see self::get_prepared()
67
 * @property-read Query<TRecord> $prepared Return a prepared query.
68
 */
69
class Query implements IteratorAggregate
70
{
71
    use PrototypeTrait;
72

73
    public const int LIMIT_MAX = PHP_INT_MAX;
74

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

212
    /*
213
     * Rendering
214
     */
215

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

304
        return $query;
27✔
305
    }
306

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

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

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

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

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

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

UNCOV
351
        return '';
×
352
    }
353

354
    /*
355
     *
356
     */
357

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

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

380
        return $this;
8✔
381
    }
382

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

432
            return $this;
4✔
433
        }
434

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

438
            return $this;
2✔
439
        }
440

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

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

446
            return $this;
1✔
447
        }
448

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

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

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

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

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

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

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

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

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

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

525
            assert(is_string($primary));
526

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

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

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

UNCOV
548
        $target = $this->model;
×
549

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

UNCOV
555
            $target = $target->parent_model;
×
556
        }
557

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

700
        return $this;
23✔
701
    }
702

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

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

726
        return $this;
6✔
727
    }
728

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

738
        return $this;
2✔
739
    }
740

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

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

UNCOV
754
        $this->having = $having;
×
UNCOV
755
        $this->having_args = $having_args;
×
756

UNCOV
757
        return $this;
×
758
    }
759

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

769
        return $this;
1✔
770
    }
771

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

781
        return $this;
12✔
782
    }
783

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

797
        return $this;
3✔
798
    }
799

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

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

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

827
        return $statement;
16✔
828
    }
829

830
    /*
831
     * FINISHER
832
     */
833

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

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

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

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

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

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

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

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

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

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

946
        $query = clone $this;
1✔
947

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

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

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

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

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

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

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

983
            # all true
984

UNCOV
985
            return true;
×
986
        }
987

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1126
    #
1127
    # Batches
1128
    #
1129

1130
    public const DEFAULT_BATCH_SIZE = 1000;
1131

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

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

1138
        return $this;
1✔
1139
    }
1140

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

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

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

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

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