Skip to main content

squonk_ast/dialect/
bigquery.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Moderately AI Inc.
3
4//! The BigQuery / ZetaSQL dialect preset (ANSI-derived, deliberately conservative).
5//!
6//! Google BigQuery's GoogleSQL (ZetaSQL) diverges widely across its type, function, and
7//! statement surface, and — unlike the five shipped oracle-compared presets — this workspace
8//! has **no BigQuery oracle**, so over-acceptance cannot be measured. Conservatism is
9//! therefore the honesty bar: this preset derives from [`FeatureSet::ANSI`], the strict
10//! standard baseline, and enables only the BigQuery surface that already has a modelled,
11//! tested parser gate and clear documentary evidence — in the headline case the flag's *own*
12//! doc names BigQuery as the motivating dialect. Every other axis keeps its ANSI value; a
13//! reader can predict from this module exactly what BigQuery accepts beyond the standard, and
14//! unsupported BigQuery syntax is a clean reject routed to a focused follow-up ticket, never a
15//! silent over-accept.
16//!
17//! # What this preset adds over ANSI
18//!
19//! Two table-expression gates carry the headline BigQuery surface:
20//!
21//! - [`unnest`](TableFactorSyntax::unnest) — the first-class `UNNEST(<expr>)` table
22//!   factor (`FROM UNNEST(…)`), BigQuery's array-to-relation expansion. It is not
23//!   BigQuery-exclusive (PostgreSQL/DuckDB/Lenient enable it too), but it is genuine BigQuery
24//!   `FROM` surface *and* the base the next gate rides.
25//! - [`unnest_with_offset`](TableFactorSyntax::unnest_with_offset) — the
26//!   `WITH OFFSET [AS <alias>]` tail on an `UNNEST` factor, a 0-based ordinal column and the
27//!   BigQuery counterpart of PostgreSQL's `WITH ORDINALITY`. Its own flag doc records this
28//!   surface as on for the BigQuery preset alone — this preset is that home, the first to
29//!   enable the flag positively. The gate rides `unnest`
30//!   ([`FeatureDependencyViolation::UnnestWithOffsetWithoutUnnest`](super::FeatureDependencyViolation)):
31//!   `unnest` is therefore on above, and this is the first shipped preset to exercise that
32//!   dependency in the satisfied direction. PostgreSQL and DuckDB both parse-*reject*
33//!   `WITH OFFSET` (engine-probed), so the tail is a clean cross-preset reject.
34//!
35//! One expression gate:
36//!
37//! - [`struct_constructor`](ExpressionSyntax::struct_constructor) — the `STRUCT(...)`
38//!   value constructor (`STRUCT(1, 2)`, `STRUCT(x AS a)`, `STRUCT<a INT64>(1)`), the
39//!   documented GoogleSQL tuple builder. Its own flag doc names BigQuery as the motivating
40//!   dialect; the `(`/`<` lookahead keeps a bare `struct` an ordinary name, so the gate is
41//!   additive over ANSI.
42//!
43//! # The two lexical facts over ANSI
44//!
45//! - **Backtick identifier quoting.** BigQuery quotes identifiers with the backtick
46//!   `` `name` `` alone — its `"…"` and `'…'` are *both* string literals. So
47//!   [`BIGQUERY_IDENTIFIER_QUOTES`] lists only the backtick (unlike the SQLite/Databricks/MSSQL
48//!   bracket-or-double-quote sets, and unlike ANSI's `"…"`), and
49//!   [`double_quoted_strings`](StringLiteralSyntax::double_quoted_strings) is correspondingly
50//!   **on** so `"x"` lexes as a string, never an identifier — exactly the MySQL default
51//!   (`ANSI_QUOTES` off) lexis. The two facts are coupled: `"` has a single claimant (the
52//!   string scanner) precisely because it is absent from the quote set, the
53//!   [`DoubleQuoteStringVersusIdentifier`](super::LexicalConflict) hazard the `const` assert
54//!   below rules out. The backtick likewise has a single claimant — no enabled expression
55//!   grammar lexes a backtick.
56//! - **Case folding.** BigQuery column and alias references resolve case-insensitively (table
57//!   and dataset names are case-sensitive), so [`identifier_casing`](FeatureSet::identifier_casing)
58//!   is [`Casing::Lower`] — the closest single fit per the [`Casing`] doc's
59//!   *known-limitation* paragraph, which names exactly this "case-insensitive column beside a
60//!   case-sensitive table" shape (shared with MySQL/T-SQL) as one no single fold can express;
61//!   `Casing::Lower` is the value it prescribes. The interned text still renders exactly as
62//!   written; the fold is identity-only and never affects acceptance. (The
63//!   per-identifier-kind table-vs-column sensitivity split is that documented `Casing`
64//!   limitation and a deliberate future extension, not modelled here.)
65//!
66//! # Deliberately deferred (conservative reject)
67//!
68//! [`pipe_syntax`](QueryTailSyntax::pipe_syntax) — BigQuery/ZetaSQL query pipe syntax
69//! (`FROM t |> WHERE x |> SELECT a`) — stays **off**, and this is a considered judgment, not an
70//! oversight. Its own flag doc names the BigQuery preset as the eventual home, but reads
71//! the honesty bar the other way for now: the framework ships only the reference `|> WHERE`
72//! operator, so enabling the gate today would accept `|> WHERE` while rejecting every other
73//! pipe operator — a fragment a reader of this module could not predict, and with no BigQuery
74//! oracle the boundary cannot be measured either. The flag doc's own argument (that even the
75//! permissive `LENIENT` must wait until the `planner-parity-pipe-*` tickets make the
76//! pipe-operator surface coherent) cuts identically for BigQuery. Leaving it off with that
77//! reasoning cited is the correct call; flipping it on merely because the doc names BigQuery
78//! would ship an incoherent half-surface. The flip is deferred to the pipe-surface tickets.
79//!
80//! Everything else BigQuery (the `STRUCT<…>`/`ARRAY<…>` *type-position* surface —
81//! `CAST(x AS STRUCT<…>)` and column types, distinct from the value constructor above —
82//! `SELECT AS STRUCT/VALUE`,
83//! `EXCEPT`/`REPLACE` in `SELECT *`, `QUALIFY`, table-name backtick paths with dots,
84//! parameterised `@name` / `?` binds, the `SAFE.` function prefix, …) has no modelled gate and
85//! is a clean reject routed to follow-up tickets, never a silent over-accept.
86
87use super::{
88    AccessControlSyntax, AggregateCallSyntax, CallSyntax, CaretOperator, Casing,
89    ColumnDefinitionSyntax, CommentSyntax, ConstraintSyntax, CreateTableClauseSyntax,
90    DoubleAmpersand, ExistenceGuards, ExpressionSyntax, FeatureSet, GroupingSyntax,
91    IdentifierQuote, IdentifierSyntax, IndexAlterSyntax, JoinSyntax, Keyword, KeywordOperators,
92    KeywordSet, MaintenanceSyntax, MutationSyntax, NullOrdering, NumericLiteralSyntax,
93    OperatorSyntax, ParameterSyntax, PipeOperator, PredicateSyntax, QueryTailSyntax,
94    RESERVED_BARE_ALIAS, RESERVED_COLUMN_NAME, RESERVED_FUNCTION_NAME, RESERVED_TYPE_NAME,
95    STANDARD_BYTE_CLASSES, SelectSyntax, SessionVariableSyntax, ShowSyntax, StatementDdlGates,
96    StringFuncForms, StringLiteralSyntax, TableExpressionSyntax, TableFactorSyntax, TargetSpelling,
97    TypeNameSyntax, UtilitySyntax,
98};
99use crate::precedence::{STANDARD_BINDING_POWERS, STANDARD_SET_OPERATION_BINDING_POWERS};
100
101/// BigQuery identifier quoting: the backtick `` `…` `` alone. BigQuery spells a quoted
102/// identifier `` `a` ``; its `"a"` and `'a'` are *both* string constants, so `"` is
103/// deliberately absent here (and [`StringLiteralSyntax::BIGQUERY`] turns
104/// `double_quoted_strings` on so `"` unambiguously lexes a string) — the same backtick-only
105/// lexis MySQL uses under its default `ANSI_QUOTES`-off mode.
106pub const BIGQUERY_IDENTIFIER_QUOTES: &[IdentifierQuote] = &[IdentifierQuote::Symmetric('`')];
107
108/// `PIVOT` and `UNPIVOT`, GoogleSQL's row/column rotation operators. Neither is a
109/// BigQuery *reserved* keyword — both are absent from the GoogleSQL reserved-keyword
110/// list and stay usable as ordinary unquoted identifiers (GoogleSQL lexical-structure
111/// reference, "Reserved keywords":
112/// <https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#reserved_keywords>).
113/// They are instead *position-reserved*: the `pivot_operator` / `unpivot_operator`
114/// grammar attaches directly to a `from_item`, so `FROM t PIVOT (…)` must read the
115/// operator, not a correlation alias named `pivot` (GoogleSQL query-syntax reference,
116/// "Pivot operator" / "Unpivot operator":
117/// <https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#pivot_operator>).
118///
119/// Modelling that reachability in this parser's shared-`ColId` alias grammar means
120/// reserving both on the `ColId` axis only — [`RESERVED_COLUMN_NAME`], the set a bare
121/// *and* `AS`-introduced table alias, a column name, and a table name all draw from —
122/// and *not* the function-name, type-name, or projection bare-label axes, which stay
123/// open (`pivot(1)`, `CAST(1 AS pivot)`, `SELECT 1 pivot` still parse), matching
124/// BigQuery's non-reserved status. This is the deliberate minimal deviation from the
125/// DuckDB `DUCKDB_PIVOT_RESERVATION`, which unions into all four positions because
126/// DuckDB's engine genuinely classes the words `reserved`. The unavoidable reachability
127/// cost: under this preset an unquoted `pivot`/`unpivot` is not admitted as a column/table
128/// name or a table alias — quote it (`` `pivot` ``) to use it as an identifier there.
129pub const BIGQUERY_PIVOT_RESERVATION: KeywordSet =
130    KeywordSet::from_keywords(&[Keyword::Pivot, Keyword::Unpivot]);
131
132/// The ANSI `ColId` reject set plus [`BIGQUERY_PIVOT_RESERVATION`]; see that const for
133/// why the reservation is confined to this one axis. The function-name, type-name, and
134/// bare-label reject sets take no delta over ANSI, so BigQuery keeps the shared consts
135/// for those three positions.
136pub const BIGQUERY_RESERVED_COLUMN_NAME: KeywordSet =
137    RESERVED_COLUMN_NAME.union(BIGQUERY_PIVOT_RESERVATION);
138
139impl StringLiteralSyntax {
140    /// BigQuery string surface: the ANSI baseline plus `"…"` double-quoted string constants
141    /// (BigQuery quotes strings with both `'…'` and `"…"`, reserving the backtick for
142    /// identifiers). Enabling this is what lets [`BIGQUERY_IDENTIFIER_QUOTES`] drop `"` from the
143    /// quote set without stranding the byte. Every other string knob is conservatively ANSI —
144    /// BigQuery's backslash escape sequences have no BigQuery-citing flag doc or oracle here and
145    /// are deferred rather than guessed at (`backslash_escapes` stays off).
146    pub const BIGQUERY: Self = Self {
147        double_quoted_strings: true,
148        ..StringLiteralSyntax::ANSI
149    };
150}
151
152impl TableExpressionSyntax {
153    /// BigQuery table-expression surface: the ANSI baseline plus the `FOR SYSTEM_TIME AS OF`
154    /// time-travel modifier. The first-class `UNNEST(…)` factor and its `WITH OFFSET` tail
155    /// ride [`TableFactorSyntax`]; every other table knob is conservatively ANSI.
156    pub const BIGQUERY: Self = Self {
157        // `FROM t FOR SYSTEM_TIME AS OF <ts>` — BigQuery's sole time-travel spelling.
158        table_version: true,
159        ..TableExpressionSyntax::ANSI
160    };
161}
162
163impl JoinSyntax {
164    /// The `BIGQUERY` preset for join syntax.
165    pub const BIGQUERY: Self = Self { ..JoinSyntax::ANSI };
166}
167
168impl ExpressionSyntax {
169    /// BigQuery expression surface: the ANSI baseline plus the `STRUCT(...)` value
170    /// constructor (`STRUCT(1, 2)`, `STRUCT(x AS a)`, `STRUCT<a INT64>(1)`), a documented
171    /// GoogleSQL form with no differential oracle here. Every other expression knob stays
172    /// conservatively ANSI.
173    pub const BIGQUERY: Self = Self {
174        struct_constructor: true,
175        ..ExpressionSyntax::ANSI
176    };
177}
178
179impl TableFactorSyntax {
180    /// The `BIGQUERY` preset for table factor syntax.
181    pub const BIGQUERY: Self = Self {
182        unnest: true,
183        unnest_with_offset: true,
184        // GoogleSQL's `FROM t PIVOT(<agg> FOR <col> IN (<vals>))` table factor. No
185        // BigQuery oracle ships here, so the standard-PIVOT gate is enabled on the
186        // conservative preset per the documented grammar (the `unnest_with_offset`
187        // precedent). BigQuery uses only the explicit value list, but the shared gate
188        // also admits `ANY`/subquery and `DEFAULT ON NULL` — over-acceptance that is
189        // unmeasurable without an oracle.
190        pivot_value_sources: true,
191        ..TableFactorSyntax::ANSI
192    };
193}
194
195impl TypeNameSyntax {
196    /// BigQuery type names: ANSI baseline plus angle-bracket `STRUCT<>`/`ARRAY<>`.
197    pub const BIGQUERY: Self = Self {
198        angle_bracket_types: true,
199        ..TypeNameSyntax::ANSI
200    };
201}
202
203impl FeatureSet {
204    /// BigQuery / ZetaSQL as ANSI-derived dialect data (see the module docs for the full
205    /// derivation rationale and the conservatism bar).
206    pub const BIGQUERY: Self = Self {
207        // BigQuery column/alias resolution is case-insensitive (table/dataset names are
208        // case-sensitive); `Casing::Lower` is the closest single fit (the `Casing` known-
209        // limitation paragraph names this shape). Identity only — the interned text still
210        // renders exactly as written, so this never affects acceptance.
211        identifier_casing: Casing::Lower,
212        // The lexical delta over ANSI: backtick-only identifier quoting (with `"` handed to the
213        // string scanner via `double_quoted_strings` below).
214        identifier_quotes: BIGQUERY_IDENTIFIER_QUOTES,
215        default_null_ordering: NullOrdering::NullsLast,
216        // The one reserved-set delta over ANSI: `PIVOT`/`UNPIVOT` are position-reserved on
217        // the `ColId` axis so a bare `FROM t PIVOT (…)` reaches the operator instead of
218        // aliasing `t` as `pivot` (see [`BIGQUERY_PIVOT_RESERVATION`]). Confined to
219        // `reserved_column_name`; the function/type/bare-label positions stay ANSI, matching
220        // BigQuery's non-reserved status for the words. (BigQuery's `QUALIFY`/`EXCEPT`-in-star
221        // reservations ride gates not modelled here.)
222        reserved_column_name: BIGQUERY_RESERVED_COLUMN_NAME,
223        reserved_function_name: RESERVED_FUNCTION_NAME,
224        reserved_type_name: RESERVED_TYPE_NAME,
225        reserved_bare_alias: RESERVED_BARE_ALIAS,
226        reserved_as_label: KeywordSet::EMPTY,
227        catalog_qualified_names: true,
228        byte_classes: STANDARD_BYTE_CLASSES,
229        binding_powers: STANDARD_BINDING_POWERS,
230        set_operation_powers: STANDARD_SET_OPERATION_BINDING_POWERS,
231        // `"…"` double-quoted strings (the coupled half of the backtick-only identifier lexis).
232        string_literals: StringLiteralSyntax::BIGQUERY,
233        numeric_literals: NumericLiteralSyntax::ANSI,
234        parameters: ParameterSyntax::ANSI,
235        session_variables: SessionVariableSyntax::ANSI,
236        identifier_syntax: IdentifierSyntax::ANSI,
237        // `FROM UNNEST(…)` and its `WITH OFFSET` tail — the capstone this preset exposes.
238        table_expressions: TableExpressionSyntax::BIGQUERY,
239        join_syntax: JoinSyntax::BIGQUERY,
240        table_factor_syntax: TableFactorSyntax::BIGQUERY,
241        expression_syntax: ExpressionSyntax::BIGQUERY,
242        operator_syntax: OperatorSyntax::ANSI,
243        call_syntax: CallSyntax::ANSI,
244        string_func_forms: StringFuncForms::ANSI,
245        aggregate_call_syntax: AggregateCallSyntax::ANSI,
246        predicate_syntax: PredicateSyntax::ANSI,
247        pipe_operator: PipeOperator::StringConcat,
248        double_ampersand: DoubleAmpersand::Unsupported,
249        keyword_operators: KeywordOperators::Unsupported,
250        caret_operator: CaretOperator::Unsupported,
251        hash_bitwise_xor: false,
252        comment_syntax: CommentSyntax::ANSI,
253        mutation_syntax: MutationSyntax::ANSI,
254        statement_ddl_gates: StatementDdlGates::ANSI,
255        create_table_clause_syntax: CreateTableClauseSyntax::ANSI,
256        column_definition_syntax: ColumnDefinitionSyntax::ANSI,
257        constraint_syntax: ConstraintSyntax::ANSI,
258        index_alter_syntax: IndexAlterSyntax::ANSI,
259        existence_guards: ExistenceGuards::ANSI,
260        // `pipe_syntax` stays off (deferred judgment — see the module docs); every other SELECT
261        // knob is conservatively ANSI.
262        select_syntax: SelectSyntax::ANSI,
263        query_tail_syntax: QueryTailSyntax::ANSI,
264        grouping_syntax: GroupingSyntax::ANSI,
265        utility_syntax: UtilitySyntax::ANSI,
266        show_syntax: ShowSyntax::ANSI,
267        maintenance_syntax: MaintenanceSyntax::ANSI,
268        access_control_syntax: AccessControlSyntax::ANSI,
269        type_name_syntax: TypeNameSyntax::BIGQUERY,
270        // No BigQuery-specific Tier-1 output spelling yet; render the portable ANSI canonical
271        // type names (a `TargetSpelling::BigQuery` is render work a later ticket owns).
272        target_spelling: TargetSpelling::Ansi,
273    };
274}
275
276/// Prefer [`FeatureSet::BIGQUERY`] for struct update.
277pub const BIGQUERY: FeatureSet = FeatureSet::BIGQUERY;
278
279// Compile-time proof the BigQuery preset claims no shared tokenizer trigger twice. Beyond ANSI
280// it adds one lexical trigger — the backtick identifier opener — with a single claimant (no
281// enabled expression grammar lexes a backtick), and it hands `"` to the string scanner
282// (`double_quoted_strings` on) *while dropping `"` from the identifier quote set*, so `"` also
283// keeps a single claimant. The two `UNNEST` gates are contextual keyword grammar with no
284// tokenizer trigger. Kept as a ratchet so a future BigQuery delta that *does* add a contending
285// trigger (e.g. re-listing `"` as an identifier quote) fails the build here.
286const _: () = assert!(FeatureSet::BIGQUERY.is_lexically_consistent());
287// The two sibling self-consistency registries are ratcheted the same way, so the
288// parse-entry `debug_assert!` folds all three to dead code for this preset: the
289// `unnest_with_offset` tail rides the enabled `unnest` base, and no two features contend
290// for one parser-position head.
291const _: () = assert!(FeatureSet::BIGQUERY.has_satisfied_feature_dependencies());
292const _: () = assert!(FeatureSet::BIGQUERY.has_no_grammar_conflict());
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn bigquery_is_ansi_plus_the_gates_and_two_lexical_facts() {
300        // The preset is ANSI with a documented, closed set of divergent axes: the two lexical
301        // facts (case-folding, backtick-only quoting coupled with double-quoted strings), the
302        // enabled table-expression/factor gates, and the `PIVOT`/`UNPIVOT` `ColId` reservation.
303        // Asserting the whole rest equals ANSI keeps the "ANSI-derived, every delta documented"
304        // claim honest against a future stray edit.
305        // Bind to locals so the const reads are not flagged by clippy's
306        // `assertions_on_constants`.
307        let ansi = FeatureSet::ANSI;
308        let bq = FeatureSet::BIGQUERY;
309
310        // The two lexical facts.
311        assert_eq!(bq.identifier_casing, Casing::Lower);
312        assert_ne!(bq.identifier_casing, ansi.identifier_casing);
313        assert_eq!(bq.identifier_quotes, BIGQUERY_IDENTIFIER_QUOTES);
314        assert_ne!(bq.identifier_quotes, ansi.identifier_quotes);
315        // The coupling: `"` is dropped from the quote set and handed to the string scanner.
316        assert!(bq.string_literals.double_quoted_strings);
317        assert!(!bq.identifier_quotes.iter().any(|quote| quote.open() == '"'));
318        // Backtick is the sole identifier quote; no bracket (unlike SQLite/MSSQL) and no `"`.
319        assert!(bq.identifier_quotes.iter().any(|quote| quote.open() == '`'));
320        assert_eq!(bq.identifier_quotes.len(), 1);
321
322        // The one divergent string sub-preset (the double-quote coupling).
323        assert_eq!(bq.string_literals, StringLiteralSyntax::BIGQUERY);
324        assert_ne!(bq.string_literals, ansi.string_literals);
325        // The divergent table-expression sub-preset.
326        assert_eq!(bq.table_expressions, TableExpressionSyntax::BIGQUERY);
327        assert_eq!(bq.table_factor_syntax, TableFactorSyntax::BIGQUERY);
328        assert_ne!(bq.table_factor_syntax, ansi.table_factor_syntax);
329
330        // The reserved-set delta: `PIVOT`/`UNPIVOT` are added on the `ColId` axis only, so a
331        // bare `FROM t PIVOT (…)` reaches the operator. Dropping them recovers the ANSI set
332        // verbatim; the other three positions take no delta over ANSI.
333        assert_eq!(bq.reserved_column_name, BIGQUERY_RESERVED_COLUMN_NAME);
334        assert_ne!(bq.reserved_column_name, ansi.reserved_column_name);
335        assert_eq!(
336            bq.reserved_column_name
337                .difference(BIGQUERY_PIVOT_RESERVATION),
338            ansi.reserved_column_name,
339        );
340        assert!(bq.reserved_column_name.contains(Keyword::Pivot));
341        assert!(bq.reserved_column_name.contains(Keyword::Unpivot));
342        // Confined to `ColId`: function/type/bare-label positions stay ANSI, so `pivot(1)`,
343        // `CAST(1 AS pivot)`, and `SELECT 1 pivot` keep parsing (BigQuery's non-reserved
344        // status for the words).
345        assert_eq!(bq.reserved_function_name, ansi.reserved_function_name);
346        assert_eq!(bq.reserved_type_name, ansi.reserved_type_name);
347        assert_eq!(bq.reserved_bare_alias, ansi.reserved_bare_alias);
348        assert!(!bq.reserved_function_name.contains(Keyword::Pivot));
349        assert!(!bq.reserved_type_name.contains(Keyword::Pivot));
350        assert!(!bq.reserved_bare_alias.contains(Keyword::Pivot));
351        assert_eq!(bq.reserved_as_label, KeywordSet::EMPTY);
352
353        // Everything else is inherited verbatim from ANSI — including SELECT (pipe_syntax is
354        // deferred, so it stays off) and numeric/parameter surfaces.
355        assert_eq!(bq.select_syntax, ansi.select_syntax);
356        assert!(!bq.query_tail_syntax.pipe_syntax);
357        assert_eq!(bq.numeric_literals, ansi.numeric_literals);
358        assert_eq!(bq.parameters, ansi.parameters);
359        // BigQuery adds the `STRUCT(...)` value constructor; every other expression knob
360        // is inherited verbatim from ANSI.
361        assert_eq!(bq.expression_syntax, ExpressionSyntax::BIGQUERY);
362        assert!(bq.expression_syntax.struct_constructor);
363        assert!(!ansi.expression_syntax.struct_constructor);
364        assert_eq!(
365            ExpressionSyntax {
366                struct_constructor: false,
367                ..bq.expression_syntax
368            },
369            ansi.expression_syntax
370        );
371        assert_eq!(bq.session_variables, ansi.session_variables);
372        assert_eq!(bq.identifier_syntax, ansi.identifier_syntax);
373        assert_eq!(bq.operator_syntax, ansi.operator_syntax);
374        assert_eq!(bq.call_syntax, ansi.call_syntax);
375        assert_eq!(bq.predicate_syntax, ansi.predicate_syntax);
376        assert_eq!(bq.mutation_syntax, ansi.mutation_syntax);
377        assert_eq!(bq.statement_ddl_gates, ansi.statement_ddl_gates);
378        assert_eq!(
379            bq.create_table_clause_syntax,
380            ansi.create_table_clause_syntax
381        );
382        assert_eq!(bq.column_definition_syntax, ansi.column_definition_syntax);
383        assert_eq!(bq.constraint_syntax, ansi.constraint_syntax);
384        assert_eq!(bq.index_alter_syntax, ansi.index_alter_syntax);
385        assert_eq!(bq.existence_guards, ansi.existence_guards);
386        assert_eq!(bq.utility_syntax, ansi.utility_syntax);
387        assert!(bq.type_name_syntax.angle_bracket_types);
388        assert!(!ansi.type_name_syntax.angle_bracket_types);
389        assert_eq!(
390            TypeNameSyntax {
391                angle_bracket_types: false,
392                ..bq.type_name_syntax
393            },
394            ansi.type_name_syntax,
395        );
396        assert_eq!(bq.byte_classes, ansi.byte_classes);
397        assert_eq!(bq.binding_powers, ansi.binding_powers);
398        assert_eq!(bq.target_spelling, ansi.target_spelling);
399        assert_eq!(bq.default_null_ordering, ansi.default_null_ordering);
400    }
401
402    #[test]
403    fn bigquery_enables_exactly_the_unnest_gates_and_double_quote_string() {
404        // The capstone: the first-class UNNEST factor, its BigQuery WITH OFFSET tail, and
405        // double-quoted strings are on, and each is off in the ANSI base it derives from.
406        // Forcing the flags back off recovers the ANSI sub-presets verbatim.
407        let ansi = FeatureSet::ANSI;
408        let bq = FeatureSet::BIGQUERY;
409
410        assert!(bq.table_factor_syntax.unnest && !ansi.table_factor_syntax.unnest);
411        assert!(
412            bq.table_factor_syntax.unnest_with_offset
413                && !ansi.table_factor_syntax.unnest_with_offset
414        );
415        assert!(
416            bq.string_literals.double_quoted_strings && !ansi.string_literals.double_quoted_strings
417        );
418        // The backtick opener is the lexical gate (an identifier-quote delta, not a bool).
419        assert!(bq.identifier_quotes.iter().any(|quote| quote.open() == '`'));
420        assert!(
421            !ansi
422                .identifier_quotes
423                .iter()
424                .any(|quote| quote.open() == '`')
425        );
426
427        assert_eq!(
428            TableFactorSyntax {
429                unnest: false,
430                unnest_with_offset: false,
431                // BigQuery adds the standard PIVOT gate over the ANSI baseline.
432                pivot_value_sources: false,
433                ..bq.table_factor_syntax
434            },
435            ansi.table_factor_syntax,
436        );
437        assert_eq!(
438            StringLiteralSyntax {
439                double_quoted_strings: false,
440                ..bq.string_literals
441            },
442            ansi.string_literals,
443        );
444    }
445
446    #[test]
447    fn bigquery_is_lexically_consistent_and_dependency_clean() {
448        // Both self-consistency registries must be clean: the backtick quote has a single
449        // claimant, `"` is handed to the string scanner (dropped from the quote set), and — the
450        // fact this preset is the first to prove positively — the `unnest_with_offset` tail
451        // rides the enabled `unnest` base, so the feature-dependency registry is satisfied.
452        let bq = FeatureSet::BIGQUERY;
453        assert_eq!(bq.lexical_conflict(), None);
454        assert!(bq.is_lexically_consistent());
455        assert_eq!(bq.feature_dependencies(), None);
456        assert!(bq.has_satisfied_feature_dependencies());
457        assert_eq!(bq.grammar_conflict(), None);
458        assert!(bq.has_no_grammar_conflict());
459        // Guard the dependency direction explicitly: dropping the `unnest` base while keeping
460        // the `WITH OFFSET` tail must trip the registry — this preset is the first whose flip
461        // exercises `UnnestWithOffsetWithoutUnnest`.
462        let broken = FeatureSet::BIGQUERY.with(
463            super::super::FeatureDelta::EMPTY.table_factor_syntax(TableFactorSyntax {
464                unnest: false,
465                ..bq.table_factor_syntax
466            }),
467        );
468        assert_eq!(
469            broken.feature_dependencies(),
470            Some(super::super::FeatureDependencyViolation::UnnestWithOffsetWithoutUnnest),
471        );
472    }
473}