Lines
98.23%
278 / 283
Functions and Methods
93.15%
68 / 73
Classes and Traits
0.00%
0 / 1
| Name | Lines | Functions and Methods | CRAP | Classes and Traits | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| QueryBuilder | 98.23% | 278 / 283 | 93.15% | 68 / 73 | 125 | 0.00% | 0 / 1 | |||
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| normalizeTableReference | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 3 | |||||
| select | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 4 | |||||
| distinct | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| fromSub | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 2 | |||||
| join | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| leftJoin | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| rightJoin | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| crossJoin | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| on | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| orOn | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| addOn | 100.00% | 15 / 15 | 100.00% | 1 / 1 | 3 | |||||
| addJoin | 100.00% | 16 / 16 | 100.00% | 1 / 1 | 3 | |||||
| assertHomogeneousList | 100.00% | 12 / 12 | 100.00% | 1 / 1 | 6 | |||||
| where | 100.00% | 54 / 54 | 100.00% | 1 / 1 | 20 | |||||
| whereRaw | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | |||||
| whereColumn | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 2 | |||||
| whereExists | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | |||||
| whereNotExists | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| orWhereExists | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| orWhereNotExists | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | |||||
| whereInQuery | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | |||||
| whereNotInQuery | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| orWhereInQuery | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | |||||
| orWhereNotInQuery | 0.00% | 0 / 1 | 0.00% | 0 / 1 | 2 | |||||
| whereNested | 100.00% | 16 / 16 | 100.00% | 1 / 1 | 3 | |||||
| newNestedBuilder | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| groupBy | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 2 | |||||
| having | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 5 | |||||
| orderBy | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 2 | |||||
| limit | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| offset | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| union | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | |||||
| lockForUpdate | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| sharedLock | 100.00% | 3 / 3 | 100.00% | 1 / 1 | 1 | |||||
| get | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| cursor | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| first | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| value | 83.33% | 5 / 6 | 0.00% | 0 / 1 | 2.02 | |||||
| pluck | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| scalarColumn | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| count | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| exists | 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% | 5 / 5 | 100.00% | 1 / 1 | 1 | |||||
| aggregateBy | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 3 | |||||
| countBy | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 3 | |||||
| insert | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| insertGetId | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| update | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| delete | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getBindings | 100.00% | 6 / 6 | 100.00% | 1 / 1 | 3 | |||||
| insertIdColumn | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | |||||
| assertSupports | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 2 | |||||
| getColumns | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| isDistinct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getFrom | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getFromAlias | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getJoins | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getWheres | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| markLastWhereTraitScope | 75.00% | 3 / 4 | 0.00% | 0 / 1 | 2.06 | |||||
| getGroups | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getHavings | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getOrders | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getUnions | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getLock | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getLimit | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getOffset | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| getInsertIdColumn | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| isInsertIdAutoIncrement | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | |||||
| 1 | <?php | |
| 2 | ||
| 3 | declare(strict_types=1); | |
| 4 | ||
| 5 | namespace BlueprintAU\Radiant\Database\Query; | |
| 6 | ||
| 7 | use BlueprintAU\Collections\Collection; | |
| 8 | use BlueprintAU\Radiant\Concerns\FiltersWhere; | |
| 9 | use BlueprintAU\Radiant\Database\Connections\ConnectionInterface; | |
| 10 | use BlueprintAU\Radiant\Database\Query\Enums\BindingCategory; | |
| 11 | use BlueprintAU\Radiant\Database\Query\Enums\ColumnOperator; | |
| 12 | use BlueprintAU\Radiant\Database\Query\Enums\JoinType; | |
| 13 | use BlueprintAU\Radiant\Database\Query\Enums\LockType; | |
| 14 | use BlueprintAU\Radiant\Database\Query\Enums\SortDirection; | |
| 15 | use BlueprintAU\Radiant\Database\Query\Enums\WhereBoolean; | |
| 16 | use BlueprintAU\Radiant\Database\Query\Enums\WhereOperator; | |
| 17 | use BlueprintAU\Radiant\Database\Query\Enums\WhereType; | |
| 18 | ||
| 19 | /** | |
| 20 | * Builds a database query fluently. | |
| 21 | * | |
| 22 | * Chain methods like `where()`, `orderBy()`, and `limit()` to describe what | |
| 23 | * you want, then run it with `get()`. The same builder works against any | |
| 24 | * backend: SQL databases compile it to SQL, while a CSV connection applies | |
| 25 | * the filters directly in PHP. | |
| 26 | * | |
| 27 | * @phpstan-type WhereClause array{type: WhereType::Basic, column: string|Expression, operator: WhereOperator, value: mixed, boolean: WhereBoolean, traitScope?: class-string} | array{type: WhereType::Between, column: string|Expression, operator: WhereOperator, value: array{0: mixed, 1: mixed}, boolean: WhereBoolean, traitScope?: class-string} | array{type: WhereType::Null, column: string|Expression, operator: WhereOperator, boolean: WhereBoolean, traitScope?: class-string} | array{type: WhereType::Raw, sql: string, boolean: WhereBoolean, traitScope?: class-string} | array{type: WhereType::Column, first: string, operator: ColumnOperator, second: string, boolean: WhereBoolean, traitScope?: class-string} | array{type: WhereType::Nested, group: WhereGroup, boolean: WhereBoolean} | array{type: WhereType::Exists, query: QueryBuilder, negated: bool, boolean: WhereBoolean, traitScope?: class-string} | array{type: WhereType::InSub, column: string, query: QueryBuilder, negated: bool, boolean: WhereBoolean, traitScope?: class-string} | |
| 28 | * @phpstan-type BindingValue string|int|float|bool|null|\DateTimeInterface|Expression|ToSqlValue | |
| 29 | * | |
| 30 | * @see \BlueprintAU\Radiant\Database\Connections\ConnectionInterface | |
| 31 | */ | |
| 32 | class QueryBuilder | |
| 33 | { | |
| 34 | use FiltersWhere; | |
| 35 | ||
| 36 | /** | |
| 37 | * The internal result alias for a grouped aggregate's value column. | |
| 38 | * | |
| 39 | * The same convention as the scalar reads: a stable alias keeps the | |
| 40 | * value readable regardless of how each driver names an unaliased | |
| 41 | * aggregate column. | |
| 42 | */ | |
| 43 | protected const AGGREGATE_ALIAS = 'radiant_aggregate'; | |
| 44 | ||
| 45 | /** | |
| 46 | * The columns to select. | |
| 47 | * | |
| 48 | * @var list<string|Expression|Aggregate|SubquerySelect> | |
| 49 | */ | |
| 50 | protected array $columns = ['*']; | |
| 51 | ||
| 52 | /** | |
| 53 | * Whether the select is `DISTINCT`. | |
| 54 | * | |
| 55 | * @var bool | |
| 56 | */ | |
| 57 | protected bool $distinct = false; | |
| 58 | ||
| 59 | /** | |
| 60 | * The from clause — a table name or a subquery builder. | |
| 61 | * | |
| 62 | * @var string|QueryBuilder | |
| 63 | */ | |
| 64 | protected string|QueryBuilder $from; | |
| 65 | ||
| 66 | /** | |
| 67 | * The alias of the from subquery, when `fromSub()` was used. | |
| 68 | * | |
| 69 | * @var string|null | |
| 70 | */ | |
| 71 | protected ?string $fromAlias = null; | |
| 72 | ||
| 73 | /** | |
| 74 | * The joins to apply. | |
| 75 | * | |
| 76 | * @var list<array{type: JoinType, table: string, wheres: list<array{type: WhereType::Column, first: string, operator: ColumnOperator, second: string, boolean: WhereBoolean}>}> | |
| 77 | */ | |
| 78 | protected array $joins = []; | |
| 79 | ||
| 80 | /** | |
| 81 | * The where clauses. | |
| 82 | * | |
| 83 | * @var list<WhereClause> | |
| 84 | */ | |
| 85 | protected array $wheres = []; | |
| 86 | ||
| 87 | /** | |
| 88 | * The group-by columns. | |
| 89 | * | |
| 90 | * @var list<string> | |
| 91 | */ | |
| 92 | protected array $groups = []; | |
| 93 | ||
| 94 | /** | |
| 95 | * The having clauses. | |
| 96 | * | |
| 97 | * The compared `column` may be a plain column, an {@see Expression}, or | |
| 98 | * an {@see Aggregate} (filtering on a computed value — | |
| 99 | * `HAVING count(*) > ?`). | |
| 100 | * | |
| 101 | * @var list<array{type: WhereType::Basic, column: string|Expression|Aggregate, operator: WhereOperator, value: BindingValue}> | |
| 102 | */ | |
| 103 | protected array $havings = []; | |
| 104 | ||
| 105 | /** | |
| 106 | * The order-by clauses. | |
| 107 | * | |
| 108 | * An {@see Expression} column is spliced verbatim; its direction is | |
| 109 | * still validated and appended after it. | |
| 110 | * | |
| 111 | * @var list<array{column: string|Expression, direction: SortDirection|null}> | |
| 112 | */ | |
| 113 | protected array $orders = []; | |
| 114 | ||
| 115 | /** | |
| 116 | * The unions to append. | |
| 117 | * | |
| 118 | * @var list<array{query: QueryBuilder, all: bool}> | |
| 119 | */ | |
| 120 | protected array $unions = []; | |
| 121 | ||
| 122 | /** | |
| 123 | * The row lock to apply, or null for none. | |
| 124 | * | |
| 125 | * @var LockType|null | |
| 126 | */ | |
| 127 | protected ?LockType $lock = null; | |
| 128 | ||
| 129 | /** | |
| 130 | * The maximum number of rows to return. | |
| 131 | * | |
| 132 | * @var int|null | |
| 133 | */ | |
| 134 | protected ?int $limit = null; | |
| 135 | ||
| 136 | /** | |
| 137 | * The number of rows to skip. | |
| 138 | * | |
| 139 | * @var int|null | |
| 140 | */ | |
| 141 | protected ?int $offset = null; | |
| 142 | ||
| 143 | /** | |
| 144 | * The PK column to return on insert, if known (null for raw). | |
| 145 | * | |
| 146 | * @var string|null | |
| 147 | */ | |
| 148 | protected ?string $insertIdColumn = null; | |
| 149 | ||
| 150 | /** | |
| 151 | * Whether the insert-id column is auto-increment (server-generated). | |
| 152 | * | |
| 153 | * @var bool|null | |
| 154 | */ | |
| 155 | protected ?bool $insertIdAutoIncrement = null; | |
| 156 | ||
| 157 | /** | |
| 158 | * Bindings grouped by the clause they belong to. | |
| 159 | * | |
| 160 | * @var array<string, list<BindingValue>> | |
| 161 | */ | |
| 162 | protected array $bindings = [ | |
| 163 | BindingCategory::Select->value => [], | |
| 164 | BindingCategory::From->value => [], | |
| 165 | BindingCategory::Join->value => [], | |
| 166 | BindingCategory::Where->value => [], | |
| 167 | BindingCategory::GroupBy->value => [], | |
| 168 | BindingCategory::Having->value => [], | |
| 169 | BindingCategory::Order->value => [], | |
| 170 | BindingCategory::Union->value => [], | |
| 171 | BindingCategory::Lock->value => [], | |
| 172 | ]; | |
| 173 | ||
| 174 | /** | |
| 175 | * Create a new query builder bound to a table on a connection. | |
| 176 | * | |
| 177 | * @param ConnectionInterface $connection | |
| 178 | * @param string $table | |
| 179 | */ | |
| 180 | public function __construct( | |
| 181 | public readonly ConnectionInterface $connection, | |
| 182 | public readonly string $table, | |
| 183 | ) { | |
| 184 | $this->from = $this->normalizeTableReference($table); | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * Validate a table reference. | |
| 189 | * | |
| 190 | * A reference containing whitespace must use the `table as alias` | |
| 191 | * spelling — the compact `profiles p1` form is rejected, so a | |
| 192 | * mistyped table name cannot become a phantom alias. | |
| 193 | * | |
| 194 | * @param string $table | |
| 195 | * @return string | |
| 196 | * @throws \InvalidArgumentException | |
| 197 | */ | |
| 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 | } | |
| 209 | ||
| 210 | // ---- Selection ---- | |
| 211 | ||
| 212 | /** | |
| 213 | * Set the columns to select. | |
| 214 | * | |
| 215 | * Calling with no arguments resets to the `['*']` default select. | |
| 216 | * A {@see SubquerySelect} node selects a scalar subquery; its | |
| 217 | * sub-builder bindings ride the Select category, derived from the | |
| 218 | * column list on every call so the bucket cannot drift. | |
| 219 | * | |
| 220 | * @param string|Expression|Aggregate|SubquerySelect ...$columns | |
| 221 | * @return static | |
| 222 | * | |
| 223 | * @throws \InvalidArgumentException | |
| 224 | */ | |
| 225 | public function select(string|Expression|Aggregate|SubquerySelect ...$columns): static | |
| 226 | { | |
| 227 | $clone = clone $this; | |
| 228 | $clone->columns = $columns === [] ? ['*'] : array_values($columns); | |
| 229 | ||
| 230 | $selectBindings = []; | |
| 231 | foreach ($clone->columns as $column) { | |
| 232 | if ($column instanceof SubquerySelect) { | |
| 233 | array_push($selectBindings, ...$column->bindings()); | |
| 234 | } | |
| 235 | } | |
| 236 | $clone->bindings[BindingCategory::Select->value] = $selectBindings; | |
| 237 | ||
| 238 | return $clone; | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * Make the select distinct. | |
| 243 | * | |
| 244 | * @return static | |
| 245 | */ | |
| 246 | final public function distinct(): static | |
| 247 | { | |
| 248 | $clone = clone $this; | |
| 249 | $clone->distinct = true; | |
| 250 | return $clone; | |
| 251 | } | |
| 252 | ||
| 253 | // ---- From ---- | |
| 254 | ||
| 255 | /** | |
| 256 | * Set the from clause to a subquery. | |
| 257 | * | |
| 258 | * @param QueryBuilder $query | |
| 259 | * @param string $alias | |
| 260 | * @return static | |
| 261 | * | |
| 262 | * @throws \LogicException | |
| 263 | */ | |
| 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 | } | |
| 279 | ||
| 280 | // ---- Joins ---- | |
| 281 | ||
| 282 | /** | |
| 283 | * Add an inner join to the query. | |
| 284 | * | |
| 285 | * @param string $table | |
| 286 | * @param string $first | |
| 287 | * @param ColumnOperator|string $operator | |
| 288 | * @param string $second | |
| 289 | * @return static | |
| 290 | */ | |
| 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 | } | |
| 295 | ||
| 296 | /** | |
| 297 | * Add a left join to the query. | |
| 298 | * | |
| 299 | * @param string $table | |
| 300 | * @param string $first | |
| 301 | * @param ColumnOperator|string $operator | |
| 302 | * @param string $second | |
| 303 | * @return static | |
| 304 | */ | |
| 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 | } | |
| 309 | ||
| 310 | /** | |
| 311 | * Add a right join to the query. | |
| 312 | * | |
| 313 | * @param string $table | |
| 314 | * @param string $first | |
| 315 | * @param ColumnOperator|string $operator | |
| 316 | * @param string $second | |
| 317 | * @return static | |
| 318 | */ | |
| 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 | } | |
| 323 | ||
| 324 | /** | |
| 325 | * Add a cross join to the query. | |
| 326 | * | |
| 327 | * @param string $table | |
| 328 | * @return static | |
| 329 | */ | |
| 330 | final public function crossJoin(string $table): static | |
| 331 | { | |
| 332 | return $this->addJoin(JoinType::Cross, $table, '', '=', ''); | |
| 333 | } | |
| 334 | ||
| 335 | /** | |
| 336 | * Add an additional ON condition to the most recent join. | |
| 337 | * | |
| 338 | * Conditions are strictly column-to-column — values belong in where() | |
| 339 | * after the join, not in the ON clause. | |
| 340 | * | |
| 341 | * @param string $first | |
| 342 | * @param ColumnOperator|string $operator | |
| 343 | * @param string $second | |
| 344 | * @return static | |
| 345 | * | |
| 346 | * @throws \LogicException | |
| 347 | * @throws \InvalidArgumentException | |
| 348 | */ | |
| 349 | public function on(string $first, ColumnOperator|string $operator = '=', string $second = ''): static | |
| 350 | { | |
| 351 | return $this->addOn(WhereBoolean::And, $first, $operator, $second); | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * Add an additional OR-connected ON condition to the most recent join. | |
| 356 | * | |
| 357 | * @param string $first | |
| 358 | * @param ColumnOperator|string $operator | |
| 359 | * @param string $second | |
| 360 | * @return static | |
| 361 | * | |
| 362 | * @throws \LogicException | |
| 363 | * @throws \InvalidArgumentException | |
| 364 | */ | |
| 365 | public function orOn(string $first, ColumnOperator|string $operator = '=', string $second = ''): static | |
| 366 | { | |
| 367 | return $this->addOn(WhereBoolean::Or, $first, $operator, $second); | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * Add an ON condition to the last added join. | |
| 372 | * | |
| 373 | * @param WhereBoolean $boolean | |
| 374 | * @param string $first | |
| 375 | * @param ColumnOperator|string $operator | |
| 376 | * @param string $second | |
| 377 | * @return static | |
| 378 | * | |
| 379 | * @throws \LogicException | |
| 380 | * @throws \InvalidArgumentException | |
| 381 | */ | |
| 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 | } | |
| 402 | ||
| 403 | /** | |
| 404 | * Add a join clause to the query. | |
| 405 | * | |
| 406 | * @param JoinType $type | |
| 407 | * @param string $table | |
| 408 | * @param string $first | |
| 409 | * @param ColumnOperator|string $operator | |
| 410 | * @param string $second | |
| 411 | * @return static | |
| 412 | * | |
| 413 | * @throws \InvalidArgumentException | |
| 414 | */ | |
| 415 | protected function addJoin(JoinType $type, string $table, string $first, ColumnOperator|string $operator, string $second): static | |
| 416 | { | |
| 417 | $resolved = $operator instanceof ColumnOperator ? $operator : ColumnOperator::fromChecked($operator); | |
| 418 | $clone = clone $this; | |
| 419 | $clone->joins[] = [ | |
| 420 | 'type' => $type, | |
| 421 | 'table' => $this->normalizeTableReference($table), | |
| 422 | 'wheres' => $second === '' | |
| 423 | ? [] | |
| 424 | : [[ | |
| 425 | 'type' => WhereType::Column, | |
| 426 | 'first' => $first, | |
| 427 | 'operator' => $resolved, | |
| 428 | 'second' => $second, | |
| 429 | 'boolean' => WhereBoolean::And, | |
| 430 | ]], | |
| 431 | ]; | |
| 432 | return $clone; | |
| 433 | } | |
| 434 | ||
| 435 | /** | |
| 436 | * Assert a list is homogeneous — all plain bindable values, or ALL raw | |
| 437 | * (Expression/ToSqlValue). | |
| 438 | * | |
| 439 | * A mixed list desyncs placeholders from bindings on the IN/BETWEEN | |
| 440 | * compile shapes. | |
| 441 | * | |
| 442 | * @param array<int, mixed> $value | |
| 443 | * @param string $method | |
| 444 | * @return void | |
| 445 | * @throws \InvalidArgumentException | |
| 446 | */ | |
| 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 | } | |
| 467 | ||
| 468 | // ---- Wheres ---- | |
| 469 | ||
| 470 | /** | |
| 471 | * Add a where clause to the query. | |
| 472 | * | |
| 473 | * The value is polymorphic by design: a bindable scalar, a list (for | |
| 474 | * IN/NOT IN/BETWEEN), or a composite column => value key map. The | |
| 475 | * operator branches below validate each shape at declaration. | |
| 476 | * | |
| 477 | * @param string|Expression $column | |
| 478 | * @param WhereOperator|string $operator | |
| 479 | * @param mixed $value | |
| 480 | * @param WhereBoolean $boolean | |
| 481 | * @return static | |
| 482 | * | |
| 483 | * @throws \InvalidArgumentException | |
| 484 | */ | |
| 485 | public function where(string|Expression $column, WhereOperator|string $operator, mixed $value, WhereBoolean $boolean = WhereBoolean::And): static | |
| 486 | { | |
| 487 | $operator = $operator instanceof WhereOperator ? $operator : WhereOperator::fromChecked($operator); | |
| 488 | ||
| 489 | if ($operator === WhereOperator::In || $operator === WhereOperator::NotIn) { | |
| 490 | if (!is_array($value)) { | |
| 491 | throw new \InvalidArgumentException( | |
| 492 | 'whereIn()/whereNotIn() require an array of values; got ' . get_debug_type($value) . '.' | |
| 493 | ); | |
| 494 | } | |
| 495 | if ($value === []) { | |
| 496 | throw new \InvalidArgumentException( | |
| 497 | 'whereIn()/whereNotIn() require a non-empty array of values; ' | |
| 498 | . 'an empty list compiles to invalid SQL. Filter in PHP or skip the clause instead.' | |
| 499 | ); | |
| 500 | } | |
| 501 | // Every list element must be the SAME kind — a plain bindable | |
| 502 | // value or a raw Expression/ToSqlValue. A MIXED list desyncs | |
| 503 | // placeholders from bindings: the grammar renders one `?` per | |
| 504 | // element, but the binding filter drops the raw ones, so the | |
| 505 | // driver receives fewer values than placeholders (a hard | |
| 506 | // QueryException on strict drivers, silent mis-binding on lax | |
| 507 | // ones). Fail fast at declaration. | |
| 508 | $this->assertHomogeneousList($value, 'whereIn()/whereNotIn()'); | |
| 509 | $clone = clone $this; | |
| 510 | $clone->wheres[] = ['type' => WhereType::Basic, 'column' => $column, 'operator' => $operator, 'value' => $value, 'boolean' => $boolean]; | |
| 511 | array_push($clone->bindings[BindingCategory::Where->value], ...array_filter( | |
| 512 | $value, | |
| 513 | fn ($item) => !$item instanceof Expression && !$item instanceof ToSqlValue, | |
| 514 | )); | |
| 515 | return $clone; | |
| 516 | } | |
| 517 | ||
| 518 | if ($operator === WhereOperator::Between || $operator === WhereOperator::NotBetween) { | |
| 519 | // A scalar (or a list of the wrong arity) cannot compile to a | |
| 520 | // BETWEEN — fail fast at declaration with the shape named. | |
| 521 | if (!is_array($value) || count($value) !== 2) { | |
| 522 | throw new \InvalidArgumentException( | |
| 523 | 'whereBetween()/whereNotBetween() require a two-value [min, max] array; got ' | |
| 524 | . get_debug_type($value) . '.' | |
| 525 | ); | |
| 526 | } | |
| 527 | ||
| 528 | // Same homogeneity contract as the IN lists above — a mixed | |
| 529 | // scalar/Expression pair (e.g. [new Expression('NOW()'), $end]) | |
| 530 | // desyncs placeholders from bindings identically. | |
| 531 | $this->assertHomogeneousList($value, 'whereBetween()/whereNotBetween()'); | |
| 532 | $clone = clone $this; | |
| 533 | $clone->wheres[] = ['type' => WhereType::Between, 'column' => $column, 'operator' => $operator, 'value' => $value, 'boolean' => $boolean]; | |
| 534 | array_push($clone->bindings[BindingCategory::Where->value], ...array_filter( | |
| 535 | $value, | |
| 536 | fn ($item) => !$item instanceof Expression && !$item instanceof ToSqlValue, | |
| 537 | )); | |
| 538 | return $clone; | |
| 539 | } | |
| 540 | ||
| 541 | if ($operator === WhereOperator::Null || $operator === WhereOperator::NotNull) { | |
| 542 | $clone = clone $this; | |
| 543 | $clone->wheres[] = ['type' => WhereType::Null, 'column' => $column, 'operator' => $operator, 'boolean' => $boolean]; | |
| 544 | return $clone; | |
| 545 | } | |
| 546 | ||
| 547 | // A null value with a comparison operator can never match: SQL | |
| 548 | // `col = NULL` (and every other comparison against NULL) is UNKNOWN, | |
| 549 | // so the clause compiles to an unbound `= ?` and silently filters | |
| 550 | // everything out. The intent is always IS NULL / IS NOT NULL — say | |
| 551 | // so, fail fast, and name the correct method. | |
| 552 | if ($value === null) { | |
| 553 | $label = is_string($column) ? $column : $column->value; | |
| 554 | throw new \InvalidArgumentException( | |
| 555 | "where('{$label}', '{$operator->value}', null) can never match — SQL comparisons against NULL" | |
| 556 | . ' are UNKNOWN. Use whereNull(\'' . $label . '\') or whereNotNull(\'' . $label . '\') instead.' | |
| 557 | ); | |
| 558 | } | |
| 559 | ||
| 560 | // A list with a comparison operator is a declaration error — lists | |
| 561 | // belong to whereIn()/whereNotIn() or whereBetween()/whereNotBetween(), | |
| 562 | // which own the arity and homogeneity validation. Reaching here with | |
| 563 | // a list would bind the array wholesale (the bind guard rejects it | |
| 564 | // with a driver-level error instead of a declaration-level one). | |
| 565 | if (is_array($value)) { | |
| 566 | $label = is_string($column) ? $column : $column->value; | |
| 567 | throw new \InvalidArgumentException( | |
| 568 | "where('{$label}', '{$operator->value}', list) — list values belong to " | |
| 569 | . 'whereIn()/whereNotIn() or whereBetween()/whereNotBetween().' | |
| 570 | ); | |
| 571 | } | |
| 572 | ||
| 573 | $clone = clone $this; | |
| 574 | $clone->wheres[] = ['type' => WhereType::Basic, 'column' => $column, 'operator' => $operator, 'value' => $value, 'boolean' => $boolean]; | |
| 575 | if (!$value instanceof Expression && !$value instanceof ToSqlValue) { | |
| 576 | $clone->bindings[BindingCategory::Where->value][] = $value; | |
| 577 | } | |
| 578 | return $clone; | |
| 579 | } | |
| 580 | ||
| 581 | /** | |
| 582 | * Add a raw where clause to the query. | |
| 583 | * | |
| 584 | * Keep user input out of the `$sql` string itself — put it in | |
| 585 | * `$bindings`. | |
| 586 | * | |
| 587 | * @param string $sql | |
| 588 | * @param array<int, mixed> $bindings | |
| 589 | * @param WhereBoolean $boolean | |
| 590 | * @return static | |
| 591 | */ | |
| 592 | public function whereRaw(string $sql, array $bindings = [], WhereBoolean $boolean = WhereBoolean::And): static | |
| 593 | { | |
| 594 | $clone = clone $this; | |
| 595 | $clone->wheres[] = ['type' => WhereType::Raw, 'sql' => $sql, 'boolean' => $boolean]; | |
| 596 | array_push($clone->bindings[BindingCategory::Where->value], ...$bindings); | |
| 597 | return $clone; | |
| 598 | } | |
| 599 | ||
| 600 | /** | |
| 601 | * Add a where clause comparing two columns to the query. | |
| 602 | * | |
| 603 | * @param string $first | |
| 604 | * @param ColumnOperator|string $operator | |
| 605 | * @param string $second | |
| 606 | * @param WhereBoolean $boolean | |
| 607 | * @return static | |
| 608 | * | |
| 609 | * @throws \InvalidArgumentException | |
| 610 | */ | |
| 611 | public function whereColumn(string $first, ColumnOperator|string $operator = '=', string $second = '', WhereBoolean $boolean = WhereBoolean::And): static | |
| 612 | { | |
| 613 | $resolved = $operator instanceof ColumnOperator ? $operator : ColumnOperator::fromChecked($operator); | |
| 614 | $clone = clone $this; | |
| 615 | $clone->wheres[] = ['type' => WhereType::Column, 'first' => $first, 'operator' => $resolved, 'second' => $second, 'boolean' => $boolean]; | |
| 616 | return $clone; | |
| 617 | } | |
| 618 | ||
| 619 | /** | |
| 620 | * Add an `EXISTS (subquery)` clause to the query. | |
| 621 | * | |
| 622 | * The subquery is a caller-built builder, typically correlated to | |
| 623 | * the outer query with `whereColumn()`. Its bindings are captured | |
| 624 | * eagerly into the Where category, so compiling stays a snapshot. | |
| 625 | * | |
| 626 | * @param QueryBuilder $query The existential subquery. | |
| 627 | * @param WhereBoolean $boolean | |
| 628 | * @param bool $negated True renders `NOT EXISTS`. | |
| 629 | * @return static | |
| 630 | */ | |
| 631 | public function whereExists(QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And, bool $negated = false): static | |
| 632 | { | |
| 633 | $clone = clone $this; | |
| 634 | $clone->wheres[] = ['type' => WhereType::Exists, 'query' => $query, 'negated' => $negated, 'boolean' => $boolean]; | |
| 635 | array_push($clone->bindings[BindingCategory::Where->value], ...$query->getBindings()); | |
| 636 | return $clone; | |
| 637 | } | |
| 638 | ||
| 639 | /** | |
| 640 | * Add a `NOT EXISTS (subquery)` clause to the query. | |
| 641 | * | |
| 642 | * @param QueryBuilder $query The existential subquery. | |
| 643 | * @param WhereBoolean $boolean | |
| 644 | * @return static | |
| 645 | */ | |
| 646 | public function whereNotExists(QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static | |
| 647 | { | |
| 648 | return $this->whereExists($query, $boolean, true); | |
| 649 | } | |
| 650 | ||
| 651 | /** | |
| 652 | * Add an OR-connected `EXISTS (subquery)` clause to the query. | |
| 653 | * | |
| 654 | * @param QueryBuilder $query The existential subquery. | |
| 655 | * @return static | |
| 656 | */ | |
| 657 | public function orWhereExists(QueryBuilder $query): static | |
| 658 | { | |
| 659 | return $this->whereExists($query, WhereBoolean::Or); | |
| 660 | } | |
| 661 | ||
| 662 | /** | |
| 663 | * Add an OR-connected `NOT EXISTS (subquery)` clause to the query. | |
| 664 | * | |
| 665 | * @param QueryBuilder $query The existential subquery. | |
| 666 | * @return static | |
| 667 | */ | |
| 668 | public function orWhereNotExists(QueryBuilder $query): static | |
| 669 | { | |
| 670 | return $this->whereExists($query, WhereBoolean::Or, true); | |
| 671 | } | |
| 672 | ||
| 673 | /** | |
| 674 | * Add a `column IN (subquery)` clause to the query. | |
| 675 | * | |
| 676 | * The subquery must select exactly one column; its bindings are | |
| 677 | * captured eagerly into the Where category, like `whereExists()`. | |
| 678 | * | |
| 679 | * @param string $column The outer column the IN constrains. | |
| 680 | * @param QueryBuilder $query The single-column value subquery. | |
| 681 | * @param WhereBoolean $boolean | |
| 682 | * @param bool $negated True renders `NOT IN`. | |
| 683 | * @return static | |
| 684 | */ | |
| 685 | public function whereInQuery(string $column, QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And, bool $negated = false): static | |
| 686 | { | |
| 687 | $clone = clone $this; | |
| 688 | $clone->wheres[] = ['type' => WhereType::InSub, 'column' => $column, 'query' => $query, 'negated' => $negated, 'boolean' => $boolean]; | |
| 689 | array_push($clone->bindings[BindingCategory::Where->value], ...$query->getBindings()); | |
| 690 | return $clone; | |
| 691 | } | |
| 692 | ||
| 693 | /** | |
| 694 | * Add a `column NOT IN (subquery)` clause to the query. | |
| 695 | * | |
| 696 | * @param string $column The outer column the NOT IN constrains. | |
| 697 | * @param QueryBuilder $query The single-column value subquery. | |
| 698 | * @param WhereBoolean $boolean | |
| 699 | * @return static | |
| 700 | */ | |
| 701 | public function whereNotInQuery(string $column, QueryBuilder $query, WhereBoolean $boolean = WhereBoolean::And): static | |
| 702 | { | |
| 703 | return $this->whereInQuery($column, $query, $boolean, true); | |
| 704 | } | |
| 705 | ||
| 706 | /** | |
| 707 | * Add an OR-connected `column IN (subquery)` clause to the query. | |
| 708 | * | |
| 709 | * @param string $column The outer column the IN constrains. | |
| 710 | * @param QueryBuilder $query The single-column value subquery. | |
| 711 | * @return static | |
| 712 | */ | |
| 713 | public function orWhereInQuery(string $column, QueryBuilder $query): static | |
| 714 | { | |
| 715 | return $this->whereInQuery($column, $query, WhereBoolean::Or); | |
| 716 | } | |
| 717 | ||
| 718 | /** | |
| 719 | * Add an OR-connected `column NOT IN (subquery)` clause to the query. | |
| 720 | * | |
| 721 | * @param string $column The outer column the NOT IN constrains. | |
| 722 | * @param QueryBuilder $query The single-column value subquery. | |
| 723 | * @return static | |
| 724 | */ | |
| 725 | public function orWhereNotInQuery(string $column, QueryBuilder $query): static | |
| 726 | { | |
| 727 | return $this->whereInQuery($column, $query, WhereBoolean::Or, true); | |
| 728 | } | |
| 729 | ||
| 730 | /** | |
| 731 | * Add a nested group of where clauses to the query. | |
| 732 | * | |
| 733 | * The callback receives a {@see WhereBuilder} — the where-family only — | |
| 734 | * and must return it: | |
| 735 | * | |
| 736 | * ->whereNested(fn (WhereBuilder $q) => $q->where('active', '=', 1)) | |
| 737 | * | |
| 738 | * @param callable(WhereBuilder): WhereBuilder $callback | |
| 739 | * @param WhereBoolean $boolean | |
| 740 | * @return static | |
| 741 | * | |
| 742 | * @throws \InvalidArgumentException | |
| 743 | */ | |
| 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 | } | |
| 781 | ||
| 782 | /** | |
| 783 | * Get the builder a nested where group stores its clauses on. | |
| 784 | * | |
| 785 | * @return self | |
| 786 | */ | |
| 787 | protected function newNestedBuilder(): self | |
| 788 | { | |
| 789 | return new self($this->connection, $this->table); | |
| 790 | } | |
| 791 | ||
| 792 | // ---- Grouping / Having ---- | |
| 793 | ||
| 794 | /** | |
| 795 | * Add a group by clause to the query. | |
| 796 | * | |
| 797 | * @param string|array<int, string> $columns | |
| 798 | * @return static | |
| 799 | */ | |
| 800 | public function groupBy(string|array $columns): static | |
| 801 | { | |
| 802 | $clone = clone $this; | |
| 803 | $clone->groups = array_merge($this->groups, is_array($columns) ? $columns : func_get_args()); | |
| 804 | return $clone; | |
| 805 | } | |
| 806 | ||
| 807 | /** | |
| 808 | * Add a having clause to the query. | |
| 809 | * | |
| 810 | * @param string|Expression|Aggregate $column | |
| 811 | * @param WhereOperator|string $operator | |
| 812 | * @param mixed $value | |
| 813 | * @return static | |
| 814 | */ | |
| 815 | public function having(string|Expression|Aggregate $column, WhereOperator|string $operator, mixed $value): static | |
| 816 | { | |
| 817 | $operator = $operator instanceof WhereOperator ? $operator : WhereOperator::fromChecked($operator); | |
| 818 | $clone = clone $this; | |
| 819 | $clone->havings[] = ['type' => WhereType::Basic, 'column' => $column, 'operator' => $operator, 'value' => $value]; | |
| 820 | if ($value !== null && !$value instanceof Expression && !$value instanceof ToSqlValue) { | |
| 821 | $clone->bindings[BindingCategory::Having->value][] = $value; | |
| 822 | } | |
| 823 | return $clone; | |
| 824 | } | |
| 825 | ||
| 826 | // ---- Ordering / Limit / Offset ---- | |
| 827 | ||
| 828 | /** | |
| 829 | * Add an order by clause to the query. | |
| 830 | * | |
| 831 | * @param string|Expression $column | |
| 832 | * @param SortDirection|string $direction | |
| 833 | * @return static | |
| 834 | * | |
| 835 | * @throws \InvalidArgumentException | |
| 836 | */ | |
| 837 | public function orderBy(string|Expression $column, SortDirection|string $direction = SortDirection::Asc): static | |
| 838 | { | |
| 839 | $normalized = $direction instanceof SortDirection | |
| 840 | ? $direction | |
| 841 | : SortDirection::fromChecked($direction); | |
| 842 | $clone = clone $this; | |
| 843 | $clone->orders[] = ['column' => $column, 'direction' => $normalized]; | |
| 844 | return $clone; | |
| 845 | } | |
| 846 | ||
| 847 | /** | |
| 848 | * Set the "limit" value of the query. | |
| 849 | * | |
| 850 | * @param int $limit | |
| 851 | * @return static | |
| 852 | */ | |
| 853 | public function limit(int $limit): static | |
| 854 | { | |
| 855 | $clone = clone $this; | |
| 856 | $clone->limit = $limit; | |
| 857 | return $clone; | |
| 858 | } | |
| 859 | ||
| 860 | /** | |
| 861 | * Set the "offset" value of the query. | |
| 862 | * | |
| 863 | * @param int $offset | |
| 864 | * @return static | |
| 865 | */ | |
| 866 | public function offset(int $offset): static | |
| 867 | { | |
| 868 | $clone = clone $this; | |
| 869 | $clone->offset = $offset; | |
| 870 | return $clone; | |
| 871 | } | |
| 872 | ||
| 873 | // ---- Unions ---- | |
| 874 | ||
| 875 | /** | |
| 876 | * Add a union to the query. | |
| 877 | * | |
| 878 | * @param QueryBuilder $query | |
| 879 | * @param bool $all Whether to use "UNION ALL". | |
| 880 | * @return static | |
| 881 | */ | |
| 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 | } | |
| 892 | ||
| 893 | // ---- Locks ---- | |
| 894 | ||
| 895 | /** | |
| 896 | * Lock the selected rows for update. | |
| 897 | * | |
| 898 | * @return static | |
| 899 | */ | |
| 900 | final public function lockForUpdate(): static | |
| 901 | { | |
| 902 | $clone = clone $this; | |
| 903 | $clone->lock = LockType::Update; | |
| 904 | return $clone; | |
| 905 | } | |
| 906 | ||
| 907 | /** | |
| 908 | * Lock the selected rows in shared mode. | |
| 909 | * | |
| 910 | * @return static | |
| 911 | */ | |
| 912 | final public function sharedLock(): static | |
| 913 | { | |
| 914 | $clone = clone $this; | |
| 915 | $clone->lock = LockType::Shared; | |
| 916 | return $clone; | |
| 917 | } | |
| 918 | ||
| 919 | // ---- Execution ---- | |
| 920 | ||
| 921 | /** | |
| 922 | * Run the query and return the matching rows. | |
| 923 | * | |
| 924 | * @return Collection<int, \stdClass> | |
| 925 | */ | |
| 926 | public function get(): Collection | |
| 927 | { | |
| 928 | return $this->connection->select($this); | |
| 929 | } | |
| 930 | ||
| 931 | /** | |
| 932 | * Run the query and yield each matching row as it arrives. | |
| 933 | * | |
| 934 | * Consume the generator fully (or let it be garbage collected) before | |
| 935 | * running another query on the connection. | |
| 936 | * | |
| 937 | * @return \Generator<int, \stdClass> | |
| 938 | */ | |
| 939 | public function cursor(): \Generator | |
| 940 | { | |
| 941 | return $this->connection->cursor($this); | |
| 942 | } | |
| 943 | ||
| 944 | /** | |
| 945 | * Run the query and return the first matching row. | |
| 946 | * | |
| 947 | * @return \stdClass|null | |
| 948 | */ | |
| 949 | public function first(): ?object | |
| 950 | { | |
| 951 | return $this->limit(1)->get()->first(); | |
| 952 | } | |
| 953 | ||
| 954 | /** | |
| 955 | * The value of a single column from the first row. | |
| 956 | * | |
| 957 | * The column is selected under a stable alias so the result can be read | |
| 958 | * back by name regardless of how the dialect names the raw expression. | |
| 959 | * | |
| 960 | * @param string|Aggregate $column | |
| 961 | * @return mixed | |
| 962 | */ | |
| 963 | public function value(string|Aggregate $column): mixed | |
| 964 | { | |
| 965 | if ($column instanceof Aggregate) { | |
| 966 | // The aggregate rides the stable `radiant_scalar` alias; the | |
| 967 | // row fetch reads it back by name. select() is immutable — it | |
| 968 | // returns a new builder, leaving this one's columns untouched. | |
| 969 | return $this | |
| 970 | ->select(new Aggregate($column->function, $column->column, 'radiant_scalar')) | |
| 971 | ->first() | |
| 972 | ->radiant_scalar ?? null; | |
| 973 | } | |
| 974 | ||
| 975 | // The columnar fetch: the connection reads the single column | |
| 976 | // directly (no per-row object), positionally — no alias read-back. | |
| 977 | // select() is immutable — the scoped select leaves this builder | |
| 978 | // untouched, so no explicit clone is needed. | |
| 979 | return $this->connection->selectColumn($this->select($this->scalarColumn($column))->limit(1))->first(); | |
| 980 | } | |
| 981 | ||
| 982 | /** | |
| 983 | * A collection of a single column's values from all rows. | |
| 984 | * | |
| 985 | * @param string $column | |
| 986 | * @return Collection<int, mixed> | |
| 987 | */ | |
| 988 | public function pluck(string $column): Collection | |
| 989 | { | |
| 990 | // The columnar fetch: values come back positionally, one per row — | |
| 991 | // no per-row object materialized, no alias read per row. select() | |
| 992 | // is immutable — the scoped select leaves this builder untouched. | |
| 993 | return $this->connection->selectColumn($this->select($this->scalarColumn($column))); | |
| 994 | } | |
| 995 | ||
| 996 | /** | |
| 997 | * Resolve a column into the SQL to select. | |
| 998 | * | |
| 999 | * A trailing `as alias` is stripped — the scalar reads fetch the column | |
| 1000 | * positionally, so the result header is never read by name. | |
| 1001 | * | |
| 1002 | * @param string $column | |
| 1003 | * @return string | |
| 1004 | */ | |
| 1005 | protected function scalarColumn(string $column): string | |
| 1006 | { | |
| 1007 | return (string) preg_replace('/\s+as\s+[`"]?[a-z_][a-z0-9_]*[`"]?$/i', '', $column); | |
| 1008 | } | |
| 1009 | ||
| 1010 | // ---- Aggregates are just select fields (built on select()) ---- | |
| 1011 | ||
| 1012 | /** | |
| 1013 | * Count the matching rows. | |
| 1014 | * | |
| 1015 | * @return int | |
| 1016 | */ | |
| 1017 | public function count(): int | |
| 1018 | { | |
| 1019 | return (int) $this->value(Aggregate::count()); | |
| 1020 | } | |
| 1021 | ||
| 1022 | /** | |
| 1023 | * Whether any matching rows exist. | |
| 1024 | * | |
| 1025 | * A limit-1 probe rather than a COUNT: the backend stops at the first | |
| 1026 | * matching row instead of counting every one. | |
| 1027 | * | |
| 1028 | * @return bool | |
| 1029 | */ | |
| 1030 | final public function exists(): bool | |
| 1031 | { | |
| 1032 | return $this->first() !== null; | |
| 1033 | } | |
| 1034 | ||
| 1035 | /** | |
| 1036 | * The maximum value of a column. | |
| 1037 | * | |
| 1038 | * @param string $column | |
| 1039 | * @return mixed | |
| 1040 | */ | |
| 1041 | public function max(string $column): mixed | |
| 1042 | { | |
| 1043 | return $this->value(Aggregate::max($column)); | |
| 1044 | } | |
| 1045 | ||
| 1046 | /** | |
| 1047 | * The minimum value of a column. | |
| 1048 | * | |
| 1049 | * @param string $column | |
| 1050 | * @return mixed | |
| 1051 | */ | |
| 1052 | public function min(string $column): mixed | |
| 1053 | { | |
| 1054 | return $this->value(Aggregate::min($column)); | |
| 1055 | } | |
| 1056 | ||
| 1057 | /** | |
| 1058 | * The sum of a column's values. | |
| 1059 | * | |
| 1060 | * @param string $column | |
| 1061 | * @return mixed | |
| 1062 | */ | |
| 1063 | public function sum(string $column): mixed | |
| 1064 | { | |
| 1065 | return $this->value(Aggregate::sum($column)); | |
| 1066 | } | |
| 1067 | ||
| 1068 | /** | |
| 1069 | * The average of a column's values. | |
| 1070 | * | |
| 1071 | * @param string $column | |
| 1072 | * @return mixed | |
| 1073 | */ | |
| 1074 | public function avg(string $column): mixed | |
| 1075 | { | |
| 1076 | return $this->value(Aggregate::avg($column)); | |
| 1077 | } | |
| 1078 | ||
| 1079 | /** | |
| 1080 | * Multiple aggregates in one query. | |
| 1081 | * | |
| 1082 | * The aggregate's own alias names its result column: | |
| 1083 | * | |
| 1084 | * $db->table('orders')->aggregates( | |
| 1085 | * Aggregate::count('*', 'total'), | |
| 1086 | * Aggregate::max('price', 'top'), | |
| 1087 | * ); | |
| 1088 | * | |
| 1089 | * @param Aggregate ...$aggregates | |
| 1090 | * @return \stdClass | |
| 1091 | */ | |
| 1092 | public function aggregates(Aggregate ...$aggregates): \stdClass | |
| 1093 | { | |
| 1094 | // The aggregate select rides select() — immutable, so this builder's | |
| 1095 | // own column list is untouched. | |
| 1096 | return $this->select(...$aggregates)->first() | |
| 1097 | ?? throw new \LogicException( | |
| 1098 | 'aggregates() cannot run — the query matched no rows to aggregate (this ' | |
| 1099 | . 'indicates a connection that returned an empty first() without an aggregate row).' | |
| 1100 | ); | |
| 1101 | } | |
| 1102 | ||
| 1103 | /** | |
| 1104 | * Run one aggregate per group of the matching rows — a grouped | |
| 1105 | * aggregate in a single query. | |
| 1106 | * | |
| 1107 | * The result is keyed by the group column's value, so the aggregate's | |
| 1108 | * own alias is ignored here (it matters only for the multi-aggregate | |
| 1109 | * row shape of aggregates()). A raw table has no casts, so values come | |
| 1110 | * back as the driver delivered them — except `count`, which is always | |
| 1111 | * an int. | |
| 1112 | * | |
| 1113 | * @param Aggregate $aggregate The aggregate to compute per group. | |
| 1114 | * @param string $groupBy The column whose values key the result. | |
| 1115 | * @return Collection<string, mixed> | |
| 1116 | */ | |
| 1117 | public function aggregateBy(Aggregate $aggregate, string $groupBy): Collection | |
| 1118 | { | |
| 1119 | $rows = $this->select($groupBy, new Aggregate($aggregate->function, $aggregate->column, self::AGGREGATE_ALIAS)) | |
| 1120 | ->groupBy($groupBy) | |
| 1121 | ->get(); | |
| 1122 | ||
| 1123 | $out = []; | |
| 1124 | ||
| 1125 | foreach ($rows as $row) { | |
| 1126 | $key = (string) $row->{$groupBy}; | |
| 1127 | $raw = $row->{self::AGGREGATE_ALIAS}; | |
| 1128 | ||
| 1129 | $out[$key] = $aggregate->function === 'count' | |
| 1130 | ? (int) $raw | |
| 1131 | : $raw; | |
| 1132 | } | |
| 1133 | ||
| 1134 | return Collection::make($out); | |
| 1135 | } | |
| 1136 | ||
| 1137 | /** | |
| 1138 | * Count the matching rows per group of a column — in a single query. | |
| 1139 | * | |
| 1140 | * The result is keyed by the group column's value with int counts. | |
| 1141 | * | |
| 1142 | * The optional seed lists group values that must appear even when the | |
| 1143 | * database has no rows for them — each seeded key absent from the | |
| 1144 | * result becomes 0. The seed is ADDITIVE: database rows always win, | |
| 1145 | * and group values found in the data but missing from the seed still | |
| 1146 | * appear. (Only counts can be seeded — an absent group has no honest | |
| 1147 | * min, max, or average.) | |
| 1148 | * | |
| 1149 | * @param string $column The column whose values key the result. | |
| 1150 | * @param list<int|string>|null $seed Group values guaranteed to appear (0 when absent). | |
| 1151 | * @return Collection<string, int> | |
| 1152 | */ | |
| 1153 | public function countBy(string $column, ?array $seed = null): Collection | |
| 1154 | { | |
| 1155 | /** @var Collection<string, int> $counts */ | |
| 1156 | $counts = $this->aggregateBy(Aggregate::count('*'), $column); | |
| 1157 | ||
| 1158 | if ($seed !== null) { | |
| 1159 | $out = $counts->all(); | |
| 1160 | ||
| 1161 | foreach ($seed as $value) { | |
| 1162 | $key = (string) $value; | |
| 1163 | $out[$key] ??= 0; | |
| 1164 | } | |
| 1165 | ||
| 1166 | return Collection::make($out); | |
| 1167 | } | |
| 1168 | ||
| 1169 | return $counts; | |
| 1170 | } | |
| 1171 | ||
| 1172 | // ---- Writes ---- | |
| 1173 | ||
| 1174 | /** | |
| 1175 | * Insert one or more rows. | |
| 1176 | * | |
| 1177 | * @param array<string, mixed>|list<array<string, mixed>> $values | |
| 1178 | * @return int | |
| 1179 | */ | |
| 1180 | public function insert(array $values): int | |
| 1181 | { | |
| 1182 | return $this->connection->insert($this, $values); | |
| 1183 | } | |
| 1184 | ||
| 1185 | /** | |
| 1186 | * Insert a single row and return the generated id. | |
| 1187 | * | |
| 1188 | * @param array<string, mixed> $values | |
| 1189 | * @return string|int|null | |
| 1190 | */ | |
| 1191 | public function insertGetId(array $values): string|int|null | |
| 1192 | { | |
| 1193 | return $this->connection->insertGetId($this, $values); | |
| 1194 | } | |
| 1195 | ||
| 1196 | /** | |
| 1197 | * Update the rows matching the query's conditions. | |
| 1198 | * | |
| 1199 | * @param array<string, mixed> $values | |
| 1200 | * @return int | |
| 1201 | */ | |
| 1202 | public function update(array $values): int | |
| 1203 | { | |
| 1204 | return $this->connection->update($this, $values); | |
| 1205 | } | |
| 1206 | ||
| 1207 | /** | |
| 1208 | * Delete the rows matching the query's conditions. | |
| 1209 | * | |
| 1210 | * @return int | |
| 1211 | */ | |
| 1212 | final public function delete(): int | |
| 1213 | { | |
| 1214 | return $this->connection->delete($this); | |
| 1215 | } | |
| 1216 | ||
| 1217 | // ---- Bindings ---- | |
| 1218 | ||
| 1219 | /** | |
| 1220 | * Get the flattened bindings for the given categories, in canonical order. | |
| 1221 | * | |
| 1222 | * @param list<BindingCategory>|null $categories | |
| 1223 | * @return list<BindingValue> | |
| 1224 | */ | |
| 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 | } | |
| 1235 | ||
| 1236 | /** | |
| 1237 | * Declare the primary key column so insertGetId() can return it. | |
| 1238 | * | |
| 1239 | * @param string $column | |
| 1240 | * @param bool $autoIncrement | |
| 1241 | * @return static | |
| 1242 | */ | |
| 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 | } | |
| 1250 | ||
| 1251 | // ---- Accessors: the query state contract ---- | |
| 1252 | ||
| 1253 | /* | |
| 1254 | * These getters ARE the public contract for every consumer of a built | |
| 1255 | * query: the SQL {@see Grammar} compiles them to text, and custom | |
| 1256 | * `ConnectionInterface` implementations (e.g. CSV) execute them directly | |
| 1257 | * in PHP. Shapes are defined here and imported elsewhere via | |
| 1258 | * `@phpstan-import-type`; they must not drift between consumers. | |
| 1259 | */ | |
| 1260 | ||
| 1261 | /** | |
| 1262 | * Assert the query uses ONLY the given features, or fail fast. | |
| 1263 | * | |
| 1264 | * @param SqlFeature ...$features | |
| 1265 | * @return static | |
| 1266 | * @throws \BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException | |
| 1267 | */ | |
| 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 | } | |
| 1285 | ||
| 1286 | /** | |
| 1287 | * The columns to select. | |
| 1288 | * | |
| 1289 | * @return list<string|Expression|Aggregate|SubquerySelect> | |
| 1290 | */ | |
| 1291 | final public function getColumns(): array | |
| 1292 | { | |
| 1293 | return $this->columns; | |
| 1294 | } | |
| 1295 | ||
| 1296 | /** | |
| 1297 | * Whether the select is distinct. | |
| 1298 | * | |
| 1299 | * @return bool | |
| 1300 | */ | |
| 1301 | final public function isDistinct(): bool | |
| 1302 | { | |
| 1303 | return $this->distinct; | |
| 1304 | } | |
| 1305 | ||
| 1306 | /** | |
| 1307 | * The from clause — a table name or a subquery builder. | |
| 1308 | * | |
| 1309 | * @return string|QueryBuilder | |
| 1310 | */ | |
| 1311 | final public function getFrom(): string|QueryBuilder | |
| 1312 | { | |
| 1313 | return $this->from; | |
| 1314 | } | |
| 1315 | ||
| 1316 | /** | |
| 1317 | * The alias of the from subquery, when `fromSub()` was used. | |
| 1318 | * | |
| 1319 | * @return string|null | |
| 1320 | */ | |
| 1321 | final public function getFromAlias(): ?string | |
| 1322 | { | |
| 1323 | return $this->fromAlias; | |
| 1324 | } | |
| 1325 | ||
| 1326 | /** | |
| 1327 | * The joins to apply. | |
| 1328 | * | |
| 1329 | * @return list<array{type: JoinType, table: string, wheres: list<array{type: WhereType::Column, first: string, operator: ColumnOperator, second: string, boolean: WhereBoolean}>}> | |
| 1330 | */ | |
| 1331 | final public function getJoins(): array | |
| 1332 | { | |
| 1333 | return $this->joins; | |
| 1334 | } | |
| 1335 | ||
| 1336 | /** | |
| 1337 | * The where clauses. | |
| 1338 | * | |
| 1339 | * A discriminated union keyed by {@see WhereType} — exhaustively match | |
| 1340 | * on `type` to handle every shape. | |
| 1341 | * | |
| 1342 | * @return list<WhereClause> | |
| 1343 | */ | |
| 1344 | final public function getWheres(): array | |
| 1345 | { | |
| 1346 | return $this->wheres; | |
| 1347 | } | |
| 1348 | ||
| 1349 | /** | |
| 1350 | * Mark the most recent where clause as a trait-declared scope clause. | |
| 1351 | * | |
| 1352 | * @param string $trait The declaring trait's class-string. | |
| 1353 | * @return void | |
| 1354 | * @throws \LogicException | |
| 1355 | */ | |
| 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 | } | |
| 1366 | ||
| 1367 | /** | |
| 1368 | * The group-by columns. | |
| 1369 | * | |
| 1370 | * @return list<string> | |
| 1371 | */ | |
| 1372 | final public function getGroups(): array | |
| 1373 | { | |
| 1374 | return $this->groups; | |
| 1375 | } | |
| 1376 | ||
| 1377 | /** | |
| 1378 | * The having clauses. | |
| 1379 | * | |
| 1380 | * @return list<array{type: WhereType::Basic, column: string|Expression|Aggregate, operator: WhereOperator, value: mixed}> | |
| 1381 | */ | |
| 1382 | final public function getHavings(): array | |
| 1383 | { | |
| 1384 | return $this->havings; | |
| 1385 | } | |
| 1386 | ||
| 1387 | /** | |
| 1388 | * The order-by clauses. | |
| 1389 | * | |
| 1390 | * @return list<array{column: string|Expression, direction: SortDirection|null}> | |
| 1391 | */ | |
| 1392 | final public function getOrders(): array | |
| 1393 | { | |
| 1394 | return $this->orders; | |
| 1395 | } | |
| 1396 | ||
| 1397 | /** | |
| 1398 | * The unions to append. | |
| 1399 | * | |
| 1400 | * @return list<array{query: QueryBuilder, all: bool}> | |
| 1401 | */ | |
| 1402 | final public function getUnions(): array | |
| 1403 | { | |
| 1404 | return $this->unions; | |
| 1405 | } | |
| 1406 | ||
| 1407 | /** | |
| 1408 | * The row lock to apply, or null for none. | |
| 1409 | * | |
| 1410 | * @return LockType|null | |
| 1411 | */ | |
| 1412 | final public function getLock(): ?LockType | |
| 1413 | { | |
| 1414 | return $this->lock; | |
| 1415 | } | |
| 1416 | ||
| 1417 | /** | |
| 1418 | * The maximum number of rows to return. | |
| 1419 | * | |
| 1420 | * @return int|null | |
| 1421 | */ | |
| 1422 | final public function getLimit(): ?int | |
| 1423 | { | |
| 1424 | return $this->limit; | |
| 1425 | } | |
| 1426 | ||
| 1427 | /** | |
| 1428 | * The number of rows to skip. | |
| 1429 | * | |
| 1430 | * @return int|null | |
| 1431 | */ | |
| 1432 | final public function getOffset(): ?int | |
| 1433 | { | |
| 1434 | return $this->offset; | |
| 1435 | } | |
| 1436 | ||
| 1437 | /** | |
| 1438 | * The PK column to return on insert, if known. | |
| 1439 | * | |
| 1440 | * @return string|null | |
| 1441 | */ | |
| 1442 | final public function getInsertIdColumn(): ?string | |
| 1443 | { | |
| 1444 | return $this->insertIdColumn; | |
| 1445 | } | |
| 1446 | ||
| 1447 | /** | |
| 1448 | * Whether the declared insert-id column is auto-increment. | |
| 1449 | * | |
| 1450 | * @return bool | |
| 1451 | */ | |
| 1452 | final public function isInsertIdAutoIncrement(): bool | |
| 1453 | { | |
| 1454 | return $this->insertIdAutoIncrement ?? false; | |
| 1455 | } | |
| 1456 | } |