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

ICanBoogie / ActiveRecord / 4438473599

pending completion
4438473599

push

github

Olivier Laviale
Change Query::join() signature to enforce types

51 of 51 new or added lines in 3 files covered. (100.0%)

1357 of 1687 relevant lines covered (80.44%)

34.99 hits per line

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

82.42
/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 LogicException;
21
use ReflectionClass;
22
use ReflectionMethod;
23
use Traversable;
24

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

34
use const PHP_INT_MAX;
35

36
/**
37
 * The class offers many features to compose model queries. Most query related
38
 * methods of the {@link Model} class create a {@link Query} object that is returned for
39
 * further specification, such as filters or limits.
40
 *
41
 * @method Query and ($conditions, $conditions_args = null, $_ = null) Alias to where().
42
 *
43
 * @property-read array $all An array with all the records matching the query.
44
 * @property-read mixed $one The first record matching the query.
45
 * @property-read array $pairs An array of key/value pairs.
46
 * @property-read array $rc The first column of the first row matching the query.
47
 * @property-read int $count The number of records matching the query.
48
 * @property-read bool|array $exists `true` if a record matching the query exists, `false`
49
 * otherwise. If there is multiple records, the property is an array of booleans.
50
 *
51
 * @property-read Model $model The target model of the query.
52
 * @property-read array $joints The joints collection from {@link join()}.
53
 * @property-read array $joints_args The arguments to the joints.
54
 * @property-read array $conditions The conditions collected from {@link where()}, {@link and()},
55
 * `filter_by_*`, and scopes.
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
     * The conditions collected from {@link where()}, {@link and()}, `filter_by_*`, and scopes.
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);
34✔
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;
2✔
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
     * Adds support for model's scopes.
224
     *
225
     * @inheritdoc
226
     */
227
    public function __get($property)
228
    {
229
        $scopes = $this->get_model_scope();
36✔
230

231
        if (\in_array($property, $scopes)) {
36✔
232
            return $this->model->scope($property, [ $this ]);
×
233
        }
234

235
        return self::accessor_get($property);
36✔
236
    }
237

238
    /**
239
     * Override the method to handle magic 'filter_by_' methods.
240
     *
241
     * @inheritdoc
242
     */
243
    public function __call($method, $arguments)
244
    {
245
        if ($method === 'and') {
9✔
246
            return $this->where(...$arguments);
5✔
247
        }
248

249
        if (\strpos($method, 'filter_by_') === 0) {
5✔
250
            return $this->dynamic_filter(substr($method, 10), $arguments); // 10 is for: strlen('filter_by_')
3✔
251
        }
252

253
        $scopes = $this->get_model_scope();
2✔
254

255
        if (\in_array($method, $scopes)) {
2✔
256
            \array_unshift($arguments, $this);
1✔
257

258
            return $this->model->scope($method, $arguments);
1✔
259
        }
260

261
        try {
262
            return self::__prototype_call($method, $arguments);
1✔
263
        } catch (MethodNotDefined $e) {
1✔
264
            throw new ScopeNotDefined($method, $this->model, $e);
1✔
265
        }
266
    }
267

268
    /*
269
     * Rendering
270
     */
271

272
    /**
273
     * Convert the query into a string.
274
     *
275
     * @return string
276
     */
277
    public function __toString(): string
278
    {
279
        return $this->resolve_statement(
47✔
280
            $this->render_select() . ' ' .
47✔
281
            $this->render_from() .
47✔
282
            $this->render_main()
47✔
283
        );
47✔
284
    }
285

286
    /**
287
     * Render the `SELECT` clause.
288
     *
289
     * @return string
290
     */
291
    private function render_select(): string
292
    {
293
        return 'SELECT ' . ($this->select ? $this->select : '*');
47✔
294
    }
295

296
    /**
297
     * Render the `FROM` clause.
298
     *
299
     * The rendered `FROM` clause might include some JOINS too.
300
     *
301
     * @return string
302
     */
303
    private function render_from(): string
304
    {
305
        return 'FROM {self_and_related}';
48✔
306
    }
307

308
    /**
309
     * Renders the `JOIN` clauses.
310
     *
311
     * @return string
312
     */
313
    private function render_joints(): string
314
    {
315
        return \implode(' ', $this->joints);
7✔
316
    }
317

318
    /**
319
     * Render the main body of the query, without the `SELECT` and `FROM` clauses.
320
     *
321
     * @return string
322
     */
323
    private function render_main(): string
324
    {
325
        $query = '';
48✔
326

327
        if ($this->joints) {
48✔
328
            $query = ' ' . $this->render_joints();
7✔
329
        }
330

331
        $conditions = $this->conditions;
48✔
332

333
        if ($conditions) {
48✔
334
            $query .= ' WHERE ' . \implode(' AND ', $conditions);
27✔
335
        }
336

337
        $group = $this->group;
48✔
338

339
        if ($group) {
48✔
340
            $query .= ' GROUP BY ' . $group;
3✔
341

342
            $having = $this->having;
3✔
343

344
            if ($having) {
3✔
345
                $query .= ' HAVING ' . $having;
×
346
            }
347
        }
348

349
        $order = $this->order;
48✔
350

351
        if ($order) {
48✔
352
            $query .= ' ' . $this->render_order($order);
9✔
353
        }
354

355
        $offset = $this->offset;
48✔
356
        $limit = $this->limit;
48✔
357

358
        if ($offset || $limit) {
48✔
359
            $query .= ' ' . $this->render_offset_and_limit($offset, $limit);
20✔
360
        }
361

362
        return $query;
48✔
363
    }
364

365
    /**
366
     * Render the `ORDER` clause.
367
     */
368
    private function render_order(array $order): string
369
    {
370
        if (\count($order) == 1) {
9✔
371
            $raw = $order[0];
7✔
372
            $rendered = preg_replace(
7✔
373
                '/-([a-zA-Z0-9_]+)/',
7✔
374
                '$1 DESC',
7✔
375
                $raw
7✔
376
            );
7✔
377

378
            return 'ORDER BY ' . $rendered;
7✔
379
        }
380

381
        $connection = $this->model->connection;
2✔
382

383
        $field = \array_shift($order);
2✔
384
        $field_values = is_array($order[0]) ? $order[0] : $order;
2✔
385
        $field_values = \array_map(function ($v) use ($connection) {
2✔
386
            return $connection->pdo->quote($v);
2✔
387
        }, $field_values);
2✔
388

389
        return "ORDER BY FIELD($field, " . \implode(', ', $field_values) . ")";
2✔
390
    }
391

392
    /**
393
     * Render the `LIMIT` and `OFFSET` clauses.
394
     *
395
     * @param int $offset
396
     * @param int $limit
397
     *
398
     * @return string
399
     */
400
    private function render_offset_and_limit($offset, $limit): string
401
    {
402
        if ($offset && $limit) {
20✔
403
            return "LIMIT $offset, $limit";
1✔
404
        } else {
405
            if ($offset) {
19✔
406
                return "LIMIT $offset, " . self::LIMIT_MAX;
1✔
407
            } else {
408
                if ($limit) {
18✔
409
                    return "LIMIT $limit";
18✔
410
                }
411
            }
412
        }
413

414
        return '';
×
415
    }
416

417
    /*
418
     *
419
     */
420

421
    /**
422
     * Resolve the placeholders of a statement.
423
     *
424
     * Note: Currently, the method simply forwards the statement to the model's
425
     * resolve_statement() method.
426
     *
427
     * @param string $statement
428
     *
429
     * @return string
430
     */
431
    private function resolve_statement(string $statement): string
432
    {
433
        return $this->model->resolve_statement($statement);
47✔
434
    }
435

436
    /**
437
     * Cache available scopes by model class.
438
     *
439
     * @var array
440
     */
441
    private static $scopes_by_classes = [];
442

443
    /**
444
     * Return the available scopes for a model class.
445
     *
446
     * The method uses reflexion to find the scopes, the result is cached.
447
     *
448
     * @return array
449
     *
450
     * @throws \ReflectionException
451
     */
452
    private function get_model_scope()
453
    {
454
        $class = get_class($this->model);
37✔
455

456
        if (isset(self::$scopes_by_classes[$class])) {
37✔
457
            return self::$scopes_by_classes[$class];
36✔
458
        }
459

460
        $reflexion = new ReflectionClass($class);
2✔
461
        $methods = $reflexion->getMethods(ReflectionMethod::IS_PROTECTED);
2✔
462

463
        $scopes = [];
2✔
464

465
        foreach ($methods as $method) {
2✔
466
            $name = $method->name;
2✔
467

468
            if (!str_starts_with($name, 'scope_')) {
2✔
469
                continue;
2✔
470
            }
471

472
            $scopes[] = substr($name, 6);
1✔
473
        }
474

475
        return self::$scopes_by_classes[$class] = $scopes;
2✔
476
    }
477

478
    /**
479
     * Define the `SELECT` clause.
480
     *
481
     * @param string $expression The expression of the `SELECT` clause. e.g. 'nid, title'.
482
     *
483
     * @return $this
484
     */
485
    public function select($expression): self
486
    {
487
        $this->select = $expression;
15✔
488

489
        return $this;
15✔
490
    }
491

492
    /**
493
     * Add a `JOIN` clause.
494
     *
495
     * @param ?string $expression
496
     *     A raw `JOIN` clause.
497
     * @param ?Query<ActiveRecord> $query
498
     *     A {@link Query} instance, it is rendered as a string and used as a subquery of the `JOIN` clause.
499
     *     The `$options` parameter can be used to customize the output.
500
     * @param ?string $model_id
501
     *     A model identifier.
502
     * @param ?Model<int|string|string[], ActiveRecord> $model
503
     *     A model.
504
     * @param ?string $mode
505
     *     Join mode. Default: "INNER"
506
     * @param ?string $as
507
     *     The alias of the subquery. Default: The query's model alias.
508
     * @param ?string $on
509
     *     The column on which to joint is created. Default: The query's model primary key.
510
     *
511
     * <pre>
512
     * <?php
513
     *
514
     * # using a model identifier
515
     *
516
     * $query->join(model_id: 'nodes');
517
     *
518
     * # using a subquery
519
     *
520
     * $subquery = get_model('updates')
521
     * ->select('updated_at, $subscriber_id, update_hash')
522
     * ->order('updated_at DESC')
523
     *
524
     * $query->join(query: $subquery, on: 'subscriber_id');
525
     *
526
     * # using a raw clause
527
     *
528
     * $query->join(expression: "INNER JOIN `articles` USING(`nid`)");
529
     * </pre>
530
     */
531
    public function join(
532
        string $expression = null,
533
        Query $query = null,
534
        string $model_id = null,
535
        Model $model = null,
536
        string $mode = 'INNER',
537
        string $as = null,
538
        string $on = null,
539
    ): self {
540
        if ($expression) {
9✔
541
            $this->joints[] = $expression;
6✔
542

543
            return $this;
6✔
544
        }
545

546
        if ($query) {
3✔
547
            $this->join_with_query($query, mode: $mode, as: $as, on: $on);
2✔
548

549
            return $this;
2✔
550
        }
551

552
        if ($model_id) {
1✔
553
            $model = $this->model->models->model_for_id($model_id);
×
554
        }
555

556
        if (!$model) {
1✔
557
            throw new LogicException("One of [ expression, query, model_id, model ] needs to be defined");
×
558
        }
559

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

562
        return $this;
1✔
563
    }
564

565
    /**
566
     * Join a subquery to the query.
567
     *
568
     * @param Query<ActiveRecord> $query
569
     * @param string $mode
570
     *     Join mode. Default: "INNER".
571
     * @param ?string $as
572
     *     The alias of the subquery. Default: The query's model alias.
573
     * @param ?string $on
574
     *     The column on which the joint is created. Default: The query's model primary key.
575
     */
576
    private function join_with_query(
577
        Query $query,
578
        string $mode = 'INNER',
579
        string $as = null,
580
        string $on = null,
581
    ): void
582
    {
583
        $as ??= $query->model->alias;
2✔
584
        $on ??= $query->model->primary;
2✔
585

586
        if ($on) {
2✔
587
            assert(is_string($on));
588

589
            $on = $this->render_join_on($on, $as, $query);
2✔
590
        }
591

592
        if ($on) {
2✔
593
            $on = ' ' . $on;
2✔
594
        }
595

596
        $this->joints[] = "$mode JOIN($query) `$as`{$on}";
2✔
597
        $this->joints_args = array_merge($this->joints_args, $query->args);
2✔
598
    }
599

600
    /**
601
     * Join a model to the query.
602
     *
603
     * @param Model<int|string|string[], ActiveRecord> $model
604
     * @param string $mode
605
     *     Join mode.
606
     * @param ?string $as
607
     *     The alias of the model. Default: The model's alias.
608
     * @param ?string $on
609
     *     The column on which the joint is created, or an _ON_ expression. Default: The model's primary key. @todo
610
     */
611
    private function join_with_model(
612
        Model $model,
613
        string $mode = 'INNER',
614
        string $as = null,
615
        string $on = null,
616
    ): void {
617
        $as ??= $model->alias;
1✔
618
        $on ??= (function () use ($model): string {
1✔
619
            $primary = $this->model->primary;
1✔
620
            $model_schema = $model->extended_schema;
1✔
621

622
            if (is_array($primary)) {
1✔
623
                foreach ($primary as $column) {
×
624
                    if (isset($model_schema[$column])) {
×
625
                        return $column;
×
626
                    }
627
                }
628
            } elseif (empty($model_schema[$primary])) {
1✔
629
                $primary = $model_schema->primary;
1✔
630

631
                if (is_array($primary)) {
1✔
632
                    $primary = reset($primary);
×
633
                }
634
            }
635

636
            assert(is_string($primary));
637

638
            return $primary;
1✔
639
        }) ();
1✔
640

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

644
    /**
645
     * Render the `on` join option.
646
     *
647
     * The method tries to determine the best solution between `ON` and `USING`.
648
     *
649
     * @param string $column
650
     * @param string $as
651
     * @param Query<ActiveRecord> $query
652
     */
653
    private function render_join_on(string $column, string $as, Query $query): string
654
    {
655
        if (isset($query->model->schema[$column]) && isset($this->model->schema[$column])) {
2✔
656
            return "USING(`$column`)";
2✔
657
        }
658

659
        $target = $this->model;
×
660

661
        while ($target) {
×
662
            if (isset($target->schema[$column])) {
×
663
                break;
×
664
            }
665

666
            $target = $target->parent_model;
×
667
        }
668

669
        if (!$target) {
×
670
            throw new InvalidArgumentException("Unable to resolve column `$column` from model {$this->model->id}");
×
671
        }
672

673
        return "ON `$as`.`$column` = `{$target->alias}`.`$column`";
×
674
    }
675

676
    /**
677
     * Parse the conditions for the {@link where()} and {@link having()} methods.
678
     *
679
     * {@link \DateTimeInterface} conditions are converted to strings.
680
     *
681
     * @param $conditions_and_args
682
     *
683
     * @return array An array made of the condition string and its arguments.
684
     */
685
    private function deferred_parse_conditions(...$conditions_and_args): array
686
    {
687
        $conditions = \array_shift($conditions_and_args);
30✔
688
        $args = $conditions_and_args;
30✔
689

690
        if (is_array($conditions)) {
30✔
691
            $c = '';
25✔
692
            $conditions_args = [];
25✔
693

694
            foreach ($conditions as $column => $arg) {
25✔
695
                if (is_array($arg) || $arg instanceof self) {
25✔
696
                    $joined = '';
7✔
697

698
                    if (is_array($arg)) {
7✔
699
                        foreach ($arg as $value) {
7✔
700
                            $joined .= ',' . (\is_numeric($value) ? $value : $this->model->quote($value));
7✔
701
                        }
702

703
                        $joined = substr($joined, 1);
7✔
704
                    } else {
705
                        $joined = (string)$arg;
×
706
                        $conditions_args = array_merge($conditions_args, $arg->args);
×
707
                    }
708

709
                    $c .= ' AND `' . ($column[0] == '!' ? substr($column, 1) . '` NOT' : $column . '`')
7✔
710
                        . ' IN(' . $joined . ')';
7✔
711
                } else {
712
                    $conditions_args[] = $arg;
20✔
713

714
                    $c .= ' AND `' . ($column[0] == '!' ? substr($column, 1) . '` !' : $column . '` ')
20✔
715
                        . '= ?';
20✔
716
                }
717
            }
718

719
            $conditions = substr($c, 5);
25✔
720
        } else {
721
            $conditions_args = [];
8✔
722

723
            if ($args) {
8✔
724
                if (is_array($args[0])) {
5✔
725
                    $conditions_args = $args[0];
×
726
                } else {
727
                    #
728
                    # We dereference values otherwise the caller would get a corrupted array.
729
                    #
730

731
                    foreach ($args as $key => $value) {
5✔
732
                        $conditions_args[$key] = $value;
5✔
733
                    }
734
                }
735
            }
736
        }
737

738
        foreach ($conditions_args as &$value) {
30✔
739
            if ($value instanceof \DateTimeInterface) {
22✔
740
                $value = DateTime::from($value)->utc->as_db;
1✔
741
            }
742
        }
743

744
        return [ $conditions ? '(' . $conditions . ')' : null, $conditions_args ];
30✔
745
    }
746

747
    /**
748
     * Handles dynamic filters.
749
     *
750
     * @param string $filter
751
     * @param array $conditions_args
752
     *
753
     * @return $this
754
     */
755
    private function dynamic_filter(string $filter, array $conditions_args = []): self
756
    {
757
        $conditions = \explode('_and_', $filter);
3✔
758

759
        return $this->where(\array_combine($conditions, $conditions_args));
3✔
760
    }
761

762
    /**
763
     * Add conditions to the SQL statement.
764
     *
765
     * Conditions can either be specified as string or array.
766
     *
767
     * 1. Pure string conditions
768
     *
769
     * If you'de like to add conditions to your statement, you could just specify them in there,
770
     * just like `$model->where('order_count = 2');`. This will find all the entries, where the
771
     * `order_count` field's value is 2.
772
     *
773
     * 2. Array conditions
774
     *
775
     * Now what if that number could vary, say as an argument from somewhere, or perhaps from the
776
     * user’s level status somewhere? The find then becomes something like:
777
     *
778
     * `$model->where('order_count = ?', 2);`
779
     *
780
     * or
781
     *
782
     * `$model->where([ 'order_count' => 2 ]);`
783
     *
784
     * Or if you want to specify two conditions, you can do it like:
785
     *
786
     * `$model->where('order_count = ? AND locked = ?', 2, false);`
787
     *
788
     * or
789
     *
790
     * `$model->where([ 'order_count' => 2, 'locked' => false ]);`
791
     *
792
     * Or if you want to specify subset conditions:
793
     *
794
     * `$model->where([ 'order_id' => [ 123, 456, 789 ] ]);`
795
     *
796
     * This will return the orders with the `order_id` 123, 456 or 789.
797
     *
798
     * 3. Modifiers
799
     *
800
     * When using the "identifier" => "value" notation, you can switch the comparison method by
801
     * prefixing the identifier with a bang "!"
802
     *
803
     * `$model->where([ '!order_id' => [ 123, 456, 789 ]]);`
804
     *
805
     * This will return the orders with the `order_id` different than 123, 456 and 789.
806
     *
807
     * `$model->where([ '!order_count' => 2 ];`
808
     *
809
     * This will return the orders with the `order_count` different than 2.
810
     *
811
     * @param mixed ...$conditions_and_args
812
     *
813
     * @return $this
814
     */
815
    public function where(...$conditions_and_args): self
816
    {
817
        [ $conditions, $conditions_args ] = $this->deferred_parse_conditions(...$conditions_and_args);
30✔
818

819
        if ($conditions) {
30✔
820
            $this->conditions[] = $conditions;
30✔
821

822
            if ($conditions_args) {
30✔
823
                $this->conditions_args = array_merge($this->conditions_args, $conditions_args);
22✔
824
            }
825
        }
826

827
        return $this;
30✔
828
    }
829

830
    /**
831
     * Defines the `ORDER` clause.
832
     *
833
     * @param string $order_or_field_name The order for the `ORDER` clause e.g.
834
     * 'weight, date DESC', or field to order with, in which case `$field_values` is required.
835
     * @param array $field_values Values of the field specified by `$order_or_field_name`.
836
     *
837
     * @return $this
838
     */
839
    public function order($order_or_field_name, $field_values = null)
840
    {
841
        $this->order = func_get_args();
11✔
842

843
        return $this;
11✔
844
    }
845

846
    /**
847
     * Defines the `GROUP` clause.
848
     *
849
     * @param string $group
850
     *
851
     * @return $this
852
     */
853
    public function group($group)
854
    {
855
        $this->group = $group;
4✔
856

857
        return $this;
4✔
858
    }
859

860
    /**
861
     * Defines the `HAVING` clause.
862
     *
863
     * @param mixed ...$conditions_and_args
864
     *
865
     * @return $this
866
     */
867
    public function having(...$conditions_and_args)
868
    {
869
        list($having, $having_args) = $this->deferred_parse_conditions(...$conditions_and_args);
×
870

871
        $this->having = $having;
×
872
        $this->having_args = $having_args;
×
873

874
        return $this;
×
875
    }
876

877
    /**
878
     * Define the offset of the `LIMIT` clause.
879
     *
880
     * @param $offset
881
     *
882
     * @return $this
883
     */
884
    public function offset($offset)
885
    {
886
        $this->offset = (int)$offset;
2✔
887

888
        return $this;
2✔
889
    }
890

891
    /**
892
     * Apply the limit and/or offset to the SQL fired.
893
     *
894
     * You can use the limit to specify the number of records to be retrieved, ad use the offset to
895
     * specify the number of records to skip before starting to return records:
896
     *
897
     *     $model->limit(10);
898
     *
899
     * Will return a maximum of 10 clients and because ti specifies no offset it will return the
900
     * first 10 in the table:
901
     *
902
     *     $model->limit(5, 10);
903
     *
904
     * Will return a maximum of 10 clients beginning with the 5th.
905
     *
906
     * @param int $limit
907
     *
908
     * @return $this
909
     */
910
    public function limit($limit)
911
    {
912
        $offset = null;
10✔
913

914
        if (\func_num_args() == 2) {
10✔
915
            $offset = $limit;
5✔
916
            $limit = \func_get_arg(1);
5✔
917
        }
918

919
        $this->offset = (int)$offset;
10✔
920
        $this->limit = (int)$limit;
10✔
921

922
        return $this;
10✔
923
    }
924

925
    /**
926
     * Set the fetch mode for the query.
927
     *
928
     * @param mixed ...$mode
929
     *
930
     * @return $this
931
     *
932
     * @see http://www.php.net/manual/en/pdostatement.setfetchmode.php
933
     */
934
    public function mode(...$mode): self
935
    {
936
        $this->mode = $mode;
3✔
937

938
        return $this;
3✔
939
    }
940

941
    /**
942
     * Prepare the query.
943
     *
944
     * We use the connection's prepare() method because the statement has already been resolved
945
     * during the __toString() method and we don't want for the statement to be parsed twice.
946
     *
947
     * @return Statement
948
     */
949
    private function prepare(): Statement
950
    {
951
        return $this->model->connection->prepare((string)$this);
27✔
952
    }
953

954
    /**
955
     * Return a prepared query.
956
     *
957
     * @return Statement
958
     */
959
    protected function get_prepared(): Statement
960
    {
961
        return $this->prepare();
×
962
    }
963

964
    /**
965
     * Prepare and executes the query.
966
     *
967
     * @return Statement
968
     */
969
    public function query(): Statement
970
    {
971
        $statement = $this->prepare();
27✔
972
        $statement->execute($this->args);
27✔
973

974
        return $statement;
27✔
975
    }
976

977
    /*
978
     * FINISHER
979
     */
980

981
    /**
982
     * Resolves fetch mode.
983
     *
984
     * @param mixed ...$mode
985
     *
986
     * @return array
987
     */
988
    private function resolve_fetch_mode(...$mode): array
989
    {
990
        if ($mode) {
25✔
991
            $args = $mode;
4✔
992
        } else {
993
            if ($this->mode) {
21✔
994
                $args = $this->mode;
1✔
995
            } else {
996
                if ($this->select) {
21✔
997
                    $args = [ \PDO::FETCH_ASSOC ];
×
998
                } else {
999
                    if ($this->model->activerecord_class) {
21✔
1000
                        $args = [ \PDO::FETCH_CLASS, $this->model->activerecord_class, [ $this->model ] ];
21✔
1001
                    } else {
1002
                        $args = [ \PDO::FETCH_CLASS, ActiveRecord::class, [ $this->model ] ];
×
1003
                    }
1004
                }
1005
            }
1006
        }
1007

1008
        return $args;
25✔
1009
    }
1010

1011
    /**
1012
     * Execute the query and returns an array of records.
1013
     *
1014
     * @param mixed ...$mode Fetch mode.
1015
     *
1016
     * @return array
1017
     */
1018
    public function all(...$mode): array
1019
    {
1020
        return $this->query()->pdo_statement->fetchAll(...$this->resolve_fetch_mode(...$mode));
13✔
1021
    }
1022

1023
    /**
1024
     * Getter for the {@link $all} magic property.
1025
     *
1026
     * @return array
1027
     */
1028
    protected function get_all(): array
1029
    {
1030
        return $this->all();
6✔
1031
    }
1032

1033
    /**
1034
     * Return the first result of the query and close the cursor.
1035
     *
1036
     * @param mixed ...$mode Fetch node.
1037
     *
1038
     * @return mixed The return value of this function on success depends on the fetch mode. In
1039
     * all cases, FALSE is returned on failure.
1040
     */
1041
    public function one(...$mode)
1042
    {
1043
        $query = clone $this;
14✔
1044
        $query->limit = 1;
14✔
1045
        $statement = $query->query();
14✔
1046
        $args = $query->resolve_fetch_mode(...$mode);
14✔
1047

1048
        if (\count($args) > 1 && $args[0] == \PDO::FETCH_CLASS) {
14✔
1049
            \array_shift($args);
14✔
1050

1051
            $rc = $statement->pdo_statement->fetchObject(...$args);
14✔
1052

1053
            $statement->pdo_statement->closeCursor();
14✔
1054

1055
            return $rc;
14✔
1056
        }
1057

1058
        return $statement->one(...$args);
×
1059
    }
1060

1061
    /**
1062
     * Getter for the {@link $one} magic property.
1063
     *
1064
     * @return mixed
1065
     *
1066
     * @see one()
1067
     */
1068
    protected function get_one()
1069
    {
1070
        return $this->one();
12✔
1071
    }
1072

1073
    /**
1074
     * Execute que query and return an array of key/value pairs, where the key is the value of
1075
     * the first column and the value of the key the value of the second column.
1076
     *
1077
     * @return array
1078
     */
1079
    protected function get_pairs(): array
1080
    {
1081
        return $this->all(\PDO::FETCH_KEY_PAIR);
×
1082
    }
1083

1084
    /**
1085
     * Return the value of the first column of the first row.
1086
     *
1087
     * @return mixed
1088
     */
1089
    protected function get_rc()
1090
    {
1091
        $previous_limit = $this->limit;
3✔
1092

1093
        $this->limit = 1;
3✔
1094

1095
        $statement = $this->query();
3✔
1096

1097
        $this->limit = $previous_limit;
3✔
1098

1099
        return $statement->rc;
3✔
1100
    }
1101

1102
    /**
1103
     * Check the existence of records in the model.
1104
     *
1105
     * $model->exists;
1106
     * $model->where('name = "max"')->exists;
1107
     * $model->exists(1);
1108
     * $model->exists(1, 2);
1109
     * $model->exists([ 1, 2 ]);
1110
     *
1111
     * @param mixed $key
1112
     *
1113
     * @return bool|array
1114
     */
1115
    public function exists($key = null)
1116
    {
1117
        if ($key !== null && \func_num_args() > 1) {
7✔
1118
            $key = \func_get_args();
3✔
1119
        }
1120

1121
        $query = clone $this;
7✔
1122

1123
        #
1124
        # Checking if the query matches any record.
1125
        #
1126

1127
        if ($key === null) {
7✔
1128
            return !!$query
3✔
1129
                ->select('1')
3✔
1130
                ->limit(1)
3✔
1131
                ->rc;
3✔
1132
        }
1133

1134
        #
1135
        # Checking if the query matches the specified record keys.
1136
        #
1137

1138
        $rc = $query
4✔
1139
            ->select('`{primary}`')
4✔
1140
            ->and([ '{primary}' => $key ])
4✔
1141
            ->limit(0, 0)
4✔
1142
            ->all(\PDO::FETCH_COLUMN);
4✔
1143

1144
        if ($rc && is_array($key)) {
4✔
1145
            $exists = \array_combine($key, \array_fill(0, \count($key), false));
2✔
1146

1147
            foreach ($rc as $key) {
2✔
1148
                $exists[$key] = true;
2✔
1149
            }
1150

1151
            foreach ($exists as $v) {
2✔
1152
                if (!$v) {
2✔
1153
                    return $exists;
1✔
1154
                }
1155
            }
1156

1157
            # all true
1158

1159
            return true;
1✔
1160
        }
1161

1162
        return !empty($rc);
3✔
1163
    }
1164

1165
    /**
1166
     * Getter for the {@link $exists} magic property.
1167
     *
1168
     * @return bool|array
1169
     *
1170
     * @see exists()
1171
     */
1172
    protected function get_exists()
1173
    {
1174
        return $this->exists();
2✔
1175
    }
1176

1177
    /**
1178
     * Handle all the computations.
1179
     *
1180
     * @param string $method
1181
     * @param string|null $column
1182
     *
1183
     * @return int|array
1184
     */
1185
    private function compute(string $method, string $column = null)
1186
    {
1187
        $query = 'SELECT ';
1✔
1188

1189
        if ($column) {
1✔
1190
            if ($method == 'COUNT') {
×
1191
                $query .= "`$column`, $method(`$column`)";
×
1192

1193
                $this->group($column);
×
1194
            } else {
1195
                $query .= "$method(`$column`)";
×
1196
            }
1197
        } else {
1198
            $query .= $method . '(*)';
1✔
1199
        }
1200

1201
        $query .= ' AS count ' . $this->render_from() . $this->render_main();
1✔
1202
        $statement = ($this->model)($query, $this->args);
1✔
1203

1204
        if ($method == 'COUNT' && $column) {
1✔
1205
            return $statement->pairs;
×
1206
        }
1207

1208
        return (int)$statement->rc;
1✔
1209
    }
1210

1211
    /**
1212
     * Implement the 'COUNT' computation.
1213
     *
1214
     * @param string|null $column The name of the column to count.
1215
     */
1216
    public function count(string $column = null): int|array
1217
    {
1218
        return $this->compute('COUNT', $column);
1✔
1219
    }
1220

1221
    /**
1222
     * Getter for the {@link $count} magic property.
1223
     *
1224
     * @return int
1225
     */
1226
    protected function get_count(): int
1227
    {
1228
        return $this->count();
×
1229
    }
1230

1231
    /**
1232
     * Implement the 'AVG' computation.
1233
     *
1234
     * @param string $column
1235
     *
1236
     * @return int
1237
     */
1238
    public function average(string $column)
1239
    {
1240
        return $this->compute('AVG', $column);
×
1241
    }
1242

1243
    /**
1244
     * Implement the 'MIN' computation.
1245
     *
1246
     * @param string $column
1247
     *
1248
     * @return mixed
1249
     */
1250
    public function minimum(string $column)
1251
    {
1252
        return $this->compute('MIN', $column);
×
1253
    }
1254

1255
    /**
1256
     * Implement the 'MAX' computation.
1257
     *
1258
     * @param string $column
1259
     *
1260
     * @return mixed
1261
     */
1262
    public function maximum(string $column)
1263
    {
1264
        return $this->compute('MAX', $column);
×
1265
    }
1266

1267
    /**
1268
     * Implement the 'SUM' computation.
1269
     *
1270
     * @param string $column
1271
     *
1272
     * @return mixed
1273
     */
1274
    public function sum(string $column)
1275
    {
1276
        return $this->compute('SUM', $column);
×
1277
    }
1278

1279
    /**
1280
     * Delete the records matching the conditions and limits of the query.
1281
     *
1282
     * @param string $tables When using a JOIN, `$tables` is used to specify the tables in which
1283
     * records should be deleted. Default: The alias of queried model, only if at least one join
1284
     * clause has been defined using the {@link join()} method.
1285
     *
1286
     * @return bool The result of the operation.
1287
     *
1288
     * @todo-20140901: reflect on join to add the required tables by default, discarding tables
1289
     * joined with the LEFT mode.
1290
     */
1291
    public function delete($tables = null)
1292
    {
1293
        if (!$tables && $this->joints) {
×
1294
            $tables = "`{alias}`";
×
1295
        }
1296

1297
        if ($tables) {
×
1298
            $query = "DELETE {$tables} FROM {self} AS `{alias}`";
×
1299
        } else {
1300
            $query = "DELETE FROM {self}";
×
1301
        }
1302

1303
        $query .= $this->render_main();
×
1304

1305
        return $this->model->execute($query, $this->args);
×
1306
    }
1307

1308
    /**
1309
     * Return an iterator for the query.
1310
     *
1311
     * @return Traversable<TRecord>
1312
     */
1313
    public function getIterator(): Traversable
1314
    {
1315
        return new \ArrayIterator($this->all());
×
1316
    }
1317
}
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