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//! Three headline gates carry the 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//! - [`angle_bracket_types`](TypeNameSyntax::angle_bracket_types) — type-position support
36//!   for BigQuery-style `ARRAY<...>` / `STRUCT<...>` and array-of-struct declarations.
37//!
38//! One expression gate:
39//!
40//! - [`struct_constructor`](ExpressionSyntax::struct_constructor) — the `STRUCT(...)`
41//!   value constructor (`STRUCT(1, 2)`, `STRUCT(x AS a)`, `STRUCT<a INT64>(1)`), the
42//!   documented GoogleSQL tuple builder. Its own flag doc names BigQuery as the motivating
43//!   dialect; the `(`/`<` lookahead keeps a bare `struct` an ordinary name, so the gate is
44//!   additive over ANSI.
45//!
46//! # The two lexical facts over ANSI
47//!
48//! - **Backtick identifier quoting.** BigQuery quotes identifiers with the backtick
49//!   `` `name` `` alone — its `"…"` and `'…'` are *both* string literals. So
50//!   [`BIGQUERY_IDENTIFIER_QUOTES`] lists only the backtick (unlike the SQLite/Databricks/MSSQL
51//!   bracket-or-double-quote sets, and unlike ANSI's `"…"`), and
52//!   [`double_quoted_strings`](StringLiteralSyntax::double_quoted_strings) is correspondingly
53//!   **on** so `"x"` lexes as a string, never an identifier — exactly the MySQL default
54//!   (`ANSI_QUOTES` off) lexis. The two facts are coupled: `"` has a single claimant (the
55//!   string scanner) precisely because it is absent from the quote set, the
56//!   [`DoubleQuoteStringVersusIdentifier`](super::LexicalConflict) hazard the `const` assert
57//!   below rules out. The backtick likewise has a single claimant — no enabled expression
58//!   grammar lexes a backtick.
59//! - **Case folding.** BigQuery column and alias references resolve case-insensitively (table
60//!   and dataset names are case-sensitive), so [`identifier_casing`](FeatureSet::identifier_casing)
61//!   is [`Casing::Lower`] — the closest single fit per the [`Casing`] doc's
62//!   *known-limitation* paragraph, which names exactly this "case-insensitive column beside a
63//!   case-sensitive table" shape (shared with MySQL/T-SQL) as one no single fold can express;
64//!   `Casing::Lower` is the value it prescribes. The interned text still renders exactly as
65//!   written; the fold is identity-only and never affects acceptance. (The
66//!   per-identifier-kind table-vs-column sensitivity split is that documented `Casing`
67//!   limitation and a deliberate future extension, not modelled here.)
68//!
69//! # Deliberately deferred (conservative reject)
70//!
71//! [`pipe_syntax`](QueryTailSyntax::pipe_syntax) — BigQuery/ZetaSQL query pipe syntax
72//! (`FROM t |> WHERE x |> SELECT a`) — stays **off**, and this is a considered judgment, not an
73//! oversight. Its own flag doc names the BigQuery preset as the eventual home, but reads
74//! the honesty bar the other way for now: the framework ships only the reference `|> WHERE`
75//! operator, so enabling the gate today would accept `|> WHERE` while rejecting every other
76//! pipe operator — a fragment a reader of this module could not predict, and with no BigQuery
77//! oracle the boundary cannot be measured either. The flag doc's own argument (that even the
78//! permissive `LENIENT` must wait until the `planner-parity-pipe-*` tickets make the
79//! pipe-operator surface coherent) cuts identically for BigQuery. Leaving it off with that
80//! reasoning cited is the correct call; flipping it on merely because the doc names BigQuery
81//! would ship an incoherent half-surface. The flip is deferred to the pipe-surface tickets.
82//!
83//! Everything else BigQuery (the `STRUCT<…>`/`ARRAY<…>` *type-position* surface —
84//! `CAST(x AS STRUCT<…>)` and column types, distinct from the value constructor above —
85//! `SELECT AS STRUCT/VALUE`,
86//! `EXCEPT`/`REPLACE` in `SELECT *`, `QUALIFY`, table-name backtick paths with dots,
87//! parameterised `@name` / `?` binds, the `SAFE.` function prefix, …) has no modelled gate and
88//! is a clean reject routed to follow-up tickets, never a silent over-accept.
89
90use super::{
91    AccessControlSyntax, AggregateCallSyntax, CallSyntax, CaretOperator, Casing,
92    ColumnDefinitionSyntax, CommentSyntax, ConstraintSyntax, CreateTableClauseSyntax,
93    DoubleAmpersand, ExistenceGuards, ExpressionSyntax, FeatureSet, GroupingSyntax,
94    IdentifierQuote, IdentifierSyntax, IndexAlterSyntax, JoinSyntax, Keyword, KeywordOperators,
95    KeywordSet, MaintenanceSyntax, MutationSyntax, NullOrdering, NumericLiteralSyntax,
96    OperatorSyntax, ParameterSyntax, PipeOperator, PredicateSyntax, QueryTailSyntax,
97    RESERVED_BARE_ALIAS, RESERVED_COLUMN_NAME, RESERVED_FUNCTION_NAME, RESERVED_TYPE_NAME,
98    STANDARD_BYTE_CLASSES, SelectSyntax, SessionVariableSyntax, ShowSyntax, StatementDdlGates,
99    StringFuncForms, StringLiteralSyntax, TableExpressionSyntax, TableFactorSyntax, TargetSpelling,
100    TransactionSyntax, TypeNameSyntax, UtilitySyntax, ViewSequenceClauseSyntax,
101};
102use crate::precedence::{STANDARD_BINDING_POWERS, STANDARD_SET_OPERATION_BINDING_POWERS};
103
104/// BigQuery identifier quoting: the backtick `` `…` `` alone. BigQuery spells a quoted
105/// identifier `` `a` ``; its `"a"` and `'a'` are *both* string constants, so `"` is
106/// deliberately absent here (and [`StringLiteralSyntax::BIGQUERY`] turns
107/// `double_quoted_strings` on so `"` unambiguously lexes a string) — the same backtick-only
108/// lexis MySQL uses under its default `ANSI_QUOTES`-off mode.
109pub const BIGQUERY_IDENTIFIER_QUOTES: &[IdentifierQuote] = &[IdentifierQuote::Symmetric('`')];
110
111/// `PIVOT` and `UNPIVOT`, GoogleSQL's row/column rotation operators. Neither is a
112/// BigQuery *reserved* keyword — both are absent from the GoogleSQL reserved-keyword
113/// list and stay usable as ordinary unquoted identifiers (GoogleSQL lexical-structure
114/// reference, "Reserved keywords":
115/// <https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#reserved_keywords>).
116/// They are instead *position-reserved*: the `pivot_operator` / `unpivot_operator`
117/// grammar attaches directly to a `from_item`, so `FROM t PIVOT (…)` must read the
118/// operator, not a correlation alias named `pivot` (GoogleSQL query-syntax reference,
119/// "Pivot operator" / "Unpivot operator":
120/// <https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#pivot_operator>).
121///
122/// Modelling that reachability in this parser's shared-`ColId` alias grammar means
123/// reserving both on the `ColId` axis only — [`RESERVED_COLUMN_NAME`], the set a bare
124/// *and* `AS`-introduced table alias, a column name, and a table name all draw from —
125/// and *not* the function-name, type-name, or projection bare-label axes, which stay
126/// open (`pivot(1)`, `CAST(1 AS pivot)`, `SELECT 1 pivot` still parse), matching
127/// BigQuery's non-reserved status. This is the deliberate minimal deviation from the
128/// DuckDB `DUCKDB_PIVOT_RESERVATION`, which unions into all four positions because
129/// DuckDB's engine genuinely classes the words `reserved`. The unavoidable reachability
130/// cost: under this preset an unquoted `pivot`/`unpivot` is not admitted as a column/table
131/// name or a table alias — quote it (`` `pivot` ``) to use it as an identifier there.
132pub const BIGQUERY_PIVOT_RESERVATION: KeywordSet =
133    KeywordSet::from_keywords(&[Keyword::Pivot, Keyword::Unpivot]);
134
135/// The ANSI `ColId` reject set plus [`BIGQUERY_PIVOT_RESERVATION`]; see that const for
136/// why the reservation is confined to this one axis. The function-name, type-name, and
137/// bare-label reject sets take no delta over ANSI, so BigQuery keeps the shared consts
138/// for those three positions.
139pub const BIGQUERY_RESERVED_COLUMN_NAME: KeywordSet =
140    RESERVED_COLUMN_NAME.union(BIGQUERY_PIVOT_RESERVATION);
141
142impl StringLiteralSyntax {
143    /// BigQuery string surface: the ANSI baseline plus `"…"` double-quoted string constants
144    /// (BigQuery quotes strings with both `'…'` and `"…"`, reserving the backtick for
145    /// identifiers). Enabling this is what lets [`BIGQUERY_IDENTIFIER_QUOTES`] drop `"` from the
146    /// quote set without stranding the byte. Every other string knob is conservatively ANSI —
147    /// BigQuery's backslash escape sequences have no BigQuery-citing flag doc or oracle here and
148    /// are deferred rather than guessed at (`backslash_escapes` stays off).
149    pub const BIGQUERY: Self = Self {
150        double_quoted_strings: true,
151        escape_strings: false,
152        dollar_quoted_strings: false,
153        national_strings: false,
154        backslash_escapes: false,
155        unicode_strings: false,
156        bit_string_literals: false,
157        blob_literals: false,
158        charset_introducers: false,
159        same_line_adjacent_concat: false,
160    };
161}
162
163impl TableExpressionSyntax {
164    /// BigQuery table-expression surface: the ANSI baseline plus the `FOR SYSTEM_TIME AS OF`
165    /// time-travel modifier. The first-class `UNNEST(…)` factor and its `WITH OFFSET` tail
166    /// ride [`TableFactorSyntax`]; every other table knob is conservatively ANSI.
167    pub const BIGQUERY: Self = Self {
168        // `FROM t FOR SYSTEM_TIME AS OF <ts>` — BigQuery's sole time-travel spelling.
169        table_version: true,
170        only: false,
171        table_sample: false,
172        parenthesized_joins: true,
173        table_alias_column_lists: true,
174        join_using_alias: false,
175        index_hints: false,
176        table_hints: false,
177        partition_selection: false,
178        base_table_alias_column_lists: true,
179        string_literal_aliases: false,
180        aliased_parenthesized_join: true,
181        bare_table_alias_is_bare_label: false,
182        table_json_path: false,
183        indexed_by: false,
184        prefix_colon_alias: false,
185    };
186}
187
188impl JoinSyntax {
189    /// The `BIGQUERY` preset for join syntax.
190    pub const BIGQUERY: Self = Self {
191        stacked_join_qualifiers: true,
192        full_outer_join: true,
193        natural_cross_join: false,
194        straight_join: false,
195        asof_join: false,
196        positional_join: false,
197        semi_anti_join: false,
198        sided_semi_anti_join: false,
199        apply_join: false,
200        recursive_search_cycle: false,
201        recursive_union_rejects_order_limit: false,
202        recursive_using_key: false,
203    };
204}
205
206impl ExpressionSyntax {
207    /// BigQuery expression surface: the ANSI baseline plus the `STRUCT(...)` value
208    /// constructor (`STRUCT(1, 2)`, `STRUCT(x AS a)`, `STRUCT<a INT64>(1)`), a documented
209    /// GoogleSQL form with no differential oracle here. Every other expression knob stays
210    /// conservatively ANSI.
211    pub const BIGQUERY: Self = Self {
212        struct_constructor: true,
213        typecast_operator: false,
214        subscript: false,
215        slice_step: false,
216        collate: false,
217        at_time_zone: false,
218        semi_structured_access: false,
219        array_constructor: false,
220        multidim_array_literals: false,
221        collection_literals: false,
222        row_constructor: false,
223        field_selection: false,
224        field_wildcard: false,
225        typed_string_literals: true,
226        typed_interval_literal: true,
227        relaxed_interval_syntax: false,
228        mysql_interval_operator: false,
229        positional_column: false,
230        lambda_keyword: false,
231    };
232}
233
234impl TableFactorSyntax {
235    /// The `BIGQUERY` preset for table factor syntax.
236    pub const BIGQUERY: Self = Self {
237        unnest: true,
238        unnest_with_offset: true,
239        // GoogleSQL's `FROM t PIVOT(<agg> FOR <col> IN (<vals>))` table factor. No
240        // BigQuery oracle ships here, so the standard-PIVOT gate is enabled on the
241        // conservative preset per the documented grammar (the `unnest_with_offset`
242        // precedent). BigQuery uses only the explicit value list, but the shared gate
243        // also admits `ANY`/subquery and `DEFAULT ON NULL` — over-acceptance that is
244        // unmeasurable without an oracle.
245        pivot_value_sources: true,
246        lateral: false,
247        table_functions: false,
248        rows_from: false,
249        table_function_ordinality: false,
250        special_function_table_source: true,
251        pivot: false,
252        unpivot: false,
253        show_ref: false,
254        from_values: false,
255        json_table: false,
256        xml_table: false,
257        table_expr_factor: false,
258        match_recognize: false,
259        open_json: false,
260    };
261}
262
263impl TypeNameSyntax {
264    /// BigQuery type names: ANSI baseline plus angle-bracket `STRUCT<>`/`ARRAY<>`.
265    pub const BIGQUERY: Self = Self {
266        angle_bracket_types: true,
267        extended_scalar_type_names: false,
268        enum_type: false,
269        set_type: false,
270        numeric_modifiers: false,
271        integer_display_width: false,
272        composite_types: false,
273        varchar_requires_length: false,
274        zoned_temporal_types: true,
275        empty_type_parens: false,
276        character_set_annotation: false,
277        signed_type_modifier: false,
278        nullable_type: false,
279        low_cardinality_type: false,
280        fixed_string_type: false,
281        datetime64_type: false,
282        nested_type: false,
283        bit_width_integer_names: false,
284        liberal_type_names: false,
285        string_type_modifiers: false,
286    };
287}
288
289impl FeatureSet {
290    /// BigQuery / ZetaSQL as ANSI-derived dialect data (see the module docs for the full
291    /// derivation rationale and the conservatism bar).
292    pub const BIGQUERY: Self = Self {
293        // BigQuery column/alias resolution is case-insensitive (table/dataset names are
294        // case-sensitive); `Casing::Lower` is the closest single fit (the `Casing` known-
295        // limitation paragraph names this shape). Identity only — the interned text still
296        // renders exactly as written, so this never affects acceptance.
297        identifier_casing: Casing::Lower,
298        // The lexical delta over ANSI: backtick-only identifier quoting (with `"` handed to the
299        // string scanner via `double_quoted_strings` below).
300        identifier_quotes: BIGQUERY_IDENTIFIER_QUOTES,
301        default_null_ordering: NullOrdering::NullsLast,
302        // The one reserved-set delta over ANSI: `PIVOT`/`UNPIVOT` are position-reserved on
303        // the `ColId` axis so a bare `FROM t PIVOT (…)` reaches the operator instead of
304        // aliasing `t` as `pivot` (see [`BIGQUERY_PIVOT_RESERVATION`]). Confined to
305        // `reserved_column_name`; the function/type/bare-label positions stay ANSI, matching
306        // BigQuery's non-reserved status for the words. (BigQuery's `QUALIFY`/`EXCEPT`-in-star
307        // reservations ride gates not modelled here.)
308        reserved_column_name: BIGQUERY_RESERVED_COLUMN_NAME,
309        reserved_function_name: RESERVED_FUNCTION_NAME,
310        reserved_type_name: RESERVED_TYPE_NAME,
311        reserved_bare_alias: RESERVED_BARE_ALIAS,
312        reserved_as_label: KeywordSet::EMPTY,
313        catalog_qualified_names: true,
314        byte_classes: STANDARD_BYTE_CLASSES,
315        binding_powers: STANDARD_BINDING_POWERS,
316        set_operation_powers: STANDARD_SET_OPERATION_BINDING_POWERS,
317        // `"…"` double-quoted strings (the coupled half of the backtick-only identifier lexis).
318        string_literals: StringLiteralSyntax::BIGQUERY,
319        numeric_literals: NumericLiteralSyntax::ANSI,
320        parameters: ParameterSyntax::ANSI,
321        session_variables: SessionVariableSyntax::ANSI,
322        identifier_syntax: IdentifierSyntax::ANSI,
323        // `FROM UNNEST(…)` and its `WITH OFFSET` tail — the capstone this preset exposes.
324        table_expressions: TableExpressionSyntax::BIGQUERY,
325        join_syntax: JoinSyntax::BIGQUERY,
326        table_factor_syntax: TableFactorSyntax::BIGQUERY,
327        expression_syntax: ExpressionSyntax::BIGQUERY,
328        operator_syntax: OperatorSyntax::ANSI,
329        call_syntax: CallSyntax::ANSI,
330        string_func_forms: StringFuncForms::ANSI,
331        aggregate_call_syntax: AggregateCallSyntax::ANSI,
332        predicate_syntax: PredicateSyntax::ANSI,
333        pipe_operator: PipeOperator::StringConcat,
334        double_ampersand: DoubleAmpersand::Unsupported,
335        keyword_operators: KeywordOperators::Unsupported,
336        caret_operator: CaretOperator::Unsupported,
337        hash_bitwise_xor: false,
338        comment_syntax: CommentSyntax::ANSI,
339        mutation_syntax: MutationSyntax::ANSI,
340        statement_ddl_gates: StatementDdlGates::ANSI,
341        view_sequence_clause_syntax: ViewSequenceClauseSyntax::ANSI,
342        create_table_clause_syntax: CreateTableClauseSyntax::ANSI,
343        column_definition_syntax: ColumnDefinitionSyntax::ANSI,
344        constraint_syntax: ConstraintSyntax::ANSI,
345        index_alter_syntax: IndexAlterSyntax::ANSI,
346        existence_guards: ExistenceGuards::ANSI,
347        // `pipe_syntax` stays off (deferred judgment — see the module docs); every other SELECT
348        // knob is conservatively ANSI.
349        select_syntax: SelectSyntax::ANSI,
350        query_tail_syntax: QueryTailSyntax::ANSI,
351        grouping_syntax: GroupingSyntax::ANSI,
352        utility_syntax: UtilitySyntax::ANSI,
353        transaction_syntax: TransactionSyntax::ANSI,
354        show_syntax: ShowSyntax::ANSI,
355        maintenance_syntax: MaintenanceSyntax::ANSI,
356        access_control_syntax: AccessControlSyntax::ANSI,
357        type_name_syntax: TypeNameSyntax::BIGQUERY,
358        // No BigQuery-specific Tier-1 output spelling yet; render the portable ANSI canonical
359        // type names (a `TargetSpelling::BigQuery` is render work a later ticket owns).
360        target_spelling: TargetSpelling::Ansi,
361    };
362}
363
364/// Prefer [`FeatureSet::BIGQUERY`] for struct update.
365pub const BIGQUERY: FeatureSet = FeatureSet::BIGQUERY;
366
367// Compile-time proof the BigQuery preset claims no shared tokenizer trigger twice. Beyond ANSI
368// it adds one lexical trigger — the backtick identifier opener — with a single claimant (no
369// enabled expression grammar lexes a backtick), and it hands `"` to the string scanner
370// (`double_quoted_strings` on) *while dropping `"` from the identifier quote set*, so `"` also
371// keeps a single claimant. The two `UNNEST` gates are contextual keyword grammar with no
372// tokenizer trigger. Kept as a ratchet so a future BigQuery delta that *does* add a contending
373// trigger (e.g. re-listing `"` as an identifier quote) fails the build here.
374const _: () = assert!(FeatureSet::BIGQUERY.is_lexically_consistent());
375// The two sibling self-consistency registries are ratcheted the same way, so the
376// parse-entry `debug_assert!` folds all three to dead code for this preset: the
377// `unnest_with_offset` tail rides the enabled `unnest` base, and no two features contend
378// for one parser-position head.
379const _: () = assert!(FeatureSet::BIGQUERY.has_satisfied_feature_dependencies());
380const _: () = assert!(FeatureSet::BIGQUERY.has_no_grammar_conflict());
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn bigquery_is_ansi_plus_the_gates_and_two_lexical_facts() {
388        // The preset is ANSI with a documented, closed set of divergent axes: the two lexical
389        // facts (case-folding, backtick-only quoting coupled with double-quoted strings), the
390        // enabled table-expression/factor gates, the `STRUCT`/`ARRAY` angle-bracket type-position
391        // support, and the `PIVOT`/`UNPIVOT` `ColId` reservation.
392        // Asserting the whole rest equals ANSI keeps the "ANSI-derived, every delta documented"
393        // claim honest against a future stray edit.
394        // Bind to locals so the const reads are not flagged by clippy's
395        // `assertions_on_constants`.
396        let ansi = FeatureSet::ANSI;
397        let bq = FeatureSet::BIGQUERY;
398
399        // The two lexical facts.
400        assert_eq!(bq.identifier_casing, Casing::Lower);
401        assert_ne!(bq.identifier_casing, ansi.identifier_casing);
402        assert_eq!(bq.identifier_quotes, BIGQUERY_IDENTIFIER_QUOTES);
403        assert_ne!(bq.identifier_quotes, ansi.identifier_quotes);
404        // The coupling: `"` is dropped from the quote set and handed to the string scanner.
405        assert!(bq.string_literals.double_quoted_strings);
406        assert!(!bq.identifier_quotes.iter().any(|quote| quote.open() == '"'));
407        // Backtick is the sole identifier quote; no bracket (unlike SQLite/MSSQL) and no `"`.
408        assert!(bq.identifier_quotes.iter().any(|quote| quote.open() == '`'));
409        assert_eq!(bq.identifier_quotes.len(), 1);
410
411        // The one divergent string sub-preset (the double-quote coupling).
412        assert_eq!(bq.string_literals, StringLiteralSyntax::BIGQUERY);
413        assert_ne!(bq.string_literals, ansi.string_literals);
414        // The divergent table-expression sub-preset.
415        assert_eq!(bq.table_expressions, TableExpressionSyntax::BIGQUERY);
416        assert_eq!(bq.table_factor_syntax, TableFactorSyntax::BIGQUERY);
417        assert_ne!(bq.table_factor_syntax, ansi.table_factor_syntax);
418
419        // The reserved-set delta: `PIVOT`/`UNPIVOT` are added on the `ColId` axis only, so a
420        // bare `FROM t PIVOT (…)` reaches the operator. Dropping them recovers the ANSI set
421        // verbatim; the other three positions take no delta over ANSI.
422        assert_eq!(bq.reserved_column_name, BIGQUERY_RESERVED_COLUMN_NAME);
423        assert_ne!(bq.reserved_column_name, ansi.reserved_column_name);
424        assert_eq!(
425            bq.reserved_column_name
426                .difference(BIGQUERY_PIVOT_RESERVATION),
427            ansi.reserved_column_name,
428        );
429        assert!(bq.reserved_column_name.contains(Keyword::Pivot));
430        assert!(bq.reserved_column_name.contains(Keyword::Unpivot));
431        // Confined to `ColId`: function/type/bare-label positions stay ANSI, so `pivot(1)`,
432        // `CAST(1 AS pivot)`, and `SELECT 1 pivot` keep parsing (BigQuery's non-reserved
433        // status for the words).
434        assert_eq!(bq.reserved_function_name, ansi.reserved_function_name);
435        assert_eq!(bq.reserved_type_name, ansi.reserved_type_name);
436        assert_eq!(bq.reserved_bare_alias, ansi.reserved_bare_alias);
437        assert!(!bq.reserved_function_name.contains(Keyword::Pivot));
438        assert!(!bq.reserved_type_name.contains(Keyword::Pivot));
439        assert!(!bq.reserved_bare_alias.contains(Keyword::Pivot));
440        assert_eq!(bq.reserved_as_label, KeywordSet::EMPTY);
441
442        // Everything else is inherited verbatim from ANSI — including SELECT (pipe_syntax is
443        // deferred, so it stays off) and numeric/parameter surfaces.
444        assert_eq!(bq.select_syntax, ansi.select_syntax);
445        assert!(!bq.query_tail_syntax.pipe_syntax);
446        assert_eq!(bq.numeric_literals, ansi.numeric_literals);
447        assert_eq!(bq.parameters, ansi.parameters);
448        // BigQuery adds the `STRUCT(...)` value constructor; every other expression knob
449        // is inherited verbatim from ANSI.
450        assert_eq!(bq.expression_syntax, ExpressionSyntax::BIGQUERY);
451        assert!(bq.expression_syntax.struct_constructor);
452        assert!(!ansi.expression_syntax.struct_constructor);
453        assert_eq!(
454            ExpressionSyntax {
455                struct_constructor: false,
456                ..bq.expression_syntax
457            },
458            ansi.expression_syntax
459        );
460        assert_eq!(bq.session_variables, ansi.session_variables);
461        assert_eq!(bq.identifier_syntax, ansi.identifier_syntax);
462        assert_eq!(bq.operator_syntax, ansi.operator_syntax);
463        assert_eq!(bq.call_syntax, ansi.call_syntax);
464        assert_eq!(bq.predicate_syntax, ansi.predicate_syntax);
465        assert_eq!(bq.mutation_syntax, ansi.mutation_syntax);
466        assert_eq!(bq.statement_ddl_gates, ansi.statement_ddl_gates);
467        assert_eq!(
468            bq.create_table_clause_syntax,
469            ansi.create_table_clause_syntax
470        );
471        assert_eq!(bq.column_definition_syntax, ansi.column_definition_syntax);
472        assert_eq!(bq.constraint_syntax, ansi.constraint_syntax);
473        assert_eq!(bq.index_alter_syntax, ansi.index_alter_syntax);
474        assert_eq!(bq.existence_guards, ansi.existence_guards);
475        assert_eq!(bq.utility_syntax, ansi.utility_syntax);
476        assert!(bq.type_name_syntax.angle_bracket_types);
477        assert!(!ansi.type_name_syntax.angle_bracket_types);
478        assert_eq!(
479            TypeNameSyntax {
480                angle_bracket_types: false,
481                ..bq.type_name_syntax
482            },
483            ansi.type_name_syntax,
484        );
485        assert_eq!(bq.byte_classes, ansi.byte_classes);
486        assert_eq!(bq.binding_powers, ansi.binding_powers);
487        assert_eq!(bq.target_spelling, ansi.target_spelling);
488        assert_eq!(bq.default_null_ordering, ansi.default_null_ordering);
489    }
490
491    #[test]
492    fn bigquery_enables_exactly_the_unnest_gates_and_double_quote_string() {
493        // The capstone: the first-class UNNEST factor, its BigQuery WITH OFFSET tail, and
494        // double-quoted strings are on, and each is off in the ANSI base it derives from.
495        // Forcing the flags back off recovers the ANSI sub-presets verbatim.
496        let ansi = FeatureSet::ANSI;
497        let bq = FeatureSet::BIGQUERY;
498
499        assert!(bq.table_factor_syntax.unnest && !ansi.table_factor_syntax.unnest);
500        assert!(
501            bq.table_factor_syntax.unnest_with_offset
502                && !ansi.table_factor_syntax.unnest_with_offset
503        );
504        assert!(
505            bq.string_literals.double_quoted_strings && !ansi.string_literals.double_quoted_strings
506        );
507        // The backtick opener is the lexical gate (an identifier-quote delta, not a bool).
508        assert!(bq.identifier_quotes.iter().any(|quote| quote.open() == '`'));
509        assert!(
510            !ansi
511                .identifier_quotes
512                .iter()
513                .any(|quote| quote.open() == '`')
514        );
515
516        assert_eq!(
517            TableFactorSyntax {
518                unnest: false,
519                unnest_with_offset: false,
520                // BigQuery adds the standard PIVOT gate over the ANSI baseline.
521                pivot_value_sources: false,
522                ..bq.table_factor_syntax
523            },
524            ansi.table_factor_syntax,
525        );
526        assert_eq!(
527            StringLiteralSyntax {
528                double_quoted_strings: false,
529                ..bq.string_literals
530            },
531            ansi.string_literals,
532        );
533    }
534
535    #[test]
536    fn bigquery_is_lexically_consistent_and_dependency_clean() {
537        // Both self-consistency registries must be clean: the backtick quote has a single
538        // claimant, `"` is handed to the string scanner (dropped from the quote set), and — the
539        // fact this preset is the first to prove positively — the `unnest_with_offset` tail
540        // rides the enabled `unnest` base, so the feature-dependency registry is satisfied.
541        let bq = FeatureSet::BIGQUERY;
542        assert_eq!(bq.lexical_conflict(), None);
543        assert!(bq.is_lexically_consistent());
544        assert_eq!(bq.feature_dependencies(), None);
545        assert!(bq.has_satisfied_feature_dependencies());
546        assert_eq!(bq.grammar_conflict(), None);
547        assert!(bq.has_no_grammar_conflict());
548        // Guard the dependency direction explicitly: dropping the `unnest` base while keeping
549        // the `WITH OFFSET` tail must trip the registry — this preset is the first whose flip
550        // exercises `UnnestWithOffsetWithoutUnnest`.
551        let broken = FeatureSet::BIGQUERY.with(
552            super::super::FeatureDelta::EMPTY.table_factor_syntax(TableFactorSyntax {
553                unnest: false,
554                ..bq.table_factor_syntax
555            }),
556        );
557        assert_eq!(
558            broken.feature_dependencies(),
559            Some(super::super::FeatureDependencyViolation::UnnestWithOffsetWithoutUnnest),
560        );
561    }
562    #[test]
563    fn bigquery_closed_delta_axes_match_documented_set() {
564        crate::dialect::closed_delta::assert_closed_delta(
565            &FeatureSet::ANSI,
566            &FeatureSet::BIGQUERY,
567            &[
568                "identifier_casing",
569                "identifier_quotes",
570                "reserved_column_name",
571                "string_literals",
572                "table_expressions",
573                "table_factor_syntax",
574                "expression_syntax",
575                "type_name_syntax",
576            ],
577        );
578    }
579}