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

ICanBoogie / ActiveRecord / 11881061236

17 Nov 2024 06:05PM UTC coverage: 86.108% (-0.02%) from 86.125%
11881061236

push

github

olvlvl
Use PHPStan 2.0

5 of 10 new or added lines in 6 files covered. (50.0%)

16 existing lines in 2 files now uncovered.

1376 of 1598 relevant lines covered (86.11%)

24.51 hits per line

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

70.42
/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-covariant 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
 * @property-read array<mixed> $all
40
 *     An array with all the records matching the query.
41
 *     {@see self::get_all()}
42
 * @property-read TRecord|array<string>|false $one
43
 *     The first record matching the query.
44
 *     {@see self::get_one()}
45
 * @property-read array<string, scalar|null> $pairs
46
 *     An array of key/value pairs.
47
 *     {@see self::get_pairs()}
48
 * @property-read int|string|false|null $rc
49
 *     The value of the first column of the first row.
50
 *     {@see self::get_rc()}
51
 * @property-read int $count
52
 *     The number of records matching the query.
53
 *     {@see self::get_count()}
54
 * @property-read bool $exists
55
 *     Whether the query has a match.
56
 *     {@see self::get_exists()}
57
 * @property-read non-empty-string[] $joins
58
 *     The join collection from {@see join()}.
59
 *     {@see self::get_joins()}
60
 * @property-read mixed[] $joins_args
61
 *     The arguments to the joins.
62
 *     {@see self::get_joins_args()}
63
 * @property-read non-empty-string[] $conditions
64
 *     The collected conditions.
65
 *     {@see self::get_conditions()}
66
 * @property-read mixed[] $conditions_args
67
 *     The arguments to the conditions.
68
 *     {@see self::get_conditions_args()}
69
 * @property-read mixed[] $having_args
70
 *     The arguments to the `HAVING` clause.
71
 *     {@see self::get_having_args()}
72
 * @property-read mixed[] $args
73
 *     Returns the arguments to the query.
74
 *     {@see self::get_args()}
75
 * @property-read Query<TRecord> $prepared
76
 *     Return a prepared query.
77
 *     {@see self::get_prepared()}
78
 */
79
class Query implements IteratorAggregate
80
{
81
    use PrototypeTrait;
82

83
    public const LIMIT_MAX = PHP_INT_MAX;
84

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

214
    /**
215
     * @param Model<TRecord> $model The model to query.
216
     */
217
    public function __construct(
218
        public readonly Model $model // @phpstan-ignore generics.variance
219
    ) {
220
    }
36✔
221

222
    /*
223
     * Rendering
224
     */
225

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

314
        return $query;
27✔
315
    }
316

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

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

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

338
        $field = array_shift($order);
1✔
339
        assert(is_string($field));
340
        $field_values = is_array($order[0]) ? $order[0] : $order;
1✔
341
        // @phpstan-ignore-next-line
342
        $field_values = array_map(fn($v) => $connection->quote($v), $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✔
355
            return "LIMIT $skip, " . self::LIMIT_MAX;
×
356
        } elseif ($take) {
11✔
357
            return "LIMIT $take";
11✔
358
        }
359

360
        return '';
×
361
    }
362

363
    /*
364
     *
365
     */
366

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

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

388
        return $this;
8✔
389
    }
390

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

440
            return $this;
4✔
441
        }
442

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

446
            return $this;
2✔
447
        }
448

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

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

454
            return $this;
1✔
455
        }
456

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

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

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

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

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

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

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

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

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

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

533
            assert(is_string($primary));
534

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

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

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

556
        $target = $this->model;
×
557

NEW
558
        while ($target instanceof Model) {
×
559
            if ($target->schema->has_column($column)) {
×
560
                break;
×
561
            }
562

NEW
563
            $target = $target->parent;
×
564
        }
565

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

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

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

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

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

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

597
                    if (is_array($arg)) {
3✔
598
                        foreach ($arg as $value) {
3✔
599
                            // @phpstan-ignore-next-line argument.type
600
                            $joined .= ',' . (is_numeric($value) ? $value : $this->model->connection->quote($value));
3✔
601
                        }
602

603
                        $joined = substr($joined, 1);
3✔
604
                    } else {
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
            assert(is_null($conditions) || is_string($conditions));
622

623
            $conditions_args = [];
8✔
624

625
            if ($args) {
8✔
626
                if (is_array($args[0])) {
5✔
627
                    $conditions_args = $args[0];
×
628
                } else {
629
                    #
630
                    # We dereference values, otherwise the caller would get a corrupted array.
631
                    #
632
                    foreach ($args as $key => $value) {
5✔
633
                        $conditions_args[$key] = $value;
5✔
634
                    }
635
                }
636
            }
637
        }
638

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

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

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

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

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

710
        return $this;
23✔
711
    }
712

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

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

736
        return $this;
6✔
737
    }
738

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

748
        return $this;
2✔
749
    }
750

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

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

764
        $this->having = $having;
×
765
        $this->having_args = $having_args;
×
766

767
        return $this;
×
768
    }
769

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

779
        return $this;
1✔
780
    }
781

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

791
        return $this;
12✔
792
    }
793

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

807
        return $this;
3✔
808
    }
809

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

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

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

838
        return $statement;
16✔
839
    }
840

841
    /*
842
     * FINISHER
843
     */
844

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

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

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

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

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

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

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

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

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

938
    /**
939
     * Check the existence of records in the model.
940
     *
941
     * $model->exists;
942
     * $model->where('name = "max"')->exists;
943
     * $model->exists(1);
944
     * $model->exists(1, 2);
945
     * $model->exists([ 1, 2 ]);
946
     *
947
     * @return bool|array
948
     */
949
    public function exists(mixed $key = null) // @phpstan-ignore-line
950
    {
951
        if ($key !== null && func_num_args() > 1) {
1✔
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}`')
×
974
            ->and([ '{primary}' => $key ])
×
975
            ->skip(null)
×
976
            ->take(null)
×
977
            ->all(PDO::FETCH_COLUMN);
×
978

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

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

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

992
            # all true
993

994
            return true;
×
995
        }
996

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

1000
    private function get_exists(): bool
1001
    {
1002
        /** @var bool */
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
    {
1016
        $query = 'SELECT ';
×
1017

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

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

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

1033
        if ($method == 'COUNT' && $column) {
×
1034
            // @phpstan-ignore-next-line
UNCOV
1035
            return $statement->pairs;
×
1036
        }
1037

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

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

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

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

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

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

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

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

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

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

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

1136
    #
1137
    # Batches
1138
    #
1139

1140
    public const DEFAULT_BATCH_SIZE = 1000;
1141

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

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

1148
        return $this;
1✔
1149
    }
1150

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

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

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

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

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