Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
HasOne
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
5 / 5
33
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
 getChild
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%
55 / 55
100.00% covered (success)
100.00%
1 / 1
22
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;
18use Pop\Db\Sql\Parser;
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 */
30class HasOne extends AbstractRelationship
31{
32
33    /**
34     * Parent record
35     * @var ?Record
36     */
37    protected ?Record $parent = null;
38
39    /**
40     * Constructor
41     *
42     * Instantiate the relationship object
43     *
44     * @param Record $parent
45     * @param string $foreignTable
46     * @param string|array $foreignKey
47     * @param ?array $options
48     */
49    public function __construct(Record $parent, string $foreignTable, string|array $foreignKey, ?array $options = null)
50    {
51        parent::__construct($foreignTable, $foreignKey, $options);
52        $this->parent = $parent;
53    }
54
55    /**
56     * Get parent record
57     *
58     * @return ?Record
59     */
60    public function getParent(): ?Record
61    {
62        return $this->parent;
63    }
64
65    /**
66     * Get child
67     *
68     * @param  ?array $options
69     * @return Record
70     */
71    public function getChild(?array $options = null): Record
72    {
73        $table = $this->foreignTable;
74
75        if (is_array($this->foreignKey)) {
76            $parentPrimaryKeys = $this->parent->getPrimaryKeys();
77            $this->assertKeyCardinality($this->foreignKey, $parentPrimaryKeys);
78            $columns = [];
79            foreach ($this->foreignKey as $i => $fkColumn) {
80                $columns[$fkColumn] = $this->parent[$parentPrimaryKeys[$i]];
81            }
82        } else {
83            $values = array_values($this->parent->getPrimaryValues());
84
85            if (count($values) == 1) {
86                $values = $values[0];
87            }
88
89            $columns = [$this->foreignKey => $values];
90        }
91
92        // An unloaded parent (e.g. Table::findById($missingId)) has no usable primary key
93        // value to look the child up by - return the same empty record findOne() returns on
94        // no match, without asking the database a degenerate question (RELATIONSHIP-GUARD-
95        // HANDOFF.md §1/§2). getChild(): Record is not nullable, so an empty new $table() is
96        // returned here rather than null, even though getEmptyRelationshipValue() returns null
97        // - that method serves the eager path, which has a different contract.
98        if (!$this->hasUsableParentKey($columns)) {
99            return new $table();
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)->getOne($columns, $options);
108        } else {
109            return $table::findOne($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 null;
121    }
122
123    /**
124     * Get eager relationships
125     *
126     * @param  array $ids
127     * @throws Exception
128     * @return array
129     */
130    public function getEagerRelationships(array $ids): array
131    {
132        if (($this->foreignTable === null) || ($this->foreignKey === null)) {
133            throw new Exception('Error: The foreign table and key values have not been set.');
134        }
135
136        // The foreign key columns on the foreign table mirror the declaring (parent)
137        // table's own primary key columns, so their counts must match — the same
138        // invariant the lazy getChild() path asserts.
139        if (is_array($this->foreignKey)) {
140            $this->assertKeyCardinality($this->foreignKey, $this->parent->getPrimaryKeys());
141            $this->assertTupleCardinality($ids, $this->foreignKey);
142        }
143
144        $results = [];
145        $table   = $this->foreignTable;
146        $db      = $table::db();
147        $sql     = $db->createSql();
148        $columns = null;
149
150        if (!empty($this->options)) {
151            if (isset($this->options['select'])) {
152                $columns = $this->options['select'];
153            }
154        }
155
156        $sql->select($columns)->from($table::table());
157
158        $params = [];
159
160        $this->applyEagerIdFilter($sql, $this->foreignKey, $ids, $params);
161
162        if (!empty($this->options)) {
163            if (isset($this->options['limit'])) {
164                $sql->select()->limit((int)$this->options['limit']);
165            }
166
167            if (isset($this->options['offset'])) {
168                $sql->select()->offset((int)$this->options['offset']);
169            }
170            if (isset($this->options['join'])) {
171                $joins = (is_array($this->options['join']) && isset($this->options['join']['table'])) ?
172                    [$this->options['join']] : $this->options['join'];
173
174                foreach ($joins as $join) {
175                    if (isset($join['type']) && method_exists($sql->select(), $join['type'])) {
176                        $joinMethod = $join['type'];
177                        $sql->select()->{$joinMethod}($join['table'], $join['columns']);
178                    } else {
179                        $sql->select()->leftJoin($join['table'], $join['columns']);
180                    }
181                }
182            }
183            if (isset($this->options['order'])) {
184                if (!is_array($this->options['order'])) {
185                    $orders = (str_contains($this->options['order'], ',')) ?
186                        explode(',', $this->options['order']) : [$this->options['order']];
187                } else {
188                    $orders = $this->options['order'];
189                }
190                foreach ($orders as $order) {
191                    $ord = Parser\Order::parse(trim($order));
192                    $sql->select()->orderBy($ord['by'], $db->escape($ord['order']));
193                }
194            }
195        }
196
197        $db->prepare($sql)
198            ->bindParams($params)
199            ->execute();
200
201        $rows        = $db->fetchAll();
202        $results     = [];
203        $leafRecords = [];
204
205        // The leaf records are rows of the foreign table, so their own primary key
206        // columns (NOT this relationship's foreign key columns, which name columns
207        // on the declaring side) are what nested child relationships look them up by.
208        $primaryKey = (new $table())->getPrimaryKeys();
209        $primaryKey = (count($primaryKey) == 1) ? reset($primaryKey) : $primaryKey;
210
211        foreach ($rows as $row) {
212            $record = new $table();
213            $record->setColumns($row);
214            $key = is_array($this->foreignKey) ?
215                self::buildCompositeKey(array_map(fn($col) => $row[$col], $this->foreignKey)) :
216                $row[$this->foreignKey];
217            $results[$key] = $record;
218            $leafRecords[] = $record;
219        }
220
221        $this->hydrateChildRelationships($leafRecords, $primaryKey);
222
223        return $results;
224    }
225
226}