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

visavi / motor-orm / 30439539820

29 Jul 2026 09:19AM UTC coverage: 98.938% (-0.5%) from 99.407%
30439539820

push

github

visavi
Добавлено замыкание в with() и чтение страницы пагинации из запроса

    В with() можно передать замыкание, сужающее связь на одну загрузку: имя
    ключом, замыкание значением, обычные имена лежат в том же списке.

        Story::query()->with([
            user,
            comments => static fn (Query $query) => $query->where(approved, 1),
        ])->get();

    constrain() остаётся: он описывает связь, которая всегда такая, и работает
    при ленивом чтении. Когда есть оба, условия складываются.

    Из paginate() и simplePaginate() убран номер страницы, вместо него добавлен
    Query::page().

        Article::query()->paginate(10);           // ?page= из запроса
        Article::query()->page(3)->paginate(10);  // сказано прямо

    В PagedCollection добавлены статические resolvePageUsing(),
    resolveCurrentPage() и pageName(), а setPageName() стал статическим:
    страницу надо знать до того, как появится сама страница строк. По
    умолчанию страница читается из $_GET, resolvePageUsing() подменяет
    источник для сред, где $_GET не заполняется.

    Ломающее: второй аргумент paginate() и simplePaginate() убран совсем,
    setPageName() больше не вызывается на объекте.

39 of 44 new or added lines in 3 files covered. (88.64%)

1025 of 1036 relevant lines covered (98.94%)

27.84 hits per line

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

98.44
/src/Query.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace MotorORM;
6

7
use ArrayIterator;
8
use BadMethodCallException;
9
use CallbackFilterIterator;
10
use Closure;
11
use Generator;
12
use InvalidArgumentException;
13
use Iterator;
14
use LimitIterator;
15
use RuntimeException;
16

17
/**
18
 * Query over a table
19
 *
20
 * @license Code and contributions have MIT License
21
 * @link    https://visavi.net
22
 * @author  Alexander Grigorev <admin@visavi.net>
23
 */
24
final class Query
25
{
26
    /**
27
     * @param Model $model the table being queried
28
     */
29
    public function __construct(private readonly Model $model)
166✔
30
    {
31
        $this->table      = new Table($model);
166✔
32
        $this->mapper     = new RecordMapper($model, $this->table);
166✔
33
        $this->conditions = new Conditions();
166✔
34
        $this->sorter     = new Sorter($this->table);
166✔
35
        $this->writer     = new TableWriter($this->table, $this->mapper, $this->conditions);
166✔
36
    }
37

38
    /** The file the model stands for */
39
    private readonly Table $table;
40

41
    /** Rows in, values out */
42
    private readonly RecordMapper $mapper;
43

44
    /** What the rows have to satisfy */
45
    private readonly Conditions $conditions;
46

47
    /** The order the rows come in */
48
    private readonly Sorter $sorter;
49

50
    /** Everything the query does to the table */
51
    private readonly TableWriter $writer;
52

53
    private int $offset = 0;
54
    private int $limit = -1;
55

56
    /** The page to paginate, taken from the request when it is not spelled out */
57
    private ?int $page = null;
58

59
    private array $with = [];
60

61
    /**
62
     * Get headers
63
     *
64
     * @return array
65
     */
66
    public function headers(): array
66✔
67
    {
68
        return $this->table->headers();
66✔
69
    }
70

71
    /**
72
     * Get primary key
73
     *
74
     * @return string|null
75
     */
76
    public function getPrimaryKey(): ?string
60✔
77
    {
78
        return $this->headers()[0] ?? null;
60✔
79
    }
80

81
    /**
82
     * The rows this query asks for, filtered and in order
83
     *
84
     * @param int|null $take how many rows from the head are going to be read,
85
     *                       null when that is not known
86
     *
87
     * @return Iterator
88
     */
89
    private function pipeline(?int $take = null): Iterator
133✔
90
    {
91
        return $this->sorter->sort($this->filtering($this->table->records()), $take);
133✔
92
    }
93

94
    /**
95
     * The rows of a read, from the one to skip to to the last one wanted
96
     *
97
     * LimitIterator knows a limit of -1 as no limit at all, but has no notion
98
     * of a read of nothing, and asking it for one is an error rather than an
99
     * empty answer
100
     *
101
     * @param Iterator $iterator
102
     * @param int      $offset
103
     * @param int      $limit
104
     *
105
     * @return Iterator
106
     */
107
    private function limited(Iterator $iterator, int $offset, int $limit): Iterator
91✔
108
    {
109
        if ($limit === 0) {
91✔
110
            return new ArrayIterator();
2✔
111
        }
112

113
        return new LimitIterator($iterator, $offset, $limit);
89✔
114
    }
115

116
    /**
117
     * How many sorted rows a read of this query comes down to
118
     *
119
     * @param int $offset
120
     * @param int $limit
121
     *
122
     * @return int|null null when the read has no end
123
     */
124
    private function take(int $offset, int $limit): ?int
82✔
125
    {
126
        return $limit < 0 ? null : $offset + $limit;
82✔
127
    }
128

129
    /**
130
     * Get the default foreign key name for the model.
131
     *
132
     * @return string
133
     */
134
    public function getForeignKey(): string
20✔
135
    {
136
        $className = basename(str_replace('\\', '/', $this->model::class));
20✔
137
        $model = strtolower(preg_replace('/(.)(?=[A-Z])/u', '$1_', $className));
20✔
138

139
        return  $model . '_' . $this->getPrimaryKey();
20✔
140
    }
141

142
    /**
143
     * Where
144
     *
145
     * @param Closure|string $field
146
     * @param mixed          $condition
147
     * @param mixed          $value
148
     * @param string         $operator
149
     *
150
     * @return $this
151
     */
152
    public function where(
84✔
153
        Closure|string $field,
154
        mixed $condition = null,
155
        mixed $value = null,
156
        string $operator = 'and'
157
    ): self {
158
        if ($field instanceof Closure) {
84✔
159
            /* Only the collected conditions are needed, so the table stays closed */
160
            $field($builder = new self($this->model));
1✔
161

162
            $this->conditions->group($operator, $builder->conditions);
1✔
163
        } else {
164
            if (func_num_args() === 2) {
84✔
165
                $value     = $condition;
73✔
166
                $condition = '=';
73✔
167
            }
168

169
            $this->conditions->compare($operator, $field, $condition, $value);
84✔
170
        }
171

172
        return $this;
83✔
173
    }
174

175
    /**
176
     * Or where
177
     *
178
     * @param Closure|string $field
179
     * @param mixed|null     $condition
180
     * @param mixed|null     $value
181
     *
182
     * @return $this
183
     */
184
    public function orWhere(Closure|string $field, mixed $condition = null, mixed $value = null): self
3✔
185
    {
186
        if ($field instanceof Closure) {
3✔
187
            /* Only the collected conditions are needed, so the table stays closed */
188
            $field($builder = new self($this->model));
1✔
189

190
            $this->conditions->group('or', $builder->conditions);
1✔
191
        } else {
192
            if (func_num_args() === 2) {
2✔
193
                $value     = $condition;
2✔
194
                $condition = '=';
2✔
195
            }
196

197
            $this->where($field, $condition, $value, 'or');
2✔
198
        }
199

200
        return $this;
3✔
201
    }
202

203
    /**
204
     * Where the value matches a pattern
205
     *
206
     * A leading or trailing % says the value may go on there, a pattern
207
     * without either has to match the whole value. The case is ignored
208
     * unless caseSensitive says otherwise
209
     *
210
     * @param string $field
211
     * @param string $value
212
     * @param bool   $caseSensitive
213
     *
214
     * @return $this
215
     */
216
    public function whereLike(string $field, string $value, bool $caseSensitive = false): self
4✔
217
    {
218
        return $this->like($field, $value, $caseSensitive, false, 'and');
4✔
219
    }
220

221
    /**
222
     * Where the value does not match a pattern
223
     *
224
     * @param string $field
225
     * @param string $value
226
     * @param bool   $caseSensitive
227
     *
228
     * @return $this
229
     */
230
    public function whereNotLike(string $field, string $value, bool $caseSensitive = false): self
1✔
231
    {
232
        return $this->like($field, $value, $caseSensitive, true, 'and');
1✔
233
    }
234

235
    /**
236
     * Or where the value matches a pattern
237
     *
238
     * @param string $field
239
     * @param string $value
240
     * @param bool   $caseSensitive
241
     *
242
     * @return $this
243
     */
244
    public function orWhereLike(string $field, string $value, bool $caseSensitive = false): self
1✔
245
    {
246
        return $this->like($field, $value, $caseSensitive, false, 'or');
1✔
247
    }
248

249
    /**
250
     * Or where the value does not match a pattern
251
     *
252
     * @param string $field
253
     * @param string $value
254
     * @param bool   $caseSensitive
255
     *
256
     * @return $this
257
     */
258
    public function orWhereNotLike(string $field, string $value, bool $caseSensitive = false): self
1✔
259
    {
260
        return $this->like($field, $value, $caseSensitive, true, 'or');
1✔
261
    }
262

263
    /**
264
     * Collect a pattern condition
265
     *
266
     * @param string $field
267
     * @param string $value
268
     * @param bool   $caseSensitive
269
     * @param bool   $not
270
     * @param string $operator
271
     *
272
     * @return $this
273
     */
274
    private function like(string $field, string $value, bool $caseSensitive, bool $not, string $operator): self
7✔
275
    {
276
        $this->conditions->pattern($operator, $field, $value, $caseSensitive, $not);
7✔
277

278
        return $this;
7✔
279
    }
280

281
    /**
282
     * Where in
283
     *
284
     * @param string $field
285
     * @param array  $values
286
     * @param string $operator
287
     *
288
     * @return $this
289
     */
290
    public function whereIn(string $field, array $values, string $operator = 'and'): self
30✔
291
    {
292
        $this->conditions->set($operator, $field, $values, false);
30✔
293

294
        return $this;
30✔
295
    }
296

297
    /**
298
     * Where not in
299
     *
300
     * @param string $field
301
     * @param array  $values
302
     * @param string $operator
303
     *
304
     * @return $this
305
     */
306
    public function whereNotIn(string $field, array $values, string $operator = 'and'): self
2✔
307
    {
308
        $this->conditions->set($operator, $field, $values, true);
2✔
309

310
        return $this;
2✔
311
    }
312

313
    /**
314
     * Sorting by asc
315
     *
316
     * @param string $field
317
     * @param SortOrder $sort
318
     *
319
     * @return $this
320
     */
321
    public function orderBy(string $field, SortOrder $sort = SortOrder::Asc): self
5✔
322
    {
323
        $this->sorter->by($field, $sort);
5✔
324

325
        return $this;
5✔
326
    }
327

328
    /**
329
     * Sorting by desc
330
     *
331
     * @param string $field
332
     *
333
     * @return $this
334
     */
335
    public function orderByDesc(string $field): self
11✔
336
    {
337
        $this->sorter->by($field, SortOrder::Desc);
11✔
338

339
        return $this;
11✔
340
    }
341

342
    /**
343
     * Get field by primary key
344
     *
345
     * @param int|string $id
346
     *
347
     * @return Record|null
348
     */
349
    public function find(int|string $id): ?Record
47✔
350
    {
351
        return $this->where($this->getPrimaryKey(), $id)->first();
47✔
352
    }
353

354
    /**
355
     * Get first record
356
     *
357
     * @return Record|null
358
     */
359
    public function first(): ?Record
55✔
360
    {
361
        /* The first row of the read, and a read may be told where it starts */
362
        $iterator = new LimitIterator($this->pipeline($this->offset + 1), $this->offset, 1);
55✔
363
        $iterator->rewind();
55✔
364

365
        /* Reading the first match is enough, counting the whole table is not */
366
        if (! $iterator->valid()) {
55✔
367
            return null;
5✔
368
        }
369

370
        $record = new Record($this, $this->mapper->read($iterator->current()));
51✔
371

372
        /* A record read on its own has no siblings, its relations load for itself */
373
        foreach ($this->with as $with => $constraint) {
50✔
374
            $this->loadRelation([$record], $with, $constraint);
4✔
375
        }
376

377
        return $record;
50✔
378
    }
379

380
    /**
381
     * Exists record
382
     *
383
     * @return bool
384
     */
385
    public function exists(): bool
2✔
386
    {
387
        $iterator = $this->filtering($this->table->records());
2✔
388
        $iterator->rewind();
2✔
389

390
        /* One match settles it, counting the rest is wasted work */
391
        return $iterator->valid();
2✔
392
    }
393

394
    /**
395
     * Get records
396
     *
397
     * @return Collection<static>
398
     */
399
    public function get(): Collection
78✔
400
    {
401
        $iterator = $this->limited($this->pipeline($this->take($this->offset, $this->limit)), $this->offset, $this->limit);
78✔
402

403
        return new Collection($this->hydrate($iterator));
76✔
404
    }
405

406
    /**
407
     * Walk the matching records one at a time
408
     *
409
     * Only the record being looked at is held in memory, so a table of any
410
     * size can be walked. Nothing is collected, so there are no siblings to
411
     * load a relation for: touching one inside the loop reads the related
412
     * table once per record, and with() has nothing to attach to
413
     *
414
     * @return Generator<Record>
415
     */
416
    public function cursor(): Generator
5✔
417
    {
418
        $iterator = $this->limited($this->pipeline($this->take($this->offset, $this->limit)), $this->offset, $this->limit);
5✔
419

420
        $reader = $this->mapper->reader();
5✔
421

422
        foreach ($iterator as $line) {
5✔
423
            yield new Record($this, $reader($line));
3✔
424
        }
425
    }
426

427
    /**
428
     * Get records with paginate
429
     *
430
     * The page is the one page() was told, or the one being asked for
431
     *
432
     * @param int $limit
433
     *
434
     * @return Pagination<static>
435
     */
436
    public function paginate(int $limit = 10): Pagination
11✔
437
    {
438
        $total = $this->count();
11✔
439

440
        /* Which rows to skip has to be known before they are read */
441
        $page   = $this->page ?? Pagination::resolveCurrentPage();
11✔
442
        $page   = min($page, Pagination::lastPageOf($total, $limit));
11✔
443
        $offset = $page * $limit - $limit;
11✔
444

445
        $iterator = $this->limited($this->pipeline($offset + $limit), $offset, $limit);
11✔
446

447
        return new Pagination($this->hydrate($iterator), $total, $limit, $page);
11✔
448
    }
449

450
    /**
451
     * Get records with paginate, without counting the table
452
     *
453
     * One row past the page is read to know whether another page follows. That
454
     * is the whole difference from paginate(): there are no page numbers and no
455
     * total, and the table is never read to the end to find them out
456
     *
457
     * @param int $limit
458
     *
459
     * @return SimplePagination<static>
460
     */
461
    public function simplePaginate(int $limit = 10): SimplePagination
7✔
462
    {
463
        /* Nothing counted the rows, so there is no last page to keep within */
464
        $page   = $this->page ?? SimplePagination::resolveCurrentPage();
7✔
465
        $offset = $page * $limit - $limit;
7✔
466

467
        $iterator = new LimitIterator($this->pipeline($offset + $limit + 1), $offset, $limit + 1);
7✔
468

469
        $records = $this->hydrate($iterator);
7✔
470
        $hasMore = count($records) > $limit;
7✔
471

472
        if ($hasMore) {
7✔
473
            /* The row that told us was never part of the page */
474
            array_pop($records);
5✔
475

476
            $this->siblings($records);
5✔
477
        }
478

479
        return new SimplePagination($records, $limit, $page, $hasMore);
7✔
480
    }
481

482
    /**
483
     * Get count records
484
     *
485
     * @return int
486
     */
487
    public function count(): int
21✔
488
    {
489
        return iterator_count($this->filtering($this->table->records()));
21✔
490
    }
491

492
    /**
493
     * Set limit
494
     *
495
     * @param int $limit
496
     *
497
     * @return $this
498
     */
499
    public function limit(int $limit): self
17✔
500
    {
501
        if ($limit < -1) {
17✔
502
            throw new InvalidArgumentException(sprintf('%s() expects the limit to be greater or equal to -1, %s given', __METHOD__, $limit));
1✔
503
        }
504

505
        if ($limit === $this->limit) {
16✔
506
            return $this;
1✔
507
        }
508

509
        $this->limit = $limit;
16✔
510

511
        return $this;
16✔
512
    }
513

514
    /**
515
     * Set the page to paginate
516
     *
517
     * Says outright which page paginate() and simplePaginate() are to read,
518
     * instead of letting them ask where the current page comes from
519
     *
520
     * @param int $page never below the first page
521
     *
522
     * @return $this
523
     */
524
    public function page(int $page): self
6✔
525
    {
526
        $this->page = max(1, $page);
6✔
527

528
        return $this;
6✔
529
    }
530

531
    /**
532
     * Set offset
533
     *
534
     * @param int $offset
535
     *
536
     * @return $this
537
     */
538
    public function offset(int $offset): self
4✔
539
    {
540
        if ($offset < 0) {
4✔
541
            throw new InvalidArgumentException(sprintf('%s() expects the offset to be a positive integer or 0, %s given', __METHOD__, $offset));
1✔
542
        }
543

544
        if ($this->offset === $offset) {
3✔
545
            return $this;
2✔
546
        }
547

548
        $this->offset = $offset;
2✔
549

550
        return $this;
2✔
551
    }
552

553
    /**
554
     * Create record
555
     *
556
     * @param array $values
557
     *
558
     * @return Record
559
     */
560
    public function create(array $values): Record
24✔
561
    {
562
        return new Record($this, $this->writer->insert($values));
24✔
563
    }
564

565
    /**
566
     * Write the values of a record back to its row
567
     *
568
     * @param array $attr column name => value, including the primary key
569
     *
570
     * @return bool whether the row was found
571
     */
572
    public function save(array $attr): bool
2✔
573
    {
574
        return $this->writer->save($attr);
2✔
575
    }
576

577
    /**
578
     * Update records
579
     *
580
     * @param array $values
581
     *
582
     * @return int affected rows
583
     */
584
    public function update(array $values): int
7✔
585
    {
586
        return $this->writer->update($values);
7✔
587
    }
588

589
    /**
590
     * Delete records
591
     *
592
     * @return int affected rows
593
     */
594
    public function delete(): int
4✔
595
    {
596
        return $this->writer->delete();
4✔
597
    }
598

599
    /**
600
     * Truncate file
601
     *
602
     * @return bool
603
     */
604
    public function truncate(): bool
51✔
605
    {
606
        $this->writer->truncate();
51✔
607

608
        return true;
51✔
609
    }
610

611
    /**
612
     * Eager loading
613
     *
614
     * A relation is named on its own, or named by the key of a closure that
615
     * narrows what this one read of it takes. The closure comes on top of
616
     * whatever the declaration of the relation already put on it
617
     *
618
     * @param string|array $relations
619
     *
620
     * @return $this
621
     */
622
    public function with(string|array $relations): self
18✔
623
    {
624
        $relations = (array) $relations;
18✔
625

626
        foreach ($relations as $key => $value) {
18✔
627
            $named      = is_int($key);
18✔
628
            $relation   = $named ? $value : $key;
18✔
629
            $constraint = $named ? null : $value;
18✔
630

631
            if (! is_string($relation)) {
18✔
NEW
632
                throw new InvalidArgumentException(
×
NEW
633
                    sprintf('%s() a relation is named by a string, %s names none', __METHOD__, get_debug_type($relation))
×
NEW
634
                );
×
635
            }
636

637
            if ($constraint !== null && ! $constraint instanceof Closure) {
18✔
638
                throw new InvalidArgumentException(
1✔
639
                    sprintf('%s() a relation is narrowed by a closure, %s narrows nothing', __METHOD__, get_debug_type($constraint))
1✔
640
                );
1✔
641
            }
642

643
            if (! $this->model->isRelation($relation)) {
17✔
644
                throw new RuntimeException(sprintf('Call to undefined relationship %s on model %s', $relation, $this->model::class));
2✔
645
            }
646

647
            $this->with[$relation] = $constraint;
15✔
648
        }
649

650
        return $this;
15✔
651
    }
652

653
    /**
654
     * Apply the callback if the given “value” is (or resolves to) truthy.
655
     *
656
     * @param mixed         $value
657
     * @param callable      $callback
658
     * @param callable|null $default
659
     *
660
     * @return $this
661
     */
662
    public function when(mixed $value, callable $callback, ?callable $default = null): self
2✔
663
    {
664
        if ($value) {
2✔
665
            return $callback($this, $value) ?? $this;
1✔
666
        }
667

668
        if ($default) {
2✔
669
            return $default($this, $value) ?? $this;
1✔
670
        }
671

672
        return $this;
1✔
673
    }
674

675
    /**
676
     * Build a model per record, with the eager loaded relations attached
677
     *
678
     * @param iterable $values
679
     *
680
     * @return Record[]
681
     */
682
    private function hydrate(iterable $values): array
93✔
683
    {
684
        $reader = $this->mapper->reader();
93✔
685

686
        $rows = [];
93✔
687
        foreach ($values as $line) {
93✔
688
            $rows[] = new Record($this, $reader($line));
87✔
689
        }
690

691
        $this->siblings($rows);
92✔
692

693
        foreach ($this->with as $with => $constraint) {
92✔
694
            if ($rows) {
11✔
695
                $this->loadRelation($rows, $with, $constraint);
11✔
696
            }
697
        }
698

699
        return $rows;
92✔
700
    }
701

702
    /**
703
     * Let every record of a result know the rest of it
704
     *
705
     * The result belongs to the records, not to the query that read it: a
706
     * relation touched on a record long after still loads for the records it
707
     * was read with, and the query is free to read something else meanwhile
708
     *
709
     * @param array<Record> $rows
710
     *
711
     * @return void
712
     */
713
    private function siblings(array $rows): void
92✔
714
    {
715
        foreach ($rows as $row) {
92✔
716
            $row->setSiblings($rows);
87✔
717
        }
718
    }
719

720
    /**
721
     * Load a relation for the given records with a single query
722
     *
723
     * @param array        $rows
724
     * @param string       $with
725
     * @param Closure|null $constraint narrows this one read of the relation
726
     *
727
     * @return void
728
     */
729
    public function loadRelation(array $rows, string $with, ?Closure $constraint = null): void
28✔
730
    {
731
        new RelationLoader($this)->load($rows, $with, $constraint);
28✔
732
    }
733

734
    /**
735
     * Keep only the rows the conditions let through
736
     *
737
     * @param Iterator $iterator
738
     *
739
     * @return Iterator
740
     */
741
    private function filtering(Iterator $iterator): Iterator
138✔
742
    {
743
        if ($this->conditions->isEmpty()) {
138✔
744
            return $iterator;
64✔
745
        }
746

747
        return new CallbackFilterIterator(
97✔
748
            $iterator,
97✔
749
            fn ($current) => $this->conditions->match($current, $this->table)
97✔
750
        );
97✔
751
    }
752

753
    /**
754
     * @param string $name
755
     * @param array  $arguments
756
     *
757
     * @return mixed
758
     */
759
    public function __call(string $name, array $arguments)
3✔
760
    {
761
        $scope = 'scope' . ucfirst($name);
3✔
762

763
        /* A scope is a method of the model that narrows a query */
764
        if (method_exists($this->model, $scope)) {
3✔
765
            return $this->model->$scope($this, ...$arguments);
2✔
766
        }
767

768
        throw new BadMethodCallException(sprintf(
1✔
769
            'Call to undefined method %s::%s()', $this->model::class, $name
1✔
770
        ));
1✔
771
    }
772

773
    /**
774
     * Name of the table being queried
775
     *
776
     * @return string
777
     */
778
    public function getTable(): string
1✔
779
    {
780
        return $this->model->getTable();
1✔
781
    }
782

783
    /**
784
     * Path of the table file
785
     *
786
     * @return string
787
     */
788
    public function getPath(): string
1✔
789
    {
790
        return $this->model->getPath();
1✔
791
    }
792

793
    /**
794
     * The table being queried
795
     *
796
     * @return Model
797
     */
798
    public function model(): Model
36✔
799
    {
800
        return $this->model;
36✔
801
    }
802
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc