Lines
97.50%
782 / 802
Methods
92.24%
107 / 116
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| clearRelationCache | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 4 | ||
| __construct | 100.00% | 46 / 46 | 100.00% | 1 / 1 | 12 | ||
| with | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 3 | ||
| assertRelationPath | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| validateRelationPath | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 3 | ||
| resolveRelation | 100.00% | 19 / 19 | 100.00% | 1 / 1 | 5 | ||
| eagerLoadRelations | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 2 | ||
| loadRelationPath | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 2 | ||
| splitPath | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| loadRelation | 100.00% | 41 / 41 | 100.00% | 1 / 1 | 14 | ||
| buildPartitions | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| applyMtiJoins | 100.00% | 26 / 26 | 100.00% | 1 / 1 | 4 | ||
| whereColumn | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| whereRaw | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| whereExists | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| whereInQuery | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| correlateWith | 55.55% | 5 / 9 | 0.00% | 0 / 1 | 3.79 | ||
| newNestedBuilder | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| addJoin | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| qualifyColumns | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 6 | ||
| on | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| orOn | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| withTrashed | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| onlyTrashed | 100.00% | 13 / 13 | 100.00% | 1 / 1 | 3 | ||
| withoutScope | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 2 | ||
| withoutScopes | 100.00% | 10 / 10 | 100.00% | 1 / 1 | 2 | ||
| get | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 1 | ||
| getRaw | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| cursor | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 2 | ||
| first | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 3 | ||
| find | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| firstOrFail | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| findOrFail | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| sole | 100.00% | 14 / 14 | 100.00% | 1 / 1 | 5 | ||
| firstOrFailWithKey | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| firstOrCreate | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| findOrCreate | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| createModel | 91.66% | 22 / 24 | 0.00% | 0 / 1 | 10.06 | ||
| invertibleWhereFills | 95.65% | 22 / 23 | 0.00% | 0 / 1 | 11 | ||
| keyFills | 55.55% | 10 / 18 | 0.00% | 0 / 1 | 9.16 | ||
| value | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 5 | ||
| pluck | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | ||
| scopedFor | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| decodeScalar | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 2 | ||
| encodeWhereValue | 100.00% | 18 / 18 | 100.00% | 1 / 1 | 13 | ||
| count | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| max | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| min | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| sum | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| avg | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| aggregates | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 6 | ||
| aggregateBy | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 3 | ||
| countBy | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 3 | ||
| decodeAggregateColumn | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 3 | ||
| whereKey | 98.33% | 59 / 60 | 0.00% | 0 / 1 | 19 | ||
| applyWhereKeyOn | 100.00% | 23 / 23 | 100.00% | 1 / 1 | 11 | ||
| assertCompositeKeyValue | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 7 | ||
| assertCompositeKeyColumn | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 3 | ||
| select | 100.00% | 39 / 39 | 100.00% | 1 / 1 | 14 | ||
| qualifyForJoin | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 4 | ||
| where | 100.00% | 15 / 15 | 100.00% | 1 / 1 | 10 | ||
| orderBy | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 2 | ||
| groupBy | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 3 | ||
| having | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 5 | ||
| validateColumn | 100.00% | 28 / 28 | 100.00% | 1 / 1 | 17 | ||
| insert | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| insertGetId | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| update | 100.00% | 2 / 2 | 100.00% | 1 / 1 | 1 | ||
| normalizeRows | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | ||
| encodeRows | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| encodeRow | 100.00% | 5 / 5 | 100.00% | 1 / 1 | 2 | ||
| encodeValue | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| validateWriteColumn | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] normalizeTableReference | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 3 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] distinct | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] fromSub | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 2 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] join | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] leftJoin | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] rightJoin | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] crossJoin | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] addOn | 100.00% | 15 / 15 | 100.00% | 1 / 1 | 3 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] assertHomogeneousList | 100.00% | 12 / 12 | 100.00% | 1 / 1 | 6 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] whereNotExists | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] orWhereExists | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] orWhereNotExists | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] whereNotInQuery | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] orWhereInQuery | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] orWhereNotInQuery | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] whereNested | 100.00% | 16 / 16 | 100.00% | 1 / 1 | 3 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] limit | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] offset | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] union | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] lockForUpdate | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] sharedLock | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] scalarColumn | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] exists | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] delete | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getBindings | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 3 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] insertIdColumn | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] assertSupports | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 2 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getColumns | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] isDistinct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getFrom | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getFromAlias | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getJoins | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getWheres | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] markLastWhereTraitScope | 75.00% | 3 / 4 | 0.00% | 0 / 1 | 2.06 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getGroups | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getHavings | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getOrders | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getUnions | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getLock | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getLimit | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getOffset | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] getInsertIdColumn | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| [BlueprintAU\Radiant\Database\Query\QueryBuilder] isInsertIdAutoIncrement | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| 37 | final class ModelQueryBuilder extends QueryBuilder | |
| 38 | { | |
| 39 | /** | |
| 40 | * The columns always force-selected so hydration keeps the identity | |
| 41 | * (PK) and trash-state (soft-delete) columns available — `whereKey()` | |
| 42 | * needs the former, `trashed()` and save()'s soft-deleted guard the | |
| 43 | * latter. | |
| 44 | * | |
| 45 | * @var list<string> | |
| 46 | */ | |
| 47 | protected array $forcedKeys = []; | |
| 48 | ||
| 49 | /** | |
| 50 | * Every declared column name — the default select and the validation | |
| 51 | * set for every column-accepting method. | |
| 52 | * | |
| 53 | * @var list<string> | |
| 54 | */ | |
| 55 | protected array $modelColumns = []; | |
| 56 | ||
| 57 | /** | |
| 58 | * The eager-loaded relation paths (validated at with() time). | |
| 59 | * | |
| 60 | * @var list<string> | |
| 61 | */ | |
| 62 | protected array $eagerLoad = []; | |
| 63 | ||
| 64 | /** | |
| 65 | * The column → owning-table partition map (MTI only; empty otherwise). | |
| 66 | * | |
| 67 | * @var array<string, string> | |
| 68 | */ | |
| 69 | protected array $partitions = []; | |
| 70 | ||
| 71 | /** | |
| 72 | * The MTI ancestor chain (nearest parent first), each as | |
| 73 | * [class, table]. Empty for non-MTI models. | |
| 74 | * | |
| 75 | * @var list<array{class-string<Model>, string}> | |
| 76 | */ | |
| 77 | protected array $mtiChain = []; | |
| 78 | ||
| 79 | /** | |
| 80 | * Memoized relation resolutions, keyed by "class::method". | |
| 81 | * | |
| 82 | * @var array<string, Relations\Relation<Model>> | |
| 83 | */ | |
| 84 | protected static array $relationCache = []; | |
| 85 | ||
| 86 | /** | |
| 87 | * Invalidate the memoized relation-resolution cache. | |
| 88 | * | |
| 89 | * @param string|null $class Clear only this class's relations; null clears everything. | |
| 90 | * @return void | |
| 91 | */ | |
| 92 | public static function clearRelationCache(?string $class = null): void | |
| 93 | { | |
| 94 | if ($class === null) { | |
| 95 | static::$relationCache = []; | |
| 96 | return; | |
| 97 | } | |
| 98 | ||
| 99 | foreach (array_keys(static::$relationCache) as $key) { | |
| 100 | if (str_starts_with($key, $class . '::')) { | |
| 101 | unset(static::$relationCache[$key]); | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Declared-column hash set for {@see validateColumn()} (lazy). | |
| 108 | * | |
| 109 | * @var array<string, true>|null | |
| 110 | */ | |
| 111 | protected ?array $columnSet = null; | |
| 112 | ||
| 113 | /** | |
| 114 | * Forced-PK hash set for {@see validateColumn()} (lazy). | |
| 115 | * | |
| 116 | * @var array<string, true>|null | |
| 117 | */ | |
| 118 | protected ?array $forcedKeySet = null; | |
| 119 | ||
| 120 | /** | |
| 121 | * Whether this builder is a nested where group — nested builders skip | |
| 122 | * trait-scope application (the scopes ride the OUTER builder; applying | |
| 123 | * them here would recurse: whereNested → newNestedBuilder → ctor → | |
| 124 | * whereNested). | |
| 125 | * | |
| 126 | * @var bool | |
| 127 | */ | |
| 128 | private bool $nested = false; | |
| 129 | ||
| 130 | /** | |
| 131 | * Tables qualified column specs may reference beyond this builder's | |
| 132 | * own — the correlation context stamped by {@see correlateWith()}. | |
| 133 | * Validation-only: never compiled. | |
| 134 | * | |
| 135 | * @var list<string> | |
| 136 | */ | |
| 137 | protected array $correlationTables = []; | |
| 138 | ||
| 139 | /** | |
| 140 | * Create a builder bound to a model class on a connection. | |
| 141 | * | |
| 142 | * @param class-string<TModel> $modelClass | |
| 143 | * @param ConnectionInterface $connection | |
| 144 | * @param bool $nested Whether this builder is a nested where group (skips trait-scope application — the scopes ride the OUTER builder; applying them here would recurse). | |
| 145 | */ | |
| 146 | public function __construct( | |
| 147 | public readonly string $modelClass, | |
| 148 | ConnectionInterface $connection, | |
| 149 | bool $nested = false, | |
| 150 | ) { | |
| 151 | $this->nested = $nested; | |
| 152 | ||
| 153 | $metadata = MetadataFactory::for($modelClass); | |
| 154 | ||
| 155 | $this->forcedKeys = array_values(array_filter(array_map( | |
| 156 | fn(Column $column) => $column->name ?? '', | |
| 157 | $metadata->primaryKeys, | |
| 158 | ), fn(string $name) => $name !== '')); | |
| 159 | ||
| 160 | // The soft-delete column force-selects alongside the PKs: a narrow | |
| 161 | // caller select that omits it hydrates models whose trash state is | |
| 162 | // unknown — trashed() reads false and save()'s soft-deleted guard | |
| 163 | // passes, letting an UPDATE under the auto-scope match 0 rows while | |
| 164 | // reporting success. | |
| 165 | if ($metadata->softDeleteColumn !== null | |
| 166 | && !in_array($metadata->softDeleteColumn, $this->forcedKeys, true) | |
| 167 | ) { | |
| 168 | $this->forcedKeys[] = $metadata->softDeleteColumn; | |
| 169 | } | |
| 170 | ||
| 171 | $this->modelColumns = array_map( | |
| 172 | fn($mapping) => $mapping->columnName, | |
| 173 | array_values($metadata->properties), | |
| 174 | ); | |
| 175 | ||
| 176 | parent::__construct($connection, $modelClass::table()); | |
| 177 | ||
| 178 | // The model's OWN columns ARE the default select — resolved to the | |
| 179 | // explicit list (PK first) at construction, so no bare `*` ever | |
| 180 | // rides a model query: a join meeting the default qualifies real | |
| 181 | // columns (`table.*` only for joins whose second table shares no | |
| 182 | // dedup hazard... it stays one qualified star), and a grammar sees | |
| 183 | // a definite list. select('*') keeps its expansion contract (same | |
| 184 | // list); the base's `['*']` sentinel never materializes here. | |
| 185 | $this->columns = array_values(array_unique(array_merge( | |
| 186 | $this->forcedKeys, | |
| 187 | array_diff($this->modelColumns, $this->forcedKeys), | |
| 188 | ))); | |
| 189 | ||
| 190 | // MTI: partition the merged columns per table and join the ancestor | |
| 191 | // chain so the read path sees ONE virtual row spanning all levels. | |
| 192 | if ($metadata->isMtiChild()) { | |
| 193 | $this->partitions = $this->buildPartitions($metadata); | |
| 194 | $this->applyMtiJoins($metadata); | |
| 195 | } | |
| 196 | ||
| 197 | // Tell the connection which column to return on insert (RETURNING / | |
| 198 | // lastInsertId). A single auto-increment PK is server-generated; a | |
| 199 | // single caller-assigned PK declares itself too — with the | |
| 200 | // auto-increment flag false, so insertGetId()'s lastInsertId() | |
| 201 | // fallback fails fast instead of returning a stale id (a composite | |
| 202 | // PK declares nothing: no single column identifies the row). | |
| 203 | $primaryKeys = $metadata->primaryKeys; | |
| 204 | if (count($primaryKeys) === 1 && $primaryKeys[0]->name !== null) { | |
| 205 | // Constructor context: assign directly (the immutable clone API | |
| 206 | // is for post-construction callers). | |
| 207 | $this->insertIdColumn = $primaryKeys[0]->name; | |
| 208 | $this->insertIdAutoIncrement = $primaryKeys[0]->autoIncrement; | |
| 209 | } | |
| 210 | // Auto-apply every trait-declared scope. A trait's conditions group | |
| 211 | // in ONE nested where group marked with the trait (`traitScope` | |
| 212 | // marker) so the opt-outs (withTrashed/withoutScope/withoutScopes) | |
| 213 | // strip the trait's whole scope atomically by MARKER, not positional | |
| 214 | // index. Between traits the groups always AND — each trait's scope | |
| 215 | // is a hard filter. MTI: the scope qualifies to the OWNING table | |
| 216 | // (the column lives where the trait declared it). | |
| 217 | // Nested builders skip this entirely — the scopes ride the OUTER | |
| 218 | // builder; applying them here would recurse (whereNested → | |
| 219 | // newNestedBuilder → ctor → whereNested). | |
| 220 | $conditionsByTrait = []; | |
| 221 | ||
| 222 | if (!$this->nested) { | |
| 223 | foreach ($metadata->traitScopes as ['trait' => $scopeTrait, 'condition' => $condition]) { | |
| 224 | $conditionsByTrait[$scopeTrait][] = $condition; | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | foreach ($conditionsByTrait as $scopeTrait => $conditions) { | |
| 229 | // The constructor is the ONE place a builder finalizes its own | |
| 230 | // state: the scope rides the instance being built, then is | |
| 231 | // marked for marker-based removal. | |
| 232 | $scoped = $this->whereNested( | |
| 233 | function (WhereBuilder $nested) use ($conditions): WhereBuilder { | |
| 234 | // WhereBuilder is immutable — each where() returns a | |
| 235 | // NEW facade; thread it through the loop. | |
| 236 | $builder = $nested; | |
| 237 | ||
| 238 | foreach ($conditions as $index => $condition) { | |
| 239 | $column = $condition->column; | |
| 240 | ||
| 241 | if (isset($this->partitions[$column])) { | |
| 242 | $column = $this->partitions[$column] . '.' . $column; | |
| 243 | } | |
| 244 | ||
| 245 | // The first condition in the group carries no | |
| 246 | // boolean — the group's internal join starts with | |
| 247 | // the SECOND condition's declared boolean. | |
| 248 | $builder = $index === 0 | |
| 249 | ? $builder->where($column, $condition->operator, $condition->value) | |
| 250 | : $builder->where($column, $condition->operator, $condition->value, $condition->boolean); | |
| 251 | } | |
| 252 | ||
| 253 | return $builder; | |
| 254 | }, | |
| 255 | ); | |
| 256 | $scoped->markLastWhereTraitScope($scopeTrait); | |
| 257 | $this->wheres = $scoped->getWheres(); | |
| 258 | // The group's VALUE bindings ride the clone whereNested() | |
| 259 | // returned — copy them back alongside the wheres, or a scope | |
| 260 | // condition with a value (e.g. `status = 'published'`) compiles | |
| 261 | // to an unbound `= ?` and matches nothing. | |
| 262 | $this->bindings = $scoped->bindings; | |
| 263 | } | |
| 264 | } | |
| 265 | ||
| 266 | // ---- Soft-delete scope ---- | |
| 267 | ||
| 268 | /** | |
| 269 | * Register relations to eager-load after hydration. | |
| 270 | * | |
| 271 | * Validation happens here — an unknown relation is a typo and fails | |
| 272 | * fast at the with() call, not at hydration. Dot-notation nests: | |
| 273 | * `'posts.comments'` loads posts, then each post's comments. Spread a | |
| 274 | * computed list through the variadic: `->with(...$paths)`. | |
| 275 | * | |
| 276 | * @param string ...$relations | |
| 277 | * @return static | |
| 278 | * @throws \InvalidArgumentException | |
| 279 | */ | |
| 280 | public function with(string ...$relations): static | |
| 281 | { | |
| 282 | foreach ($relations as $path) { | |
| 283 | $this->assertRelationPath($path); | |
| 284 | $this->validateRelationPath($path); | |
| 285 | } | |
| 286 | ||
| 287 | $clone = clone $this; | |
| 288 | ||
| 289 | foreach ($relations as $path) { | |
| 290 | $clone->eagerLoad[] = $path; | |
| 291 | } | |
| 292 | ||
| 293 | return $clone; | |
| 294 | } | |
| 295 | ||
| 296 | /** | |
| 297 | * Assert one eager-load path is a non-empty string. | |
| 298 | * | |
| 299 | * The variadic types the elements at the language level — the empty | |
| 300 | * path is the only violation a static analyzer cannot see, so it is | |
| 301 | * the shape still checked at runtime. | |
| 302 | * | |
| 303 | * @param string $path Must be a non-empty string naming a relation path. | |
| 304 | * @return void | |
| 305 | * @throws \InvalidArgumentException | |
| 306 | */ | |
| 307 | private function assertRelationPath(string $path): void | |
| 308 | { | |
| 309 | if ($path === '') { | |
| 310 | throw new \InvalidArgumentException( | |
| 311 | 'Relation paths must be non-empty strings; got an empty path.' | |
| 312 | ); | |
| 313 | } | |
| 314 | } | |
| 315 | ||
| 316 | /** | |
| 317 | * Assert a dotted relation path resolves to relation methods. | |
| 318 | * | |
| 319 | * @param string $path | |
| 320 | * @return void | |
| 321 | * @throws \InvalidArgumentException | |
| 322 | */ | |
| 323 | protected function validateRelationPath(string $path): void | |
| 324 | { | |
| 325 | $class = $this->modelClass; | |
| 326 | ||
| 327 | foreach (explode('.', $path) as $segment) { | |
| 328 | $relation = static::resolveRelation($class, $segment, $path); | |
| 329 | $classes = $relation->relatedClasses(); | |
| 330 | ||
| 331 | if ($classes === []) { | |
| 332 | // A DYNAMIC related set (MorphTo) — the deeper segments | |
| 333 | // cannot be validated statically; the runtime recursion | |
| 334 | // resolves them off the actual loaded models. | |
| 335 | return; | |
| 336 | } | |
| 337 | ||
| 338 | $class = $classes[0]; | |
| 339 | } | |
| 340 | } | |
| 341 | ||
| 342 | /** | |
| 343 | * Resolve one relation-method name on a model class. | |
| 344 | * | |
| 345 | * @param class-string<Model> $class | |
| 346 | * @param string $name | |
| 347 | * @param string $path | |
| 348 | * @return Relations\Relation<Model> | |
| 349 | * @throws \InvalidArgumentException | |
| 350 | */ | |
| 351 | protected static function resolveRelation(string $class, string $name, string $path): Relations\Relation | |
| 352 | { | |
| 353 | $cacheKey = $class . '::' . $name; | |
| 354 | ||
| 355 | if (isset(static::$relationCache[$cacheKey])) { | |
| 356 | return static::$relationCache[$cacheKey]; | |
| 357 | } | |
| 358 | ||
| 359 | if (!method_exists($class, $name)) { | |
| 360 | throw new \InvalidArgumentException( | |
| 361 | "Unknown relation [{$path}] — model [{$class}] has no method [{$name}()]." | |
| 362 | ); | |
| 363 | } | |
| 364 | ||
| 365 | $reflection = new \ReflectionMethod($class, $name); | |
| 366 | ||
| 367 | if (!$reflection->isPublic()) { | |
| 368 | throw new \InvalidArgumentException( | |
| 369 | "Relation [{$path}] — method [{$class}::{$name}()] is not public; relations must be callable." | |
| 370 | ); | |
| 371 | } | |
| 372 | ||
| 373 | // A relation method takes no arguments and returns a Relation. | |
| 374 | // Invoke it on a detached instance (the newInstance() hook — | |
| 375 | // hydration without constructor) so the parent's attribute reads | |
| 376 | // see nulls rather than an uninitialized-property error. | |
| 377 | $prototype = $class::newInstance(); | |
| 378 | ||
| 379 | /** @var mixed $result */ | |
| 380 | $result = $reflection->invoke($prototype); | |
| 381 | ||
| 382 | if (!$result instanceof Relations\Relation) { | |
| 383 | throw new \InvalidArgumentException( | |
| 384 | "Unknown relation [{$path}] — method [{$class}::{$name}()] does not return a Relation." | |
| 385 | ); | |
| 386 | } | |
| 387 | ||
| 388 | return static::$relationCache[$cacheKey] = $result; | |
| 389 | } | |
| 390 | ||
| 391 | /** | |
| 392 | * Eager-load the registered relations onto a hydrated collection. | |
| 393 | * | |
| 394 | * One extra query per relation path — an `IN` on the FK, no joins, no | |
| 395 | * row multiplication. | |
| 396 | * | |
| 397 | * @template TLoaded of Model | |
| 398 | * | |
| 399 | * @param Collection<int, TLoaded> $models | |
| 400 | * @return void | |
| 401 | */ | |
| 402 | protected function eagerLoadRelations(Collection $models): void | |
| 403 | { | |
| 404 | foreach ($this->eagerLoad as $path) { | |
| 405 | $this->loadRelationPath($models, $path); | |
| 406 | } | |
| 407 | } | |
| 408 | ||
| 409 | /** | |
| 410 | * Load one dotted relation path onto the models. | |
| 411 | * | |
| 412 | * @template TLoaded of Model | |
| 413 | * | |
| 414 | * @param Collection<int, TLoaded> $models | |
| 415 | * @param string $path | |
| 416 | * @return void | |
| 417 | */ | |
| 418 | public function loadRelationPath(Collection $models, string $path): void | |
| 419 | { | |
| 420 | [$name, $nested] = $this->splitPath($path); | |
| 421 | ||
| 422 | /** @var list<Model> $modelsArray */ | |
| 423 | $modelsArray = $models->all(); | |
| 424 | ||
| 425 | if ($modelsArray === []) { | |
| 426 | return; | |
| 427 | } | |
| 428 | ||
| 429 | // The relation prototype comes off the FIRST model (all models in | |
| 430 | // a collection are the same class — that is the hydration contract). | |
| 431 | $first = $modelsArray[0]; | |
| 432 | $relation = static::resolveRelation($first::class, $name, $path); | |
| 433 | ||
| 434 | $this->loadRelation($modelsArray, $relation, $name, $nested, $path); | |
| 435 | } | |
| 436 | ||
| 437 | /** | |
| 438 | * Split a dotted path into its first segment and the nested remainder. | |
| 439 | * | |
| 440 | * @param string $path The full path. | |
| 441 | * @return array{string, string|null} The first segment plus the remainder, or null when the path has one segment. | |
| 442 | */ | |
| 443 | protected function splitPath(string $path): array | |
| 444 | { | |
| 445 | $dot = strpos($path, '.'); | |
| 446 | ||
| 447 | if ($dot === false) { | |
| 448 | return [$path, null]; | |
| 449 | } | |
| 450 | ||
| 451 | return [substr($path, 0, $dot), substr($path, $dot + 1)]; | |
| 452 | } | |
| 453 | ||
| 454 | /** | |
| 455 | * Run the eager query for a relation and stitch the results. | |
| 456 | * | |
| 457 | * @param list<Model> $parents | |
| 458 | * @param Relations\Relation<Model> $relation | |
| 459 | * @param string $name | |
| 460 | * @param string|null $nested | |
| 461 | * @param string $path | |
| 462 | * @return void | |
| 463 | */ | |
| 464 | protected function loadRelation(array $parents, Relations\Relation $relation, string $name, ?string $nested, string $path): void | |
| 465 | { | |
| 466 | // Collect the parents' key values for the IN clause. The column the | |
| 467 | // keys come from is relation-specific: HasOne/HasMany/through read | |
| 468 | // the parent's LOCAL key (the related table's FK points at it), while | |
| 469 | // BelongsTo reads the parent's FOREIGN key (it points at the related | |
| 470 | // table's owner key). BelongsTo overrides the accessor so the loader | |
| 471 | // stays key-agnostic. A composite key collects the full tuple — | |
| 472 | // deduped by its serialized form, distinct values in first-seen order. | |
| 473 | $keys = []; | |
| 474 | $keyIndex = []; | |
| 475 | ||
| 476 | foreach ($parents as $parent) { | |
| 477 | $column = $relation->eagerKeyColumn(); | |
| 478 | ||
| 479 | if (is_string($column)) { | |
| 480 | $value = $parent->attribute($column); | |
| 481 | ||
| 482 | if ($value === null) { | |
| 483 | continue; | |
| 484 | } | |
| 485 | ||
| 486 | $serialized = (string) $value; | |
| 487 | $keys[$serialized] = true; | |
| 488 | $keyIndex[$serialized] = $value; | |
| 489 | ||
| 490 | continue; | |
| 491 | } | |
| 492 | ||
| 493 | $tuple = []; | |
| 494 | ||
| 495 | foreach ($column as $keyColumn) { | |
| 496 | $tuple[$keyColumn] = $parent->attribute($keyColumn); | |
| 497 | } | |
| 498 | ||
| 499 | if (in_array(null, $tuple, true)) { | |
| 500 | continue; // a null key component matches nothing — skip | |
| 501 | } | |
| 502 | ||
| 503 | $serialized = json_encode($tuple, JSON_THROW_ON_ERROR); | |
| 504 | $keys[$serialized] = true; | |
| 505 | $keyIndex[$serialized] = $tuple; | |
| 506 | } | |
| 507 | ||
| 508 | if ($keys === []) { | |
| 509 | foreach ($parents as $parent) { | |
| 510 | // A single-valued relation (HasOne/BelongsTo/MorphTo) loads | |
| 511 | // as NULL when no parent carries a key; a to-many relation | |
| 512 | // loads as an EMPTY collection. The relation's cardinality | |
| 513 | // decides — a probe match against an empty result set | |
| 514 | // keeps the shapes honest without special-casing names. | |
| 515 | $empty = Collection::make([]); | |
| 516 | ||
| 517 | $relation->match([$parent], $empty, $name, []); | |
| 518 | } | |
| 519 | ||
| 520 | return; | |
| 521 | } | |
| 522 | ||
| 523 | $keyList = array_values($keyIndex); | |
| 524 | ||
| 525 | $result = $relation->eagerLoad($keyList); | |
| 526 | ||
| 527 | $relation->match($parents, $result->models, $name, $result->parentKeys); | |
| 528 | ||
| 529 | if ($nested !== null) { | |
| 530 | // Recurse onto the freshly-loaded related models. | |
| 531 | /** @var list<Model> $children */ | |
| 532 | $children = []; | |
| 533 | ||
| 534 | foreach ($parents as $parent) { | |
| 535 | $value = $parent->cachedRelation($name); | |
| 536 | ||
| 537 | if ($value instanceof Collection) { | |
| 538 | foreach ($value as $child) { | |
| 539 | $children[] = $child; | |
| 540 | } | |
| 541 | } elseif ($value instanceof Model) { | |
| 542 | $children[] = $value; | |
| 543 | } | |
| 544 | } | |
| 545 | ||
| 546 | if ($children !== []) { | |
| 547 | // Recurse ONE segment at a time: split the remaining dotted | |
| 548 | // path, resolve only its first segment as a method name, and | |
| 549 | // pass the rest down as the next level's nested path. Passing | |
| 550 | // the whole remainder (`b.c`) as a method name would fail for | |
| 551 | // any path three or more levels deep. | |
| 552 | [$childName, $childNested] = $this->splitPath($nested); | |
| 553 | $childRelation = static::resolveRelation($children[0]::class, $childName, $path); | |
| 554 | $this->loadRelation($children, $childRelation, $childName, $childNested, $path); | |
| 555 | } | |
| 556 | } | |
| 557 | } | |
| 558 | ||
| 559 | // ---- MTI (multi-table inheritance) reads ---- | |
| 560 | ||
| 561 | /** | |
| 562 | * Build the column → owning-table partition map from the metadata. | |
| 563 | * | |
| 564 | * @param \BlueprintAU\Radiant\Metadata\ClassMetadata $metadata | |
| 565 | * @return array<string, string> | |
| 566 | */ | |
| 567 | protected function buildPartitions(\BlueprintAU\Radiant\Metadata\ClassMetadata $metadata): array | |
| 568 | { | |
| 569 | $partitions = []; | |
| 570 | ||
| 571 | foreach ($metadata->properties as $mapping) { | |
| 572 | $partitions[$mapping->columnName] = $metadata->tableFor($mapping->columnName); | |
| 573 | } | |
| 574 | ||
| 575 | return $partitions; | |
| 576 | } | |
| 577 | ||
| 578 | /** | |
| 579 | * INNER JOIN every ancestor table on the shared PK and select each | |
| 580 | * level's columns qualified + aliased back to the plain columnName. | |
| 581 | * | |
| 582 | * @param \BlueprintAU\Radiant\Metadata\ClassMetadata $metadata | |
| 583 | * @return void | |
| 584 | */ | |
| 585 | protected function applyMtiJoins(\BlueprintAU\Radiant\Metadata\ClassMetadata $metadata): void | |
| 586 | { | |
| 587 | $table = $metadata->tableName; | |
| 588 | $this->mtiChain = []; | |
| 589 | ||
| 590 | // Walk the parent chain; each table-owning level joins on the | |
| 591 | // shared PK (same-named key columns ARE the link). | |
| 592 | $parent = $metadata->parentModel; | |
| 593 | ||
| 594 | while ($parent !== null) { | |
| 595 | $parentMetadata = MetadataFactory::for($parent); | |
| 596 | $parentTable = $parentMetadata->tableName; | |
| 597 | ||
| 598 | if ($parentTable !== null) { | |
| 599 | $pk = $parentMetadata->primaryKeys[0]->name ?? 'id'; | |
| 600 | ||
| 601 | $this->joins[] = [ | |
| 602 | 'type' => JoinType::Inner, | |
| 603 | 'table' => $parentTable, | |
| 604 | 'wheres' => [[ | |
| 605 | 'type' => WhereType::Column, | |
| 606 | 'first' => "{$table}.{$pk}", | |
| 607 | 'operator' => ColumnOperator::Eq, | |
| 608 | 'second' => "{$parentTable}.{$pk}", | |
| 609 | 'boolean' => WhereBoolean::And, | |
| 610 | ]], | |
| 611 | ]; | |
| 612 | ||
| 613 | $this->mtiChain[] = [$parent, $parentTable]; | |
| 614 | } | |
| 615 | ||
| 616 | $parent = $parentMetadata->parentModel; | |
| 617 | } | |
| 618 | ||
| 619 | // Re-select every column qualified + aliased back to its plain | |
| 620 | // columnName — raw keys stay unambiguous across all levels. Plain | |
| 621 | // `table.column as column` specs: the Grammar's wrapColumn() owns | |
| 622 | // the quoting AND the AS rendering. Constructor-time: the builder | |
| 623 | // finalizes its own state here (the one self-mutation point). | |
| 624 | $selects = []; | |
| 625 | ||
| 626 | foreach ($this->modelColumns as $column) { | |
| 627 | $ownerTable = $this->partitions[$column] ?? $table; | |
| 628 | $selects[] = "{$ownerTable}.{$column} as {$column}"; | |
| 629 | } | |
| 630 | ||
| 631 | $this->columns = $selects; | |
| 632 | } | |
| 633 | ||
| 634 | /** | |
| 635 | * Add a column-to-column comparison with model-aware column validation. | |
| 636 | * | |
| 637 | * @param string $first | |
| 638 | * @param ColumnOperator|string $operator | |
| 639 | * @param string $second | |
| 640 | * @param WhereBoolean $boolean | |
| 641 | * @return static | |
| 642 | * @throws \InvalidArgumentException | |
| 643 | */ | |
| 644 | public function whereColumn(string $first, ColumnOperator|string $operator = '=', string $second = '', WhereBoolean $boolean = WhereBoolean::And): static | |
| 645 | { | |
| 646 | $this->validateColumn($first); | |
| 647 | $this->validateColumn($second); | |
| 648 | ||
| 649 | return parent::whereColumn($first, $operator, $second, $boolean); | |
| 650 | } | |
| 651 | /** | |
| 652 | * Add a raw SQL where clause. | |
| 653 | * | |
| 654 | * @param string $sql | |
| 655 | * @param array<int, mixed> $bindings | |
| 656 | * @param WhereBoolean $boolean | |
| 657 | * @return static | |
| 658 | */ | |
| 659 | public function whereRaw(string $sql, array $bindings = [], WhereBoolean $boolean = WhereBoolean::And): static | |
| 660 | { | |
| 661 | return parent::whereRaw($sql, $bindings, $boolean); | |
| 662 | } | |
| 663 | ||
| 664 | /** | |
| 665 | * Add an `EXISTS (subquery)` clause to the query. | |
| 666 | * | |
| 667 | * There is no outer column to validate — the subquery's columns were | |
| 668 | * validated against its own model at declaration. Declare the | |
| 669 | * correlation with {@see correlateWith()} before referencing outer | |
| 670 | * tables. | |
| 671 | * | |
| 672 | * @param QueryBuilder $query The existential subquery. | |
| 673 | * @param WhereBoolean $boolean | |
| 674 | * @param bool $negated True renders `NOT EXISTS`. | |
| 675 | * @return static | |
| 676 | */ | |
| 677 | #[\Override] | |
| 678 | public function whereExists(QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And, bool $negated = false): static | |
| 679 | { | |
| 680 | return parent::whereExists($query, $boolean, $negated); | |
| 681 | } | |
| 682 | ||
| 683 | /** | |
| 684 | * Add a `column IN (subquery)` clause to the query. | |
| 685 | * | |
| 686 | * The outer column validates against this model's allowlist; the | |
| 687 | * subquery's columns validate against its own model. | |
| 688 | * | |
| 689 | * @param string $column The outer column the IN constrains. | |
| 690 | * @param QueryBuilder $query The single-column value subquery. | |
| 691 | * @param WhereBoolean $boolean | |
| 692 | * @param bool $negated True renders `NOT IN`. | |
| 693 | * @return static | |
| 694 | * @throws \InvalidArgumentException | |
| 695 | */ | |
| 696 | #[\Override] | |
| 697 | public function whereInQuery(string $column, QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And, bool $negated = false): static | |
| 698 | { | |
| 699 | $this->validateColumn($column); | |
| 700 | ||
| 701 | return parent::whereInQuery($column, $query, $boolean, $negated); | |
| 702 | } | |
| 703 | ||
| 704 | /** | |
| 705 | * Declare this builder a correlated subquery of the given outer | |
| 706 | * builder. | |
| 707 | * | |
| 708 | * Column validation is eager, so this must be called before the | |
| 709 | * correlated clauses: afterward, qualified specs may reference the | |
| 710 | * outer's tables (plus its join tables and aliases), while a | |
| 711 | * foreign-table reference without it still throws at the clause call. | |
| 712 | * | |
| 713 | * @param QueryBuilder $outer The builder this subquery correlates against. | |
| 714 | * @return static | |
| 715 | */ | |
| 716 | public function correlateWith(QueryBuilder $outer): static | |
| 717 | { | |
| 718 | $tables = [$outer->table]; | |
| 719 | ||
| 720 | foreach ($outer->getJoins() as $join) { | |
| 721 | if (preg_match('/^(.*?)\s+as\s+(\S+)$/i', $join['table'], $m) === 1) { | |
| 722 | $tables[] = trim($m[1]); | |
| 723 | $tables[] = $m[2]; | |
| 724 | } else { | |
| 725 | $tables[] = $join['table']; | |
| 726 | } | |
| 727 | } | |
| 728 | ||
| 729 | $clone = clone $this; | |
| 730 | $clone->correlationTables = $tables; | |
| 731 | return $clone; | |
| 732 | } | |
| 733 | ||
| 734 | /** | |
| 735 | * Create a new builder for a nested where group. | |
| 736 | * | |
| 737 | * @return QueryBuilder | |
| 738 | * @throws \InvalidArgumentException | |
| 739 | */ | |
| 740 | protected function newNestedBuilder(): QueryBuilder | |
| 741 | { | |
| 742 | $nested = new self($this->modelClass, $this->connection, nested: true); | |
| 743 | ||
| 744 | // A nested where group may reference columns on tables the OUTER | |
| 745 | // query joins (e.g. a through-relation constraint qualifying the | |
| 746 | // intermediate table). validateColumn() admits qualified names by | |
| 747 | // checking the joins list, so the nested builder must see the outer | |
| 748 | // joins. The copy is validation-only: whereNested() compiles the | |
| 749 | // group from getWheres() alone — the nested builder's joins are | |
| 750 | // never compiled, so no double-join is possible. | |
| 751 | $nested->joins = $this->joins; | |
| 752 | ||
| 753 | return $nested; | |
| 754 | } | |
| 755 | ||
| 756 | /** | |
| 757 | * Add a join, shielding and qualifying the select for the second | |
| 758 | * table the join introduces. | |
| 759 | * | |
| 760 | * A join turns previously-harmless select shapes ambiguous: under a | |
| 761 | * raw `*`, duplicate names (every table has an `id`) collide last-wins | |
| 762 | * in the fetched row — hydration would decode the joined table's | |
| 763 | * values through THIS model's casts; under bare column names, the | |
| 764 | * shared names fail at the driver as ambiguous columns. The clone's | |
| 765 | * select is therefore QUALIFIED as join-aware: a raw star (the | |
| 766 | * untouched default, or a hand-built bare star among specs) becomes | |
| 767 | * `table.*`, bare column specs become `table.column` — producing the | |
| 768 | * SAME compiled list whichever way the caller ordered `select()` and | |
| 769 | * `join()` (select() applies the identical qualification once the | |
| 770 | * joins exist). Qualified specs, Aliases, Aggregates and Expressions | |
| 771 | * pass through untouched. | |
| 772 | * | |
| 773 | * @param JoinType $type | |
| 774 | * @param string $table | |
| 775 | * @param string $first | |
| 776 | * @param ColumnOperator|string $operator | |
| 777 | * @param string $second | |
| 778 | * @return static | |
| 779 | * @throws \InvalidArgumentException | |
| 780 | */ #[\Override] | |
| 781 | protected function addJoin(JoinType $type, string $table, string $first, ColumnOperator|string $operator, string $second): static | |
| 782 | { | |
| 783 | $clone = parent::addJoin($type, $table, $first, $operator, $second); | |
| 784 | ||
| 785 | // Per-spec qualify: every bare name prefixes with this model's | |
| 786 | // table; every raw star in place becomes `table.*`. The untouched | |
| 787 | // default (`['*']`) therefore collapses to exactly one qualified | |
| 788 | // star, while a hand-built mixed shape (`count(*) AS t`, `*`, | |
| 789 | // `name`) keeps its aggregate AND its other columns — each star | |
| 790 | // shields in place, none of the caller's specs are dropped. | |
| 791 | // Bare names selected BEFORE the join are already in `$columns` — | |
| 792 | // qualifying them closes the join-first half of the ordering | |
| 793 | // (select() handles its OWN bare specs post-join). | |
| 794 | $clone->columns = $this->qualifyColumns($clone->columns); | |
| 795 | ||
| 796 | return $clone; | |
| 797 | } | |
| 798 | ||
| 799 | /** | |
| 800 | * Qualify bare specs in an arbitrary column list — the shared body | |
| 801 | * of {@see qualifyForJoin()} and {@see addJoin()}'s ordering shield. | |
| 802 | * | |
| 803 | * Every plain-string spec WITHOUT a `.` gets this model's table | |
| 804 | * prefix — including a raw `*`, which becomes `table.*` IN PLACE (a | |
| 805 | * mixed shape keeps its sibling specs); Aggregate, Expression, | |
| 806 | * SubquerySelect and already-qualified entries pass through. | |
| 807 | * | |
| 808 | * @param list<string|Expression|Aggregate|SubquerySelect> $columns | |
| 809 | * @return list<string|Expression|Aggregate|SubquerySelect> | |
| 810 | */ | |
| 811 | private function qualifyColumns(array $columns): array | |
| 812 | { | |
| 813 | $qualified = []; | |
| 814 | ||
| 815 | foreach ($columns as $column) { | |
| 816 | if ($column instanceof Expression || $column instanceof Aggregate || $column instanceof SubquerySelect) { | |
| 817 | $qualified[] = $column; | |
| 818 | continue; | |
| 819 | } | |
| 820 | ||
| 821 | $qualified[] = str_contains($column, '.') | |
| 822 | ? $column | |
| 823 | : $this->table . '.' . $column; | |
| 824 | } | |
| 825 | ||
| 826 | return $qualified; | |
| 827 | } | |
| 828 | ||
| 829 | /** | |
| 830 | * Append an ON condition to the last added join, with validation. | |
| 831 | * | |
| 832 | * @param string $first | |
| 833 | * @param ColumnOperator|string $operator | |
| 834 | * @param string $second | |
| 835 | * @return static | |
| 836 | * @throws \LogicException | |
| 837 | * @throws \InvalidArgumentException | |
| 838 | */ | |
| 839 | public function on(string $first, ColumnOperator|string $operator = '=', string $second = ''): static | |
| 840 | { | |
| 841 | $this->validateColumn($first); | |
| 842 | $this->validateColumn($second); | |
| 843 | ||
| 844 | return parent::on($first, $operator, $second); | |
| 845 | } | |
| 846 | ||
| 847 | /** | |
| 848 | * Append an OR-connected ON condition to the last added join, with | |
| 849 | * validation. | |
| 850 | * | |
| 851 | * @param string $first | |
| 852 | * @param ColumnOperator|string $operator | |
| 853 | * @param string $second | |
| 854 | * @return static | |
| 855 | * @throws \LogicException | |
| 856 | * @throws \InvalidArgumentException | |
| 857 | */ | |
| 858 | public function orOn(string $first, ColumnOperator|string $operator = '=', string $second = ''): static | |
| 859 | { | |
| 860 | $this->validateColumn($first); | |
| 861 | $this->validateColumn($second); | |
| 862 | ||
| 863 | return parent::orOn($first, $operator, $second); | |
| 864 | } | |
| 865 | ||
| 866 | // ---- Trait scopes ---- | |
| 867 | ||
| 868 | // The soft-delete query vocabulary lives HERE, not on the SoftDeletes | |
| 869 | // trait: these methods return a builder (fluent chains) and strip/re-mark | |
| 870 | // the traitScope where-markers below — query state a model-side trait | |
| 871 | // method cannot own. The trait keeps the row behavior (scope, delete | |
| 872 | // hook, restore/trashed). | |
| 873 | ||
| 874 | /** | |
| 875 | * Include soft-deleted rows — strips only the SoftDeletes scope. | |
| 876 | * | |
| 877 | * @return static | |
| 878 | */ | |
| 879 | public function withTrashed(): static | |
| 880 | { | |
| 881 | return $this->withoutScope(SoftDeletes::class); | |
| 882 | } | |
| 883 | ||
| 884 | /** | |
| 885 | * Only soft-deleted rows — strips the scope and adds a marked | |
| 886 | * `whereNotNull` so the toggle round-trips. | |
| 887 | * | |
| 888 | * @return static | |
| 889 | */ | |
| 890 | public function onlyTrashed(): static | |
| 891 | { | |
| 892 | $metadata = MetadataFactory::for($this->modelClass); | |
| 893 | $column = $metadata->softDeleteColumn; | |
| 894 | ||
| 895 | if ($column === null) { | |
| 896 | throw new \LogicException( | |
| 897 | "Model [{$this->modelClass}] does not use SoftDeletes; onlyTrashed() is unavailable." | |
| 898 | ); | |
| 899 | } | |
| 900 | ||
| 901 | // First clear any existing soft-delete state (scope and/or a | |
| 902 | // previous onlyTrashed clause) so the toggle is idempotent. | |
| 903 | $cleared = $this->withTrashed(); | |
| 904 | ||
| 905 | if (isset($cleared->partitions[$column])) { | |
| 906 | $column = $cleared->partitions[$column] . '.' . $column; | |
| 907 | } | |
| 908 | ||
| 909 | $scoped = $cleared->whereNotNull($column); | |
| 910 | $scoped->markLastWhereTraitScope(SoftDeletes::class); | |
| 911 | ||
| 912 | $clone = clone $scoped; | |
| 913 | return $clone; | |
| 914 | } | |
| 915 | ||
| 916 | /** | |
| 917 | * Strip every where clause declared by one trait's scope. | |
| 918 | * | |
| 919 | * @param class-string $trait | |
| 920 | * @return static | |
| 921 | */ | |
| 922 | public function withoutScope(string $trait): static | |
| 923 | { | |
| 924 | $wheres = $this->getWheres(); | |
| 925 | ||
| 926 | $filtered = array_values(array_filter( | |
| 927 | $wheres, | |
| 928 | fn(array $where): bool => ($where['traitScope'] ?? null) !== $trait, | |
| 929 | )); | |
| 930 | ||
| 931 | if ($filtered === $wheres) { | |
| 932 | return $this; // nothing to remove — reuse the instance. | |
| 933 | } | |
| 934 | ||
| 935 | $clone = clone $this; | |
| 936 | $clone->wheres = $filtered; | |
| 937 | return $clone; | |
| 938 | } | |
| 939 | ||
| 940 | /** | |
| 941 | * Strip every trait-declared scope. | |
| 942 | * | |
| 943 | * @return static | |
| 944 | */ | |
| 945 | public function withoutScopes(): static | |
| 946 | { | |
| 947 | $wheres = $this->getWheres(); | |
| 948 | ||
| 949 | $filtered = array_values(array_filter( | |
| 950 | $wheres, | |
| 951 | fn(array $where): bool => !array_key_exists('traitScope', $where), | |
| 952 | )); | |
| 953 | ||
| 954 | if ($filtered === $wheres) { | |
| 955 | return $this; // nothing to remove — reuse the instance. | |
| 956 | } | |
| 957 | ||
| 958 | $clone = clone $this; | |
| 959 | $clone->wheres = $filtered; | |
| 960 | return $clone; | |
| 961 | } | |
| 962 | ||
| 963 | // ---- Execution (hydration) ---- | |
| 964 | ||
| 965 | /** | |
| 966 | * Run the query and hydrate every row into a model. | |
| 967 | * | |
| 968 | * @return Collection<int, TModel> | |
| 969 | * | |
| 970 | * @phpstan-ignore method.childReturnType, generics.variance | |
| 971 | */ | |
| 972 | public function get(): Collection | |
| 973 | { | |
| 974 | $rows = parent::get(); | |
| 975 | ||
| 976 | $models = array_map( | |
| 977 | fn(\stdClass $row) => $this->modelClass::fromRow($row), | |
| 978 | $rows->all(), | |
| 979 | ); | |
| 980 | ||
| 981 | $collection = Collection::make($models); | |
| 982 | ||
| 983 | $this->eagerLoadRelations($collection); | |
| 984 | ||
| 985 | return $collection; | |
| 986 | } | |
| 987 | ||
| 988 | /** | |
| 989 | * Get the raw rows (stdClass), bypassing hydration. | |
| 990 | * | |
| 991 | * @return BaseCollection<int, \stdClass> | |
| 992 | */ | |
| 993 | public function getRaw(): BaseCollection | |
| 994 | { | |
| 995 | return parent::get(); | |
| 996 | } | |
| 997 | ||
| 998 | /** | |
| 999 | * Stream the query, hydrating each row into a model as it arrives. | |
| 1000 | * | |
| 1001 | * Eager loads cannot ride a stream — use `get()` when relations are | |
| 1002 | * required. | |
| 1003 | * | |
| 1004 | * @return \Generator<int, TModel> | |
| 1005 | * | |
| 1006 | * @phpstan-ignore method.childReturnType | |
| 1007 | */ | |
| 1008 | public function cursor(): \Generator | |
| 1009 | { | |
| 1010 | foreach ($this->connection->cursor($this) as $row) { | |
| 1011 | yield $this->modelClass::fromRow($row); | |
| 1012 | } | |
| 1013 | } | |
| 1014 | ||
| 1015 | /** | |
| 1016 | * Run the query and hydrate the first row. | |
| 1017 | * | |
| 1018 | * @return TModel|null | |
| 1019 | */ | |
| 1020 | public function first(): ?Model | |
| 1021 | { | |
| 1022 | // limit() is immutable — it returns a scoped clone, leaving this | |
| 1023 | // builder's own limit untouched. | |
| 1024 | $rows = $this->connection->select($this->limit(1)); | |
| 1025 | $row = $rows[0] ?? null; | |
| 1026 | ||
| 1027 | if ($row === null) { | |
| 1028 | return null; | |
| 1029 | } | |
| 1030 | ||
| 1031 | $model = $this->modelClass::fromRow($row); | |
| 1032 | ||
| 1033 | // Eager loads apply to single-model reads too. | |
| 1034 | if ($this->eagerLoad !== []) { | |
| 1035 | $single = Collection::make([$model]); | |
| 1036 | ||
| 1037 | $this->eagerLoadRelations($single); | |
| 1038 | } | |
| 1039 | ||
| 1040 | return $model; | |
| 1041 | } | |
| 1042 | ||
| 1043 | /** | |
| 1044 | * Find a model by primary key. | |
| 1045 | * | |
| 1046 | * @param KeyValue $id The primary-key value, or a column => value map for a composite key. | |
| 1047 | * @return TModel|null | |
| 1048 | */ | |
| 1049 | public function find(int|string|null|array $id): ?Model | |
| 1050 | { | |
| 1051 | return $this->whereKey($id)->first(); | |
| 1052 | } | |
| 1053 | ||
| 1054 | /** | |
| 1055 | * Get the first hydrated row or throw if no rows match. | |
| 1056 | * | |
| 1057 | * @return TModel | |
| 1058 | * | |
| 1059 | * @throws ModelNotFoundException | |
| 1060 | */ | |
| 1061 | public function firstOrFail(): Model | |
| 1062 | { | |
| 1063 | return (clone $this)->firstOrFailWithKey(null); | |
| 1064 | } | |
| 1065 | ||
| 1066 | /** | |
| 1067 | * Find a model by primary key or throw if it does not exist. | |
| 1068 | * | |
| 1069 | * @param KeyValue $id The primary-key value, or a column => value map for a composite key. | |
| 1070 | * @return TModel | |
| 1071 | * | |
| 1072 | * @throws ModelNotFoundException | |
| 1073 | */ | |
| 1074 | public function findOrFail(int|string|null|array $id): Model | |
| 1075 | { | |
| 1076 | // whereKey() also runs on the clone — the added wheres never land | |
| 1077 | // on the shared builder. | |
| 1078 | return (clone $this)->whereKey($id)->firstOrFailWithKey($id); | |
| 1079 | } | |
| 1080 | ||
| 1081 | /** | |
| 1082 | * Get the single matching row or throw if the count differs. | |
| 1083 | * | |
| 1084 | * @return TModel | |
| 1085 | * | |
| 1086 | * @throws ModelNotFoundException | |
| 1087 | * @throws MultipleRecordsFoundException | |
| 1088 | */ | |
| 1089 | public function sole(): Model | |
| 1090 | { | |
| 1091 | // select() returns a Collection (not a bare array) — count it, | |
| 1092 | // never `=== []`. | |
| 1093 | $rows = $this->connection->select((clone $this)->limit(2)); | |
| 1094 | $rowCount = \count($rows); | |
| 1095 | ||
| 1096 | if ($rowCount === 0) { | |
| 1097 | throw new ModelNotFoundException($this->modelClass); | |
| 1098 | } | |
| 1099 | ||
| 1100 | if ($rowCount > 1) { | |
| 1101 | throw new MultipleRecordsFoundException($rowCount, $this->modelClass); | |
| 1102 | } | |
| 1103 | ||
| 1104 | $row = $rows[0] ?? null; | |
| 1105 | ||
| 1106 | if (!$row instanceof \stdClass) { | |
| 1107 | throw new ModelNotFoundException($this->modelClass); | |
| 1108 | } | |
| 1109 | ||
| 1110 | $model = $this->modelClass::fromRow($row); | |
| 1111 | ||
| 1112 | // Eager loads apply to single-model reads too — same tail as first(). | |
| 1113 | if ($this->eagerLoad !== []) { | |
| 1114 | $single = Collection::make([$model]); | |
| 1115 | ||
| 1116 | $this->eagerLoadRelations($single); | |
| 1117 | } | |
| 1118 | ||
| 1119 | return $model; | |
| 1120 | } | |
| 1121 | ||
| 1122 | /** | |
| 1123 | * The shared fail-fast fetch behind {@see firstOrFail()} and | |
| 1124 | * {@see findOrFail()}. | |
| 1125 | * | |
| 1126 | * @param KeyValue|null $keyForMessage The lookup key for the exception, or null. | |
| 1127 | * @return TModel | |
| 1128 | * | |
| 1129 | * @throws ModelNotFoundException | |
| 1130 | */ | |
| 1131 | private function firstOrFailWithKey(int|string|null|array $keyForMessage): Model | |
| 1132 | { | |
| 1133 | $model = $this->first(); | |
| 1134 | ||
| 1135 | if ($model === null) { | |
| 1136 | throw new ModelNotFoundException($this->modelClass, $keyForMessage); | |
| 1137 | } | |
| 1138 | ||
| 1139 | return $model; | |
| 1140 | } | |
| 1141 | ||
| 1142 | // ---- Find-or-create ---- | |
| 1143 | ||
| 1144 | /** | |
| 1145 | * Return the first matching model, or create one carrying the wheres. | |
| 1146 | * | |
| 1147 | * The builder's wheres are the match. Every simple `column = value` | |
| 1148 | * clause over a declared column becomes both a match clause and a | |
| 1149 | * fill, so the created model satisfies the match that failed to find | |
| 1150 | * it; non-invertible wheres fail fast unless their column is carried | |
| 1151 | * in `$values` (trait scopes never seed a fill). The lookup-then-insert | |
| 1152 | * is not atomic: against a `#[Unique]`-backed column a lost race | |
| 1153 | * surfaces as a QueryException. | |
| 1154 | * | |
| 1155 | * @param array<string, mixed> $values Extra column values for the created model. | |
| 1156 | * @return TModel | |
| 1157 | * @throws \InvalidArgumentException | |
| 1158 | * @throws WriteVetoException | |
| 1159 | */ | |
| 1160 | public function firstOrCreate(array $values = []): Model | |
| 1161 | { | |
| 1162 | $model = $this->first(); | |
| 1163 | ||
| 1164 | if ($model !== null) { | |
| 1165 | return $model; | |
| 1166 | } | |
| 1167 | ||
| 1168 | return $this->createModel($this->invertibleWhereFills($values), $values); | |
| 1169 | } | |
| 1170 | ||
| 1171 | /** | |
| 1172 | * Find a model by primary key, or create one carrying that key. | |
| 1173 | * | |
| 1174 | * The key map is both the match and the fill, so a miss always | |
| 1175 | * creates an addressable row; a scalar expands to the single PK, a | |
| 1176 | * composite key passes the full column => value map. A | |
| 1177 | * non-auto-generated key left without a value fails fast. The | |
| 1178 | * lookup-then-insert is not atomic: against a `#[Unique]`-backed key | |
| 1179 | * a lost race surfaces as a QueryException. | |
| 1180 | * | |
| 1181 | * @param KeyValue $id The primary-key value, or a column => value map for a composite key. | |
| 1182 | * @param array<string, mixed> $values Extra column values for the created model. | |
| 1183 | * @return TModel | |
| 1184 | * @throws \InvalidArgumentException | |
| 1185 | * @throws WriteVetoException | |
| 1186 | */ | |
| 1187 | public function findOrCreate(int|string|null|array $id, array $values = []): Model | |
| 1188 | { | |
| 1189 | $model = $this->find($id); | |
| 1190 | ||
| 1191 | if ($model !== null) { | |
| 1192 | return $model; | |
| 1193 | } | |
| 1194 | ||
| 1195 | // The key is authoritative; the builder's invertible wheres fill | |
| 1196 | // beneath it. PHP's + keeps the left operand's entries on collision. | |
| 1197 | return $this->createModel($this->keyFills($id) + $this->invertibleWhereFills($values), $values); | |
| 1198 | } | |
| 1199 | ||
| 1200 | /** | |
| 1201 | * Build, fill, and save a new model. | |
| 1202 | * | |
| 1203 | * The shared miss path. A vetoed save throws — the | |
| 1204 | * {@see WriteVetoException} propagates to the caller and no row | |
| 1205 | * lands. Synthetic columns route through {@see Model::setAttribute()}; | |
| 1206 | * typed-property columns through {@see Model::setColumn()}. | |
| 1207 | * | |
| 1208 | * @param array<string, mixed> $match The match-derived fills. | |
| 1209 | * @param array<string, mixed> $values The caller's create-only extras. | |
| 1210 | * @return TModel | |
| 1211 | * @throws \InvalidArgumentException | |
| 1212 | * @throws WriteVetoException | |
| 1213 | */ | |
| 1214 | private function createModel(array $match, array $values): Model | |
| 1215 | { | |
| 1216 | $metadata = MetadataFactory::for($this->modelClass); | |
| 1217 | ||
| 1218 | // Auto-increment single PKs skip the guard — the INSERT generates | |
| 1219 | // the key. A caller-assigned or composite key must be fully | |
| 1220 | // covered by the fills, or the row is born unaddressable. | |
| 1221 | $pks = $metadata->primaryKeys; | |
| 1222 | $generated = count($pks) === 1 && $pks[0]->autoIncrement; | |
| 1223 | ||
| 1224 | if (!$generated) { | |
| 1225 | $missing = []; | |
| 1226 | ||
| 1227 | foreach ($pks as $pk) { | |
| 1228 | $name = $pk->name; | |
| 1229 | ||
| 1230 | if ($name !== null && !array_key_exists($name, $match) && !array_key_exists($name, $values)) { | |
| 1231 | $missing[] = $name; | |
| 1232 | } | |
| 1233 | } | |
| 1234 | ||
| 1235 | if ($missing !== []) { | |
| 1236 | throw new \InvalidArgumentException( | |
| 1237 | 'Creating model [' . $this->modelClass . '] needs its primary-key column(s) ' | |
| 1238 | . '[' . implode(', ', $missing) . '] in the match or the values — the key is ' | |
| 1239 | . 'not auto-generated, so the INSERT would produce a row no later lookup ' | |
| 1240 | . 'can address.' | |
| 1241 | ); | |
| 1242 | } | |
| 1243 | } | |
| 1244 | ||
| 1245 | /** @var TModel $model */ | |
| 1246 | $model = $this->modelClass::newInstance(); | |
| 1247 | ||
| 1248 | foreach ($match + $values as $column => $value) { | |
| 1249 | if ($metadata->mappingFor($column)->property === null) { | |
| 1250 | $model->setAttribute($column, $value); | |
| 1251 | ||
| 1252 | continue; | |
| 1253 | } | |
| 1254 | ||
| 1255 | $model->setColumn($column, $value); | |
| 1256 | } | |
| 1257 | ||
| 1258 | $model->save(); | |
| 1259 | ||
| 1260 | return $model; | |
| 1261 | } | |
| 1262 | ||
| 1263 | /** | |
| 1264 | * Extract the invertible where fills from the builder's where state. | |
| 1265 | * | |
| 1266 | * A simple `column = value` over a declared column is invertible — | |
| 1267 | * the clause's value seeds the column. Trait-scope-marked groups | |
| 1268 | * (the soft-delete filter) are framework-owned and skipped, as is a | |
| 1269 | * non-invertible where whose column `$values` covers. Every other | |
| 1270 | * shape fails fast. | |
| 1271 | * | |
| 1272 | * @param array<string, mixed> $values The caller's create-only extras — the seeded constraint check. | |
| 1273 | * @return array<string, mixed> | |
| 1274 | * @throws \InvalidArgumentException | |
| 1275 | */ | |
| 1276 | private function invertibleWhereFills(array $values): array | |
| 1277 | { | |
| 1278 | $fills = []; | |
| 1279 | ||
| 1280 | foreach ($this->getWheres() as $where) { | |
| 1281 | // Trait scopes are the framework's own filter (the soft-delete | |
| 1282 | // `deleted_at IS NULL` group) — they must filter the lookup | |
| 1283 | // but never seed a fill. | |
| 1284 | if (array_key_exists('traitScope', $where)) { | |
| 1285 | continue; | |
| 1286 | } | |
| 1287 | ||
| 1288 | if ($where['type'] === WhereType::Basic | |
| 1289 | && $where['operator'] === WhereOperator::Eq | |
| 1290 | && is_string($where['column']) | |
| 1291 | ) { | |
| 1292 | $column = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $where['column'])); | |
| 1293 | ||
| 1294 | if (MetadataFactory::for($this->modelClass)->hasColumn($column)) { | |
| 1295 | $fills[$column] = $where['value']; | |
| 1296 | ||
| 1297 | continue; | |
| 1298 | } | |
| 1299 | } | |
| 1300 | ||
| 1301 | // A non-invertible shape whose column the caller seeds in | |
| 1302 | // `$values` is satisfied by that value. | |
| 1303 | if ($where['type'] === WhereType::Basic && is_string($where['column'])) { | |
| 1304 | $column = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $where['column'])); | |
| 1305 | ||
| 1306 | if (array_key_exists($column, $values) | |
| 1307 | && MetadataFactory::for($this->modelClass)->hasColumn($column) | |
| 1308 | ) { | |
| 1309 | continue; | |
| 1310 | } | |
| 1311 | } | |
| 1312 | ||
| 1313 | throw new \InvalidArgumentException( | |
| 1314 | 'firstOrCreate() cannot invert the where clause into a fill — only simple ' | |
| 1315 | . '`column = value` clauses over declared columns can seed the created model, ' | |
| 1316 | . 'or the constraint\'s column can be carried in the values explicitly. ' | |
| 1317 | . 'Compose the constraint so every filter is an equality on the builder.' | |
| 1318 | ); | |
| 1319 | } | |
| 1320 | ||
| 1321 | return $fills; | |
| 1322 | } | |
| 1323 | ||
| 1324 | /** | |
| 1325 | * The PK fills for a findOrCreate miss — the key map itself. | |
| 1326 | * | |
| 1327 | * The lookup already validated the shapes (whereKey's guards run on | |
| 1328 | * the read); this only asserts the value side is present so the map | |
| 1329 | * is directly writable. The mixed parameter is the runtime boundary — | |
| 1330 | * the KeyValue contract on the public method is PHPDoc-only, so the | |
| 1331 | * list/null shapes are still checked here. | |
| 1332 | * | |
| 1333 | * @param mixed $id The validated KeyValue from the public method. | |
| 1334 | * @return array<string, mixed> | |
| 1335 | * | |
| 1336 | * @throws \InvalidArgumentException | |
| 1337 | */ | |
| 1338 | private function keyFills(mixed $id): array | |
| 1339 | { | |
| 1340 | if ($id === null) { | |
| 1341 | throw new \InvalidArgumentException( | |
| 1342 | 'findOrCreate() cannot create from a null key — the created model would have ' | |
| 1343 | . 'no primary key.' | |
| 1344 | ); | |
| 1345 | } | |
| 1346 | ||
| 1347 | if (is_array($id) && array_is_list($id)) { | |
| 1348 | throw new \InvalidArgumentException( | |
| 1349 | 'findOrCreate() takes a single key (scalar or column => value map), not a key ' | |
| 1350 | . 'list — a list matches several rows, and a create has exactly one target.' | |
| 1351 | ); | |
| 1352 | } | |
| 1353 | ||
| 1354 | if (is_array($id)) { | |
| 1355 | return $id; | |
| 1356 | } | |
| 1357 | ||
| 1358 | $pkName = MetadataFactory::for($this->modelClass)->primaryKeys[0]->name; | |
| 1359 | ||
| 1360 | if ($pkName === null) { | |
| 1361 | throw new \InvalidArgumentException( | |
| 1362 | "Model [{$this->modelClass}] has an unnamed primary key; pass a column => value map." | |
| 1363 | ); | |
| 1364 | } | |
| 1365 | ||
| 1366 | return [$pkName => $id]; | |
| 1367 | } | |
| 1368 | ||
| 1369 | // ---- Scalar reads (decoded through the column casts) ---- | |
| 1370 | ||
| 1371 | /** | |
| 1372 | * The value of a single column from the first row, decoded through | |
| 1373 | * the column's cast. | |
| 1374 | * | |
| 1375 | * Raw SQL and user-aliased columns pass through raw — the model layer | |
| 1376 | * has no cast for a computed value. | |
| 1377 | * | |
| 1378 | * @param string|Aggregate $column | |
| 1379 | * @return mixed | |
| 1380 | */ | |
| 1381 | public function value(string|Aggregate $column): mixed | |
| 1382 | { | |
| 1383 | if ($column instanceof Aggregate) { | |
| 1384 | $rows = $this->connection->select( | |
| 1385 | $this->scopedFor(new Aggregate($column->function, $column->column, 'radiant_scalar'))->limit(1), | |
| 1386 | ); | |
| 1387 | $raw = $rows[0] ?? null; | |
| 1388 | ||
| 1389 | // An Expression argument is a computed value — no cast applies. | |
| 1390 | return $column->column instanceof Expression | |
| 1391 | ? ($raw === null ? null : $raw->radiant_scalar) | |
| 1392 | : $this->decodeScalar($column->column, $raw === null ? null : $raw->radiant_scalar); | |
| 1393 | } | |
| 1394 | ||
| 1395 | $sql = $this->scalarColumn($column); | |
| 1396 | ||
| 1397 | // Fetch the RAW column directly (mirroring first()'s raw-row | |
| 1398 | // fetch): the hydrating first() would return a Model, which has no | |
| 1399 | // scalar property to read the value back from. The columnar fetch | |
| 1400 | // reads the single column positionally — no per-row object | |
| 1401 | // materialized. The scoped builder keeps THIS builder's select | |
| 1402 | // untouched. | |
| 1403 | $raw = $this->connection->selectColumn($this->scopedFor($sql)->limit(1))->first(); | |
| 1404 | ||
| 1405 | return $this->decodeScalar($column, $raw); | |
| 1406 | } | |
| 1407 | ||
| 1408 | /** | |
| 1409 | * A collection of a single column's values, decoded through the casts. | |
| 1410 | * | |
| 1411 | * @param string $column | |
| 1412 | * @return BaseCollection<int, mixed> | |
| 1413 | */ | |
| 1414 | public function pluck(string $column): BaseCollection | |
| 1415 | { | |
| 1416 | $sql = $this->scalarColumn($column); | |
| 1417 | ||
| 1418 | // The columnar fetch: values come back positionally, one per row — | |
| 1419 | // no per-row object materialized, no alias read per row. map() | |
| 1420 | // preserves the 0-based list keys — the result is already a list, | |
| 1421 | // so no trailing values() re-index (it would be a no-op copy). | |
| 1422 | return $this->connection->selectColumn($this->scopedFor($sql))->map( | |
| 1423 | fn(mixed $raw) => $this->decodeScalar($column, $raw), | |
| 1424 | ); | |
| 1425 | } | |
| 1426 | ||
| 1427 | /** | |
| 1428 | * A clone of this builder scoped to a scalar or aggregate select. | |
| 1429 | * | |
| 1430 | * The clone carries the constraints (wheres, joins, soft-delete state) | |
| 1431 | * but owns its own column list — the bypass primitive for internal | |
| 1432 | * scalar reads, where `select()` cannot be used (it re-merges the | |
| 1433 | * forced PK and validates against the declared columns). | |
| 1434 | * | |
| 1435 | * @param string|Aggregate ...$sql | |
| 1436 | * @return static | |
| 1437 | */ | |
| 1438 | protected function scopedFor(string|Aggregate ...$sql): static | |
| 1439 | { | |
| 1440 | $clone = clone $this; | |
| 1441 | $clone->columns = $sql === [] ? ['*'] : array_values($sql); | |
| 1442 | ||
| 1443 | return $clone; | |
| 1444 | } | |
| 1445 | ||
| 1446 | /** | |
| 1447 | * Decode one scalar read when the column is a declared model column. | |
| 1448 | * | |
| 1449 | * @param string $column | |
| 1450 | * @param mixed $raw | |
| 1451 | * @return mixed | |
| 1452 | */ | |
| 1453 | private function decodeScalar(string $column, mixed $raw): mixed | |
| 1454 | { | |
| 1455 | $bare = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $column)); | |
| 1456 | $metadata = MetadataFactory::for($this->modelClass); | |
| 1457 | ||
| 1458 | if (!$metadata->hasColumn($bare)) { | |
| 1459 | return $raw; | |
| 1460 | } | |
| 1461 | ||
| 1462 | $mapping = $metadata->mappingFor($bare); | |
| 1463 | ||
| 1464 | return $mapping->column->decode($raw, $mapping->propertyType); | |
| 1465 | } | |
| 1466 | ||
| 1467 | /** | |
| 1468 | * Encode one where value when the column is a declared model column | |
| 1469 | * — the where-path twin of {@see decodeScalar()}. | |
| 1470 | * | |
| 1471 | * The value routes through the column's cast — the same | |
| 1472 | * {@see Column::encode()} the write path uses — so every shape the | |
| 1473 | * write path accepts filters identically: enum cases, datetimes, | |
| 1474 | * Json arrays, int timestamps. Raw scalars pass through when valid | |
| 1475 | * (the cast's idempotence trade) and fail fast naming the column | |
| 1476 | * when not. Raw SQL expressions ({@see Expression}/{@see ToSqlValue}) | |
| 1477 | * and LIKE patterns skip the cast: the former splice verbatim and | |
| 1478 | * must never be encoded; the latter is a match template, not a cell | |
| 1479 | * value. A value on an unknown (or joined, or synthetic) column is | |
| 1480 | * returned untouched too — the binding layer rejects non-bindables | |
| 1481 | * as before, and a raw scalar on a joined column has no model cast | |
| 1482 | * to consult. | |
| 1483 | * | |
| 1484 | * @param string $column | |
| 1485 | * @param mixed $value | |
| 1486 | * @param WhereOperator|string|null $operator The clause operator, when known — the LIKE shapes bypass encoding. | |
| 1487 | * @return mixed | |
| 1488 | * @throws \InvalidArgumentException | |
| 1489 | */ | |
| 1490 | private function encodeWhereValue(string $column, mixed $value, WhereOperator|string|null $operator = null): mixed | |
| 1491 | { | |
| 1492 | if ($value === null || $value instanceof Expression || $value instanceof ToSqlValue) { | |
| 1493 | return $value; | |
| 1494 | } | |
| 1495 | ||
| 1496 | if ($operator instanceof WhereOperator | |
| 1497 | ? ($operator === WhereOperator::Like || $operator === WhereOperator::NotLike) | |
| 1498 | : ($operator === 'LIKE' || $operator === 'NOT LIKE') | |
| 1499 | ) { | |
| 1500 | return $value; | |
| 1501 | } | |
| 1502 | ||
| 1503 | $bare = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $column)); | |
| 1504 | $metadata = MetadataFactory::for($this->modelClass); | |
| 1505 | ||
| 1506 | if (!$metadata->hasColumn($bare)) { | |
| 1507 | return $value; | |
| 1508 | } | |
| 1509 | ||
| 1510 | $mapping = $metadata->mappingFor($bare); | |
| 1511 | $propertyType = $mapping->propertyType; | |
| 1512 | ||
| 1513 | // The enum identity guard: the cast's enum arm encodes ANY enum | |
| 1514 | // case to its backing value — it never needed a class check | |
| 1515 | // because the write path enforces the column's enum through the | |
| 1516 | // typed property. A where/having value has no property to enforce | |
| 1517 | // it, so a case of a DIFFERENT enum fails fast here instead of | |
| 1518 | // silently binding a value the column's enum may not allow. | |
| 1519 | if ($propertyType !== null && enum_exists($propertyType) && $value instanceof \UnitEnum && !is_a($value, $propertyType, true)) { | |
| 1520 | throw new \InvalidArgumentException( | |
| 1521 | 'Column [' . $bare . '] expects an enum value of type [' | |
| 1522 | . $propertyType . ']; got ' . get_debug_type($value) . '.' | |
| 1523 | ); | |
| 1524 | } | |
| 1525 | ||
| 1526 | return $mapping->column->encode($value, $propertyType); | |
| 1527 | } | |
| 1528 | ||
| 1529 | // ---- Aggregates (decoded like every other scalar read) ---- | |
| 1530 | ||
| 1531 | /** | |
| 1532 | * Count the matching rows. | |
| 1533 | * | |
| 1534 | * @return int | |
| 1535 | */ | |
| 1536 | public function count(): int | |
| 1537 | { | |
| 1538 | return (int) $this->value(Aggregate::count()); | |
| 1539 | } | |
| 1540 | ||
| 1541 | /** | |
| 1542 | * The maximum value of a column, decoded through the cast for | |
| 1543 | * declared columns. | |
| 1544 | * | |
| 1545 | * @param string $column | |
| 1546 | * @return mixed | |
| 1547 | */ | |
| 1548 | public function max(string $column): mixed | |
| 1549 | { | |
| 1550 | return $this->value(Aggregate::max($column)); | |
| 1551 | } | |
| 1552 | ||
| 1553 | /** | |
| 1554 | * The minimum value of a column, decoded through the cast for | |
| 1555 | * declared columns. | |
| 1556 | * | |
| 1557 | * @param string $column | |
| 1558 | * @return mixed | |
| 1559 | */ | |
| 1560 | public function min(string $column): mixed | |
| 1561 | { | |
| 1562 | return $this->value(Aggregate::min($column)); | |
| 1563 | } | |
| 1564 | ||
| 1565 | /** | |
| 1566 | * The sum of a column's values, decoded through the cast for | |
| 1567 | * declared columns. | |
| 1568 | * | |
| 1569 | * @param string $column | |
| 1570 | * @return mixed | |
| 1571 | */ | |
| 1572 | public function sum(string $column): mixed | |
| 1573 | { | |
| 1574 | return $this->value(Aggregate::sum($column)); | |
| 1575 | } | |
| 1576 | ||
| 1577 | /** | |
| 1578 | * The average of a column's values, decoded through the cast for | |
| 1579 | * declared columns. | |
| 1580 | * | |
| 1581 | * @param string $column | |
| 1582 | * @return mixed | |
| 1583 | */ | |
| 1584 | public function avg(string $column): mixed | |
| 1585 | { | |
| 1586 | return $this->value(Aggregate::avg($column)); | |
| 1587 | } | |
| 1588 | ||
| 1589 | /** | |
| 1590 | * Multiple aggregates in one query, decoded through each aggregate's | |
| 1591 | * column cast. | |
| 1592 | * | |
| 1593 | * The aggregate's own alias names its result column: | |
| 1594 | * | |
| 1595 | * User::query()->aggregates( | |
| 1596 | * Aggregate::count('*', 'total'), | |
| 1597 | * Aggregate::max('signed_up_at', 'latest'), | |
| 1598 | * )->latest; | |
| 1599 | * | |
| 1600 | * @param Aggregate ...$aggregates | |
| 1601 | * @return \stdClass | |
| 1602 | */ | |
| 1603 | public function aggregates(Aggregate ...$aggregates): \stdClass | |
| 1604 | { | |
| 1605 | // Raw rows — a hydrated Model has no aggregate-alias properties to | |
| 1606 | // read the values back from. The aggregate select rides the scoped | |
| 1607 | // clone: this builder's own column list is untouched. The spread | |
| 1608 | // forwards the variadic list directly. | |
| 1609 | $row = $this->scopedFor(...$aggregates)->getRaw()->first(); | |
| 1610 | ||
| 1611 | $out = new \stdClass(); | |
| 1612 | ||
| 1613 | foreach ($aggregates as $aggregate) { | |
| 1614 | $column = $aggregate->column; | |
| 1615 | $isExpression = $column instanceof Expression; | |
| 1616 | $columnKey = $isExpression ? $column->value : $column; | |
| 1617 | $key = $aggregate->alias ?? "{$aggregate->function}({$columnKey})"; | |
| 1618 | ||
| 1619 | // An Expression argument is a computed value — no cast applies; | |
| 1620 | // declared-column arguments decode through the column's cast. | |
| 1621 | $out->{$key} = $isExpression | |
| 1622 | ? ($row === null ? null : $row->{$key}) | |
| 1623 | : $this->decodeScalar($column, $row === null ? null : $row->{$key}); | |
| 1624 | } | |
| 1625 | ||
| 1626 | return $out; | |
| 1627 | } | |
| 1628 | ||
| 1629 | /** | |
| 1630 | * Run one aggregate per group of the matching rows — a grouped | |
| 1631 | * aggregate in a single query. | |
| 1632 | * | |
| 1633 | * The result is keyed by the group column's value, so the aggregate's | |
| 1634 | * own alias is ignored here (it matters only for the multi-aggregate | |
| 1635 | * row shape of aggregates()). Declared columns decode through the | |
| 1636 | * column's cast; `count` is always an int. | |
| 1637 | * | |
| 1638 | * @param Aggregate $aggregate The aggregate to compute per group. | |
| 1639 | * @param string $groupBy The column whose values key the result. | |
| 1640 | * @return BaseCollection<string, mixed> | |
| 1641 | */ | |
| 1642 | public function aggregateBy(Aggregate $aggregate, string $groupBy): BaseCollection | |
| 1643 | { | |
| 1644 | $rows = $this->scopedFor($groupBy, new Aggregate($aggregate->function, $aggregate->column, self::AGGREGATE_ALIAS)) | |
| 1645 | ->groupBy($groupBy) | |
| 1646 | ->getRaw(); | |
| 1647 | ||
| 1648 | $out = []; | |
| 1649 | ||
| 1650 | foreach ($rows as $row) { | |
| 1651 | $key = (string) $row->{$groupBy}; | |
| 1652 | $raw = $row->{self::AGGREGATE_ALIAS}; | |
| 1653 | ||
| 1654 | $out[$key] = $aggregate->function === 'count' | |
| 1655 | ? (int) $raw | |
| 1656 | : $this->decodeAggregateColumn($aggregate->column, $raw); | |
| 1657 | } | |
| 1658 | ||
| 1659 | /** @var BaseCollection<string, mixed> */ | |
| 1660 | return BaseCollection::make($out); | |
| 1661 | } | |
| 1662 | ||
| 1663 | /** | |
| 1664 | * Count the matching rows per group of a column — in a single query. | |
| 1665 | * | |
| 1666 | * The result is keyed by the group column's value with int counts. | |
| 1667 | * | |
| 1668 | * The optional seed lists group values that must appear even when the | |
| 1669 | * database has no rows for them — each seeded key absent from the | |
| 1670 | * result becomes 0. The seed is ADDITIVE: database rows always win, | |
| 1671 | * and group values found in the data but missing from the seed still | |
| 1672 | * appear. (Only counts can be seeded — an absent group has no honest | |
| 1673 | * min, max, or average.) | |
| 1674 | * | |
| 1675 | * @param string $column The column whose values key the result. | |
| 1676 | * @param list<int|string>|null $seed Group values guaranteed to appear (0 when absent). | |
| 1677 | * @return BaseCollection<string, int> | |
| 1678 | */ | |
| 1679 | public function countBy(string $column, ?array $seed = null): BaseCollection | |
| 1680 | { | |
| 1681 | /** @var BaseCollection<string, int> $counts */ | |
| 1682 | $counts = $this->aggregateBy(Aggregate::count('*'), $column); | |
| 1683 | ||
| 1684 | if ($seed !== null) { | |
| 1685 | $out = $counts->all(); | |
| 1686 | ||
| 1687 | foreach ($seed as $value) { | |
| 1688 | $key = (string) $value; | |
| 1689 | $out[$key] ??= 0; | |
| 1690 | } | |
| 1691 | ||
| 1692 | /** @var BaseCollection<string, int> */ | |
| 1693 | return BaseCollection::make($out); | |
| 1694 | } | |
| 1695 | ||
| 1696 | return $counts; | |
| 1697 | } | |
| 1698 | ||
| 1699 | /** | |
| 1700 | * Decode one grouped-aggregate value when the aggregated column is a | |
| 1701 | * declared model column. | |
| 1702 | * | |
| 1703 | * Mirrors the scalar decode: declared columns decode through the | |
| 1704 | * column's cast, everything else (raw SQL, Expression arguments, | |
| 1705 | * computed values) passes through raw. | |
| 1706 | * | |
| 1707 | * @param string|Expression $column | |
| 1708 | * @param mixed $raw | |
| 1709 | * @return mixed | |
| 1710 | */ | |
| 1711 | private function decodeAggregateColumn(string|Expression $column, mixed $raw): mixed | |
| 1712 | { | |
| 1713 | if ($column instanceof Expression || $raw === null) { | |
| 1714 | return $raw; | |
| 1715 | } | |
| 1716 | ||
| 1717 | return $this->decodeScalar($column, $raw); | |
| 1718 | } | |
| 1719 | ||
| 1720 | /** | |
| 1721 | * Constrain the query to a primary-key value. | |
| 1722 | * | |
| 1723 | * Accepts a scalar (the single-PK form), a column => value map (the | |
| 1724 | * composite-key form), or a list of either (the batching form, match | |
| 1725 | * ANY). | |
| 1726 | * | |
| 1727 | * @param KeyValue|list<KeyValue> $id | |
| 1728 | * @return static | |
| 1729 | * @throws \InvalidArgumentException | |
| 1730 | */ | |
| 1731 | public function whereKey(int|string|null|array $id): static | |
| 1732 | { | |
| 1733 | $primaryKeys = MetadataFactory::for($this->modelClass)->primaryKeys; | |
| 1734 | ||
| 1735 | // PK-selection guard: whereKey's results feed save()/delete() | |
| 1736 | // (getKeyForRefresh()), which need the PK hydrated. A caller-owned | |
| 1737 | // select that omits the PK column hydrates models whose key | |
| 1738 | // property is uninitialized — the key reads as null and the write | |
| 1739 | // silently targets `WHERE pk IS NULL` (matching nothing, or worse). | |
| 1740 | // Fail fast at the API boundary instead. | |
| 1741 | $selected = $this->getColumns(); | |
| 1742 | if ($selected !== ['*']) { | |
| 1743 | $pkNames = array_filter( | |
| 1744 | array_map(fn($pk) => $pk->name, $primaryKeys), | |
| 1745 | fn($name) => $name !== null, | |
| 1746 | ); | |
| 1747 | ||
| 1748 | foreach ($selected as $column) { | |
| 1749 | if ($column instanceof Expression) { | |
| 1750 | continue; // raw expressions carry no column contract. | |
| 1751 | } | |
| 1752 | if ($column instanceof Aggregate) { | |
| 1753 | continue; // aggregates are computed columns, not the PK. | |
| 1754 | } | |
| 1755 | if ($column instanceof SubquerySelect) { | |
| 1756 | continue; // a scalar subquery column is not the PK. | |
| 1757 | } | |
| 1758 | $bare = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $column)); | |
| 1759 | ||
| 1760 | if ($bare === $this->table . '.*') { | |
| 1761 | $pkNames = []; | |
| 1762 | break; | |
| 1763 | } | |
| 1764 | ||
| 1765 | // Match the PK bare (`id`) or qualified (`table.id` — the | |
| 1766 | // MTI builder's own default select qualifies every column). | |
| 1767 | foreach ($pkNames as $pkName) { | |
| 1768 | if ($bare === $pkName || $bare === $this->table . '.' . $pkName) { | |
| 1769 | $pkNames = []; | |
| 1770 | break 2; | |
| 1771 | } | |
| 1772 | } | |
| 1773 | } | |
| 1774 | ||
| 1775 | if ($pkNames !== []) { | |
| 1776 | throw new \InvalidArgumentException( | |
| 1777 | 'whereKey() requires the primary key in the select list — the result feeds ' | |
| 1778 | . 'save()/delete(), which need the key hydrated. Add the PK column ' | |
| 1779 | . '[' . implode(', ', array_map( | |
| 1780 | fn($name) => $this->table . '.' . $name, | |
| 1781 | $pkNames, | |
| 1782 | )) . '] to the select, or use select(\'' . $this->table . '.*\').' | |
| 1783 | ); | |
| 1784 | } | |
| 1785 | } | |
| 1786 | ||
| 1787 | // A LIST of key values (scalars or key maps) constrains to ANY of | |
| 1788 | // them — the batching path used by Collection::fresh(). An | |
| 1789 | // associative map (string keys) is a composite key; a list (int | |
| 1790 | // keys) is a key set. An empty list matches nothing (1 = 0). | |
| 1791 | // | |
| 1792 | // Each key becomes its own nested AND-group ORed at the edges | |
| 1793 | // (mirroring the eager-load OR-of-groups shape): `pk = 1 OR | |
| 1794 | // (a = ? AND b = ?) OR pk = 3`. Flattening would let one key's | |
| 1795 | // parts AND against the NEXT key. | |
| 1796 | // | |
| 1797 | // The whole OR-of-groups lands INSIDE one outer AND-group: the | |
| 1798 | // key set is ONE constraint unit. The constructor auto-applies | |
| 1799 | // trait scopes (e.g. soft-delete `deleted_at IS NULL`) as leading | |
| 1800 | // AND-groups — flat top-level ORs would compile to | |
| 1801 | // `(scope) OR (pk = 1) OR ...` and let a scope-excluded row back | |
| 1802 | // in whenever its key matched. Grouped, the scope ANDs against | |
| 1803 | // the whole set: `(scope) AND ((pk = 1) OR (pk = 2) OR ...)`. | |
| 1804 | // | |
| 1805 | // No chunking: whereKey() returns ONE builder, so every key | |
| 1806 | // compiles into the same statement regardless of how the loop is | |
| 1807 | // sliced — chunking the loop cannot bound the statement. Splitting | |
| 1808 | // the keys across SEPARATE top-level groups would AND the chunks | |
| 1809 | // together (a row would need a key in EVERY chunk to match), so | |
| 1810 | // the only correct shape is one group holding all the keys. An | |
| 1811 | // oversized list therefore hits the driver's own placeholder cap | |
| 1812 | // (SQLite's 999 variables, MySQL's max_allowed_packet) with the | |
| 1813 | // driver's error — the same exposure every whereIn([...]) has. | |
| 1814 | if (is_array($id) && array_is_list($id)) { | |
| 1815 | if ($id === []) { | |
| 1816 | return $this->whereRaw('1 = 0'); | |
| 1817 | } | |
| 1818 | ||
| 1819 | return $this->whereNested( | |
| 1820 | function (WhereBuilder $nested) use ($id): WhereBuilder { | |
| 1821 | $grouped = $nested; | |
| 1822 | ||
| 1823 | foreach ($id as $key) { | |
| 1824 | $grouped = $grouped->orWhereNested( | |
| 1825 | fn (WhereBuilder $keyGroup): WhereBuilder => $this->applyWhereKeyOn($keyGroup, $key) | |
| 1826 | ); | |
| 1827 | } | |
| 1828 | ||
| 1829 | return $grouped; | |
| 1830 | } | |
| 1831 | ); | |
| 1832 | } | |
| 1833 | ||
| 1834 | // Composite PK → accept an associative array of column => value. | |
| 1835 | // Every column MUST be a declared PK column (a typo'd column would | |
| 1836 | // otherwise silently match nothing), and each where is qualified | |
| 1837 | // for MTI (the PK exists on EVERY joined table — a bare column | |
| 1838 | // would compile to an ambiguous-column error). | |
| 1839 | // | |
| 1840 | // The key map lands inside a whereNested GROUP — the tuple is ONE | |
| 1841 | // constraint unit. Flat, a caller's later `->orWhere(...)` would OR | |
| 1842 | // against the tuple's PARTS ((pk1 = ? AND pk2 = ?) OR x — matching | |
| 1843 | // the wrong rows); grouped, the parts AND within the parens and the | |
| 1844 | // caller's OR stays at the constraint's edges. | |
| 1845 | if (is_array($id)) { | |
| 1846 | return $this->whereNested( | |
| 1847 | fn (WhereBuilder $nested): WhereBuilder => $this->applyWhereKeyOn($nested, $id) | |
| 1848 | ); | |
| 1849 | } | |
| 1850 | ||
| 1851 | if (count($primaryKeys) !== 1 || $primaryKeys[0]->name === null) { | |
| 1852 | throw new \InvalidArgumentException( | |
| 1853 | "Model [{$this->modelClass}] has a composite PK; pass an array of column => value." | |
| 1854 | ); | |
| 1855 | } | |
| 1856 | ||
| 1857 | $pkName = $primaryKeys[0]->name; | |
| 1858 | ||
| 1859 | // MTI: the PK exists on EVERY joined table — qualify to avoid an | |
| 1860 | // ambiguous-column error in the compiled SQL. The qualified form | |
| 1861 | // validates through validateColumn's partition branch. | |
| 1862 | if ($this->partitions !== []) { | |
| 1863 | return $this->where( | |
| 1864 | ($this->partitions[$pkName] ?? $this->table) . '.' . $pkName, | |
| 1865 | WhereOperator::Eq, | |
| 1866 | $id, | |
| 1867 | ); | |
| 1868 | } | |
| 1869 | ||
| 1870 | return $this->where($pkName, WhereOperator::Eq, $id); | |
| 1871 | } | |
| 1872 | ||
| 1873 | /** | |
| 1874 | * Apply ONE key value onto a where-group — the shared body of | |
| 1875 | * {@see whereKey()}'s single and list branches. | |
| 1876 | * | |
| 1877 | * @param WhereBuilder $nested | |
| 1878 | * @param mixed $key The scalar key value or column => value map. | |
| 1879 | * @return WhereBuilder | |
| 1880 | * @throws \InvalidArgumentException | |
| 1881 | */ | |
| 1882 | private function applyWhereKeyOn(WhereBuilder $nested, mixed $key): WhereBuilder | |
| 1883 | { | |
| 1884 | $primaryKeys = MetadataFactory::for($this->modelClass)->primaryKeys; | |
| 1885 | ||
| 1886 | if (is_array($key) && $key !== []) { | |
| 1887 | foreach ($key as $column => $value) { | |
| 1888 | $validated = $this->assertCompositeKeyValue($column, $value); | |
| 1889 | ||
| 1890 | $this->assertCompositeKeyColumn($validated['column'], $primaryKeys); | |
| 1891 | ||
| 1892 | $qualified = $validated['column']; | |
| 1893 | ||
| 1894 | if ($this->partitions !== []) { | |
| 1895 | $qualified = ($this->partitions[$qualified] ?? $this->table) . '.' . $qualified; | |
| 1896 | } | |
| 1897 | ||
| 1898 | $nested = $nested->where($qualified, WhereOperator::Eq, $validated['value']); | |
| 1899 | } | |
| 1900 | ||
| 1901 | return $nested; | |
| 1902 | } | |
| 1903 | ||
| 1904 | // Runtime boundary: `$key` is a list ELEMENT — the KeyValue scalar | |
| 1905 | // contract is PHPDoc-only, so an untyped caller can pass anything. | |
| 1906 | // The native whereKey() union already TypeErrors at the public | |
| 1907 | // boundary; this guard covers the mixed path behind it. | |
| 1908 | if (!is_int($key) && !is_string($key) && $key !== null) { | |
| 1909 | throw new \InvalidArgumentException( | |
| 1910 | 'A single primary-key value must be int, string or null; got ' . get_debug_type($key) . '.' | |
| 1911 | ); | |
| 1912 | } | |
| 1913 | ||
| 1914 | $single = $key; | |
| 1915 | ||
| 1916 | if (count($primaryKeys) !== 1 || $primaryKeys[0]->name === null) { | |
| 1917 | throw new \InvalidArgumentException( | |
| 1918 | "Model [{$this->modelClass}] has a composite PK; pass an array of column => value." | |
| 1919 | ); | |
| 1920 | } | |
| 1921 | ||
| 1922 | $pkName = $primaryKeys[0]->name; | |
| 1923 | ||
| 1924 | if ($this->partitions !== []) { | |
| 1925 | $pkName = ($this->partitions[$pkName] ?? $this->table) . '.' . $pkName; | |
| 1926 | } | |
| 1927 | ||
| 1928 | return $nested->where($pkName, WhereOperator::Eq, $single); | |
| 1929 | } | |
| 1930 | ||
| 1931 | /** | |
| 1932 | * Validate one composite-key entry and its value. | |
| 1933 | * | |
| 1934 | * @param mixed $column Must be a non-empty string naming a declared PK column. | |
| 1935 | * @param mixed $value Must be int, string or null. | |
| 1936 | * @return array{column: string, value: int|string|null} | |
| 1937 | * @throws \InvalidArgumentException | |
| 1938 | */ | |
| 1939 | private function assertCompositeKeyValue(mixed $column, mixed $value): array | |
| 1940 | { | |
| 1941 | if (!is_string($column) || $column === '') { | |
| 1942 | throw new \InvalidArgumentException( | |
| 1943 | 'A composite key must be a column => value map with string column names; got ' | |
| 1944 | . (is_string($column) ? 'an empty column name' : get_debug_type($column)) . '.' | |
| 1945 | ); | |
| 1946 | } | |
| 1947 | ||
| 1948 | if (!is_int($value) && !is_string($value) && $value !== null) { | |
| 1949 | throw new \InvalidArgumentException( | |
| 1950 | "Composite key value for [{$column}] must be int, string or null; got " | |
| 1951 | . get_debug_type($value) . '.' | |
| 1952 | ); | |
| 1953 | } | |
| 1954 | ||
| 1955 | return ['column' => $column, 'value' => $value]; | |
| 1956 | } | |
| 1957 | ||
| 1958 | /** | |
| 1959 | * Assert a composite-key column is one of the model's primary keys. | |
| 1960 | * | |
| 1961 | * @param string $column | |
| 1962 | * @param list<Column> $primaryKeys | |
| 1963 | * @return void | |
| 1964 | * @throws \InvalidArgumentException | |
| 1965 | */ | |
| 1966 | private function assertCompositeKeyColumn(string $column, array $primaryKeys): void | |
| 1967 | { | |
| 1968 | foreach ($primaryKeys as $primaryKey) { | |
| 1969 | if ($primaryKey->name === $column) { | |
| 1970 | return; | |
| 1971 | } | |
| 1972 | } | |
| 1973 | ||
| 1974 | throw new \InvalidArgumentException( | |
| 1975 | "Composite key column [{$column}] is not a primary key of model " | |
| 1976 | . "[{$this->modelClass}]; expected one of: " | |
| 1977 | . implode(', ', array_map(fn(Column $pk) => $pk->name ?? '(unnamed)', $primaryKeys)) . '.' | |
| 1978 | ); | |
| 1979 | } | |
| 1980 | ||
| 1981 | // ---- Model-aware overrides ---- | |
| 1982 | ||
| 1983 | /** | |
| 1984 | * Select columns, mapping `*` to the model's own columns. | |
| 1985 | * | |
| 1986 | * The PK columns and the soft-delete column (when the model uses | |
| 1987 | * {@see SoftDeletes}) are always present so hydration, `whereKey()` | |
| 1988 | * and the trash-state reads work. An {@see Expression} bypasses | |
| 1989 | * validation — raw SQL by contract. | |
| 1990 | * | |
| 1991 | * When the builder already JOINs another table, every bare spec is | |
| 1992 | * QUALIFIED to this model's table (`table.column`) — unqualified | |
| 1993 | * names would compile to ambiguous-column SQL once a second table | |
| 1994 | * carries the same name. The expansion, the caller-owned passthrough | |
| 1995 | * and the forced-key merge all ride the qualification, so ordering is | |
| 1996 | * irrelevant: `select('*')` before or after `join()` lands on the | |
| 1997 | * same qualified list. | |
| 1998 | * | |
| 1999 | * @param string|Expression|Aggregate|SubquerySelect ...$columns Each column as its own argument, or none to reset to `*`. | |
| 2000 | * @return static | |
| 2001 | * @throws \InvalidArgumentException | |
| 2002 | */ | |
| 2003 | public function select(string|Expression|Aggregate|SubquerySelect ...$columns): static | |
| 2004 | { | |
| 2005 | $flat = $columns === [] ? ['*'] : array_values($columns); | |
| 2006 | ||
| 2007 | if ($flat === ['*']) { | |
| 2008 | // `*` → the model's own columns (PK first, then the rest). | |
| 2009 | $flat = array_values(array_unique(array_merge( | |
| 2010 | $this->forcedKeys, | |
| 2011 | array_diff($this->modelColumns, $this->forcedKeys), | |
| 2012 | ))); | |
| 2013 | } else { | |
| 2014 | foreach ($flat as $column) { | |
| 2015 | if ($column instanceof Aggregate) { | |
| 2016 | // The aggregate's column gets the same allowlist check | |
| 2017 | // as a plain select column; an Expression argument is | |
| 2018 | // raw SQL by contract and passes through. | |
| 2019 | if (!$column->column instanceof Expression && $column->column !== '*') { | |
| 2020 | $this->validateColumn($column->column); | |
| 2021 | } | |
| 2022 | } elseif ($column instanceof SubquerySelect) { | |
| 2023 | // The node's sub-builder validates its columns | |
| 2024 | // against its own model; the alias was validated by | |
| 2025 | // the constructor. No outer validation applies. | |
| 2026 | } elseif (!$column instanceof Expression) { | |
| 2027 | $this->validateColumn($column); | |
| 2028 | } | |
| 2029 | } | |
| 2030 | ||
| 2031 | // An explicit list containing QUALIFIED specs (or `table.*`) | |
| 2032 | // is caller-owned — typically a joined read where a bare PK | |
| 2033 | // would be ambiguous. No forced-key merge. Every string spec has | |
| 2034 | // been allowlist-validated above (validateColumn's qualified | |
| 2035 | // branch checks the partition map / own table / joined tables), | |
| 2036 | // so a typo'd or attacker-influenced `table.column` fails fast | |
| 2037 | // here rather than compiling into the SQL quote-only. | |
| 2038 | $callerOwned = (bool) array_filter( | |
| 2039 | $flat, | |
| 2040 | fn(string|Expression|Aggregate|SubquerySelect $column) => !is_string($column) | |
| 2041 | ? true | |
| 2042 | : str_contains($column, '.'), | |
| 2043 | ); | |
| 2044 | ||
| 2045 | if ($callerOwned) { | |
| 2046 | // Order-stable qualification: each spec qualifies in its | |
| 2047 | // own position — bare strings map to `table.col`, objects | |
| 2048 | // (Aggregate/Expression/SubquerySelect) pass through | |
| 2049 | // verbatim — so the compiled list matches the caller's | |
| 2050 | // written order. | |
| 2051 | return parent::select(...array_map( | |
| 2052 | fn(string|Expression|Aggregate|SubquerySelect $column) => is_string($column) | |
| 2053 | ? $this->qualifyForJoin([$column])[0] | |
| 2054 | : $column, | |
| 2055 | $flat, | |
| 2056 | )); | |
| 2057 | } | |
| 2058 | } | |
| 2059 | ||
| 2060 | if ($this->getGroups() !== []) { | |
| 2061 | return parent::select(...array_map( | |
| 2062 | fn(string|Expression|Aggregate|SubquerySelect $column) => is_string($column) | |
| 2063 | ? $this->qualifyForJoin([$column])[0] | |
| 2064 | : $column, | |
| 2065 | $flat, | |
| 2066 | )); | |
| 2067 | } | |
| 2068 | ||
| 2069 | // Merge forced keys (PK always selected), dedupe, preserve order. | |
| 2070 | // At this point every entry is a validated string column (all | |
| 2071 | // Expression entries exited via the caller-owned branch above — and | |
| 2072 | // an aggregate query with an Expression select skips the merge too, | |
| 2073 | // since grouping changes the shape). Filter defensively for the | |
| 2074 | // type system: array_unique/array_merge need strings here. | |
| 2075 | $stringColumns = array_values(array_filter( | |
| 2076 | $flat, | |
| 2077 | fn(string|Expression|Aggregate|SubquerySelect $column) => is_string($column), | |
| 2078 | )); | |
| 2079 | $merged = array_values(array_unique(array_merge($this->forcedKeys, $stringColumns))); | |
| 2080 | ||
| 2081 | return parent::select(...$this->qualifyForJoin($merged)); | |
| 2082 | } | |
| 2083 | ||
| 2084 | /** | |
| 2085 | * Qualify bare column specs when the query joins another table. | |
| 2086 | * | |
| 2087 | * A bare name compiles unqualified — unambiguous for a single-table | |
| 2088 | * query, but AMBIGUOUS once the join adds a second table carrying the | |
| 2089 | * same column (`id` above all). Every string spec gets this model's | |
| 2090 | * table prefix; already-qualified specs, Aggregate and Expression | |
| 2091 | * entries pass through untouched. No-op when nothing is joined — bare | |
| 2092 | * names stay the join-less ergonomic default. | |
| 2093 | * | |
| 2094 | * @param list<string> $columns | |
| 2095 | * @return list<string> | |
| 2096 | */ | |
| 2097 | private function qualifyForJoin(array $columns): array | |
| 2098 | { | |
| 2099 | if ($this->joins === []) { | |
| 2100 | return $columns; | |
| 2101 | } | |
| 2102 | ||
| 2103 | $qualified = []; | |
| 2104 | ||
| 2105 | foreach ($columns as $column) { | |
| 2106 | $qualified[] = str_contains($column, '.') | |
| 2107 | ? $column | |
| 2108 | : $this->table . '.' . $column; | |
| 2109 | } | |
| 2110 | ||
| 2111 | return $qualified; | |
| 2112 | } | |
| 2113 | ||
| 2114 | /** | |
| 2115 | * Add a where clause with model-aware column validation and cast | |
| 2116 | * encoding. | |
| 2117 | * | |
| 2118 | * Every where value encodes through the validated column's cast | |
| 2119 | * ({@see Column::encode()}) — the query-path twin of the write path: | |
| 2120 | * an enum case binds its backing value (a unit case, its name), a | |
| 2121 | * `Carbon`/DateTime binds the column's datetime or date form, an int | |
| 2122 | * timestamp binds the datetime string, a Json column accepts arrays | |
| 2123 | * and JsonStorable objects. Valid raw values pass through unchanged, | |
| 2124 | * so hosts passing backing values decoded from request bodies keep | |
| 2125 | * working, and an INVALID raw value ('bogus' against an enum column, | |
| 2126 | * a non-uuid on a Uuid column) fails fast naming the column instead | |
| 2127 | * of silently matching nothing. Two shapes bypass encoding by | |
| 2128 | * contract: a LIKE pattern (a match template, not a cell value — | |
| 2129 | * `'%og%'` is not an enum backing value) and raw SQL expressions. | |
| 2130 | * | |
| 2131 | * @param string|Expression $column | |
| 2132 | * @param WhereOperator|string $operator | |
| 2133 | * @param mixed $value | |
| 2134 | * @param WhereBoolean $boolean | |
| 2135 | * @return static | |
| 2136 | * @throws \InvalidArgumentException | |
| 2137 | */ | |
| 2138 | public function where( | |
| 2139 | string|Expression $column, | |
| 2140 | WhereOperator|string $operator, | |
| 2141 | mixed $value, | |
| 2142 | WhereBoolean $boolean = WhereBoolean::And, | |
| 2143 | ): static { | |
| 2144 | if ($column instanceof Expression) { | |
| 2145 | return parent::where($column, $operator, $value, $boolean); | |
| 2146 | } | |
| 2147 | ||
| 2148 | $this->validateColumn($column); | |
| 2149 | ||
| 2150 | // Resolve the operator FIRST: list encoding is gated on the | |
| 2151 | // operators that legitimately take lists, so an illegal shape | |
| 2152 | // (a nested array under a comparison operator) reaches | |
| 2153 | // parent::where() UN-encoded and fails with its accurate | |
| 2154 | // declaration error, not a spurious cast error. | |
| 2155 | $resolved = $operator instanceof WhereOperator ? $operator : WhereOperator::fromChecked($operator); | |
| 2156 | ||
| 2157 | $listShaped = $resolved === WhereOperator::In | |
| 2158 | || $resolved === WhereOperator::NotIn | |
| 2159 | || $resolved === WhereOperator::Between | |
| 2160 | || $resolved === WhereOperator::NotBetween; | |
| 2161 | ||
| 2162 | // The list shapes (IN/NOT IN/BETWEEN) encode element-wise; every | |
| 2163 | // other operator carries at most one bindable value. Null and raw | |
| 2164 | // SQL expressions pass through untouched (the null-vs-comparison | |
| 2165 | // guard lives in parent::where()), and an ARRAY under a | |
| 2166 | // non-list operator passes through too — parent::where() owns | |
| 2167 | // that declaration error. | |
| 2168 | $encoded = match (true) { | |
| 2169 | $listShaped && is_array($value) => array_map( | |
| 2170 | fn($item) => $this->encodeWhereValue($column, $item), | |
| 2171 | $value, | |
| 2172 | ), | |
| 2173 | is_array($value) => $value, | |
| 2174 | default => $this->encodeWhereValue($column, $value, $resolved), | |
| 2175 | }; | |
| 2176 | ||
| 2177 | return parent::where($column, $resolved, $encoded, $boolean); | |
| 2178 | } | |
| 2179 | ||
| 2180 | /** | |
| 2181 | * Add an order-by clause with model-aware column validation. | |
| 2182 | * | |
| 2183 | * A bare column QUALIFIES when the query joins another table — the | |
| 2184 | * same join-aware rule the select list follows (through relations | |
| 2185 | * order by the related model's bare PK; unqualified, both joined | |
| 2186 | * tables carry `id` and the driver rejects the ambiguity). | |
| 2187 | * | |
| 2188 | * @param string|Expression $column | |
| 2189 | * @param SortDirection|string $direction | |
| 2190 | * @return static | |
| 2191 | * @throws \InvalidArgumentException | |
| 2192 | */ | |
| 2193 | public function orderBy(string|Expression $column, SortDirection|string $direction = SortDirection::Asc): static | |
| 2194 | { | |
| 2195 | if (!$column instanceof Expression) { | |
| 2196 | $this->validateColumn($column); | |
| 2197 | ||
| 2198 | $qualified = $this->qualifyForJoin([$column]); | |
| 2199 | ||
| 2200 | return parent::orderBy($qualified[0], $direction); | |
| 2201 | } | |
| 2202 | ||
| 2203 | return parent::orderBy($column, $direction); | |
| 2204 | } | |
| 2205 | ||
| 2206 | /** | |
| 2207 | * Group by columns with model-aware column validation. | |
| 2208 | * | |
| 2209 | * @param string|array<int, string> $columns | |
| 2210 | * @return static | |
| 2211 | * @throws \InvalidArgumentException | |
| 2212 | */ | |
| 2213 | public function groupBy(string|array $columns): static | |
| 2214 | { | |
| 2215 | foreach (is_array($columns) ? $columns : [$columns] as $column) { | |
| 2216 | $this->validateColumn($column); | |
| 2217 | } | |
| 2218 | ||
| 2219 | return parent::groupBy($columns); | |
| 2220 | } | |
| 2221 | ||
| 2222 | /** | |
| 2223 | * Add a having clause with model-aware validation and cast encoding. | |
| 2224 | * | |
| 2225 | * The comparison value encodes through the compared column's cast — | |
| 2226 | * an enum case filters a grouped enum column by its backing value, a | |
| 2227 | * datetime by its stored form. An {@see Aggregate} encodes by its | |
| 2228 | * INNER column (`max('level')` compares a level-cell value, exactly | |
| 2229 | * how the aggregate decode reads the inner column); an {@see Expression} | |
| 2230 | * is raw SQL by contract and skips both validation and encoding. | |
| 2231 | * | |
| 2232 | * @param string|Expression|Aggregate $column | |
| 2233 | * @param WhereOperator|string $operator | |
| 2234 | * @param mixed $value | |
| 2235 | * @return static | |
| 2236 | * @throws \InvalidArgumentException | |
| 2237 | */ | |
| 2238 | public function having(string|Expression|Aggregate $column, WhereOperator|string $operator, mixed $value): static | |
| 2239 | { | |
| 2240 | if ($column instanceof Aggregate) { | |
| 2241 | // The column of a typed aggregate gets the same allowlist check | |
| 2242 | // as a plain column — the old string path bypassed it. An | |
| 2243 | // Expression argument is raw SQL by contract. | |
| 2244 | if (!$column->column instanceof Expression && $column->column !== '*') { | |
| 2245 | $this->validateColumn($column->column); | |
| 2246 | ||
| 2247 | $value = $this->encodeWhereValue($column->column, $value, $operator); | |
| 2248 | } | |
| 2249 | ||
| 2250 | return parent::having($column, $operator, $value); | |
| 2251 | } | |
| 2252 | ||
| 2253 | if (!$column instanceof Expression) { | |
| 2254 | $this->validateColumn($column); | |
| 2255 | $value = $this->encodeWhereValue($column, $value, $operator); | |
| 2256 | } | |
| 2257 | ||
| 2258 | return parent::having($column, $operator, $value); | |
| 2259 | } | |
| 2260 | ||
| 2261 | /** | |
| 2262 | * Fail fast on an unknown model column. | |
| 2263 | * | |
| 2264 | * @param string $column | |
| 2265 | * @return void | |
| 2266 | * @throws \InvalidArgumentException | |
| 2267 | */ | |
| 2268 | protected function validateColumn(string $column): void | |
| 2269 | { | |
| 2270 | // Hash-set lookups, not linear scans: every where/orderBy/groupBy/ | |
| 2271 | // having/select validates, so O(clauses × columns) list scans on | |
| 2272 | // clause-heavy queries against wide models collapse to O(1) each. | |
| 2273 | // The sets are immutable per builder — built lazily once. | |
| 2274 | $columns = $this->columnSet ??= array_fill_keys($this->modelColumns, true); | |
| 2275 | $forced = $this->forcedKeySet ??= array_fill_keys($this->forcedKeys, true); | |
| 2276 | ||
| 2277 | if (isset($columns[$column]) || isset($forced[$column])) { | |
| 2278 | return; | |
| 2279 | } | |
| 2280 | ||
| 2281 | // Strip a trailing `as alias` — the spec validates by its SOURCE | |
| 2282 | // reference; the alias only names the result key (the Grammar owns | |
| 2283 | // the AS rendering). | |
| 2284 | $source = trim((string) preg_replace('/\s+as\s+\S+$/i', '', $column)); | |
| 2285 | ||
| 2286 | if ($source !== $column && isset($columns[$source])) { | |
| 2287 | return; // `column as alias` over a declared column. | |
| 2288 | } | |
| 2289 | ||
| 2290 | if ($source === '*') { | |
| 2291 | return; // a raw star — it qualifies IN PLACE under a join (table.*). | |
| 2292 | } | |
| 2293 | ||
| 2294 | // Qualified reference — `table.column[ as alias]`. The qualified | |
| 2295 | // form names its table explicitly: the column must exist on the | |
| 2296 | // NAMED table per the partition map (MTI), on a table this query | |
| 2297 | // JOINs (the through-relation select spec), or on the builder's | |
| 2298 | // OWN table — and in the own-table case the COLUMN part must still | |
| 2299 | // be a declared model column (or `*`). Without that check a | |
| 2300 | // `table.bogus` spec slipped through where the plain `bogus` form | |
| 2301 | // would have failed fast — quoting prevents injection, but the | |
| 2302 | // model layer's allowlist contract was not uniformly applied. | |
| 2303 | if (str_contains($source, '.')) { | |
| 2304 | [$table, $rest] = explode('.', $source, 2); | |
| 2305 | ||
| 2306 | // A join spec may carry an alias (`bp_users as other`): the | |
| 2307 | // alias, the source table, and the verbatim spec all address | |
| 2308 | // the joined table in ON conditions. | |
| 2309 | $joinTables = []; | |
| 2310 | foreach ($this->joins as $join) { | |
| 2311 | $joinTables[] = $join['table']; | |
| 2312 | if (preg_match('/^(.*?)\s+as\s+(\S+)$/i', $join['table'], $m) === 1) { | |
| 2313 | $joinTables[] = trim($m[1]); | |
| 2314 | $joinTables[] = $m[2]; | |
| 2315 | } | |
| 2316 | } | |
| 2317 | ||
| 2318 | if (($this->partitions !== [] && ($this->partitions[$rest] ?? null) === $table) | |
| 2319 | || in_array($table, $joinTables, true) | |
| 2320 | ) { | |
| 2321 | return; | |
| 2322 | } | |
| 2323 | ||
| 2324 | // Correlation context: a table the OUTER query names (its own | |
| 2325 | // table, its join tables and aliases) when this builder is a | |
| 2326 | // whereExists subquery — the correlation references those | |
| 2327 | // tables by design. Their column allowlist belongs to the | |
| 2328 | // OUTER model, not this one, so admission here is by table | |
| 2329 | // only; a typo in the outer table's column is the outer | |
| 2330 | // builder's contract to catch (and SQL fails fast on an | |
| 2331 | // unknown column regardless). | |
| 2332 | if (in_array($table, $this->correlationTables, true)) { | |
| 2333 | return; | |
| 2334 | } | |
| 2335 | ||
| 2336 | if ($table === $this->table) { | |
| 2337 | if ($rest === '*' || isset($columns[$rest]) || isset($forced[$rest])) { | |
| 2338 | return; | |
| 2339 | } | |
| 2340 | // Own-table prefix but an unknown column — fall through to | |
| 2341 | // the throw below, same as the unqualified form would. | |
| 2342 | } | |
| 2343 | } | |
| 2344 | ||
| 2345 | throw new \InvalidArgumentException( | |
| 2346 | "Unknown column [{$column}] on model [{$this->modelClass}]." | |
| 2347 | ); | |
| 2348 | } | |
| 2349 | ||
| 2350 | // ---- Writes (validated + encoded through the column casts) ---- | |
| 2351 | ||
| 2352 | /** | |
| 2353 | * Insert rows with model-aware validation, cast encoding, and bulk | |
| 2354 | * row hooks. | |
| 2355 | * | |
| 2356 | * @param array<string, mixed>|list<array<string, mixed>> $values | |
| 2357 | * @return int | |
| 2358 | * @throws \InvalidArgumentException | |
| 2359 | * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException | |
| 2360 | */ | |
| 2361 | public function insert(array $values): int | |
| 2362 | { | |
| 2363 | $rows = $this->normalizeRows($values); | |
| 2364 | ||
| 2365 | Model::dispatchInsertHooks($this->modelClass, $rows); | |
| 2366 | ||
| 2367 | return parent::insert($this->encodeRows($rows)); | |
| 2368 | } | |
| 2369 | ||
| 2370 | /** | |
| 2371 | * Insert a single row and return the generated id, validated and | |
| 2372 | * encoded like {@see insert()}. | |
| 2373 | * | |
| 2374 | * @param array<string, mixed> $values | |
| 2375 | * @return string|int|null | |
| 2376 | * @throws \InvalidArgumentException | |
| 2377 | * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException | |
| 2378 | */ | |
| 2379 | public function insertGetId(array $values): string|int|null | |
| 2380 | { | |
| 2381 | $rows = [$values]; | |
| 2382 | ||
| 2383 | Model::dispatchInsertHooks($this->modelClass, $rows); | |
| 2384 | ||
| 2385 | return parent::insertGetId($this->encodeRow($rows[0])); | |
| 2386 | } | |
| 2387 | ||
| 2388 | /** | |
| 2389 | * Update the matching rows with model-aware validation, cast | |
| 2390 | * encoding, and bulk update hooks. | |
| 2391 | * | |
| 2392 | * @param array<string, mixed> $values | |
| 2393 | * @return int | |
| 2394 | * @throws \InvalidArgumentException | |
| 2395 | * @throws \BlueprintAU\Radiant\Exceptions\WriteVetoException | |
| 2396 | */ | |
| 2397 | public function update(array $values): int | |
| 2398 | { | |
| 2399 | Model::dispatchUpdateHooks($this->modelClass, $values); | |
| 2400 | ||
| 2401 | return parent::update($this->encodeRow($values)); | |
| 2402 | } | |
| 2403 | ||
| 2404 | /** | |
| 2405 | * Normalize an insert payload to a row list. | |
| 2406 | * | |
| 2407 | * A single map is a one-row batch — the normalization makes the hook | |
| 2408 | * contract uniform (insert hooks always see a row list) and keeps | |
| 2409 | * {@see encodeRows()} on the single list path. | |
| 2410 | * | |
| 2411 | * @param array<string, mixed>|list<array<int|string, mixed>> $values | |
| 2412 | * @return list<array<int|string, mixed>> | |
| 2413 | */ | |
| 2414 | private function normalizeRows(array $values): array | |
| 2415 | { | |
| 2416 | if (array_is_list($values)) { | |
| 2417 | /** @var list<array<int|string, mixed>> */ | |
| 2418 | return $values; | |
| 2419 | } | |
| 2420 | ||
| 2421 | return [$values]; | |
| 2422 | } | |
| 2423 | ||
| 2424 | /** | |
| 2425 | * Encode a row list through the column casts. | |
| 2426 | * | |
| 2427 | * @param list<array<int|string, mixed>> $rows | |
| 2428 | * @return list<array<string, mixed>> | |
| 2429 | * @throws \InvalidArgumentException | |
| 2430 | */ | |
| 2431 | private function encodeRows(array $rows): array | |
| 2432 | { | |
| 2433 | // A list whose entries are NOT arrays is caller error — a list of | |
| 2434 | // scalars (`insert(['name', 'age'])`) is never a valid row set. | |
| 2435 | // Delegating each entry to encodeRow() makes that fail fast: its | |
| 2436 | // native `array` parameter TypeErrors on a scalar, where a | |
| 2437 | // duplicated foreach would only WARN and silently encode empty | |
| 2438 | // rows (PHP 8 foreach-over-string skips the loop). | |
| 2439 | $encoded = []; | |
| 2440 | ||
| 2441 | foreach ($rows as $row) { | |
| 2442 | $encoded[] = $this->encodeRow($row); | |
| 2443 | } | |
| 2444 | ||
| 2445 | return $encoded; | |
| 2446 | } | |
| 2447 | ||
| 2448 | /** | |
| 2449 | * Encode one row map — the shared body of {@see encodeRows()}. | |
| 2450 | * | |
| 2451 | * @param array<int|string, mixed> $row | |
| 2452 | * @return array<string, mixed> | |
| 2453 | * @throws \InvalidArgumentException | |
| 2454 | */ | |
| 2455 | private function encodeRow(array $row): array | |
| 2456 | { | |
| 2457 | $encoded = []; | |
| 2458 | ||
| 2459 | foreach ($row as $column => $value) { | |
| 2460 | $name = (string) $column; | |
| 2461 | $encoded[$name] = $this->encodeValue($name, $value); | |
| 2462 | } | |
| 2463 | ||
| 2464 | return $encoded; | |
| 2465 | } | |
| 2466 | ||
| 2467 | /** | |
| 2468 | * Validate one write-path column key and encode its value. | |
| 2469 | * | |
| 2470 | * @param string $column | |
| 2471 | * @param mixed $value | |
| 2472 | * @return mixed | |
| 2473 | * @throws \InvalidArgumentException | |
| 2474 | */ | |
| 2475 | private function encodeValue(string $column, mixed $value): mixed | |
| 2476 | { | |
| 2477 | $this->validateWriteColumn($column); | |
| 2478 | ||
| 2479 | $mapping = MetadataFactory::for($this->modelClass)->mappingFor($column); | |
| 2480 | ||
| 2481 | return $mapping->column->encode($value, $mapping->propertyType); | |
| 2482 | } | |
| 2483 | ||
| 2484 | /** | |
| 2485 | * Validate one write-path column key. | |
| 2486 | * | |
| 2487 | * @param string $column | |
| 2488 | * @return void | |
| 2489 | * @throws \InvalidArgumentException | |
| 2490 | */ | |
| 2491 | private function validateWriteColumn(string $column): void | |
| 2492 | { | |
| 2493 | if (!MetadataFactory::for($this->modelClass)->hasColumn($column)) { | |
| 2494 | throw new \InvalidArgumentException( | |
| 2495 | "Unknown column [{$column}] on model [{$this->modelClass}]." | |
| 2496 | ); | |
| 2497 | } | |
| 2498 | } | |
| 2499 | } |
Inherited from BlueprintAU\Radiant\Database\Query\QueryBuilder
| 198 | protected function normalizeTableReference(string $table): string | |
| 199 | { | |
| 200 | if (preg_match('/\s+as\s+/i', $table) !== 1 && preg_match('/\s/', $table) === 1) { | |
| 201 | throw new \InvalidArgumentException( | |
| 202 | "Invalid table reference [{$table}] — use `table` or `table as alias`" | |
| 203 | . ' (the compact `table alias` spelling is not accepted).' | |
| 204 | ); | |
| 205 | } | |
| 206 | ||
| 207 | return $table; | |
| 208 | } |
| 246 | final public function distinct(): static | |
| 247 | { | |
| 248 | $clone = clone $this; | |
| 249 | $clone->distinct = true; | |
| 250 | return $clone; | |
| 251 | } |
| 264 | final public function fromSub(QueryBuilder $query, string $alias): static | |
| 265 | { | |
| 266 | if ($this->from instanceof QueryBuilder) { | |
| 267 | throw new \LogicException('The query from is already set and cannot be changed.'); | |
| 268 | } | |
| 269 | $clone = clone $this; | |
| 270 | $clone->from = $query; | |
| 271 | $clone->fromAlias = $alias; | |
| 272 | // Eager capture: the sub-builder's full binding list rides the | |
| 273 | // From category of the RETURNED clone (the subquery's `?`s all sit | |
| 274 | // inside the compiled from clause, so their order is exactly the | |
| 275 | // sub-builder's flattened order). | |
| 276 | $clone->bindings[BindingCategory::From->value] = $query->getBindings(); | |
| 277 | return $clone; | |
| 278 | } |
| 291 | public function join(string $table, string $first, ColumnOperator|string $operator = '=', string $second = ''): static | |
| 292 | { | |
| 293 | return $this->addJoin(JoinType::Inner, $table, $first, $operator, $second); | |
| 294 | } |
| 305 | public function leftJoin(string $table, string $first, ColumnOperator|string $operator = '=', string $second = ''): static | |
| 306 | { | |
| 307 | return $this->addJoin(JoinType::Left, $table, $first, $operator, $second); | |
| 308 | } |
| 319 | public function rightJoin(string $table, string $first, ColumnOperator|string $operator = '=', string $second = ''): static | |
| 320 | { | |
| 321 | return $this->addJoin(JoinType::Right, $table, $first, $operator, $second); | |
| 322 | } |
| 330 | final public function crossJoin(string $table): static | |
| 331 | { | |
| 332 | return $this->addJoin(JoinType::Cross, $table, '', '=', ''); | |
| 333 | } |
| 382 | protected function addOn(WhereBoolean $boolean, string $first, ColumnOperator|string $operator, string $second): static | |
| 383 | { | |
| 384 | if ($this->joins === []) { | |
| 385 | throw new \LogicException( | |
| 386 | 'Cannot call on()/orOn() before a join: an ON condition belongs to the join it follows. Call join()/leftJoin()/rightJoin()/crossJoin() first.' | |
| 387 | ); | |
| 388 | } | |
| 389 | ||
| 390 | $resolved = $operator instanceof ColumnOperator ? $operator : ColumnOperator::fromChecked($operator); | |
| 391 | $last = count($this->joins) - 1; | |
| 392 | $clone = clone $this; | |
| 393 | $clone->joins[$last]['wheres'][] = [ | |
| 394 | 'type' => WhereType::Column, | |
| 395 | 'first' => $first, | |
| 396 | 'operator' => $resolved, | |
| 397 | 'second' => $second, | |
| 398 | 'boolean' => $boolean, | |
| 399 | ]; | |
| 400 | return $clone; | |
| 401 | } |
| 447 | private function assertHomogeneousList(array $value, string $method): void | |
| 448 | { | |
| 449 | $hasRaw = false; | |
| 450 | $hasPlain = false; | |
| 451 | foreach ($value as $item) { | |
| 452 | if ($item instanceof Expression || $item instanceof ToSqlValue) { | |
| 453 | $hasRaw = true; | |
| 454 | } else { | |
| 455 | $hasPlain = true; | |
| 456 | } | |
| 457 | ||
| 458 | if ($hasRaw && $hasPlain) { | |
| 459 | throw new \InvalidArgumentException( | |
| 460 | "{$method} require a list of ALL plain values or ALL raw SQL expressions " | |
| 461 | . '(Expression/ToSqlValue) — a mixed list desyncs placeholders from bindings. ' | |
| 462 | . 'Split into separate clauses or normalize the list.' | |
| 463 | ); | |
| 464 | } | |
| 465 | } | |
| 466 | } |
| 646 | public function whereNotExists(QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static | |
| 647 | { | |
| 648 | return $this->whereExists($query, $boolean, true); | |
| 649 | } |
| 657 | public function orWhereExists(QueryBuilder $query): static | |
| 658 | { | |
| 659 | return $this->whereExists($query, WhereBoolean::Or); | |
| 660 | } |
| 668 | public function orWhereNotExists(QueryBuilder $query): static | |
| 669 | { | |
| 670 | return $this->whereExists($query, WhereBoolean::Or, true); | |
| 671 | } |
| 701 | public function whereNotInQuery(string $column, QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static | |
| 702 | { | |
| 703 | return $this->whereInQuery($column, $query, $boolean, true); | |
| 704 | } |
| 713 | public function orWhereInQuery(string $column, QueryBuilder $query): static | |
| 714 | { | |
| 715 | return $this->whereInQuery($column, $query, WhereBoolean::Or); | |
| 716 | } |
| 725 | public function orWhereNotInQuery(string $column, QueryBuilder $query): static | |
| 726 | { | |
| 727 | return $this->whereInQuery($column, $query, WhereBoolean::Or, true); | |
| 728 | } |
| 744 | final public function whereNested(callable $callback, WhereBoolean $boolean = WhereBoolean::And): static | |
| 745 | { | |
| 746 | $nested = $this->newNestedBuilder(); | |
| 747 | $group = $callback(new WhereBuilder($nested)); | |
| 748 | ||
| 749 | // Runtime boundary: the callable signature is PHPDoc-only, so a | |
| 750 | // mutation-style callback (mutates the argument, returns nothing) | |
| 751 | // hands back NULL here. PHPStan cannot see this — it trusts the | |
| 752 | // declared signature and would flag the instanceof as always-true — | |
| 753 | // but at runtime it is the difference between a clear declaration | |
| 754 | // error and a bare "call to a member function on null". The ignore | |
| 755 | // is scoped and justified: the check is redundant FOR TYPED | |
| 756 | // CALLERS, which is exactly who PHPStan analyzes. | |
| 757 | /** @phpstan-ignore instanceof.alwaysTrue (runtime boundary: untyped callbacks may return null — see the project convention on scoped ignores) */ | |
| 758 | if (!$group instanceof WhereBuilder) { | |
| 759 | throw new \InvalidArgumentException( | |
| 760 | 'whereNested() callback must RETURN the WhereBuilder it received ' | |
| 761 | . '(the builder is immutable — mutating the argument without returning it adds nothing).' | |
| 762 | ); | |
| 763 | } | |
| 764 | ||
| 765 | $groupQuery = $group->getNestedQuery(); | |
| 766 | ||
| 767 | // An empty group is a declaration bug, not a neutral filter: on SQL | |
| 768 | // it compiles to degenerate `()` SQL, and evaluators that walk the | |
| 769 | // clause list would read past its end. Fail fast at declaration. | |
| 770 | if ($groupQuery->getWheres() === []) { | |
| 771 | throw new \InvalidArgumentException( | |
| 772 | 'A nested where group must contain at least one clause; the callback added none.' | |
| 773 | ); | |
| 774 | } | |
| 775 | ||
| 776 | $clone = clone $this; | |
| 777 | $clone->wheres[] = ['type' => WhereType::Nested, 'group' => new WhereGroup($groupQuery->getWheres()), 'boolean' => $boolean]; | |
| 778 | array_push($clone->bindings[BindingCategory::Where->value], ...$groupQuery->getBindings([BindingCategory::Where])); | |
| 779 | return $clone; | |
| 780 | } |
| 853 | public function limit(int $limit): static | |
| 854 | { | |
| 855 | $clone = clone $this; | |
| 856 | $clone->limit = $limit; | |
| 857 | return $clone; | |
| 858 | } |
| 866 | public function offset(int $offset): static | |
| 867 | { | |
| 868 | $clone = clone $this; | |
| 869 | $clone->offset = $offset; | |
| 870 | return $clone; | |
| 871 | } |
| 882 | final public function union(QueryBuilder $query, bool $all = false): static | |
| 883 | { | |
| 884 | $clone = clone $this; | |
| 885 | $clone->unions[] = ['query' => $query, 'all' => $all]; | |
| 886 | // Eager capture: APPEND, because unions are a SEQUENCE — each | |
| 887 | // union() call adds its sub-builder's bindings after the previous | |
| 888 | // ones, matching the compiled order of the UNION clauses. | |
| 889 | array_push($clone->bindings[BindingCategory::Union->value], ...$query->getBindings()); | |
| 890 | return $clone; | |
| 891 | } |
| 900 | final public function lockForUpdate(): static | |
| 901 | { | |
| 902 | $clone = clone $this; | |
| 903 | $clone->lock = LockType::Update; | |
| 904 | return $clone; | |
| 905 | } |
| 912 | final public function sharedLock(): static | |
| 913 | { | |
| 914 | $clone = clone $this; | |
| 915 | $clone->lock = LockType::Shared; | |
| 916 | return $clone; | |
| 917 | } |
| 1005 | protected function scalarColumn(string $column): string | |
| 1006 | { | |
| 1007 | return (string) preg_replace('/\s+as\s+[`"]?[a-z_][a-z0-9_]*[`"]?$/i', '', $column); | |
| 1008 | } |
| 1030 | final public function exists(): bool | |
| 1031 | { | |
| 1032 | return $this->first() !== null; | |
| 1033 | } |
| 1212 | final public function delete(): int | |
| 1213 | { | |
| 1214 | return $this->connection->delete($this); | |
| 1215 | } |
| 1225 | final public function getBindings(?array $categories = null): array | |
| 1226 | { | |
| 1227 | $categories ??= array_keys($this->bindings); | |
| 1228 | $bindings = []; | |
| 1229 | foreach ($categories as $category) { | |
| 1230 | $key = $category instanceof BindingCategory ? $category->value : $category; | |
| 1231 | array_push($bindings, ...$this->bindings[$key]); | |
| 1232 | } | |
| 1233 | return $bindings; | |
| 1234 | } |
| 1243 | final public function insertIdColumn(string $column, bool $autoIncrement = true): static | |
| 1244 | { | |
| 1245 | $clone = clone $this; | |
| 1246 | $clone->insertIdColumn = $column; | |
| 1247 | $clone->insertIdAutoIncrement = $autoIncrement; | |
| 1248 | return $clone; | |
| 1249 | } |
| 1268 | final public function assertSupports(SqlFeature ...$features): static | |
| 1269 | { | |
| 1270 | $used = SqlFeature::usedBy($this); | |
| 1271 | $violated = array_values(array_filter( | |
| 1272 | $used, | |
| 1273 | fn(SqlFeature $feature) => !in_array($feature, $features, true), | |
| 1274 | )); | |
| 1275 | ||
| 1276 | if ($violated !== []) { | |
| 1277 | $names = implode(', ', array_map(fn(SqlFeature $f) => $f->value, $violated)); | |
| 1278 | throw new \BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException( | |
| 1279 | "This query uses feature(s) [{$names}] outside the supported set." | |
| 1280 | ); | |
| 1281 | } | |
| 1282 | ||
| 1283 | return $this; | |
| 1284 | } |
| 1291 | final public function getColumns(): array | |
| 1292 | { | |
| 1293 | return $this->columns; | |
| 1294 | } |
| 1301 | final public function isDistinct(): bool | |
| 1302 | { | |
| 1303 | return $this->distinct; | |
| 1304 | } |
| 1311 | final public function getFrom(): string|QueryBuilder | |
| 1312 | { | |
| 1313 | return $this->from; | |
| 1314 | } |
| 1321 | final public function getFromAlias(): ?string | |
| 1322 | { | |
| 1323 | return $this->fromAlias; | |
| 1324 | } |
| 1331 | final public function getJoins(): array | |
| 1332 | { | |
| 1333 | return $this->joins; | |
| 1334 | } |
| 1344 | final public function getWheres(): array | |
| 1345 | { | |
| 1346 | return $this->wheres; | |
| 1347 | } |
| 1356 | protected function markLastWhereTraitScope(string $trait): void | |
| 1357 | { | |
| 1358 | if ($this->wheres === []) { | |
| 1359 | throw new \LogicException('Cannot mark the trait scope: the builder has no where clauses.'); | |
| 1360 | } | |
| 1361 | ||
| 1362 | $last = count($this->wheres) - 1; | |
| 1363 | /** @phpstan-ignore assign.propertyType (the marker key is only meaningful on the clause arms that carry scopes; the union shape lists it per-arm) */ | |
| 1364 | $this->wheres[$last]['traitScope'] = $trait; | |
| 1365 | } |
| 1372 | final public function getGroups(): array | |
| 1373 | { | |
| 1374 | return $this->groups; | |
| 1375 | } |
| 1382 | final public function getHavings(): array | |
| 1383 | { | |
| 1384 | return $this->havings; | |
| 1385 | } |
| 1392 | final public function getOrders(): array | |
| 1393 | { | |
| 1394 | return $this->orders; | |
| 1395 | } |
| 1402 | final public function getUnions(): array | |
| 1403 | { | |
| 1404 | return $this->unions; | |
| 1405 | } |
| 1412 | final public function getLock(): ?LockType | |
| 1413 | { | |
| 1414 | return $this->lock; | |
| 1415 | } |
| 1422 | final public function getLimit(): ?int | |
| 1423 | { | |
| 1424 | return $this->limit; | |
| 1425 | } |
| 1432 | final public function getOffset(): ?int | |
| 1433 | { | |
| 1434 | return $this->offset; | |
| 1435 | } |
| 1442 | final public function getInsertIdColumn(): ?string | |
| 1443 | { | |
| 1444 | return $this->insertIdColumn; | |
| 1445 | } |
| 1452 | final public function isInsertIdAutoIncrement(): bool | |
| 1453 | { | |
| 1454 | return $this->insertIdAutoIncrement ?? false; | |
| 1455 | } |