Lines 96.93% 190 / 196
Functions and Methods 90.90% 30 / 33
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
Grammar 96.93% 190 / 196 90.90% 30 / 33 98 0.00% 0 / 1
 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
1<?php
2
3declare(strict_types=1);
4
5namespace BlueprintAU\Radiant\Database\Grammars;
6
7use BlueprintAU\Radiant\Database\Concerns\ConcatenatesStatements;
8use BlueprintAU\Radiant\Database\Concerns\NormalizesInsertRows;
9use BlueprintAU\Radiant\Database\Concerns\QuotesLiterals;
10use BlueprintAU\Radiant\Database\Exceptions\UnsupportedFeatureException;
11use BlueprintAU\Radiant\Database\Query\Aggregate;
12use BlueprintAU\Radiant\Database\Query\Expression;
13use BlueprintAU\Radiant\Database\Query\Enums\ColumnOperator;
14use BlueprintAU\Radiant\Database\Query\Enums\JoinType;
15use BlueprintAU\Radiant\Database\Query\QueryBuilder;
16use BlueprintAU\Radiant\Database\Query\SubquerySelect;
17use BlueprintAU\Radiant\Database\Query\ToSqlValue;
18use BlueprintAU\Radiant\Database\Query\Enums\WhereBoolean;
19use BlueprintAU\Radiant\Database\Query\Enums\WhereOperator;
20use BlueprintAU\Radiant\Database\Query\Enums\WhereType;
21
22/**
23 * Compiles query-builder state into SQL for a specific dialect.
24 *
25 * The base Grammar is dialect-agnostic: it owns the decomposition of a query
26 * into its smallest SQL fragments and the composition of those fragments back
27 * up into the four statement roots — {@see compileSelect()}, {@see compileInsert()},
28 * {@see compileUpdate()}, and {@see compileDelete()}. Subclasses provide the
29 * dialect specifics: identifier quoting via {@see wrap()}, `RETURNING` support
30 * via {@see usesReturning()}, and the limit/offset/lock rendering that differs
31 * per dialect. Only the four roots are public; a feature the active dialect
32 * cannot express throws {@see UnsupportedFeatureException}.
33 *
34 * @phpstan-import-type WhereClause from \BlueprintAU\Radiant\Database\Query\QueryBuilder
35 */
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}