sql-dialect-fmt-parser 1.18.0

Error-resilient, event-based recursive-descent parser building a lossless rowan CST for Snowflake SQL.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Comprehensive Databricks/Spark SQL **parser** invariant matrix + cross-dialect guards.
//!
//! The `CASES` array mirrors the formatter matrix (`sql-dialect-fmt-formatter/tests/databricks_matrix.rs`)
//! and asserts, under the Databricks dialect, that every case (1) parses with no diagnostics and
//! (2) round-trips byte-for-byte. A second block asserts the *structure* (specific `SyntaxKind`
//! nodes are produced for the Databricks-specific constructs). A third block is the cross-dialect
//! guard set: Snowflake-only syntax must NOT be silently accepted under Databricks where the two
//! dialects must differ, and Databricks-only syntax must NOT parse clean under Snowflake.

use sql_dialect_fmt_lexer::tokenize_for_dialect;
use sql_dialect_fmt_parser::{parse, parse_with_dialect, Dialect, SyntaxKind};

/// The shared corpus: every entry must parse clean and round-trip losslessly under Databricks.
const CASES: &[&str] = &[
    // ---- shared SQL ----
    "SELECT a, b FROM t",
    "SELECT a, b FROM t WHERE a > 1",
    "SELECT DISTINCT a FROM t",
    "SELECT count(DISTINCT a) FROM t",
    "SELECT a FROM t ORDER BY a DESC NULLS LAST",
    "SELECT a FROM t LIMIT 10 OFFSET 5",
    "SELECT a FROM t JOIN u ON t.id = u.id",
    "SELECT a FROM t LEFT JOIN u ON t.id = u.id",
    "SELECT a FROM t LEFT OUTER JOIN u ON t.id = u.id",
    "SELECT a FROM t FULL OUTER JOIN u ON t.id = u.id",
    "SELECT a FROM t CROSS JOIN u",
    "WITH c AS (SELECT 1 AS n) SELECT n FROM c",
    "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM a, b",
    "SELECT sum(x) OVER (PARTITION BY a ORDER BY b) FROM t",
    "SELECT a FROM t QUALIFY row_number() OVER (ORDER BY b) = 1",
    "SELECT a FROM t QUALIFY rank() OVER (PARTITION BY a ORDER BY b) = 1",
    "SELECT a, count(*) FROM t GROUP BY a",
    "SELECT a, count(*) FROM t GROUP BY cube (a, b)",
    "SELECT a, count(*) FROM t GROUP BY rollup (a, b)",
    "SELECT a, count(*) FROM t GROUP BY grouping sets ((a), (b))",
    "SELECT a FROM t GROUP BY a HAVING count(*) > 1",
    "SELECT a FROM t UNION SELECT a FROM u",
    "SELECT a FROM t UNION ALL SELECT a FROM u",
    "SELECT a FROM t INTERSECT SELECT a FROM u",
    "SELECT a FROM t EXCEPT SELECT a FROM u",
    "SELECT CASE WHEN a > 0 THEN 1 ELSE 0 END FROM t",
    "SELECT CAST(a AS int) FROM t",
    "SELECT CAST(a AS decimal(10, 2)) FROM t",
    "SELECT a FROM t WHERE a IN (SELECT b FROM u)",
    "SELECT a <=> b FROM t",
    "SELECT r'raw\\n', x'0A0B' FROM t",
    "SELECT a FROM (SELECT a FROM u) sub",
    "INSERT INTO t (a, b) VALUES (1, 2)",
    "UPDATE t SET a = 1 WHERE b = 2",
    "DELETE FROM t WHERE a = 1",
    "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.x = s.x",
    "SELECT * FROM t PIVOT (sum(x) FOR k IN ('a', 'b'))",
    "SELECT * FROM t TABLESAMPLE (10 percent)",
    "SELECT * FROM t DISTRIBUTE BY bucket_id SORT BY event_ts DESC",
    "SELECT * FROM t CLUSTER BY bucket_id, event_ts",
    // ---- databricks: backtick identifiers ----
    "SELECT `a` FROM t",
    "SELECT `a b` FROM t",
    "SELECT `it``s` FROM t",
    "SELECT c AS `my col` FROM t",
    "SELECT `weird``col` AS `out``put` FROM `db`.`tbl`",
    // ---- databricks: Unity Catalog 3-level names ----
    "SELECT * FROM cat.sch.tbl",
    "SELECT * FROM `catalog`.`schema`.`table`",
    "SELECT a FROM main.default.events e JOIN main.default.users u ON e.uid = u.id",
    // ---- databricks: CREATE TABLE USING + options ----
    "CREATE TABLE t (id BIGINT) USING DELTA",
    "CREATE TABLE t (id BIGINT) USING parquet LOCATION '/mnt/x'",
    "CREATE TABLE t (id BIGINT) USING DELTA PARTITIONED BY (id)",
    "CREATE TABLE t (id BIGINT) USING DELTA CLUSTER BY (id)",
    "CREATE TABLE t (id BIGINT) USING DELTA TBLPROPERTIES ('k' = 'v')",
    "CREATE TABLE events (id BIGINT, payload STRING) USING DELTA LOCATION '/mnt/events' TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true')",
    "CREATE OR REPLACE TABLE t (id int) USING DELTA",
    // ---- databricks: LATERAL VIEW ----
    "SELECT * FROM events LATERAL VIEW explode(items) t AS item",
    "SELECT * FROM events LATERAL VIEW OUTER explode(items) t AS item",
    "SELECT * FROM events LATERAL VIEW posexplode(items) t AS pos, item",
    // ---- databricks: time travel ----
    "SELECT * FROM t VERSION AS OF 5",
    "SELECT * FROM t TIMESTAMP AS OF '2024-01-01'",
    "SELECT * FROM a JOIN b VERSION AS OF 3 ON a.id = b.id",
    "SELECT * FROM t1 JOIN t2 TIMESTAMP AS OF '2024-01-01' ON t1.id = t2.id",
    // ---- databricks: higher-order lambdas ----
    "SELECT transform(xs, x -> x + 1) FROM t",
    "SELECT filter(xs, x -> x > 0) FROM t",
    "SELECT aggregate(xs, 0, (acc, x) -> acc + x) FROM t",
    "SELECT reduce(xs, 0, (acc, x) -> acc + x, acc -> acc) FROM t",
    "SELECT zip_with(a, b, (x, y) -> x + y) FROM t",
    "SELECT transform(xs, x -> transform(x, y -> y + 1)) FROM t",
    "SELECT transform(filter(xs, x -> x > 0), y -> y * 2) FROM t",
    // ---- databricks: operators ----
    "SELECT a || b FROM t",
    "SELECT a::int FROM t",
    // ---- databricks: Snowflake-only words become plain identifiers ----
    "SELECT task, flatten, warehouse FROM t",
    "SELECT a FROM task",
    "SELECT cursor, resultset, undrop FROM t",
    // ---- databricks: Delta maintenance + cache statements (full matrix in databricks_delta.rs) ----
    "VACUUM t RETAIN 168 HOURS DRY RUN",
    "OPTIMIZE t WHERE a > 1 ZORDER BY (a, b)",
    "INSERT OVERWRITE TABLE t PARTITION (dt = '2024-01-01') SELECT a, b FROM s",
    "CACHE TABLE t AS SELECT * FROM s",
    "UNCACHE TABLE IF EXISTS t",
    "REFRESH TABLE t",
    "DESCRIBE HISTORY t",
    "RESTORE TABLE t TO VERSION AS OF 5",
    "ANALYZE TABLE t COMPUTE STATISTICS",
    "MSCK REPAIR TABLE t",
    "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED BY SOURCE THEN DELETE",
    "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT *",
];

fn parse_databricks_clean(sql: &str) {
    let parsed = parse_with_dialect(sql, Dialect::Databricks);
    assert_eq!(
        parsed.syntax().to_string(),
        sql,
        "Databricks parse tree must round-trip for {sql:?}"
    );
    assert!(
        parsed.errors().is_empty(),
        "unexpected Databricks parse errors for {sql:?}: {:?}",
        parsed.errors()
    );
}

fn has_node(sql: &str, kind: SyntaxKind) -> bool {
    parse_with_dialect(sql, Dialect::Databricks)
        .syntax()
        .descendants()
        .any(|node| node.kind() == kind)
}

#[test]
fn all_cases_parse_clean_and_round_trip() {
    for sql in CASES {
        parse_databricks_clean(sql);
    }
}

// ---- structural assertions ----

#[test]
fn lateral_view_is_structured() {
    assert!(has_node(
        "SELECT * FROM events LATERAL VIEW explode(items) t AS item",
        SyntaxKind::LATERAL_VIEW
    ));
    assert!(has_node(
        "SELECT * FROM events LATERAL VIEW OUTER posexplode(items) t AS pos, item",
        SyntaxKind::LATERAL_VIEW
    ));
}

#[test]
fn time_travel_is_structured() {
    assert!(has_node(
        "SELECT * FROM t VERSION AS OF 5",
        SyntaxKind::AS_OF_TRAVEL
    ));
    assert!(has_node(
        "SELECT * FROM t TIMESTAMP AS OF '2024-01-01'",
        SyntaxKind::AS_OF_TRAVEL
    ));
}

#[test]
fn lambdas_are_structured() {
    assert!(has_node(
        "SELECT transform(xs, x -> x + 1) FROM t",
        SyntaxKind::LAMBDA_EXPR
    ));
    assert!(has_node(
        "SELECT zip_with(a, b, (x, y) -> x + y) FROM t",
        SyntaxKind::LAMBDA_EXPR
    ));
    assert!(has_node(
        "SELECT zip_with(a, b, (x, y) -> x + y) FROM t",
        SyntaxKind::LAMBDA_PARAMS
    ));
}

#[test]
fn qualify_is_structured_under_databricks() {
    // The regression guard for the QUALIFY fix: under Databricks the clause must produce a
    // QUALIFY_CLAUSE node, not a bare identifier select item.
    assert!(has_node(
        "SELECT a FROM t QUALIFY row_number() OVER (ORDER BY b) = 1",
        SyntaxKind::QUALIFY_CLAUSE
    ));
}

#[test]
fn query_distribution_clauses_are_structured_under_databricks() {
    assert!(has_node(
        "SELECT * FROM t DISTRIBUTE BY bucket_id SORT BY event_ts DESC",
        SyntaxKind::DISTRIBUTE_BY_CLAUSE
    ));
    assert!(has_node(
        "SELECT * FROM t DISTRIBUTE BY bucket_id SORT BY event_ts DESC",
        SyntaxKind::SORT_BY_CLAUSE
    ));
    assert!(has_node(
        "SELECT * FROM t CLUSTER BY bucket_id, event_ts",
        SyntaxKind::CLUSTER_BY_CLAUSE
    ));
}

#[test]
fn delta_table_options_are_structured() {
    let sql = "CREATE TABLE events (id BIGINT) USING DELTA LOCATION '/mnt/events' TBLPROPERTIES ('delta.enableChangeDataFeed' = 'true')";
    assert!(has_node(sql, SyntaxKind::CREATE_STMT));
    assert!(has_node(sql, SyntaxKind::OBJECT_PROPERTY));
}

// ---- cross-dialect guards ----

#[test]
fn backtick_identifiers_are_databricks_only() {
    let sql = "SELECT `a b` FROM `catalog`.`schema`.`table`";
    // Clean under Databricks.
    parse_databricks_clean(sql);
    let databricks = tokenize_for_dialect(sql, Dialect::Databricks);
    assert!(databricks.errors.is_empty());
    assert!(databricks
        .tokens
        .iter()
        .any(|t| t.kind == SyntaxKind::QUOTED_IDENT && t.text == "`a b`"));
    // Snowflake must reject the backtick at the lexer.
    let snowflake = tokenize_for_dialect(sql, Dialect::Snowflake);
    assert!(
        !snowflake.errors.is_empty(),
        "Snowflake mode must reject backtick-quoted identifiers"
    );
}

#[test]
fn snowflake_dollar_constructs_are_not_databricks() {
    // `$1` positional references and `$$ ... $$` dollar-quoted bodies are Snowflake-only. Under
    // Databricks the lexer has no dollar-quoting/positional-ref rule, so the `$` falls out as a
    // bare `DOLLAR` token rather than a single `VARIABLE` / `DOLLAR_STRING` — and the parser then
    // reports errors. Snowflake accepts both cleanly. (Lexing itself does not error in either
    // dialect; the divergence is in tokenization and in the parse result.)
    for (sql, snow_kind) in [
        ("SELECT $1 FROM t", SyntaxKind::VARIABLE),
        ("SELECT $$body$$ FROM t", SyntaxKind::DOLLAR_STRING),
    ] {
        let snowflake = tokenize_for_dialect(sql, Dialect::Snowflake);
        assert!(
            snowflake.tokens.iter().any(|t| t.kind == snow_kind),
            "Snowflake should tokenize {sql:?} as a single {snow_kind:?}"
        );
        assert!(parse_with_dialect(sql, Dialect::Snowflake)
            .errors()
            .is_empty());

        let databricks = tokenize_for_dialect(sql, Dialect::Databricks);
        assert!(
            databricks.tokens.iter().all(|t| t.kind != snow_kind),
            "Databricks must NOT produce a {snow_kind:?} token for {sql:?}"
        );
        assert!(
            databricks
                .tokens
                .iter()
                .any(|t| t.kind == SyntaxKind::DOLLAR),
            "Databricks should leave a bare DOLLAR token for {sql:?}"
        );
        assert!(
            !parse_with_dialect(sql, Dialect::Databricks)
                .errors()
                .is_empty(),
            "Databricks must not parse Snowflake dollar construct {sql:?} cleanly"
        );
        // Lossless either way.
        assert_eq!(
            parse_with_dialect(sql, Dialect::Databricks)
                .syntax()
                .to_string(),
            sql
        );
    }
}

#[test]
fn snowflake_stage_refs_are_not_databricks() {
    // `@stage` path refs are Snowflake-only: Snowflake lexes the `@` as an `AT` token and parses
    // clean, while Databricks has no stage-ref rule, so the `@` lex-errors.
    let sql = "SELECT * FROM @stage";
    let snowflake = tokenize_for_dialect(sql, Dialect::Snowflake);
    assert!(
        snowflake.errors.is_empty(),
        "Snowflake should lex @stage clean"
    );
    assert!(snowflake.tokens.iter().any(|t| t.kind == SyntaxKind::AT));

    let databricks = tokenize_for_dialect(sql, Dialect::Databricks);
    assert!(
        !databricks.errors.is_empty(),
        "Databricks must reject the @stage reference at the lexer"
    );
}

#[test]
fn snowflake_only_words_are_identifiers_under_databricks_but_reserved_in_snowflake() {
    // `TASK` / `FLATTEN` are reserved in Snowflake (so a bare select item is not clean) but plain
    // identifiers in Databricks (so it parses clean).
    let sql = "SELECT task, flatten FROM t";
    let databricks = parse_with_dialect(sql, Dialect::Databricks);
    assert!(
        databricks.errors().is_empty(),
        "Databricks should treat task/flatten as identifiers: {:?}",
        databricks.errors()
    );
    assert_eq!(databricks.syntax().to_string(), sql);

    let snowflake = parse_with_dialect(sql, Dialect::Snowflake);
    assert!(
        !snowflake.errors().is_empty(),
        "Snowflake must keep task/flatten reserved"
    );
    // Never-fail / lossless still holds in the dialect that errors.
    assert_eq!(snowflake.syntax().to_string(), sql);
}

#[test]
fn databricks_lambdas_do_not_parse_clean_under_snowflake() {
    let parsed = parse_with_dialect(
        "SELECT transform(items, x -> x + 1) FROM events",
        Dialect::Snowflake,
    );
    assert!(
        !parsed.errors().is_empty(),
        "Snowflake must not parse Databricks lambda arrows cleanly"
    );
    // Lossless even when it errors.
    assert_eq!(
        parsed.syntax().to_string(),
        "SELECT transform(items, x -> x + 1) FROM events"
    );
}

#[test]
fn qualify_stays_reserved_in_both_dialects() {
    // Regression guard: making QUALIFY shared must not change Snowflake. `parse` (default) and an
    // explicit Snowflake parse remain byte-identical, and Databricks now agrees.
    let sql = "SELECT a FROM t QUALIFY row_number() OVER (ORDER BY a) = 1";
    let default = parse(sql);
    let snowflake = parse_with_dialect(sql, Dialect::Snowflake);
    let databricks = parse_with_dialect(sql, Dialect::Databricks);
    assert!(default.errors().is_empty());
    assert!(snowflake.errors().is_empty());
    assert!(databricks.errors().is_empty());
    assert_eq!(default.syntax().to_string(), snowflake.syntax().to_string());
}

#[test]
fn never_fails_on_databricks_gap_constructs() {
    // Constructs the grammar does not yet model (gap report) must still never panic and must
    // round-trip losslessly under BOTH dialects, even though they produce diagnostics.
    for sql in [
        // Higher-order `exists(array, lambda)` is not modeled as a generator (the bare `exists`
        // predicate `EXISTS (subquery)` is); the lambda arrow still round-trips verbatim.
        "SELECT exists(xs, x -> x > 0) FROM t",
    ] {
        for dialect in [Dialect::Snowflake, Dialect::Databricks] {
            let parsed = parse_with_dialect(sql, dialect);
            assert_eq!(
                parsed.syntax().to_string(),
                sql,
                "lossless round-trip must hold for gap construct {sql:?} @ {dialect:?}"
            );
        }
    }
}

#[test]
fn delta_commands_now_parse_clean_under_databricks_and_round_trip_under_snowflake() {
    // Statements that were previously gap constructs are now first-class under Databricks: they
    // parse clean there, while remaining lossless-but-unrecognized under Snowflake (the leading
    // words stay plain identifiers). `VACUUM t` in particular no longer mis-splits under Databricks.
    for sql in [
        "OPTIMIZE t ZORDER BY (a)",
        "VACUUM t",
        "VACUUM t RETAIN 0 HOURS DRY RUN",
        "CACHE TABLE t",
        "REFRESH TABLE t",
        "INSERT OVERWRITE TABLE t SELECT * FROM s",
        "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED BY SOURCE THEN DELETE",
        "DESCRIBE HISTORY t",
        "RESTORE TABLE t TO VERSION AS OF 5",
        "ANALYZE TABLE t COMPUTE STATISTICS",
        "MSCK REPAIR TABLE t",
    ] {
        let databricks = parse_with_dialect(sql, Dialect::Databricks);
        assert!(
            databricks.errors().is_empty(),
            "Databricks must now parse {sql:?} clean: {:?}",
            databricks.errors()
        );
        assert_eq!(databricks.syntax().to_string(), sql);

        let snowflake = parse_with_dialect(sql, Dialect::Snowflake);
        assert_eq!(
            snowflake.syntax().to_string(),
            sql,
            "Snowflake must round-trip losslessly for {sql:?}"
        );
    }
}