Lines 97.72% 43 / 44
Functions and Methods 94.73% 18 / 19
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
FetchesResults 97.72% 43 / 44 94.73% 18 / 19 35 0.00% 0 / 1
 readQuery n/a 0 / 0 n/a 0 / 0 0
 compositionQuery n/a 0 / 0 n/a 0 / 0 0
 eagerCache n/a 0 / 0 n/a 0 / 0 0
 servesCache n/a 0 / 0 n/a 0 / 0 0
 relatedClass n/a 0 / 0 n/a 0 / 0 0
 first 100.00% 3 / 3 100.00% 1 / 1 3
 find 100.00% 3 / 3 100.00% 1 / 1 3
 findOrFail 100.00% 4 / 4 100.00% 1 / 1 2
 firstOrFail 100.00% 4 / 4 100.00% 1 / 1 2
 sole 100.00% 9 / 9 100.00% 1 / 1 5
 firstOrCreate 100.00% 1 / 1 100.00% 1 / 1 1
 findOrCreate 100.00% 1 / 1 100.00% 1 / 1 1
 count 100.00% 3 / 3 100.00% 1 / 1 3
 exists 100.00% 3 / 3 100.00% 1 / 1 3
 cursor 100.00% 4 / 4 100.00% 1 / 1 3
 value 100.00% 1 / 1 100.00% 1 / 1 1
 pluck 100.00% 1 / 1 100.00% 1 / 1 1
 max 100.00% 1 / 1 100.00% 1 / 1 1
 min 0.00% 0 / 1 0.00% 0 / 1 2
 sum 100.00% 1 / 1 100.00% 1 / 1 1
 avg 100.00% 1 / 1 100.00% 1 / 1 1
 aggregates 100.00% 1 / 1 100.00% 1 / 1 1
 aggregateBy 100.00% 1 / 1 100.00% 1 / 1 1
 countBy 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Concerns;
6
7use BlueprintAU\Collections\Collection as BaseCollection;
8use BlueprintAU\Radiant\Collection;
9use BlueprintAU\Radiant\Exceptions\ModelNotFoundException;
10use BlueprintAU\Radiant\Exceptions\MultipleRecordsFoundException;
11use BlueprintAU\Radiant\Database\Query\Aggregate;
12use BlueprintAU\Radiant\Model;
13use BlueprintAU\Radiant\ModelQueryBuilder;
14
15/**
16 * The shared read vocabulary for a wrapper over a model query builder —
17 * the row reads, the scalar reads, and the grouped aggregates.
18 *
19 * @template TRelated of Model
20 * @phpstan-import-type KeyValue from \BlueprintAU\Radiant\Model
21 */
22trait FetchesResults
23{
24    /**
25     * The query the row reads run against.
26     *
27     * @return ModelQueryBuilder<TRelated>
28     */
29    abstract protected function readQuery(): ModelQueryBuilder;
30
31    /**
32     * The query the scalar reads run against.
33     *
34     * A wrapper whose query is built lazily per row (MorphTo) throws
35     * here — the scalar reads have no stable table to target.
36     *
37     * @return ModelQueryBuilder<TRelated>
38     */
39    abstract protected function compositionQuery(): ModelQueryBuilder;
40
41    /**
42     * The eagerly-loaded result — the row reads' cache path.
43     *
44     * @return Collection<int, TRelated>
45     */
46    abstract protected function eagerCache(): Collection;
47
48    /**
49     * Whether the eagerly-loaded result can serve the row reads.
50     *
51     * @return bool
52     */
53    abstract protected function servesCache(): bool;
54
55    /**
56     * The related model class — the fail-fast exceptions' identity.
57     *
58     * @return class-string<TRelated>
59     */
60    abstract protected function relatedClass(): string;
61
62    /**
63     * Run the query and hydrate the first related model.
64     *
65     * Served from an eagerly-loaded result when one applies — the
66     * relation was named, no filter composed, and the parent carries the
67     * relation loaded. Pass `fresh: true` to always run the query.
68     *
69     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
70     * @return TRelated|null
71     */
72    final public function first(bool $fresh = false): ?Model
73    {
74        if (!$fresh && $this->servesCache()) {
75            /** @var TRelated|null */
76            return $this->eagerCache()->first();
77        }
78
79        return $this->readQuery()->first();
80    }
81
82    /**
83     * Find a related model by its primary key, scoped to the relation's
84     * constraint.
85     *
86     * Served from an eagerly-loaded result when one applies (the lookup
87     * is then membership of the loaded set). Pass `fresh: true` to
88     * always run the query.
89     *
90     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
91     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
92     * @return TRelated|null
93     */
94    final public function find(int|string|null|array $id, bool $fresh = false): ?Model
95    {
96        if (!$fresh && $this->servesCache()) {
97            /** @var TRelated|null */
98            return $this->eagerCache()->find($id);
99        }
100
101        return $this->readQuery()->find($id);
102    }
103
104    /**
105     * Find a related model by its primary key or throw if it does not
106     * exist.
107     *
108     * Served from an eagerly-loaded result when one applies — a miss is
109     * then the loaded set holding no match. Pass `fresh: true` to
110     * always run the query.
111     *
112     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
113     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
114     * @return TRelated
115     *
116     * @throws \BlueprintAU\Radiant\Exceptions\ModelNotFoundException
117     */
118    final public function findOrFail(int|string|null|array $id, bool $fresh = false): Model
119    {
120        $model = $this->find($id, $fresh);
121
122        if ($model === null) {
123            throw new ModelNotFoundException($this->relatedClass(), $id);
124        }
125
126        return $model;
127    }
128
129    /**
130     * Get the first related model or throw if no related models exist.
131     *
132     * Served from an eagerly-loaded result when one applies — a miss is
133     * then the loaded set being empty. Pass `fresh: true` to always run
134     * the query.
135     *
136     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
137     * @return TRelated
138     *
139     * @throws \BlueprintAU\Radiant\Exceptions\ModelNotFoundException
140     */
141    final public function firstOrFail(bool $fresh = false): Model
142    {
143        $model = $this->first($fresh);
144
145        if ($model === null) {
146            throw new ModelNotFoundException($this->relatedClass());
147        }
148
149        return $model;
150    }
151
152    /**
153     * Require the relation to match exactly one related model.
154     *
155     * Served from an eagerly-loaded result when one applies — the loaded
156     * set's size decides (zero throws, more than one throws). Pass
157     * `fresh: true` to always run the query.
158     *
159     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
160     * @return TRelated
161     *
162     * @throws \BlueprintAU\Radiant\Exceptions\ModelNotFoundException
163     * @throws \BlueprintAU\Radiant\Exceptions\MultipleRecordsFoundException
164     */
165    final public function sole(bool $fresh = false): Model
166    {
167        if (!$fresh && $this->servesCache()) {
168            $models = $this->eagerCache();
169            $count = $models->count();
170
171            if ($count === 0) {
172                throw new ModelNotFoundException($this->relatedClass());
173            }
174
175            if ($count > 1) {
176                throw new MultipleRecordsFoundException($count, $this->relatedClass());
177            }
178
179            /** @var TRelated */
180            return $models->first();
181        }
182
183        return $this->readQuery()->sole();
184    }
185
186    /**
187     * Return the first related model, or create one carrying the
188     * relation's constraint.
189     *
190     * The constraint's equality clauses (the FK pointing at the parent, a
191     * morph pair's key) become fills, so the created model satisfies the
192     * match that failed to find it. Not served from an eagerly-loaded
193     * result — a create must consult the database. For MorphTo, the
194     * resolved pair's key equality inverts into the fill, so the created
195     * target's primary key equals the parent's morph key; an unresolved
196     * pair fails the audit.
197     *
198     * @param  array<string, mixed>  $values  Extra column values for the created model.
199     * @return TRelated
200     * @throws \InvalidArgumentException
201     * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException
202     */
203    final public function firstOrCreate(array $values = []): Model
204    {
205        return $this->readQuery()->firstOrCreate($values);
206    }
207
208    /**
209     * Find a related model by its primary key, or create one carrying
210     * that key and the relation's constraint.
211     *
212     * The constraint Eqs become fills exactly as in
213     * {@see self::firstOrCreate()}; a hit reads the model's
214     * `wasRecentlyCreated` as false, a miss as true.
215     *
216     * @param  KeyValue  $id  The primary-key value, or a column => value map for a composite key.
217     * @param  array<string, mixed>  $values  Extra column values for the created model.
218     * @return TRelated
219     * @throws \InvalidArgumentException
220     * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException
221     */
222    final public function findOrCreate(int|string|null|array $id, array $values = []): Model
223    {
224        return $this->readQuery()->findOrCreate($id, $values);
225    }
226
227    /**
228     * Count the related rows matching the relation's constraint.
229     *
230     * Served from an eagerly-loaded result when one applies — the count
231     * is then the loaded set's size (a snapshot). Pass `fresh: true` to
232     * always run the query.
233     *
234     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
235     * @return int
236     */
237    final public function count(bool $fresh = false): int
238    {
239        if (!$fresh && $this->servesCache()) {
240            return $this->eagerCache()->count();
241        }
242
243        return $this->readQuery()->count();
244    }
245
246    /**
247     * Whether any related row matches the relation's constraint.
248     *
249     * Served from an eagerly-loaded result when one applies. Pass
250     * `fresh: true` to always run the query.
251     *
252     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
253     * @return bool
254     */
255    final public function exists(bool $fresh = false): bool
256    {
257        if (!$fresh && $this->servesCache()) {
258            return $this->eagerCache()->count() !== 0;
259        }
260
261        return $this->readQuery()->exists();
262    }
263
264    /**
265     * Stream the related models, hydrating each row as it arrives.
266     *
267     * Served from an eagerly-loaded result when one applies. Pass
268     * `fresh: true` to always run the query.
269     *
270     * @param  bool  $fresh  Bypass the eagerly-loaded result and run the query.
271     * @return \Generator<int, TRelated>
272     */
273    final public function cursor(bool $fresh = false): \Generator
274    {
275        if (!$fresh && $this->servesCache()) {
276            yield from $this->eagerCache();
277
278            return;
279        }
280
281        // A body holding yield makes this a generator — the live path
282        // forwards the builder's stream item by item (a plain return
283        // would terminate this generator empty).
284        yield from $this->readQuery()->cursor();
285    }
286
287    /**
288     * The value of a single column from the first related row, decoded
289     * through the column's cast.
290     *
291     * @param  string|Aggregate  $column
292     * @return mixed
293     */
294    final public function value(string|Aggregate $column): mixed
295    {
296        return $this->compositionQuery()->value($column);
297    }
298
299    /**
300     * A collection of a single column's values, decoded through the casts.
301     *
302     * @param  string  $column
303     * @return BaseCollection<int, mixed>
304     */
305    final public function pluck(string $column): BaseCollection
306    {
307        return $this->compositionQuery()->pluck($column);
308    }
309
310    /**
311     * The maximum value of a column, decoded through the cast for
312     * declared columns.
313     *
314     * @param  string  $column
315     * @return mixed
316     */
317    final public function max(string $column): mixed
318    {
319        return $this->compositionQuery()->max($column);
320    }
321
322    /**
323     * The minimum value of a column, decoded through the cast for
324     * declared columns.
325     *
326     * @param  string  $column
327     * @return mixed
328     */
329    final public function min(string $column): mixed
330    {
331        return $this->compositionQuery()->min($column);
332    }
333
334    /**
335     * The sum of a column's values, decoded through the cast for
336     * declared columns.
337     *
338     * @param  string  $column
339     * @return mixed
340     */
341    final public function sum(string $column): mixed
342    {
343        return $this->compositionQuery()->sum($column);
344    }
345
346    /**
347     * The average of a column's values, decoded through the cast for
348     * declared columns.
349     *
350     * @param  string  $column
351     * @return mixed
352     */
353    final public function avg(string $column): mixed
354    {
355        return $this->compositionQuery()->avg($column);
356    }
357
358    /**
359     * Multiple aggregates in one query, decoded through each aggregate's
360     * column cast.
361     *
362     * @param  Aggregate  ...$aggregates
363     * @return \stdClass
364     */
365    final public function aggregates(Aggregate ...$aggregates): \stdClass
366    {
367        return $this->compositionQuery()->aggregates(...$aggregates);
368    }
369
370    /**
371     * Run one aggregate per group of the related rows — a grouped
372     * aggregate in a single query.
373     *
374     * The FK constraint rides along automatically: the groups only ever
375     * cover THIS parent's related rows. The result is keyed by the group
376     * column's value, so the aggregate's own alias is ignored here (it
377     * matters only for the multi-aggregate row shape of the builder's
378     * aggregates()).
379     *
380     * The value type follows the aggregate: `count` yields int;
381     * `sum`/`avg` over numeric columns yield int|float; `min`/`max` yield
382     * the column's decoded type (a datetime column yields Carbon); custom
383     * functions and Expression arguments yield the raw driver value. For
384     * a guaranteed-numeric grouped count, use {@see self::countBy()}.
385     *
386     * @param  Aggregate  $aggregate
387     * @param  string  $groupBy
388     * @return BaseCollection<string, mixed>
389     *
390     * @throws \LogicException
391     */
392    final public function aggregateBy(Aggregate $aggregate, string $groupBy): BaseCollection
393    {
394        return $this->compositionQuery()->aggregateBy($aggregate, $groupBy);
395    }
396
397    /**
398     * Count the related rows per group of a column — in a single query.
399     *
400     * The FK constraint rides along automatically: the counts only ever
401     * cover THIS parent's related rows. The result is keyed by the group
402     * column's value with int counts.
403     *
404     * The optional seed lists group values that must appear even when the
405     * database has no rows for them — each seeded key absent from the
406     * result becomes 0. The seed is ADDITIVE: database rows always win,
407     * and group values found in the data but missing from the seed still
408     * appear. (Only counts can be seeded — an absent group has no honest
409     * min, max, or average.)
410     *
411     * @param  string  $column
412     * @param  list<int|string>|null  $seed  Group values guaranteed to appear (0 when absent).
413     * @return BaseCollection<string, int>
414     *
415     * @throws \LogicException
416     */
417    final public function countBy(string $column, ?array $seed = null): BaseCollection
418    {
419        return $this->compositionQuery()->countBy($column, $seed);
420    }
421}