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

ICanBoogie / ActiveRecord / 4542546258

pending completion
4542546258

push

github

Olivier Laviale
Add 'belongs_to' to the SchemaBuilder

29 of 29 new or added lines in 4 files covered. (100.0%)

1356 of 1726 relevant lines covered (78.56%)

36.14 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
        $as ??= $query->model->alias;
2✔
583
        $on ??= $query->model->primary;
2✔
584

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

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

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

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

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

622
            assert(is_array($primary) || is_string($primary));
623

624
            if (is_array($primary)) {
1✔
625
                foreach ($primary as $column) {
×
626
                    if ($model_schema->has_column($column)) {
×
627
                        return $column;
×
628
                    }
629
                }
630
            } elseif (!$model_schema->has_column($primary)) {
1✔
631
                $primary = $model_schema->primary;
1✔
632

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

638
            assert(is_string($primary));
639

640
            return $primary;
1✔
641
        }) ();
1✔
642

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

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

661
        $target = $this->model;
×
662

663
        while ($target) {
×
664
            if ($target->schema->has_column($column)) {
×
665
                break;
×
666
            }
667

668
            $target = $target->parent_model;
×
669
        }
670

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

829
        return $this;
30✔
830
    }
831

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

845
        return $this;
11✔
846
    }
847

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

859
        return $this;
4✔
860
    }
861

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

873
        $this->having = $having;
×
874
        $this->having_args = $having_args;
×
875

876
        return $this;
×
877
    }
878

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

890
        return $this;
2✔
891
    }
892

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

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

921
        $this->offset = (int)$offset;
10✔
922
        $this->limit = (int)$limit;
10✔
923

924
        return $this;
10✔
925
    }
926

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

940
        return $this;
3✔
941
    }
942

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

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

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

976
        return $statement;
27✔
977
    }
978

979
    /*
980
     * FINISHER
981
     */
982

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

1010
        return $args;
25✔
1011
    }
1012

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

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

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

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

1053
            $rc = $statement->pdo_statement->fetchObject(...$args);
14✔
1054

1055
            $statement->pdo_statement->closeCursor();
14✔
1056

1057
            return $rc;
14✔
1058
        }
1059

1060
        return $statement->one(...$args);
×
1061
    }
1062

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

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

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

1095
        $this->limit = 1;
3✔
1096

1097
        $statement = $this->query();
3✔
1098

1099
        $this->limit = $previous_limit;
3✔
1100

1101
        return $statement->rc;
3✔
1102
    }
1103

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

1123
        $query = clone $this;
7✔
1124

1125
        #
1126
        # Checking if the query matches any record.
1127
        #
1128

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

1136
        #
1137
        # Checking if the query matches the specified record keys.
1138
        #
1139

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

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

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

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

1159
            # all true
1160

1161
            return true;
1✔
1162
        }
1163

1164
        return !empty($rc);
3✔
1165
    }
1166

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

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

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

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

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

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

1210
        return (int)$statement->rc;
1✔
1211
    }
1212

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

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

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

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

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

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

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

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

1305
        $query .= $this->render_main();
×
1306

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

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