Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.92% covered (success)
98.92%
92 / 93
93.75% covered (success)
93.75%
15 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractRelationship
98.92% covered (success)
98.92%
92 / 93
93.75% covered (success)
93.75%
15 / 16
47
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getForeignTable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getForeignKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getChildRelationships
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setChildRelationships
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getEagerRelationships
n/a
0 / 0
n/a
0 / 0
0
 getEmptyRelationshipValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildCompositeKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tupleFor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 bindPlaceholder
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 bindPlaceholders
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 applyEagerIdFilter
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 assertTupleCardinality
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 assertKeyCardinality
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 hasUsableParentKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 hydrateChildRelationships
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
17
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Db\Record\Relationships;
16
17use Pop\Db\Record\Collection;
18use Pop\Db\Sql;
19
20/**
21 * Relationship class for "has one" relationships
22 *
23 * @category   Pop
24 * @package    Pop\Db
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    7.0.0
29 */
30abstract class AbstractRelationship implements RelationshipInterface
31{
32
33    /**
34     * Delimiter used to join multiple column values into one composite lookup key
35     * @var string
36     */
37    public const COMPOSITE_KEY_DELIMITER = "\x1F";
38
39    /**
40     * Foreign table class
41     * @var ?string
42     */
43    protected ?string $foreignTable = null;
44
45    /**
46     * Foreign key
47     * @var string|array|null
48     */
49    protected string|array|null $foreignKey = null;
50
51    /**
52     * Relationship options
53     * @var ?array
54     */
55    protected ?array $options = null;
56
57    /**
58     * Relationship children (list of dotted child paths to eager-load under this relationship)
59     * @var array
60     */
61    protected array $children = [];
62
63    /**
64     * Constructor
65     *
66     * Instantiate the relationship object
67     *
68     * @param string $foreignTable
69     * @param string|array $foreignKey
70     * @param ?array $options
71     */
72    public function __construct(string $foreignTable, string|array $foreignKey, ?array $options = null)
73    {
74        $this->foreignTable = $foreignTable;
75        $this->foreignKey   = $foreignKey;
76        $this->options      = $options;
77    }
78
79    /**
80     * Get foreign table class
81     *
82     * @return string|null
83     */
84    public function getForeignTable(): string|null
85    {
86        return $this->foreignTable;
87    }
88
89    /**
90     * Get foreign key
91     *
92     * @return string|array|null
93     */
94    public function getForeignKey(): string|array|null
95    {
96        return $this->foreignKey;
97    }
98
99    /**
100     * Get options
101     *
102     * @return array|null
103     */
104    public function getOptions(): array|null
105    {
106        return $this->options;
107    }
108
109    /**
110     * Get child relationships
111     *
112     * @return array
113     */
114    public function getChildRelationships(): array
115    {
116        return $this->children;
117    }
118
119    /**
120     * Set children child relationships
121     *
122     * @param  array $children
123     * @return static
124     */
125    public function setChildRelationships(array $children): static
126    {
127        $this->children = $children;
128        return $this;
129    }
130
131    /**
132     * Get eager relationships
133     *
134     * @param  array $ids
135     * @throws Exception
136     * @return array
137     */
138    abstract public function getEagerRelationships(array $ids): array;
139
140    /**
141     * Get the value to use for a leaf record's named child relationship when no eager-loaded
142     * results were found for it (e.g. a "has many" child with zero matching rows should still
143     * see an empty Collection rather than a plain array). Concrete relationship classes whose
144     * populated results aren't plain arrays should override this.
145     *
146     * @return mixed
147     */
148    public function getEmptyRelationshipValue(): mixed
149    {
150        return [];
151    }
152
153    /**
154     * Build a single composite lookup key from an ordered list of column values
155     *
156     * @param  array $values
157     * @return string
158     */
159    public static function buildCompositeKey(array $values): string
160    {
161        return implode(self::COMPOSITE_KEY_DELIMITER, $values);
162    }
163
164    /**
165     * Build an ordered tuple of values for the given columns from a record (either a
166     * Record instance or a plain array row). Returns null if any one of the columns is
167     * missing or null, i.e. the record has no usable composite key and should be skipped.
168     *
169     * @param  mixed $record
170     * @param  array $columns
171     * @return array|null
172     */
173    public static function tupleFor(mixed $record, array $columns): ?array
174    {
175        $tuple = array_map(fn($col) => $record[$col] ?? null, $columns);
176        return (in_array(null, $tuple, true)) ? null : $tuple;
177    }
178
179    /**
180     * Bind a single value as a query parameter and return the placeholder token that must be
181     * rendered in its place.
182     *
183     * A bare placeholder character is NOT a usable placeholder on every dialect: only MySQL/SQL
184     * Server take a bare '?'. PostgreSQL needs a positional '$N' and SQLite/PDO need a named
185     * ':name', so the token has to be generated here rather than repeating whatever
186     * Sql::getPlaceholder() returns. The Sql object's own parameter counter is used to number
187     * (PostgreSQL) and to uniquely name (SQLite/PDO) every parameter in the statement, so
188     * callers can bind several groups of parameters into one query without colliding.
189     *
190     * The generated names deliberately start with the counter, i.e. with a digit, which is
191     * exactly what keeps them from ever colliding with the column-derived names that
192     * Sql\Parser\Expression::parseShorthand() emits for the same statement.
193     *
194     * @param  Sql    $sql
195     * @param  string $column
196     * @param  mixed  $value
197     * @param  array  $params
198     * @return string
199     */
200    protected static function bindPlaceholder(Sql $sql, string $column, mixed $value, array &$params): string
201    {
202        $placeholder = $sql->getPlaceholder();
203
204        $sql->incrementParameterCount();
205
206        if ($placeholder == ':') {
207            $key          = $sql->getParameterCount() . '_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $column);
208            $params[$key] = $value;
209            return ':' . $key;
210        } else if ($placeholder == '$') {
211            $params[] = $value;
212            return '$' . $sql->getParameterCount();
213        } else {
214            $params[] = $value;
215            return '?';
216        }
217    }
218
219    /**
220     * Bind a flat list of values for a single column and return their placeholder tokens
221     *
222     * @param  Sql    $sql
223     * @param  string $column
224     * @param  array  $values
225     * @param  array  $params
226     * @return array
227     */
228    protected static function bindPlaceholders(Sql $sql, string $column, array $values, array &$params): array
229    {
230        $placeholders = [];
231
232        foreach ($values as $value) {
233            $placeholders[] = static::bindPlaceholder($sql, $column, $value, $params);
234        }
235
236        return $placeholders;
237    }
238
239    /**
240     * Apply the eager-load id filter to a SELECT's WHERE clause and collect its bound values.
241     *
242     * A single-column key renders as one "column IN (...)" predicate. A composite key renders
243     * as one AND-nested group of per-tuple OR-nested equality groups, so that whatever gets
244     * appended to the WHERE clause afterward is ANDed against the whole "matches any of these
245     * id tuples" block rather than becoming a sibling OR at the top level. It renders
246     * identically when there is no sibling predicate.
247     *
248     * @param  Sql          $sql
249     * @param  string|array $key
250     * @param  array        $ids
251     * @param  array        $params
252     * @return void
253     */
254    protected function applyEagerIdFilter(Sql $sql, string|array $key, array $ids, array &$params): void
255    {
256        if (is_array($key)) {
257            $columns    = array_values($key);
258            $tupleGroup = $sql->select()->where->andNest();
259            foreach ($ids as $idTuple) {
260                $idTuple = array_values((array)$idTuple);
261                $group   = $tupleGroup->orNest();
262                foreach ($columns as $i => $column) {
263                    $group->equalTo($column, static::bindPlaceholder($sql, $column, $idTuple[$i] ?? null, $params));
264                }
265            }
266        } else {
267            $sql->select()->where->in($key, static::bindPlaceholders($sql, $key, $ids, $params));
268        }
269    }
270
271    /**
272     * Validate that the tuples in an eager-load id list have the same number of
273     * components as the array foreign key they will be bound to. A plain string
274     * $foreignKey (cardinality 1) and an empty $ids list are always valid.
275     *
276     * @param  array        $ids
277     * @param  string|array $foreignKey
278     * @throws Exception
279     * @return void
280     */
281    protected function assertTupleCardinality(array $ids, string|array $foreignKey): void
282    {
283        if (!is_array($foreignKey) || empty($ids)) {
284            return;
285        }
286
287        $tuple = reset($ids);
288
289        if (!is_array($tuple) || (count($tuple) !== count($foreignKey))) {
290            throw new Exception(
291                'Error: The number of foreign key columns (' . count($foreignKey) .
292                ') does not match the number of values (' . (is_array($tuple) ? count($tuple) : 1) .
293                ') provided for the relationship lookup.'
294            );
295        }
296    }
297
298    /**
299     * Validate that an array foreign-key column count matches the target
300     * table's own primary-key column count. A plain string $foreignKey is
301     * always treated as cardinality 1.
302     *
303     * @param  string|array $foreignKey
304     * @param  array        $targetPrimaryKeys
305     * @throws Exception
306     * @return void
307     */
308    protected function assertKeyCardinality(string|array $foreignKey, array $targetPrimaryKeys): void
309    {
310        $foreignKeyCount = is_array($foreignKey) ? count($foreignKey) : 1;
311        $targetKeyCount  = count($targetPrimaryKeys);
312
313        if ($foreignKeyCount !== $targetKeyCount) {
314            throw new Exception(
315                'Error: The number of foreign key columns (' . $foreignKeyCount .
316                ') does not match the number of primary key columns (' . $targetKeyCount .
317                ') on the target table.'
318            );
319        }
320    }
321
322    /**
323     * Determine whether a lazily-built relationship lookup's column filter carries at least
324     * one usable parent key value, i.e. the parent record was actually loaded.
325     *
326     * An unloaded parent degenerates the lookup two different ways depending on whether the
327     * foreign key is a single column or composite: the single-key branch collapses
328     * getPrimaryValues()'s empty array into a bare `[]` value (legacy empty-IN shorthand,
329     * `IN ('')`), while the composite branch reads each column through the row gateway and
330     * gets `null` for each one (`IS NULL`, which matches any orphan row with a null FK). Both
331     * mean "no parent loaded" and must short-circuit before either shape reaches the query
332     * layer - but a legitimate `0` or `''` key value must NOT be treated the same way, so this
333     * checks value identity, not truthiness.
334     *
335     * @param  array $columns
336     * @return bool
337     */
338    protected function hasUsableParentKey(array $columns): bool
339    {
340        return !empty(array_filter($columns, fn($value) => ($value !== null) && ($value !== [])));
341    }
342
343    /**
344     * Hydrate nested child relationships onto a flat list of leaf records, resolving each
345     * named child relationship once (accumulated by name) and distributing every one of them
346     * onto every leaf record â€” so multiple differently-named children under this relationship
347     * don't overwrite each other.
348     *
349     * The column used to query and match a given child relationship is decided per relationship
350     * name, not once for all of them: "to-one by foreign key" children (HasOneOf and BelongsTo)
351     * are keyed by the leaf record's own foreign key column â€” the column holding the value that
352     * identifies which foreign row to fetch â€” while every other kind is keyed by the leaf
353     * record's primary key.
354     *
355     * @param  array        $leafRecords
356     * @param  string|array $primaryKeyColumn
357     * @return void
358     */
359    protected function hydrateChildRelationships(array $leafRecords, string|array $primaryKeyColumn): void
360    {
361        if (empty($this->children) || empty($leafRecords)) {
362            return;
363        }
364
365        $accumulated         = [];
366        $relationshipsByName = [];
367        $lookupColumns       = [];
368
369        foreach ($leafRecords as $record) {
370            foreach ($this->children as $childPath) {
371                $record->addWith($childPath);
372            }
373            $record->getWithRelationships();
374            foreach ($record->getRelationships() as $name => $relationship) {
375                if (!isset($accumulated[$name])) {
376                    $column = (($relationship instanceof HasOneOf) || ($relationship instanceof BelongsTo)) ?
377                        $relationship->getForeignKey() : $primaryKeyColumn;
378
379                    $lookupColumns[$name] = $column;
380
381                    if (is_array($column)) {
382                        $tuplesByKey = [];
383                        foreach ($leafRecords as $leafRecord) {
384                            $tuple = self::tupleFor($leafRecord, $column);
385                            if ($tuple === null) {
386                                continue;
387                            }
388                            $tuplesByKey[self::buildCompositeKey($tuple)] = $tuple;
389                        }
390                        $ids = array_values($tuplesByKey);
391                    } else {
392                        $ids = array_values(array_unique(array_map(
393                            fn($leafRecord) => $leafRecord[$column], $leafRecords
394                        )));
395                    }
396
397                    // An empty id list must not reach getEagerRelationships(), which would
398                    // render either an invalid or an entirely unfiltered WHERE clause.
399                    $accumulated[$name]         = (!empty($ids)) ? $relationship->getEagerRelationships($ids) : [];
400                    $relationshipsByName[$name] = $relationship;
401                }
402            }
403        }
404
405        foreach ($leafRecords as $record) {
406            foreach ($accumulated as $name => $resultsByKey) {
407                $column = $lookupColumns[$name];
408                if (is_array($column)) {
409                    $tuple       = self::tupleFor($record, $column);
410                    $lookupValue = ($tuple !== null) ? self::buildCompositeKey($tuple) : null;
411                } else {
412                    $lookupValue = $record[$column] ?? null;
413                }
414                $record->setRelationship(
415                    $name,
416                    $resultsByKey[$lookupValue] ?? $relationshipsByName[$name]->getEmptyRelationshipValue()
417                );
418            }
419        }
420    }
421
422}