Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
HasMany
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
8 / 8
45
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getParent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getChildren
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
8
 getEmptyRelationshipValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEagerRelationships
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 applyAdditionalColumnsFilter
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 applyQueryOptions
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
14
 hydrateRows
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
7
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\Adapter\AbstractAdapter;
18use Pop\Db\Record;
19use Pop\Db\Sql;
20use Pop\Db\Sql\Parser;
21
22/**
23 * Relationship class for "has many" relationships
24 *
25 * @category   Pop
26 * @package    Pop\Db
27 * @author     Nick Sagona, III <nick@popphp.org>
28 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    7.0.0
31 */
32class HasMany extends AbstractRelationship
33{
34
35    /**
36     * Parent record
37     * @var ?Record
38     */
39    protected ?Record $parent = null;
40
41    /**
42     * Constructor
43     *
44     * Instantiate the relationship object
45     *
46     * @param Record $parent
47     * @param string $foreignTable
48     * @param string|array $foreignKey
49     * @param ?array $options
50     */
51    public function __construct(Record $parent, string $foreignTable, string|array $foreignKey, ?array $options = null)
52    {
53        parent::__construct($foreignTable, $foreignKey, $options);
54        $this->parent = $parent;
55    }
56
57    /**
58     * Get parent record
59     *
60     * @return ?Record
61     */
62    public function getParent(): ?Record
63    {
64        return $this->parent;
65    }
66
67    /**
68     * Get children
69     *
70     * @param  ?array $options
71     * @return Record\Collection
72     */
73    public function getChildren(?array $options = null): Record\Collection
74    {
75        $table = $this->foreignTable;
76
77        if (is_array($this->foreignKey)) {
78            $parentPrimaryKeys = $this->parent->getPrimaryKeys();
79            $this->assertKeyCardinality($this->foreignKey, $parentPrimaryKeys);
80            $columns = [];
81            foreach ($this->foreignKey as $i => $fkColumn) {
82                $columns[$fkColumn] = $this->parent[$parentPrimaryKeys[$i]];
83            }
84        } else {
85            $values = array_values($this->parent->getPrimaryValues());
86
87            if (count($values) == 1) {
88                $values = $values[0];
89            }
90
91            $columns = [$this->foreignKey => $values];
92        }
93
94        // An unloaded parent (e.g. Table::findById($missingId)) has no usable primary key
95        // value to look children up by - return the same empty result a real parent with no
96        // children would, without asking the database a degenerate question (RELATIONSHIP-
97        // GUARD-HANDOFF.md Â§1/§2).
98        if (!$this->hasUsableParentKey($columns)) {
99            return new Record\Collection();
100        }
101
102        if (!empty($options) && !empty($options['columns'])) {
103            $columns = array_merge($columns, $options['columns']);
104        }
105
106        if (!empty($this->children)) {
107            return $table::with($this->children)->getBy($columns, $options);
108        } else {
109            return $table::findBy($columns, $options);
110        }
111    }
112
113    /**
114     * Get the value to use when no eager-loaded result exists for a given leaf record
115     *
116     * @return mixed
117     */
118    public function getEmptyRelationshipValue(): mixed
119    {
120        return new Record\Collection();
121    }
122
123    /**
124     * Get eager relationships
125     *
126     * @param  array $ids
127     * @param  bool  $toArray
128     * @throws Exception
129     * @return array
130     */
131    public function getEagerRelationships(array $ids, bool|array $toArray = false): array
132    {
133        if (($this->foreignTable === null) || ($this->foreignKey === null)) {
134            throw new Exception('Error: The foreign table and key values have not been set.');
135        }
136
137        // The foreign key columns on the foreign table mirror the declaring (parent)
138        // table's own primary key columns, so their counts must match â€” the same
139        // invariant the lazy getChildren() path asserts.
140        if (is_array($this->foreignKey)) {
141            $this->assertKeyCardinality($this->foreignKey, $this->parent->getPrimaryKeys());
142            $this->assertTupleCardinality($ids, $this->foreignKey);
143        }
144
145        $table   = $this->foreignTable;
146        $db      = $table::db();
147        $sql     = $db->createSql();
148        $columns = null;
149
150        if (!empty($this->options) && isset($this->options['select'])) {
151            $columns = $this->options['select'];
152        }
153
154        $sql->select($columns)->from($table::table());
155
156        $params = [];
157
158        // The options['columns'] filter is applied BEFORE the id filter so that the parameters
159        // are generated (and therefore numbered/named/ordered) in the same order the WHERE
160        // clause renders them: PredicateSet::render() always renders its top-level predicates
161        // (this filter) before its nested predicate sets (the composite tuple group), and for a
162        // single-column key both are top-level predicates rendered in insertion order.
163        $this->applyAdditionalColumnsFilter($sql, $params);
164        $this->applyEagerIdFilter($sql, $this->foreignKey, $ids, $params);
165        $this->applyQueryOptions($sql, $db);
166
167        $db->prepare($sql)
168            ->bindParams($params)
169            ->execute();
170
171        return $this->hydrateRows($db->fetchAll(), $table, $toArray);
172    }
173
174    /**
175     * Apply the options['columns'] additional WHERE filter (if any) and collect its bound values
176     *
177     * The parsed expressions carry their own dialect-correct placeholder tokens, so the SQL
178     * object's parameter counter is advanced by one per bound value to keep any placeholder
179     * generated afterward for the same statement in step with them.
180     *
181     * @param  Sql   $sql
182     * @param  array $params
183     * @return void
184     */
185    private function applyAdditionalColumnsFilter(Sql $sql, array &$params): void
186    {
187        if (empty($this->options) || !isset($this->options['columns'])) {
188            return;
189        }
190
191        $additionalColumns = Parser\Expression::parseShorthand($this->options['columns'], $sql->getPlaceholder());
192
193        if (!empty($additionalColumns['expressions'])) {
194            foreach ($additionalColumns['expressions'] as $expression) {
195                $sql->select()->where($expression);
196            }
197        }
198
199        foreach ($additionalColumns['params'] as $key => $value) {
200            if (is_string($key)) {
201                $params[$key] = $value;
202            } else {
203                $params[] = $value;
204            }
205            $sql->incrementParameterCount();
206        }
207    }
208
209    /**
210     * Apply the limit, offset, join, and order options (if any)
211     *
212     * @param  Sql             $sql
213     * @param  AbstractAdapter $db
214     * @return void
215     */
216    private function applyQueryOptions(Sql $sql, AbstractAdapter $db): void
217    {
218        if (empty($this->options)) {
219            return;
220        }
221
222        if (isset($this->options['limit'])) {
223            $sql->select()->limit((int)$this->options['limit']);
224        }
225
226        if (isset($this->options['offset'])) {
227            $sql->select()->offset((int)$this->options['offset']);
228        }
229
230        if (isset($this->options['join'])) {
231            $joins = (is_array($this->options['join']) && isset($this->options['join']['table'])) ?
232                [$this->options['join']] : $this->options['join'];
233
234            foreach ($joins as $join) {
235                if (isset($join['type']) && method_exists($sql->select(), $join['type'])) {
236                    $joinMethod = $join['type'];
237                    $sql->select()->{$joinMethod}($join['table'], $join['columns']);
238                } else {
239                    $sql->select()->leftJoin($join['table'], $join['columns']);
240                }
241            }
242        }
243
244        if (isset($this->options['order'])) {
245            if (!is_array($this->options['order'])) {
246                $orders = (str_contains($this->options['order'], ',')) ?
247                    explode(',', $this->options['order']) : [$this->options['order']];
248            } else {
249                $orders = $this->options['order'];
250            }
251            foreach ($orders as $order) {
252                $ord = Parser\Order::parse(trim($order));
253                $sql->select()->orderBy($ord['by'], $db->escape($ord['order']));
254            }
255        }
256    }
257
258    /**
259     * Hydrate the fetched rows into a per-parent-key map of Collections (or raw arrays), and
260     * eager-load any nested child relationships on the resulting leaf records
261     *
262     * @param  array      $rows
263     * @param  string     $table
264     * @param  bool|array $toArray
265     * @return array
266     */
267    private function hydrateRows(array $rows, string $table, bool|array $toArray): array
268    {
269        $results     = [];
270        $leafRecords = [];
271
272        // The leaf records are rows of the foreign table, so their own primary key
273        // columns (NOT this relationship's foreign key columns, which name columns
274        // on the declaring side) are what nested child relationships look them up by.
275        $primaryKey = (new $table())->getPrimaryKeys();
276        $primaryKey = (count($primaryKey) == 1) ? reset($primaryKey) : $primaryKey;
277
278        foreach ($rows as $row) {
279            $key = is_array($this->foreignKey) ?
280                self::buildCompositeKey(array_map(fn($col) => $row[$col], $this->foreignKey)) :
281                $row[$this->foreignKey];
282
283            if ($toArray === false) {
284                if (!isset($results[$key])) {
285                    $results[$key] = new Record\Collection();
286                }
287                $record = new $table();
288                $record->setColumns($row);
289                $results[$key]->push($record);
290                $leafRecords[] = $record;
291            } else {
292                if (!isset($results[$key])) {
293                    $results[$key] = [];
294                }
295                $results[$key][] = $row;
296            }
297        }
298
299        $this->hydrateChildRelationships($leafRecords, $primaryKey);
300
301        return $results;
302    }
303
304}