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

ICanBoogie / ActiveRecord / 6362433236

30 Sep 2023 11:14AM UTC coverage: 85.731% (+5.6%) from 80.178%
6362433236

push

github

olvlvl
Rename StaticModelProvider methods

1436 of 1675 relevant lines covered (85.73%)

29.41 hits per line

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

80.24
/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 ICanBoogie\ActiveRecord;
15
use ICanBoogie\DateTime;
16
use ICanBoogie\Prototype\MethodNotDefined;
17
use ICanBoogie\PrototypeTrait;
18
use InvalidArgumentException;
19
use IteratorAggregate;
20
use JetBrains\PhpStorm\Deprecated;
21
use LogicException;
22
use ReflectionClass;
23
use ReflectionMethod;
24
use Traversable;
25

26
use function array_merge;
27
use function get_class;
28
use function is_array;
29
use function is_string;
30
use function preg_replace;
31
use function preg_replace_callback;
32
use function reset;
33
use function substr;
34

35
use const PHP_INT_MAX;
36

37
/**
38
 * The class offers many features to compose model queries. Most query related
39
 * methods of the {@link Model} class create a {@link Query} object that is returned for
40
 * further specification, such as filters or limits.
41
 *
42
 * @method Query and ($conditions, $conditions_args = null, $_ = null) Alias to where().
43
 *
44
 * @property-read array $all An array with all the records matching the query.
45
 * @property-read mixed $one The first record matching the query.
46
 * @property-read array $pairs An array of key/value pairs.
47
 * @property-read array $rc The first column of the first row matching the query.
48
 * @property-read int $count The number of records matching the query.
49
 * @property-read bool|array $exists `true` if a record matching the query exists, `false`
50
 * otherwise. If there is multiple records, the property is an array of booleans.
51
 *
52
 * @property-read Model $model The target model of the query.
53
 * @property-read array $joints The joints collection from {@link join()}.
54
 * @property-read array $joints_args The arguments to the joints.
55
 * @property-read array $conditions The collected conditions.
56
 * @property-read array $conditions_args The arguments to the conditions.
57
 * @property-read array $having_args The arguments to the `HAVING` clause.
58
 * @property-read array $args Returns the arguments to the query.
59
 * @property-read Query $prepared Return a prepared query.
60
 *
61
 * @template TRecord of ActiveRecord
62
 *
63
 * @implements IteratorAggregate<TRecord>
64
 */
65
class Query implements IteratorAggregate
66
{
67
    use PrototypeTrait {
68
        PrototypeTrait::__call as private __prototype_call;
69
    }
70

71
    public const LIMIT_MAX = PHP_INT_MAX;
72

73
    /**
74
     * Part of the `SELECT` clause.
75
     *
76
     * @var string
77
     */
78
    private $select;
79

80
    /**
81
     * `JOIN` clauses.
82
     *
83
     * @var array
84
     * @uses get_joints
85
     */
86
    private $joints = [];
87

88
    private function get_joints(): array
89
    {
90
        return $this->joints;
2✔
91
    }
92

93
    /**
94
     * Joints arguments.
95
     *
96
     * @var array
97
     * @uses get_joints_args
98
     * @uses get_args
99
     */
100
    private $joints_args = [];
101

102
    private function get_joints_args(): array
103
    {
104
        return $this->joints_args;
1✔
105
    }
106

107
    /**
108
     * Collected conditions.
109
     *
110
     * @var array
111
     * @uses get_conditions
112
     */
113
    private $conditions = [];
114

115
    private function get_conditions(): array
116
    {
117
        return $this->conditions;
1✔
118
    }
119

120
    /**
121
     * Arguments for the conditions.
122
     *
123
     * @var array
124
     * @uses get_conditions_args
125
     * @uses get_args
126
     */
127
    private $conditions_args = [];
128

129
    private function get_conditions_args(): array
130
    {
131
        return $this->conditions_args;
2✔
132
    }
133

134
    /**
135
     * Part of the `HAVING` clause.
136
     *
137
     * @var string
138
     */
139
    private $having;
140

141
    /**
142
     * Arguments to the `HAVING` clause.
143
     *
144
     * @var array
145
     * @uses get_having_args
146
     * @uses get_args
147
     */
148
    private $having_args = [];
149

150
    private function get_having_args(): array
151
    {
152
        return $this->having_args;
×
153
    }
154

155
    /**
156
     * Returns the arguments to the query, which include joints arguments, conditions arguments,
157
     * and _having_ arguments.
158
     *
159
     * @return array
160
     */
161
    private function get_args(): array
162
    {
163
        return array_merge($this->joints_args, $this->conditions_args, $this->having_args);
32✔
164
    }
165

166
    /**
167
     * Part of the `GROUP BY` clause.
168
     *
169
     * @var string
170
     */
171
    private $group;
172

173
    /**
174
     * Part of the `ORDER BY` clause.
175
     *
176
     * @var mixed
177
     */
178
    private $order;
179

180
    /**
181
     * The number of records the skip before fetching.
182
     *
183
     * @var int
184
     */
185
    private $offset;
186

187
    /**
188
     * The maximum number of records to fetch.
189
     *
190
     * @var int
191
     */
192
    private $limit;
193

194
    /**
195
     * Fetch mode.
196
     *
197
     * @var mixed
198
     */
199
    private $mode;
200

201
    /**
202
     * The target model of the query.
203
     *
204
     * @var Model
205
     * @uses get_model
206
     */
207
    private $model;
208

209
    private function get_model(): Model
210
    {
211
        return $this->model;
5✔
212
    }
213

214
    /**
215
     * @param Model $model The model to query.
216
     */
217
    public function __construct(Model $model)
218
    {
219
        $this->model = $model;
59✔
220
    }
221

222
    /**
223
     * Override the method to handle magic 'filter_by_' methods.
224
     *
225
     * @inheritdoc
226
     */
227
    public function __call($method, $arguments)
228
    {
229
        if ($method === 'and') {
7✔
230
            return $this->where(...$arguments);
5✔
231
        }
232

233
        if (\strpos($method, 'filter_by_') === 0) {
3✔
234
            return $this->dynamic_filter(substr($method, 10), $arguments); // 10 is for: strlen('filter_by_')
3✔
235
        }
236

237
        try {
238
            return self::__prototype_call($method, $arguments);
×
239
        } catch (MethodNotDefined $e) {
×
240
            throw new ScopeNotDefined($method, $this->model, $e);
×
241
        }
242
    }
243

244
    /*
245
     * Rendering
246
     */
247

248
    /**
249
     * Convert the query into a string.
250
     *
251
     * @return string
252
     */
253
    public function __toString(): string
254
    {
255
        return $this->resolve_statement(
45✔
256
            $this->render_select() . ' ' .
45✔
257
            $this->render_from() .
45✔
258
            $this->render_main()
45✔
259
        );
45✔
260
    }
261

262
    /**
263
     * Render the `SELECT` clause.
264
     *
265
     * @return string
266
     */
267
    private function render_select(): string
268
    {
269
        return 'SELECT ' . ($this->select ? $this->select : '*');
45✔
270
    }
271

272
    /**
273
     * Render the `FROM` clause.
274
     *
275
     * The rendered `FROM` clause might include some JOINS too.
276
     *
277
     * @return string
278
     */
279
    private function render_from(): string
280
    {
281
        return 'FROM {self_and_related}';
46✔
282
    }
283

284
    /**
285
     * Renders the `JOIN` clauses.
286
     *
287
     * @return string
288
     */
289
    private function render_joints(): string
290
    {
291
        return \implode(' ', $this->joints);
7✔
292
    }
293

294
    /**
295
     * Render the main body of the query, without the `SELECT` and `FROM` clauses.
296
     *
297
     * @return string
298
     */
299
    private function render_main(): string
300
    {
301
        $query = '';
46✔
302

303
        if ($this->joints) {
46✔
304
            $query = ' ' . $this->render_joints();
7✔
305
        }
306

307
        $conditions = $this->conditions;
46✔
308

309
        if ($conditions) {
46✔
310
            $query .= ' WHERE ' . \implode(' AND ', $conditions);
26✔
311
        }
312

313
        $group = $this->group;
46✔
314

315
        if ($group) {
46✔
316
            $query .= ' GROUP BY ' . $group;
3✔
317

318
            $having = $this->having;
3✔
319

320
            if ($having) {
3✔
321
                $query .= ' HAVING ' . $having;
×
322
            }
323
        }
324

325
        $order = $this->order;
46✔
326

327
        if ($order) {
46✔
328
            $query .= ' ' . $this->render_order($order);
8✔
329
        }
330

331
        $offset = $this->offset;
46✔
332
        $limit = $this->limit;
46✔
333

334
        if ($offset || $limit) {
46✔
335
            $query .= ' ' . $this->render_offset_and_limit($offset, $limit);
18✔
336
        }
337

338
        return $query;
46✔
339
    }
340

341
    /**
342
     * Render the `ORDER` clause.
343
     */
344
    private function render_order(array $order): string
345
    {
346
        if (\count($order) == 1) {
8✔
347
            $raw = $order[0];
6✔
348
            $rendered = preg_replace(
6✔
349
                '/-([a-zA-Z0-9_]+)/',
6✔
350
                '$1 DESC',
6✔
351
                $raw
6✔
352
            );
6✔
353

354
            return 'ORDER BY ' . $rendered;
6✔
355
        }
356

357
        $connection = $this->model->connection;
2✔
358

359
        $field = \array_shift($order);
2✔
360
        $field_values = is_array($order[0]) ? $order[0] : $order;
2✔
361
        $field_values = \array_map(function ($v) use ($connection) {
2✔
362
            return $connection->pdo->quote($v);
2✔
363
        }, $field_values);
2✔
364

365
        return "ORDER BY FIELD($field, " . \implode(', ', $field_values) . ")";
2✔
366
    }
367

368
    /**
369
     * Render the `LIMIT` and `OFFSET` clauses.
370
     *
371
     * @param int $offset
372
     * @param int $limit
373
     *
374
     * @return string
375
     */
376
    private function render_offset_and_limit($offset, $limit): string
377
    {
378
        if ($offset && $limit) {
18✔
379
            return "LIMIT $offset, $limit";
1✔
380
        } else {
381
            if ($offset) {
17✔
382
                return "LIMIT $offset, " . self::LIMIT_MAX;
1✔
383
            } else {
384
                if ($limit) {
16✔
385
                    return "LIMIT $limit";
16✔
386
                }
387
            }
388
        }
389

390
        return '';
×
391
    }
392

393
    /*
394
     *
395
     */
396

397
    /**
398
     * Resolve the placeholders of a statement.
399
     *
400
     * Note: Currently, the method simply forwards the statement to the model's
401
     * resolve_statement() method.
402
     *
403
     * @param string $statement
404
     *
405
     * @return string
406
     */
407
    private function resolve_statement(string $statement): string
408
    {
409
        return $this->model->resolve_statement($statement);
45✔
410
    }
411

412
    /**
413
     * Define the `SELECT` clause.
414
     *
415
     * @param string $expression The expression of the `SELECT` clause. e.g. 'nid, title'.
416
     *
417
     * @return $this
418
     */
419
    public function select($expression): self
420
    {
421
        $this->select = $expression;
15✔
422

423
        return $this;
15✔
424
    }
425

426
    /**
427
     * Add a `JOIN` clause.
428
     *
429
     * @param ?string $expression
430
     *     A raw `JOIN` clause.
431
     * @param ?Query<ActiveRecord> $query
432
     *     A {@link Query} instance, it is rendered as a string and used as a subquery of the `JOIN` clause.
433
     *     The `$options` parameter can be used to customize the output.
434
     * @param ?class-string<ActiveRecord> $with
435
     * @param ?class-string<Model> $model_class
436
     *     A model class.
437
     * @param ?Model $model
438
     *     A model.
439
     * @param ?non-empty-string $mode
440
     *     Join mode. Default: "INNER"
441
     * @param ?non-empty-string $as
442
     *     The alias of the subquery. Default: The query's model alias.
443
     * @param ?non-empty-string $on
444
     *     The column on which to joint is created. Default: The query's model primary key.
445
     *
446
     * @return $this
447
     *
448
     * <pre>
449
     * <?php
450
     *
451
     * # using a model identifier
452
     *
453
     * $query->join(model_id: 'nodes');
454
     *
455
     * # using a subquery
456
     *
457
     * $subquery = get_model('updates')
458
     * ->select('updated_at, $subscriber_id, update_hash')
459
     * ->order('updated_at DESC')
460
     *
461
     * $query->join(query: $subquery, on: 'subscriber_id');
462
     *
463
     * # using a raw clause
464
     *
465
     * $query->join(expression: "INNER JOIN `articles` USING(`nid`)");
466
     * </pre>
467
     */
468
    public function join(
469
        string $expression = null,
470
        Query $query = null,
471
        string $with = null,
472
        string $mode = 'INNER',
473
        string $as = null,
474
        string $on = null,
475
    ): self {
476
        if ($expression) {
9✔
477
            $this->joints[] = $expression;
6✔
478

479
            return $this;
6✔
480
        }
481

482
        if ($query) {
3✔
483
            $this->join_with_query($query, mode: $mode, as: $as, on: $on);
2✔
484

485
            return $this;
2✔
486
        }
487

488
        if ($with) {
1✔
489
            $model = $this->model->models->model_for_record($with);
1✔
490

491
            $this->join_with_model($model, mode: $mode, as: $as, on: $on);
1✔
492

493
            return $this;
1✔
494
        }
495

496
        throw new LogicException("One of [ expression, query, record ] needs to be defined");
×
497
    }
498

499
    /**
500
     * Join a subquery to the query.
501
     *
502
     * @param Query<ActiveRecord> $query
503
     * @param string $mode
504
     *     Join mode. Default: "INNER".
505
     * @param ?string $as
506
     *     The alias of the subquery. Default: The query's model alias.
507
     * @param ?string $on
508
     *     The column on which the joint is created. Default: The query's model primary key.
509
     */
510
    private function join_with_query(
511
        Query $query,
512
        string $mode = 'INNER',
513
        string $as = null,
514
        string $on = null,
515
    ): void {
516
        $as ??= $query->model->alias;
2✔
517
        $on ??= $query->model->primary;
2✔
518

519
        if ($on) {
2✔
520
            assert(is_string($on));
521

522
            $on = $this->render_join_on($on, $as, $query);
2✔
523
        }
524

525
        if ($on) {
2✔
526
            $on = ' ' . $on;
2✔
527
        }
528

529
        $this->joints[] = "$mode JOIN($query) `$as`{$on}";
2✔
530
        $this->joints_args = array_merge($this->joints_args, $query->args);
2✔
531
    }
532

533
    /**
534
     * Join a model to the query.
535
     *
536
     * @param Model<int|string|string[], ActiveRecord> $model
537
     * @param non-empty-string $mode
538
     *     Join mode.
539
     * @param ?non-empty-string $as
540
     *     The alias of the model. Default: The model's alias.
541
     * @param ?non-empty-string $on
542
     *     The column on which the joint is created, or an _ON_ expression. Default: The model's primary key. @todo
543
     */
544
    private function join_with_model( // @phpstan-ignore-line
545
        Model $model,
546
        string $mode = 'INNER',
547
        string $as = null,
548
        string $on = null,
549
    ): void {
550
        $as ??= $model->alias;
1✔
551
        //phpcs:disable PSR2.Methods.FunctionCallSignature.SpaceBeforeOpenBracket
552
        $on ??= (function () use ($model): string {
1✔
553
            $primary = $this->model->primary;
1✔
554
            $model_schema = $model->extended_schema;
1✔
555

556
            assert(is_array($primary) || is_string($primary));
557

558
            if (is_array($primary)) {
1✔
559
                foreach ($primary as $column) {
×
560
                    if ($model_schema->has_column($column)) {
×
561
                        return $column;
×
562
                    }
563
                }
564
            } elseif (!$model_schema->has_column($primary)) {
1✔
565
                $primary = $model_schema->primary;
1✔
566

567
                if (is_array($primary)) {
1✔
568
                    $primary = reset($primary);
×
569
                }
570
            }
571

572
            assert(is_string($primary));
573

574
            return $primary;
1✔
575
        }) ();
1✔
576

577
        $this->joints[] = "$mode JOIN `$model->name` AS `$as` USING(`$on`)";
1✔
578
    }
579

580
    /**
581
     * Render the `on` join option.
582
     *
583
     * The method tries to determine the best solution between `ON` and `USING`.
584
     *
585
     * @param string $column
586
     * @param string $as
587
     * @param Query<ActiveRecord> $query
588
     */
589
    private function render_join_on(string $column, string $as, Query $query): string
590
    {
591
        if ($query->model->schema->has_column($column) && $this->model->schema->has_column($column)) {
2✔
592
            return "USING(`$column`)";
2✔
593
        }
594

595
        $target = $this->model;
×
596

597
        while ($target) {
×
598
            if ($target->schema->has_column($column)) {
×
599
                break;
×
600
            }
601

602
            $target = $target->parent_model;
×
603
        }
604

605
        if (!$target) {
×
606
            $model_class = $this->model::class;
×
607

608
            throw new InvalidArgumentException("Unable to resolve column `$column` from model {$model_class}");
×
609
        }
610

611
        return "ON `$as`.`$column` = `{$target->alias}`.`$column`";
×
612
    }
613

614
    /**
615
     * Parse the conditions for the {@link where()} and {@link having()} methods.
616
     *
617
     * {@link \DateTimeInterface} conditions are converted to strings.
618
     *
619
     * @param $conditions_and_args
620
     *
621
     * @return array An array made of the condition string and its arguments.
622
     */
623
    private function deferred_parse_conditions(...$conditions_and_args): array
624
    {
625
        $conditions = \array_shift($conditions_and_args);
30✔
626
        $args = $conditions_and_args;
30✔
627

628
        if (is_array($conditions)) {
30✔
629
            $c = '';
25✔
630
            $conditions_args = [];
25✔
631

632
            foreach ($conditions as $column => $arg) {
25✔
633
                if (is_array($arg) || $arg instanceof self) {
25✔
634
                    $joined = '';
7✔
635

636
                    if (is_array($arg)) {
7✔
637
                        foreach ($arg as $value) {
7✔
638
                            $joined .= ',' . (\is_numeric($value) ? $value : $this->model->quote($value));
7✔
639
                        }
640

641
                        $joined = substr($joined, 1);
7✔
642
                    } else {
643
                        $joined = (string)$arg;
×
644
                        $conditions_args = array_merge($conditions_args, $arg->args);
×
645
                    }
646

647
                    $c .= ' AND `' . ($column[0] == '!' ? substr($column, 1) . '` NOT' : $column . '`')
7✔
648
                        . ' IN(' . $joined . ')';
7✔
649
                } else {
650
                    $conditions_args[] = $arg;
20✔
651

652
                    $c .= ' AND `' . ($column[0] == '!' ? substr($column, 1) . '` !' : $column . '` ')
20✔
653
                        . '= ?';
20✔
654
                }
655
            }
656

657
            $conditions = substr($c, 5);
25✔
658
        } else {
659
            $conditions_args = [];
8✔
660

661
            if ($args) {
8✔
662
                if (is_array($args[0])) {
5✔
663
                    $conditions_args = $args[0];
×
664
                } else {
665
                    #
666
                    # We dereference values otherwise the caller would get a corrupted array.
667
                    #
668

669
                    foreach ($args as $key => $value) {
5✔
670
                        $conditions_args[$key] = $value;
5✔
671
                    }
672
                }
673
            }
674
        }
675

676
        foreach ($conditions_args as &$value) {
30✔
677
            if ($value instanceof \DateTimeInterface) {
22✔
678
                $value = DateTime::from($value)->utc->as_db;
1✔
679
            }
680
        }
681

682
        return [ $conditions ? '(' . $conditions . ')' : null, $conditions_args ];
30✔
683
    }
684

685
    /**
686
     * Handles dynamic filters.
687
     *
688
     * @param string $filter
689
     * @param array $conditions_args
690
     *
691
     * @return $this
692
     */
693
    private function dynamic_filter(string $filter, array $conditions_args = []): self
694
    {
695
        $conditions = \explode('_and_', $filter);
3✔
696

697
        return $this->where(\array_combine($conditions, $conditions_args));
3✔
698
    }
699

700
    /**
701
     * Add conditions to the SQL statement.
702
     *
703
     * Conditions can either be specified as string or array.
704
     *
705
     * 1. Pure string conditions
706
     *
707
     * If you'de like to add conditions to your statement, you could just specify them in there,
708
     * just like `$model->where('order_count = 2');`. This will find all the entries, where the
709
     * `order_count` field's value is 2.
710
     *
711
     * 2. Array conditions
712
     *
713
     * Now what if that number could vary, say as an argument from somewhere, or perhaps from the
714
     * user’s level status somewhere? The find then becomes something like:
715
     *
716
     * `$model->where('order_count = ?', 2);`
717
     *
718
     * or
719
     *
720
     * `$model->where([ 'order_count' => 2 ]);`
721
     *
722
     * Or if you want to specify two conditions, you can do it like:
723
     *
724
     * `$model->where('order_count = ? AND locked = ?', 2, false);`
725
     *
726
     * or
727
     *
728
     * `$model->where([ 'order_count' => 2, 'locked' => false ]);`
729
     *
730
     * Or if you want to specify subset conditions:
731
     *
732
     * `$model->where([ 'order_id' => [ 123, 456, 789 ] ]);`
733
     *
734
     * This will return the orders with the `order_id` 123, 456 or 789.
735
     *
736
     * 3. Modifiers
737
     *
738
     * When using the "identifier" => "value" notation, you can switch the comparison method by
739
     * prefixing the identifier with a bang "!"
740
     *
741
     * `$model->where([ '!order_id' => [ 123, 456, 789 ]]);`
742
     *
743
     * This will return the orders with the `order_id` different than 123, 456 and 789.
744
     *
745
     * `$model->where([ '!order_count' => 2 ];`
746
     *
747
     * This will return the orders with the `order_count` different than 2.
748
     *
749
     * @param mixed ...$conditions_and_args
750
     *
751
     * @return $this
752
     */
753
    public function where(...$conditions_and_args): self
754
    {
755
        [ $conditions, $conditions_args ] = $this->deferred_parse_conditions(...$conditions_and_args);
30✔
756

757
        if ($conditions) {
30✔
758
            $this->conditions[] = $conditions;
30✔
759

760
            if ($conditions_args) {
30✔
761
                $this->conditions_args = array_merge($this->conditions_args, $conditions_args);
22✔
762
            }
763
        }
764

765
        return $this;
30✔
766
    }
767

768
    /**
769
     * Defines the `ORDER` clause.
770
     *
771
     * @param string $order_or_field_name The order for the `ORDER` clause e.g.
772
     * 'weight, date DESC', or field to order with, in which case `$field_values` is required.
773
     * @param array $field_values Values of the field specified by `$order_or_field_name`.
774
     *
775
     * @return $this
776
     */
777
    public function order($order_or_field_name, $field_values = null)
778
    {
779
        $this->order = func_get_args();
9✔
780

781
        return $this;
9✔
782
    }
783

784
    /**
785
     * Defines the `GROUP` clause.
786
     *
787
     * @param string $group
788
     *
789
     * @return $this
790
     */
791
    public function group($group)
792
    {
793
        $this->group = $group;
4✔
794

795
        return $this;
4✔
796
    }
797

798
    /**
799
     * Defines the `HAVING` clause.
800
     *
801
     * @param mixed ...$conditions_and_args
802
     *
803
     * @return $this
804
     */
805
    public function having(...$conditions_and_args)
806
    {
807
        list($having, $having_args) = $this->deferred_parse_conditions(...$conditions_and_args);
×
808

809
        $this->having = $having;
×
810
        $this->having_args = $having_args;
×
811

812
        return $this;
×
813
    }
814

815
    /**
816
     * Define the offset of the `LIMIT` clause.
817
     *
818
     * @param $offset
819
     *
820
     * @return $this
821
     */
822
    public function offset($offset)
823
    {
824
        $this->offset = (int)$offset;
2✔
825

826
        return $this;
2✔
827
    }
828

829
    /**
830
     * Apply the limit and/or offset to the SQL fired.
831
     *
832
     * You can use the limit to specify the number of records to be retrieved, ad use the offset to
833
     * specify the number of records to skip before starting to return records:
834
     *
835
     *     $model->limit(10);
836
     *
837
     * Will return a maximum of 10 clients and because ti specifies no offset it will return the
838
     * first 10 in the table:
839
     *
840
     *     $model->limit(5, 10);
841
     *
842
     * Will return a maximum of 10 clients beginning with the 5th.
843
     *
844
     * @param int $limit
845
     *
846
     * @return $this
847
     */
848
    public function limit($limit)
849
    {
850
        $offset = null;
10✔
851

852
        if (\func_num_args() == 2) {
10✔
853
            $offset = $limit;
5✔
854
            $limit = \func_get_arg(1);
5✔
855
        }
856

857
        $this->offset = (int)$offset;
10✔
858
        $this->limit = (int)$limit;
10✔
859

860
        return $this;
10✔
861
    }
862

863
    /**
864
     * Set the fetch mode for the query.
865
     *
866
     * @param mixed ...$mode
867
     *
868
     * @return $this
869
     *
870
     * @see http://www.php.net/manual/en/pdostatement.setfetchmode.php
871
     */
872
    public function mode(...$mode): self
873
    {
874
        $this->mode = $mode;
3✔
875

876
        return $this;
3✔
877
    }
878

879
    /**
880
     * Prepare the query.
881
     *
882
     * We use the connection's prepare() method because the statement has already been resolved
883
     * during the __toString() method and we don't want for the statement to be parsed twice.
884
     *
885
     * @return Statement
886
     */
887
    private function prepare(): Statement
888
    {
889
        return $this->model->connection->prepare((string)$this);
25✔
890
    }
891

892
    /**
893
     * Return a prepared query.
894
     *
895
     * @return Statement
896
     */
897
    protected function get_prepared(): Statement
898
    {
899
        return $this->prepare();
×
900
    }
901

902
    /**
903
     * Prepare and executes the query.
904
     *
905
     * @return Statement
906
     */
907
    public function query(): Statement
908
    {
909
        $statement = $this->prepare();
25✔
910
        $statement->execute($this->args);
25✔
911

912
        return $statement;
25✔
913
    }
914

915
    /*
916
     * FINISHER
917
     */
918

919
    /**
920
     * Resolves fetch mode.
921
     *
922
     * @param mixed ...$mode
923
     *
924
     * @return array
925
     */
926
    private function resolve_fetch_mode(...$mode): array
927
    {
928
        if ($mode) {
23✔
929
            $args = $mode;
4✔
930
        } else {
931
            if ($this->mode) {
19✔
932
                $args = $this->mode;
1✔
933
            } else {
934
                if ($this->select) {
19✔
935
                    $args = [ \PDO::FETCH_ASSOC ];
×
936
                } else {
937
                    if ($this->model->activerecord_class) {
19✔
938
                        $args = [ \PDO::FETCH_CLASS, $this->model->activerecord_class, [ $this->model ] ];
19✔
939
                    } else {
940
                        $args = [ \PDO::FETCH_CLASS, ActiveRecord::class, [ $this->model ] ];
×
941
                    }
942
                }
943
            }
944
        }
945

946
        return $args;
23✔
947
    }
948

949
    /**
950
     * Execute the query and returns an array of records.
951
     *
952
     * @param mixed ...$mode Fetch mode.
953
     *
954
     * @return array
955
     */
956
    public function all(...$mode): array
957
    {
958
        return $this->query()->pdo_statement->fetchAll(...$this->resolve_fetch_mode(...$mode));
13✔
959
    }
960

961
    /**
962
     * Getter for the {@link $all} magic property.
963
     *
964
     * @return array
965
     */
966
    protected function get_all(): array
967
    {
968
        return $this->all();
6✔
969
    }
970

971
    /**
972
     * Return the first result of the query and close the cursor.
973
     *
974
     * @param mixed ...$mode Fetch node.
975
     *
976
     * @return mixed The return value of this function on success depends on the fetch mode. In
977
     * all cases, FALSE is returned on failure.
978
     */
979
    public function one(...$mode)
980
    {
981
        $query = clone $this;
12✔
982
        $query->limit = 1;
12✔
983
        $statement = $query->query();
12✔
984
        $args = $query->resolve_fetch_mode(...$mode);
12✔
985

986
        if (\count($args) > 1 && $args[0] == \PDO::FETCH_CLASS) {
12✔
987
            \array_shift($args);
12✔
988

989
            $rc = $statement->pdo_statement->fetchObject(...$args);
12✔
990

991
            $statement->pdo_statement->closeCursor();
12✔
992

993
            return $rc;
12✔
994
        }
995

996
        return $statement->one(...$args);
×
997
    }
998

999
    /**
1000
     * Getter for the {@link $one} magic property.
1001
     *
1002
     * @return mixed
1003
     *
1004
     * @see one()
1005
     */
1006
    protected function get_one()
1007
    {
1008
        return $this->one();
10✔
1009
    }
1010

1011
    /**
1012
     * Execute que query and return an array of key/value pairs, where the key is the value of
1013
     * the first column and the value of the key the value of the second column.
1014
     *
1015
     * @return array
1016
     */
1017
    protected function get_pairs(): array
1018
    {
1019
        return $this->all(\PDO::FETCH_KEY_PAIR);
×
1020
    }
1021

1022
    /**
1023
     * Return the value of the first column of the first row.
1024
     *
1025
     * @return mixed
1026
     */
1027
    protected function get_rc()
1028
    {
1029
        $previous_limit = $this->limit;
3✔
1030

1031
        $this->limit = 1;
3✔
1032

1033
        $statement = $this->query();
3✔
1034

1035
        $this->limit = $previous_limit;
3✔
1036

1037
        return $statement->rc;
3✔
1038
    }
1039

1040
    /**
1041
     * Check the existence of records in the model.
1042
     *
1043
     * $model->exists;
1044
     * $model->where('name = "max"')->exists;
1045
     * $model->exists(1);
1046
     * $model->exists(1, 2);
1047
     * $model->exists([ 1, 2 ]);
1048
     *
1049
     * @param mixed $key
1050
     *
1051
     * @return bool|array
1052
     */
1053
    public function exists($key = null)
1054
    {
1055
        if ($key !== null && \func_num_args() > 1) {
7✔
1056
            $key = \func_get_args();
3✔
1057
        }
1058

1059
        $query = clone $this;
7✔
1060

1061
        #
1062
        # Checking if the query matches any record.
1063
        #
1064

1065
        if ($key === null) {
7✔
1066
            return !!$query
3✔
1067
                ->select('1')
3✔
1068
                ->limit(1)
3✔
1069
                ->rc;
3✔
1070
        }
1071

1072
        #
1073
        # Checking if the query matches the specified record keys.
1074
        #
1075

1076
        $rc = $query
4✔
1077
            ->select('`{primary}`')
4✔
1078
            ->and([ '{primary}' => $key ])
4✔
1079
            ->limit(0, 0)
4✔
1080
            ->all(\PDO::FETCH_COLUMN);
4✔
1081

1082
        if ($rc && is_array($key)) {
4✔
1083
            $exists = \array_combine($key, \array_fill(0, \count($key), false));
2✔
1084

1085
            foreach ($rc as $key) {
2✔
1086
                $exists[$key] = true;
2✔
1087
            }
1088

1089
            foreach ($exists as $v) {
2✔
1090
                if (!$v) {
2✔
1091
                    return $exists;
1✔
1092
                }
1093
            }
1094

1095
            # all true
1096

1097
            return true;
1✔
1098
        }
1099

1100
        return !empty($rc);
3✔
1101
    }
1102

1103
    /**
1104
     * Getter for the {@link $exists} magic property.
1105
     *
1106
     * @return bool|array
1107
     *
1108
     * @see exists()
1109
     */
1110
    protected function get_exists()
1111
    {
1112
        return $this->exists();
2✔
1113
    }
1114

1115
    /**
1116
     * Handle all the computations.
1117
     *
1118
     * @param string $method
1119
     * @param string|null $column
1120
     *
1121
     * @return int|array
1122
     */
1123
    private function compute(string $method, string $column = null)
1124
    {
1125
        $query = 'SELECT ';
1✔
1126

1127
        if ($column) {
1✔
1128
            if ($method == 'COUNT') {
×
1129
                $query .= "`$column`, $method(`$column`)";
×
1130

1131
                $this->group($column);
×
1132
            } else {
1133
                $query .= "$method(`$column`)";
×
1134
            }
1135
        } else {
1136
            $query .= $method . '(*)';
1✔
1137
        }
1138

1139
        $query .= ' AS count ' . $this->render_from() . $this->render_main();
1✔
1140
        $statement = ($this->model)($query, $this->args);
1✔
1141

1142
        if ($method == 'COUNT' && $column) {
1✔
1143
            return $statement->pairs;
×
1144
        }
1145

1146
        return (int)$statement->rc;
1✔
1147
    }
1148

1149
    /**
1150
     * Implement the 'COUNT' computation.
1151
     *
1152
     * @param string|null $column The name of the column to count.
1153
     */
1154
    public function count(string $column = null): int|array
1155
    {
1156
        return $this->compute('COUNT', $column);
1✔
1157
    }
1158

1159
    /**
1160
     * Getter for the {@link $count} magic property.
1161
     *
1162
     * @return int
1163
     */
1164
    protected function get_count(): int
1165
    {
1166
        return $this->count();
×
1167
    }
1168

1169
    /**
1170
     * Implement the 'AVG' computation.
1171
     *
1172
     * @param string $column
1173
     *
1174
     * @return int
1175
     */
1176
    public function average(string $column)
1177
    {
1178
        return $this->compute('AVG', $column);
×
1179
    }
1180

1181
    /**
1182
     * Implement the 'MIN' computation.
1183
     *
1184
     * @param string $column
1185
     *
1186
     * @return mixed
1187
     */
1188
    public function minimum(string $column)
1189
    {
1190
        return $this->compute('MIN', $column);
×
1191
    }
1192

1193
    /**
1194
     * Implement the 'MAX' computation.
1195
     *
1196
     * @param string $column
1197
     *
1198
     * @return mixed
1199
     */
1200
    public function maximum(string $column)
1201
    {
1202
        return $this->compute('MAX', $column);
×
1203
    }
1204

1205
    /**
1206
     * Implement the 'SUM' computation.
1207
     *
1208
     * @param string $column
1209
     *
1210
     * @return mixed
1211
     */
1212
    public function sum(string $column)
1213
    {
1214
        return $this->compute('SUM', $column);
×
1215
    }
1216

1217
    /**
1218
     * Delete the records matching the conditions and limits of the query.
1219
     *
1220
     * @param string $tables When using a JOIN, `$tables` is used to specify the tables in which
1221
     * records should be deleted. Default: The alias of queried model, only if at least one join
1222
     * clause has been defined using the {@link join()} method.
1223
     *
1224
     * @return bool The result of the operation.
1225
     *
1226
     * @todo-20140901: reflect on join to add the required tables by default, discarding tables
1227
     * joined with the LEFT mode.
1228
     */
1229
    public function delete($tables = null)
1230
    {
1231
        if (!$tables && $this->joints) {
×
1232
            $tables = "`{alias}`";
×
1233
        }
1234

1235
        if ($tables) {
×
1236
            $query = "DELETE {$tables} FROM {self} AS `{alias}`";
×
1237
        } else {
1238
            $query = "DELETE FROM {self}";
×
1239
        }
1240

1241
        $query .= $this->render_main();
×
1242

1243
        return $this->model->execute($query, $this->args);
×
1244
    }
1245

1246
    /**
1247
     * Return an iterator for the query.
1248
     *
1249
     * @return Traversable<TRecord>
1250
     */
1251
    public function getIterator(): Traversable
1252
    {
1253
        return new \ArrayIterator($this->all());
×
1254
    }
1255
}
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

© 2026 Coveralls, Inc