Lines 97.24% 212 / 218
Methods 91.89% 34 / 37
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 wrap n/a 0 / 0 n/a 0 / 0 0
 wrapSegments 100.00% 6 / 6 100.00% 1 / 1 3
 wrapTable 80.00% 4 / 5 0.00% 0 / 1 3.07
 wrapColumn 100.00% 13 / 13 100.00% 1 / 1 6
 wrapAggregateInner 66.66% 8 / 12 0.00% 0 / 1 5.93
 columnize 100.00% 1 / 1 100.00% 1 / 1 1
 parameter 100.00% 5 / 5 100.00% 1 / 1 3
 parameterize 100.00% 1 / 1 100.00% 1 / 1 1
 compileSelect 100.00% 16 / 16 100.00% 1 / 1 2
 compileInsert 100.00% 14 / 14 100.00% 1 / 1 3
 compileEmptyInsert 100.00% 1 / 1 100.00% 1 / 1 1
 usesReturning 100.00% 1 / 1 100.00% 1 / 1 1
 withReturning 100.00% 3 / 3 100.00% 1 / 1 3
 compileInsertForId 100.00% 4 / 4 100.00% 1 / 1 1
 compileUpdate 100.00% 9 / 9 100.00% 1 / 1 2
 compileDelete 100.00% 5 / 5 100.00% 1 / 1 2
 compileColumns 100.00% 4 / 4 100.00% 1 / 1 2
 compileFrom 100.00% 6 / 6 100.00% 1 / 1 2
 compileJoins 100.00% 10 / 10 100.00% 1 / 1 7
 compileJoinWheres 100.00% 5 / 5 100.00% 1 / 1 3
 compileWheres 100.00% 4 / 4 100.00% 1 / 1 2
 compileWhereGroup 100.00% 5 / 5 100.00% 1 / 1 3
 compileWhere 100.00% 12 / 12 100.00% 1 / 1 11
 compileBasicWhere 100.00% 12 / 12 100.00% 1 / 1 8
 compileBetweenWhere 100.00% 3 / 3 100.00% 1 / 1 1
 compileNullWhere 100.00% 3 / 3 100.00% 1 / 1 1
 compileGroups 100.00% 4 / 4 100.00% 1 / 1 2
 compileHavings 100.00% 9 / 9 100.00% 1 / 1 4
 compileOrders 100.00% 8 / 8 100.00% 1 / 1 4
 compileLimit 100.00% 1 / 1 100.00% 1 / 1 2
 compileOffset 100.00% 1 / 1 100.00% 1 / 1 2
 compileLock 100.00% 3 / 3 100.00% 1 / 1 2
 compileUnions 100.00% 6 / 6 100.00% 1 / 1 3
 wrapFromTable 75.00% 3 / 4 0.00% 0 / 1 2.06
 [BlueprintAU\Radiant\Database\Concerns\NormalizesInsertRows] normalizeInsertRows 100.00% 3 / 3 100.00% 1 / 1 4
 [BlueprintAU\Radiant\Database\Concerns\NormalizesInsertRows] assertUniformInsertRows 100.00% 11 / 11 100.00% 1 / 1 4
 [BlueprintAU\Radiant\Database\Concerns\QuotesLiterals] quoteLiteral 100.00% 7 / 7 100.00% 1 / 1 6
 [BlueprintAU\Radiant\Database\Concerns\ConcatenatesStatements] concatenate 100.00% 1 / 1 100.00% 1 / 1 1
36abstract class Grammar
37{
38    use NormalizesInsertRows;
39    use QuotesLiterals;
40    use ConcatenatesStatements;
41
42    /**
43     * Wrap an identifier in the dialect's quote character.
44     *
45     * @param  string  $value
46     * @return string
47     */
48    abstract protected function wrap(string $value): string;
49
50    // ---- Identifier helpers ----
51
52    /**
53     * Wrap a possibly-qualified identifier, quoting each `.`-separated segment.
54     *
55     * A `*` segment (bare or qualified) passes through unquoted; an
56     * {@see Expression} is passed through untouched.
57     *
58     * @param  string|Expression  $value
59     * @return string
60     */
61    protected function wrapSegments(string|Expression $value): string
62    {
63        if ($value instanceof Expression) {
64            return $value->value;
65        }
66        return implode('.', array_map(
67            fn($segment) => $segment === '*' ? '*' : $this->wrap($segment),
68            explode('.', $value)
69        ));
70    }
71
72    /**
73     * Wrap a table reference, respecting an `as` alias.
74     *
75     * @param  string|Expression  $table
76     * @return string
77     */
78    protected function wrapTable(string|Expression $table): string
79    {
80        if ($table instanceof Expression) {
81            return $table->value;
82        }
83        if (preg_match('/^(.+?)(?:\s+as\s+)(.+)$/i', $table, $m)) {
84            return $this->wrapSegments($m[1]) . ' AS ' . $this->wrapSegments($m[2]);
85        }
86        return $this->wrapSegments($table);
87    }
88
89    /**
90     * Wrap a column reference, respecting an `as` alias.
91     *
92     * An {@see Aggregate} renders from its structured parts; an {@see Expression}
93     * is passed through; a {@see SubquerySelect} renders the parenthesized
94     * subquery with its AS alias.
95     *
96     * @param  string|Expression|Aggregate|SubquerySelect  $column
97     * @return string
98     */
99    protected function wrapColumn(string|Expression|Aggregate|SubquerySelect $column): string
100    {
101        if ($column instanceof Expression) {
102            return $column->value;
103        }
104
105        if ($column instanceof SubquerySelect) {
106            return '(' . $this->compileSelect($column->query) . ') AS '
107                . $this->wrapSegments($column->alias);
108        }
109
110        if ($column instanceof Aggregate) {
111            $rendered = $column->function . '(' . $this->wrapAggregateInner($column->column) . ')';
112
113            // Only an EXPLICIT alias renders an AS â€” the derived result key
114            // (e.g. `count(*)`) is for reading the row back, not for SQL.
115            return $column->alias === null
116                ? $rendered
117                : $rendered . ' AS ' . $this->wrapSegments($column->alias);
118        }
119
120        if (preg_match('/^(.+?)(?:\s+as\s+)(.+)$/i', $column, $m)) {
121            return $this->wrapSegments($m[1]) . ' AS ' . $this->wrapSegments($m[2]);
122        }
123        
124        return $this->wrapSegments($column);
125    }
126
127    /**
128     * Wrap the inner content of an aggregate expression.
129     *
130     * The accepted string shapes are strict â€” `*`, `distinct x`, or a single
131     * identifier path (optionally `.*`). Anything else fails closed with
132     * {@see UnsupportedFeatureException}; complex arguments belong in an
133     * Expression.
134     *
135     * @param  string|Expression  $inner
136     * @return string
137     * @throws UnsupportedFeatureException
138     */
139    protected function wrapAggregateInner(string|Expression $inner): string
140    {
141        if ($inner instanceof Expression) {
142            return $inner->value;
143        }
144        if (preg_match('/^distinct\s+(.+)$/i', $inner, $m)) {
145            return 'DISTINCT ' . $this->wrapSegments($m[1]);
146        }
147        if ($inner === '*') {
148            return '*';
149        }
150
151        // Strict single-identifier path: `a`, `a.b`, `a.*` â€” no spaces,
152        // commas, parentheses, or other expression machinery. Anything
153        // more complex is not a supported aggregate argument.
154        if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*|\.\*)?$/', $inner) !== 1) {
155            throw new UnsupportedFeatureException(
156                'Aggregate arguments support only a single column (optionally schema-qualified '
157                    . "or `distinct col`); got [{$inner}]. Use a raw Expression for complex arguments.",
158            );
159        }
160
161        return $this->wrapSegments($inner);
162    }
163
164    /**
165     * Wrap a list of columns into a comma-separated list.
166     *
167     * @param  list<string|Expression|Aggregate|SubquerySelect>  $columns
168     * @return string
169     */
170    protected function columnize(array $columns): string
171    {
172        return implode(', ', array_map(fn($column) => $this->wrapColumn($column), $columns));
173    }
174
175    // ---- Value helpers ----
176
177    /**
178     * Render a bindable value as a `?` placeholder, or inline a raw literal.
179     *
180     * An `Expression` is spliced in verbatim; a `ToSqlValue` is extracted to
181     * its scalar and quoted as a literal.
182     *
183     * @param  mixed  $value
184     * @return string
185     */
186    protected function parameter(mixed $value): string
187    {
188        if ($value instanceof Expression) {
189            return $value->value;
190        }
191        if ($value instanceof ToSqlValue) {
192            return $this->quoteLiteral($value->toSqlValue());
193        }
194        return '?';
195    }
196
197    /**
198     * Render a list of values as comma-separated placeholders/literals.
199     *
200     * @param  array<int, mixed>  $values
201     * @return string
202     */
203    protected function parameterize(array $values): string
204    {
205        return implode(', ', array_map(fn($value) => $this->parameter($value), $values));
206    }
207
208    // ---- Select root ----
209
210    /**
211     * Compile a select statement.
212     *
213     * Compiling is a pure snapshot: the builder passed in is never modified.
214     *
215     * @param  QueryBuilder  $builder
216     * @return string
217     */
218    final public function compileSelect(QueryBuilder $builder): string
219    {
220        $sql = $this->concatenate([
221            'SELECT',
222            $builder->isDistinct() ? 'DISTINCT' : '',
223            $this->compileColumns($builder),
224            'FROM',
225            $this->compileFrom($builder),
226            $this->compileJoins($builder),
227            $this->compileWheres($builder),
228            $this->compileGroups($builder),
229            $this->compileHavings($builder),
230            $this->compileOrders($builder),
231            $this->compileLimit($builder),
232            $this->compileOffset($builder),
233            $this->compileLock($builder),
234        ]);
235
236        return $this->compileUnions($builder, $sql);
237    }
238
239    // ---- Insert root ----
240
241    /**
242     * Compile an insert statement.
243     *
244     * @param  QueryBuilder  $builder
245     * @param  array<string, mixed>|list<array<string, mixed>>  $values
246     * @param  string|null  $pk
247     * @return string
248     */
249    final public function compileInsert(QueryBuilder $builder, array $values, ?string $pk = null): string
250    {
251        $rows = $this->normalizeInsertRows($values);
252
253        // Ragged rows cannot compile: the column list comes from row 0 and
254        // each row's placeholder group is sized by its own arity. Fail fast
255        // rather than emit a malformed statement (or silently write NULL).
256        $this->assertUniformInsertRows($rows);
257
258        // An EMPTY row (a model with no set properties, a DEFAULTS-only
259        // insert) cannot compile to the degenerate `INSERT INTO t () VALUES
260        // ()` â€” invalid SQL on every dialect. The dialect owns the form via
261        // {@see compileEmptyInsert()} (the SQL-standard DEFAULT VALUES by
262        // default; MySQL overrides with the one-row `VALUES ()` it
263        // accepts).
264        if (isset($rows[0]) && $rows[0] === []) {
265            return $this->withReturning(
266                $this->compileEmptyInsert($builder),
267                $pk,
268            );
269        }
270
271        $columns = implode(', ', array_map(fn($column) => $this->wrapSegments($column), array_keys($rows[0])));
272        $placeholders = implode(', ', array_map(
273            fn($row) => '(' . implode(', ', array_fill(0, count($row), '?')) . ')',
274            $rows
275        ));
276        $sql = "INSERT INTO {$this->wrapFromTable($builder)} ({$columns}) VALUES {$placeholders}";
277
278        return $this->withReturning($sql, $pk);
279    }
280
281    /**
282     * Compile the empty-row insert â€” the statement body for a row with no
283     * columns.
284     *
285     * The SQL-standard form is `INSERT INTO t DEFAULT VALUES`; a dialect
286     * without it overrides with the form it accepts.
287     *
288     * @param  QueryBuilder  $builder
289     * @return string
290     */
291    protected function compileEmptyInsert(QueryBuilder $builder): string
292    {
293        return "INSERT INTO {$this->wrapFromTable($builder)} DEFAULT VALUES";
294    }
295
296    /**
297     * Whether the dialect compiles `INSERT ... RETURNING`.
298     *
299     * @return bool
300     */
301    protected function usesReturning(): bool
302    {
303        return false;
304    }
305
306    /**
307     * Append the `RETURNING` clause to a compiled statement when the
308     * dialect supports it and a PK was declared.
309     *
310     * @param  string  $sql
311     * @param  string|null  $pk
312     * @return string
313     */
314    protected function withReturning(string $sql, ?string $pk): string
315    {
316        if ($pk !== null && $this->usesReturning()) {
317            return $sql . ' RETURNING ' . $this->wrapSegments($pk);
318        }
319
320        return $sql;
321    }
322
323    /**
324     * Compile an insert whose generated key the caller needs back.
325     *
326     * The result carries the compiled SQL plus whether that statement yields
327     * the key (fetch the row) or not (read `lastInsertId()` after execution).
328     *
329     * @param  QueryBuilder  $builder
330     * @param  array<string, mixed>  $values
331     * @param  string  $pk
332     * @return array{sql: string, returnsKey: bool}
333     */
334    final public function compileInsertForId(QueryBuilder $builder, array $values, string $pk): array
335    {
336        return [
337            'sql' => $this->compileInsert($builder, $values, $pk),
338            'returnsKey' => $this->usesReturning(),
339        ];
340    }
341
342    // ---- Update root ----
343
344    /**
345     * Compile an update statement.
346     *
347     * @param  QueryBuilder  $builder
348     * @param  array<string, mixed>  $values
349     * @return string
350     */
351    final public function compileUpdate(QueryBuilder $builder, array $values): string
352    {
353        $sets = implode(', ', array_map(
354            fn($column) => $this->wrapSegments($column) . ' = ?',
355            array_keys($values)
356        ));
357        $sql = "UPDATE {$this->wrapFromTable($builder)} SET {$sets}";
358        $wheres = $this->compileWheres($builder);
359        if ($wheres !== '') {
360            $sql .= ' ' . $wheres;
361        }
362        return $sql;
363    }
364
365    // ---- Delete root ----
366
367    /**
368     * Compile a delete statement.
369     *
370     * @param  QueryBuilder  $builder
371     * @return string
372     */
373    final public function compileDelete(QueryBuilder $builder): string
374    {
375        $sql = "DELETE FROM {$this->wrapFromTable($builder)}";
376        $wheres = $this->compileWheres($builder);
377        if ($wheres !== '') {
378            $sql .= ' ' . $wheres;
379        }
380        return $sql;
381    }
382
383    // ---- Parts compilers ---
384
385    /**
386     * Compile the select column list.
387     *
388     * @param  QueryBuilder  $builder
389     * @return string
390     */
391    protected function compileColumns(QueryBuilder $builder): string
392    {
393        $columns = $builder->getColumns();
394        if ($columns === ['*']) {
395            return '*';
396        }
397        return $this->columnize($columns);
398    }
399
400    /**
401     * Compile the from clause â€” a table or a subquery.
402     *
403     * @param  QueryBuilder  $builder
404     * @return string
405     */
406    protected function compileFrom(QueryBuilder $builder): string
407    {
408        $from = $builder->getFrom();
409        if ($from instanceof QueryBuilder) {
410            $alias = $builder->getFromAlias();
411            $subSql = $this->compileSelect($from);
412            return '(' . $subSql . ') AS ' . $this->wrapSegments($alias ?? '');
413        }
414        return $this->wrapTable($from);
415    }
416
417    /**
418     * Compile the join clauses.
419     *
420     * @param  QueryBuilder  $builder
421     * @return string
422     */
423    protected function compileJoins(QueryBuilder $builder): string
424    {
425        $segments = [];
426        foreach ($builder->getJoins() as $join) {
427            $keyword = match ($join['type']) {
428                JoinType::Inner => 'INNER JOIN',
429                JoinType::Left => 'LEFT JOIN',
430                JoinType::Right => 'RIGHT JOIN',
431                JoinType::Cross => 'CROSS JOIN',
432            };
433            $on = $this->compileJoinWheres($join['wheres']);
434            $segments[] = $keyword . ' ' . $this->wrapTable($join['table']) . ($on !== '' ? ' ON ' . $on : '');
435        }
436        return implode(' ', $segments);
437    }
438
439    /**
440     * Compile a join's on conditions.
441     *
442     * @param  list<array{type: WhereType::Column, first: string, operator: ColumnOperator, second: string, boolean: WhereBoolean}>  $wheres
443     * @return string
444     */
445    protected function compileJoinWheres(array $wheres): string
446    {
447        $segments = [];
448        foreach ($wheres as $i => $where) {
449            $boolean = $i === 0 ? '' : strtoupper($where['boolean']->value) . ' ';
450            $segments[] = $boolean . $this->wrapSegments($where['first']) . ' ' . $where['operator']->value . ' ' . $this->wrapSegments($where['second']);
451        }
452        return implode(' ', $segments);
453    }
454
455    /**
456     * Compile the where clauses.
457     *
458     * @param  QueryBuilder  $builder
459     * @return string
460     */
461    protected function compileWheres(QueryBuilder $builder): string
462    {
463        $wheres = $builder->getWheres();
464        if ($wheres === []) {
465            return '';
466        }
467        return 'WHERE ' . $this->compileWhereGroup($wheres);
468    }
469
470    /**
471     * Compile a list of where clauses into a boolean-connected group.
472     *
473     * @param  list<WhereClause>  $wheres
474     * @return string
475     */
476    protected function compileWhereGroup(array $wheres): string
477    {
478        $segments = [];
479        foreach ($wheres as $i => $where) {
480            $boolean = $i === 0 ? '' : strtoupper($where['boolean']->value) . ' ';
481            $segments[] = $boolean . $this->compileWhere($where);
482        }
483        return implode(' ', $segments);
484    }
485
486    /**
487     * Compile a single where clause.
488     *
489     * @param  WhereClause  $where
490     * @return string
491     */
492    protected function compileWhere(array $where): string
493    {
494        return match ($where['type']) {
495            WhereType::Basic => $this->compileBasicWhere($where),
496            WhereType::Between => $this->compileBetweenWhere($where),
497            WhereType::Null => $this->compileNullWhere($where),
498            WhereType::Raw => $where['sql'],
499            WhereType::Column => $this->wrapSegments($where['first']) . ' ' . $where['operator']->value . ' ' . $this->wrapSegments($where['second']),
500            WhereType::Nested => '(' . $this->compileWhereGroup($where['group']->wheres) . ')',
501            WhereType::Exists => ($where['negated'] ? 'NOT ' : '')
502                . 'EXISTS (' . $this->compileSelect($where['query']) . ')',
503            WhereType::InSub => $this->wrapSegments($where['column'])
504                . ($where['negated'] ? ' NOT' : '') . ' IN ('
505                . $this->compileSelect($where['query']) . ')',
506        };
507    }
508
509    /**
510     * Compile a basic comparison where clause.
511     *
512     * @param  array{type: WhereType::Basic, column: string|Expression, operator: WhereOperator, value: mixed, boolean: WhereBoolean}  $where
513     * @return string
514     */
515    protected function compileBasicWhere(array $where): string
516    {
517        $column = $this->wrapSegments($where['column']);
518        $operator = $where['operator'];
519        return match ($operator) {
520            WhereOperator::Null => "{$column} IS NULL",
521            WhereOperator::NotNull => "{$column} IS NOT NULL",
522            WhereOperator::In => "{$column} IN (" . $this->parameterize($where['value']) . ')',
523            WhereOperator::NotIn => "{$column} NOT IN (" . $this->parameterize($where['value']) . ')',
524            WhereOperator::Between => "{$column} BETWEEN " . $this->parameterize($where['value']),
525            WhereOperator::NotBetween => "{$column} NOT BETWEEN " . $this->parameterize($where['value']),
526            WhereOperator::Eq, WhereOperator::NotEq, WhereOperator::Lt, WhereOperator::LtEq,
527            WhereOperator::Gt, WhereOperator::GtEq, WhereOperator::Like, WhereOperator::NotLike,
528            WhereOperator::Is, WhereOperator::IsNot => "{$column} {$operator->value} " . $this->parameter($where['value']),
529        };
530    }
531
532    /**
533     * Compile a between where clause.
534     *
535     * @param  array{type: WhereType::Between, column: string|Expression, operator: WhereOperator, value: array{0: mixed, 1: mixed}, boolean: WhereBoolean}  $where
536     * @return string
537     */
538    protected function compileBetweenWhere(array $where): string
539    {
540        $column = $this->wrapSegments($where['column']);
541        $operator = $where['operator'];
542        return "{$column} {$operator->value} " . $this->parameter($where['value'][0]) . ' AND ' . $this->parameter($where['value'][1]);
543    }
544
545    /**
546     * Compile a null where clause.
547     *
548     * @param  array{type: WhereType::Null, column: string|Expression, operator: WhereOperator, boolean: WhereBoolean}  $where
549     * @return string
550     */
551    protected function compileNullWhere(array $where): string
552    {
553        $column = $this->wrapSegments($where['column']);
554        $operator = $where['operator'];
555        return "{$column} IS {$operator->value}";
556    }
557
558    /**
559     * Compile the group-by clause.
560     *
561     * @param  QueryBuilder  $builder
562     * @return string
563     */
564    protected function compileGroups(QueryBuilder $builder): string
565    {
566        $groups = $builder->getGroups();
567        if ($groups === []) {
568            return '';
569        }
570        return 'GROUP BY ' . implode(', ', array_map(fn($group) => $this->wrapSegments($group), $groups));
571    }
572
573    /**
574     * Compile the having clauses.
575     *
576     * @param  QueryBuilder  $builder
577     * @return string
578     */
579    protected function compileHavings(QueryBuilder $builder): string
580    {
581        $havings = $builder->getHavings();
582        if ($havings === []) {
583            return '';
584        }
585        $segments = [];
586        foreach ($havings as $i => $having) {
587            $boolean = $i === 0 ? '' : 'AND ';
588            $column = $this->wrapColumn($having['column']);
589            $segments[] = $boolean . $column . ' ' . $having['operator']->value . ' ' . $this->parameter($having['value']);
590        }
591        return 'HAVING ' . implode(' ', $segments);
592    }
593
594    /**
595     * Compile the order-by clauses.
596     *
597     * @param  QueryBuilder  $builder
598     * @return string
599     */
600    protected function compileOrders(QueryBuilder $builder): string
601    {
602        $orders = $builder->getOrders();
603        if ($orders === []) {
604            return '';
605        }
606        $segments = [];
607        foreach ($orders as $order) {
608            $column = $this->wrapColumn($order['column']);
609            $segments[] = $order['direction'] !== null ? $column . ' ' . $order['direction']->value : $column;
610        }
611        return 'ORDER BY ' . implode(', ', $segments);
612    }
613
614    /**
615     * Compile the limit clause.
616     *
617     * @param  QueryBuilder  $builder
618     * @return string
619     */
620    protected function compileLimit(QueryBuilder $builder): string
621    {
622        return $builder->getLimit() !== null ? 'LIMIT ' . $builder->getLimit() : '';
623    }
624
625    /**
626     * Compile the offset clause.
627     *
628     * @param  QueryBuilder  $builder
629     * @return string
630     */
631    protected function compileOffset(QueryBuilder $builder): string
632    {
633        return $builder->getOffset() !== null ? 'OFFSET ' . $builder->getOffset() : '';
634    }
635
636    /**
637     * Compile the row lock.
638     *
639     * The base Grammar is the fail-fast default: row locks are dialect-gated,
640     * so a dialect that does not override this throws rather than silently
641     * dropping the lock.
642     *
643     * @param  QueryBuilder  $builder
644     * @return string
645     * @throws UnsupportedFeatureException
646     */
647    protected function compileLock(QueryBuilder $builder): string
648    {
649        if ($builder->getLock() === null) {
650            return '';
651        }
652        throw new UnsupportedFeatureException('This dialect does not support row locks.');
653    }
654
655    /**
656     * Compile the unions, appending them to the compiled select.
657     *
658     * @param  QueryBuilder  $builder
659     * @param  string  $sql
660     * @return string
661     */
662    protected function compileUnions(QueryBuilder $builder, string $sql): string
663    {
664        foreach ($builder->getUnions() as $union) {
665            $keyword = $union['all'] ? 'UNION ALL' : 'UNION';
666            $sub = $union['query'];
667            $unionSql = $this->compileSelect($sub);
668            $sql .= ' ' . $keyword . ' (' . $unionSql . ')';
669        }
670        return $sql;
671    }
672
673    /**
674     * Wrap the from table for a statement root that requires a plain table.
675     *
676     * @param  QueryBuilder  $builder
677     * @return string
678     * @throws UnsupportedFeatureException
679     */
680    protected function wrapFromTable(QueryBuilder $builder): string
681    {
682        $from = $builder->getFrom();
683        if ($from instanceof QueryBuilder) {
684            throw new UnsupportedFeatureException('Insert/update/delete cannot target a subquery.');
685        }
686        return $this->wrapTable($from);
687    }
688}

From BlueprintAU\Radiant\Database\Concerns\NormalizesInsertRows

15trait NormalizesInsertRows
16{
17    /**
18     * Normalize a single row or a list of rows into a list of rows.
19     *
20     * @param  array<string,mixed>|list<array<string,mixed>>  $values
21     * @return list<array<string,mixed>>
22     */
23    protected function normalizeInsertRows(array $values): array
24    {
25        // A list of rows: [[...], [...]] â€” each element is an associative row.
26        if (array_is_list($values) && isset($values[0]) && is_array($values[0])) {
27            /** @var list<array<string, mixed>> $values */
28            return $values;
29        }
30
31        /** @var array<string, mixed> $values */
32        return [$values];
33    }
34
35    /**
36     * Assert every row in a bulk insert carries the SAME column set.
37     *
38     * A multi-row INSERT compiles ONE column list and ONE placeholder
39     * group per row â€” rows of differing arity produce a malformed
40     * statement or a placeholder/binding mismatch. Padding a missing
41     * column with NULL would silently write NULL into a nullable column
42     * the caller never named, so ragged rows fail fast instead.
43     *
44     * @param  list<array<string,mixed>>  $rows
45     * @return void
46     *
47     * @throws \InvalidArgumentException
48     */
49    protected function assertUniformInsertRows(array $rows): void
50    {
51        if (count($rows) < 2) {
52            return;
53        }
54
55        $expected = array_keys($rows[0]);
56
57        foreach (array_slice($rows, 1) as $i => $row) {
58            if (array_keys($row) !== $expected) {
59                throw new \InvalidArgumentException(
60                    'A bulk insert requires every row to carry the same columns; row '
61                        . ($i + 1) . ' differs from row 0. Split the call or give every '
62                        . 'row the same column set (an absent column would otherwise '
63                        . 'silently write NULL).'
64                );
65            }
66        }
67    }
68}

From BlueprintAU\Radiant\Database\Concerns\QuotesLiterals

16trait QuotesLiterals
17{
18    /**
19     * Quote a scalar as a SQL literal.
20     *
21     * @param  string|int|float|bool|null  $value
22     * @return string
23     */
24    protected function quoteLiteral(string|int|float|bool|null $value): string
25    {
26        if ($value === null) {
27            return 'null';
28        }
29        if (is_bool($value)) {
30            return $value ? '1' : '0';
31        }
32        if (is_int($value) || is_float($value)) {
33            return (string) $value;
34        }
35        return "'" . str_replace("'", "''", $value) . "'";
36    }
37}

From BlueprintAU\Radiant\Database\Concerns\ConcatenatesStatements

16trait ConcatenatesStatements
17{
18    /**
19     * Join statement segments with a single space, dropping empty ones.
20     *
21     * Do NOT use this to join list items (columns, orders, bindings) â€” those
22     * are non-optional and joined with plain `implode`.
23     *
24     * @param  list<string>  $segments
25     * @return string
26     */
27    protected function concatenate(array $segments): string
28    {
29        return implode(' ', array_filter($segments, fn ($segment) => $segment !== ''));
30    }
31}