Repository navigation
Expand file tree
/
Copy pathgrammar.test.ts
More file actions
856 lines (792 loc) · 43.5 KB
/
Copy pathgrammar.test.ts
File metadata and controls
856 lines (792 loc) · 43.5 KB
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
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
import { describe, test, expect } from "bun:test";
import {
type BlockCommentGrammar,
type BracketGrammar,
DEFAULT_SQL_GRAMMAR,
hashRunIsAmbiguous,
readsSqlText,
resolveSqlGrammar,
} from "@/lib/sql/grammar";
import type { DatabaseType } from "@/lib/types";
/**
* The one place a database type becomes a set of grammar facts (#292).
*
* This test pins the TABLE, not the implementation: every row below was
* established from an authoritative source, and a row that was NOT established
* has to stay at the compatibility default rather than be guessed from a
* neighbouring dialect. Both halves are asserted, because "which dialects were
* left undecided" is part of the answer this milestone owes its readers.
*/
describe("resolveSqlGrammar", () => {
// ── `#`: established readings ────────────────────────────────────────────
//
// - MySQL/MariaDB: `#` opens a line comment. The repo already skipped such a run
// in a statement's LEADING trivia before this record existed - and still does,
// on every dialect, because none of them can open a statement with one - and it
// pins that in the MySQL provider suite (`# note\nSELECT …` is bounded), which
// is what #275 closed.
// - ClickHouse: `#` and `#!` open a line comment (ClickHouse syntax reference).
// - PostgreSQL: the only comment forms are `--` and `/* */`; `#` is an operator
// character (`#>`, `#-`, `##`, integer XOR).
// - Oracle: node-oracledb's own SQL tokenizer (`lib/thin/statement.js`) accepts
// `#` as an identifier character and opens comments on `--` and `/*` only.
// - SQLite: the bundled SQLite amalgamation classifies `#` as `CC_VARALPHA`
// (a bind-variable prefix, i.e. code) and opens comments on `--` and `/*`.
test.each<[DatabaseType, "comment" | "code"]>([
["mysql", "comment"],
["clickhouse", "comment"],
["postgres", "code"],
["oracle", "code"],
["mssql", "code"],
["sqlite", "code"],
["libsql", "code"],
// Trino, probed 2026-08-20 on 476: `#` opens nothing in either position.
// `SELECT 1 AS a # trailing` is "line 1:15: mismatched input '#'" and
// `SELECT # x` is "line 1:8: mismatched input '#'", so the rest of the line is
// not hidden - which is the only thing the readers in this folder ask. Leaving it
// at the compatibility default would ALSO make every `#` run ambiguous, and since
// #297 an unreadable span is a confirmation PROMPT - on a statement the engine
// simply refuses.
["trino", "code"],
// Cassandra, probed 2026-08-20 on 5.0.9 through the native protocol, in three
// positions: `… WHERE id = 1 # trailing` is "line 1:44 no viable alternative at
// character '#'", `SELECT # x\n id …` is the same at 1:7, and even `SELECT id#a`
// is refused - so `#` is not a comment marker, not an operator and not an
// identifier character. It hides nothing, which is the only thing these readers
// ask, and leaving it at the compatibility default would make every `#` run
// ambiguous and prompt on a statement CQL refuses outright.
["cassandra", "code"],
// DuckDB, probed 2026-08-27 on v1.5.5: `SELECT 1 # x` is `Parser Error: syntax
// error at or near "#"`, so the rest of the line is not hidden. NOT read off the
// SQLite row it sits beside in this file - each of DuckDB's five facts was taken
// against the engine.
["duckdb", "code"],
])("%s reads `#` as %s", (type, hash) => {
expect(resolveSqlGrammar(type).hash).toBe(hash);
});
// ── Dialects deliberately left undecided ────────────────────────────────
//
// No authoritative offline source was established for these, and guessing a
// dialect's rule from a neighbouring one is exactly what this milestone forbids.
// They keep today's reading, which costs them nothing they had.
test.each<DatabaseType>(["libredb", "mongodb", "redis"])("%s is left at the compatibility default", (type) => {
expect(resolveSqlGrammar(type)).toBe(DEFAULT_SQL_GRAMMAR);
});
test("a call that names no dialect gets the compatibility default", () => {
expect(resolveSqlGrammar(undefined)).toBe(DEFAULT_SQL_GRAMMAR);
});
/**
* A string that is not a database type at all gets the default too, prototype
* keys included. The packaged confirmation dialog resolves its own grammar from an
* optional prop a HOST application fills in as a plain string (#297), so this
* lookup is reached with text nothing in this repo validated; a bare index would
* answer `Object.prototype.constructor` for one of these - not a grammar.
*/
test.each(["constructor", "toString", "hasOwnProperty", "not-a-database"])(
"resolves %p to the compatibility default rather than a prototype value",
(key) => {
expect(resolveSqlGrammar(key as DatabaseType)).toBe(DEFAULT_SQL_GRAMMAR);
},
);
test("the compatibility default is today's reading, not one of the honest two", () => {
expect(DEFAULT_SQL_GRAMMAR.hash).toBe("comment-unless-operator");
});
// ── `q'…'`: Oracle's alternate quoting ──────────────────────────────────
//
// Unlike `#`, this fact is not two engines disagreeing about a character: only
// Oracle has the form at all, so every other dialect's reading of that text is
// "a name followed by an ordinary string". Established from node-oracledb's own
// SQL tokenizer (`lib/thin/statement.js`, `_parseQstring`), which parses a `'`
// preceded by `q`/`Q` as a q-string, and from Oracle's SQL Language Reference
// for the delimiter pairing and the `nq'…'` spelling.
test("oracle is the only dialect that reads `q'…'` as a literal", () => {
expect(resolveSqlGrammar("oracle").alternateQuoting).toBe(true);
});
test.each<DatabaseType>([
"mysql",
"clickhouse",
"postgres",
"mssql",
"sqlite",
"libsql",
"trino",
"cassandra",
// `SELECT q'[x]' AS a` on v1.5.5 is `Catalog Error: Type with name q does not
// exist!` - the `q` was read as a type name, so the form is not in the grammar.
"duckdb",
])("%s does not read `q'…'` as a literal", (type) => {
expect(resolveSqlGrammar(type).alternateQuoting).toBe(false);
});
test("a call that names no dialect does not read the form either", () => {
// The compatibility default: before the channel existed no reader here had a
// branch for the form, so keeping it out is what keeps those answers still.
expect(DEFAULT_SQL_GRAMMAR.alternateQuoting).toBe(false);
});
// ── `[…]`: two readings that cannot both apply (#295) ────────────────────
//
// - SQL Server: `[name]` is a delimited identifier and a `]` inside it is
// written doubled. This repo's own quoter emits exactly that
// (`src/lib/sql/identifier.ts`), and the MSSQL provider's
// `escapeIdentifier` is pinned on it.
// - SQLite: the SQLite amalgamation bundled with `better-sqlite3` classifies
// `[` as `CC_QUOTE2` - "`[...]` style quoted ids", the Microsoft-style form -
// so it is a name there too.
// - ClickHouse: `[…]` is an array literal or a subscript, it NESTS, and it has
// no doubling escape; identifiers there are quoted with backticks or double
// quotes (`identifier.ts` falls back to the standard form for it). So the two
// readings are mutually exclusive rather than two spellings of one rule.
// - PostgreSQL: `expression[subscript]` and `expression[lower:upper]` are how it
// reads an element and a slice (manual 4.2.3 Subscripts), and array
// constructors nest - the manual's own example is `SELECT ARRAY[[1,2],[3,4]]`
// (4.2.12 Array Constructors). Identifiers there are quoted with double quotes
// (4.1.1), so `[` is never a name quote and the subscript reading is the only
// one the dialect has.
test.each<[DatabaseType, BracketGrammar]>([
["mssql", "quoted-identifier"],
["sqlite", "quoted-identifier"],
["libsql", "quoted-identifier"],
["clickhouse", "subscript"],
["postgres", "subscript"],
// Trino, probed on 476, and BOTH halves of the rule were measured rather than one
// inferred from the other. It subscripts: `SELECT ARRAY[1,2][1]` answers 1. It
// NESTS: `SELECT ARRAY[ARRAY[1,2],ARRAY[3,4]][1][2]` answers 2. And it is not a
// name quote: `SELECT [customer] FROM tpch.sf1.nation` fails with "Column
// 'customer' cannot be resolved" - the brackets were read THROUGH to an
// expression, which the identifier reading could never do.
["trino", "subscript"],
// Cassandra, probed on 5.0.9, and both halves measured. `[…]` is a TERM the
// parser reads through, not a name quote: `SELECT [id] FROM probe.customers`
// answers a column literally named `[id]` whose type is `list` and whose value is
// `[1]` - a one-element list built from the column - while `SELECT [1, 2] …` is
// refused with "Cannot infer type for term [1, 2] in selection clause", which is
// a TYPING complaint about a term the parser had already read. It nests:
// `SELECT [[id]]` answers `[[1]]`. And a `]` inside a string inside the brackets
// does not end the run - `SELECT ['a]b']` reaches the same type-inference
// complaint - which the identifier reading, stopping at the first `]`, could not
// do. CQL also subscripts a collection for real (`m['k']`).
["cassandra", "subscript"],
// DuckDB, probed on v1.5.5: `SELECT [1,2][1]` answers 1 - a LIST literal followed
// by a 1-based index. This is the one fact where copying the SQLite row would have
// been actively wrong, and the temptation to copy it is real: both engines open a
// local file through an in-process driver. The identifier reading would take an
// everyday DuckDB list literal for a name and lose the statement's bound.
["duckdb", "subscript"],
])("%s reads `[…]` as %s", (type, bracket) => {
expect(resolveSqlGrammar(type).bracket).toBe(bracket);
});
// A dialect established for ONE character can be undecided about another, so
// the default is per fact rather than per dialect. `[` is not an identifier
// quote in MySQL or Oracle either, but neither has a SUBSCRIPT rule to read it
// under - MySQL gives the characters no meaning outside a JSON path written in a
// string, Oracle none outside an alternate-quote delimiter - so no authoritative
// reading was established for them and they keep the one they had.
test.each<DatabaseType>(["mysql", "oracle"])("%s is left at the compatibility default for `[…]`", (type) => {
expect(resolveSqlGrammar(type).bracket).toBe(DEFAULT_SQL_GRAMMAR.bracket);
});
test("the compatibility default reads `[…]` as a quoted name", () => {
// Today's reading, kept for the same reason as the `#` row: it is what the
// #291/#299 fixtures assert, and the corrupted-statement shape those closed
// (`SELECT [a LIMIT 500--b] FROM t`) is what a code reading brings back.
expect(DEFAULT_SQL_GRAMMAR.bracket).toBe("quoted-identifier");
});
// ── `/* … /* … */ … */`: where a block comment ENDS (#300) ────────────────
//
// The one grammar fact that moves the end of a construct the reader is
// otherwise happy with: a second opener written inside a comment is either part
// of the comment (so the run continues past the next `*/`) or it is nothing at
// all (so the run ends there and everything after it is the statement's code).
//
// - PostgreSQL: block comments nest, "as specified in the SQL standard but
// unlike C" (PostgreSQL manual, 4.1.5 Comments).
// - SQL Server: nested comments are supported - a `/*` anywhere inside a comment
// opens a nested one and needs its own `*/` (T-SQL "Slash Star (Block
// Comment)").
// - ClickHouse: C-style comments can be nested (ClickHouse SQL syntax
// reference, which gives a nested example).
// - MySQL/MariaDB: nested comments are NOT supported and are deprecated (MySQL
// reference manual, 11.7 Comments), so the first `*/` ends the run.
// - SQLite: the bundled amalgamation's tokenizer (`case CC_SLASH`) scans for the
// first `*/` with no depth count at all.
// - Oracle: node-oracledb's own tokenizer (`_parseMultiLineComment`) stops at the
// first `*/`, and Oracle's PL/SQL reference states one multiline comment
// cannot contain another.
test.each<[DatabaseType, BlockCommentGrammar]>([
["postgres", "nesting"],
["mssql", "nesting"],
["clickhouse", "nesting"],
["mysql", "flat"],
["sqlite", "flat"],
["libsql", "flat"],
["oracle", "flat"],
// Trino, probed on 476: `SELECT /* a /* b */ 1 AS a` returns the column, so the
// FIRST `*/` closed the run. A nesting reader would have seen an unterminated
// comment and refused to bound the statement.
["trino", "flat"],
// Cassandra, probed on 5.0.9: `SELECT /* a /* b */ id FROM probe.customers WHERE
// id = 1` returns the row, so the FIRST `*/` closed the run. A nesting reader
// would have seen an unterminated comment and refused to bound the statement.
["cassandra", "flat"],
// DuckDB, probed on v1.5.5: `/* a /* b */ still */ SELECT 1` runs, so the inner
// `*/` did NOT close the run - the opposite reading from the SQLite row above.
["duckdb", "nesting"],
])("%s closes a block comment the %s way", (type, blockComment) => {
expect(resolveSqlGrammar(type).blockComment).toBe(blockComment);
});
test("the compatibility default does not nest block comments", () => {
// Today's reading again: every fixture written for #275, #280, #287, #291 and
// #294 calls these readers without a dialect, and `indexOf("*/")` is what they
// were written against.
expect(DEFAULT_SQL_GRAMMAR.blockComment).toBe("flat");
});
// ── `//`: a THIRD line-comment form (S1 follow-up) ───────────────────────
//
// The one fact `grammar.ts` said it could not carry, and the splitter is where
// that cost was live: `//` hides the rest of the line on TWO shipped engines, so
// a `;` written inside such a comment is not a statement boundary - and
// `/api/db/multi-query` RUNS every fragment the splitter returns, so the
// dialect-blind reading manufactured a bare `DROP` out of text the server reads
// as one statement. Every row below was probed 2026-08-25 against a live engine,
// through the surface the provider itself uses, because "it is documented" is not
// the same claim as "it is what the server does".
//
// A comment: two engines, and both halves measured rather than one inferred.
// - `cassandra` (Apache Cassandra 5.0.9 and ScyllaDB 2026.2.4, which shares this
// type-id, both over the native protocol):
// `SELECT release_version FROM system.local // note; DROP KEYSPACE nope\n`
// returns the ROW - one read - and the DROP does not run (a bare
// `DROP KEYSPACE nope` answers "Keyspace 'nope' doesn't exist", so the OK is
// proof it was hidden). Without the trailing newline the same text is "line
// 1:68 mismatched character '<EOF>' expecting set null", which is the SECOND
// CQL fact - a line comment needs a newline to close it - and that one still
// has no field here (see `CASSANDRA_GRAMMAR`).
// - `clickhouse` (26.7.1, over HTTP): `SELECT 1 AS a // note; SELECT 999`
// answers `1` with no error, while `SELECT 1; DROP TABLE nope` is refused with
// "Syntax error (Multi-statements are not allowed)" - so the `;` was inside the
// comment rather than ignored. `SELECT 1 AS a // note\n, 2 AS b` answers TWO
// columns, so the run ends at the NEWLINE: it is a LINE comment, not a
// to-end-of-input one. Unlike CQL, end of input closes it (`SELECT 1 AS a //
// note` answers 1).
//
// Code: five engines plus SQLite, each refusing the characters outright, so
// nothing on that line is hidden - which is the only thing these readers ask.
// - `postgres` (18): `SELECT 1 // 2` is "operator does not exist: integer //
// integer", so `//` is an OPERATOR NAME there, and `SELECT 1 AS a // note` is
// "syntax error at or near \"//\"".
// - `mysql` (26.7.0): ERROR 1064 near '// note'.
// - `oracle` (Oracle Free 23, via sqlplus): ORA-00923 "FROM keyword not found
// where expected".
// - `mssql` (2022, via sqlcmd): Msg 102 "Incorrect syntax near '/'".
// - `trino` (476, `POST /v1/statement`): "line 1:15: mismatched input '/'".
// - `sqlite` (the bundled driver): 'near "/": syntax error'.
test.each<[DatabaseType, boolean]>([
["cassandra", true],
["clickhouse", true],
// Druid (37.0.0, `POST /druid/v2/sql`): `SELECT 4 // 2 AS a` answers 4, so the rest
// of the line is hidden, and Calcite's line comment token is `("//"|"--")…`.
["druid", true],
["postgres", false],
["mysql", false],
["oracle", false],
["mssql", false],
["trino", false],
["sqlite", false],
["libsql", false],
// DuckDB, probed on v1.5.5: `SELECT 1 AS a // note` is `Parser Error: syntax error
// at or near "//"`, so the characters are refused where they stand rather than
// hiding the rest of the line.
["duckdb", false],
])("%s reads `//` as a line comment: %s", (type, doubleSlashComment) => {
expect(resolveSqlGrammar(type).doubleSlashComment).toBe(doubleSlashComment);
});
// Neither search engine is reachable from this run, so neither row was
// established - and grammar.ts's rule 2 forbids reading one dialect's rule off
// another's behaviour, which
// here would mean copying five refusals onto a sixth grammar. They keep the
// compatibility default, and the direction that costs is stated rather than
// implied: if `//` DOES open a comment in one of them, its splitter over-splits
// exactly as `cassandra`'s did.
test.each<DatabaseType>(["elasticsearch", "opensearch"])(
"%s is left at the compatibility default for `//`",
(type) => {
expect(resolveSqlGrammar(type).doubleSlashComment).toBe(DEFAULT_SQL_GRAMMAR.doubleSlashComment);
},
);
test("the compatibility default does not read `//` as a comment", () => {
// Today's reading once more: no reader in this folder had a `//` branch before
// this fact existed, so keeping it out is what leaves every dialect-less
// fixture answering what it answered.
expect(DEFAULT_SQL_GRAMMAR.doubleSlashComment).toBe(false);
});
});
describe("hashRunIsAmbiguous", () => {
test("is true only where the reading is the undecided one", () => {
expect(hashRunIsAmbiguous(DEFAULT_SQL_GRAMMAR)).toBe(true);
expect(hashRunIsAmbiguous(resolveSqlGrammar("mysql"))).toBe(false);
expect(hashRunIsAmbiguous(resolveSqlGrammar("mssql"))).toBe(false);
});
});
/**
* Whether the query text is SQL at all - the question that comes BEFORE which SQL
* grammar to read it under.
*
* `resolveSqlGrammar` answers the second question and has an answer for every
* input, so a MongoDB document or a Redis command gets the compatibility grammar
* and is then read as SQL. That is what the readers under `src/lib/sql/` do to
* text no dialect here describes, and for the confirmation gate it produced a
* prompt on ordinary reads: the escaped quote in a Mongo filter or a Redis value
* is an unresolvable literal to a SQL span reader and a perfectly closed one in
* the grammar the text is actually written in.
*
* Same shape as the grammar table and for the same reason: ONE place maps a
* database type to the answer, so no reader grows a type test of its own.
*/
/**
* The two maps below are the real checklist for a new provider, in the shape this
* repo already uses for the other type-keyed tables (`PICKER_COVERAGE`,
* `MODIFIED_COLUMN_COVERAGE`): a `Record<DatabaseType, …>` the compiler refuses to
* accept until the new id is listed.
*
* When this table was written, neither table in `grammar.ts` could enforce this on its own - `SQL_GRAMMARS` was a
* `Partial<Record<…>>` because "absent" was a meaningful answer, and the non-SQL set
* was a set. So a provider added without touching that file silently inherited the
* compatibility grammar and was silently declared to write SQL, and nothing failed.
* Deciding is mandatory; deciding "leave it at the default" is a fine decision.
*/
const GRAMMAR_COVERAGE: Record<DatabaseType, "established" | "default"> = {
postgres: "established",
mysql: "established",
sqlite: "established",
// Re-measured over Hrana rather than inherited from the row above: all four facts
// answered the same way on sqld 0.24.33 (#, brackets, nesting, q'…'), which is why
// the registry shares SQLite's grammar object.
libsql: "established",
// Five facts probed 2026-08-27 against DuckDB v1.5.5 through `@duckdb/node-api`,
// and NOT inherited from either neighbour: the SQLite row's `[…]` reading is wrong
// here (a list literal, not a name quote) and the PostgreSQL object is not shared
// even though the five values coincide. See DUCKDB_GRAMMAR in `providers/sql/duckdb/sql-grammar.ts` for the
// statement behind each one.
duckdb: "established",
oracle: "established",
// Probed 2026-10-03 on Db2 LUW 12.1.0.0 through db2-node 1.0.22 (#786); see the db2 block above
// and DB2_GRAMMAR in `providers/sql/db2/sql-grammar.ts` for the statement behind each fact.
db2: "established",
mssql: "established",
clickhouse: "established",
// Established by live probe rather than from a document: their SQL surface is an
// HTTP endpoint, so the grammar was asked directly (2026-08-19, Elasticsearch 9.1.4
// and OpenSearch 3.8.0). They disagree about `#` (a line comment on OpenSearch, not
// in Elasticsearch's grammar at all) and about `[…]` (an identifier quote on
// OpenSearch, meaningless on Elasticsearch), which is why one provider
// implementation still needs two rows.
elasticsearch: "established",
opensearch: "established",
// Established the same way, and for the same reason: the coordinator IS the source.
// Probed 2026-08-20 against Trino 476 - see the TRINO_GRAMMAR comments in
// `grammar.ts` for the statement behind each of the four facts.
trino: "established",
// All four facts probed 2026-08-20 against Apache Cassandra 5.0.9 over the native
// protocol, before any provider code existed. See CASSANDRA_GRAMMAR in
// `grammar.ts` for the statement behind each one - and for the fifth fact this
// record cannot hold: CQL has a THIRD comment form, `//`, and a line comment there
// must be closed by a newline (at end of input it is a syntax error), which the
// provider's own `prepareQuery` handles because no field here can express it.
cassandra: "established",
// Read off the SQL++ lexer (`n1ql.nex`) and probed 2026-10-28 on Couchbase Server 8.0.2 Community over
// `POST /query/service`; see the couchbase block below and COUCHBASE_GRAMMAR in
// `providers/document/couchbase/sql-grammar.ts` for the statement behind each fact.
couchbase: "established",
// Read off Druid's Calcite parser (`Parser.jj` at druid-37.0.0) and probed 2026-10-28 on apache/druid:37.0.0 over
// `POST /druid/v2/sql`; see DRUID_GRAMMAR in `providers/sql/druid/sql-grammar.ts` and `druid-grammar.test.ts` for
// the statement behind each fact.
druid: "established",
libredb: "default",
// Not SQL at all - see SQL_TEXT_COVERAGE below.
mongodb: "default",
redis: "default",
// PromQL, not SQL (#1085): no SQL grammar is established for it, and none is read.
prometheus: "default",
// A JSON read request, not SQL (#1088): no SQL grammar is established for it, and none is read.
kafka: "default",
// An etcdctl command, not SQL (#1089): no SQL grammar is established for it, and none is read.
etcd: "default",
// Cypher, not SQL: the graph lexer reads it, so no SQL grammar is established for it, and none is read.
neo4j: "default",
// A Milvus console request, not SQL (vector-family spec 5.4): no SQL grammar is established for it, and none is read.
milvus: "default",
// A Qdrant console request, not SQL (vector-family spec 6.4): no SQL grammar is established for it, and none is read.
qdrant: "default",
weaviate: "default",
// InfluxQL, not SQL: the provider's own lexer reads it, so no SQL grammar is established for it, and none is read.
influxdb: "default",
// Five facts probed on InfluxDB 3.12.0 Core through `POST /api/v3/query_sql`, the route the provider
// sends on; see the influxdb3 block below and DATAFUSION_GRAMMAR in `influxdb3-sql-grammar.ts` for the statement
// behind each one.
influxdb3: "established",
// One `oxia client` read command, not SQL (SB2-4.3): no SQL grammar is established for it, and none is read.
oxia: "default",
// Read off Databend's own lexer (`token.rs`) and parser, and the trailing clause measured on the pinned image (L3);
// see the databend block below and DATABEND_GRAMMAR in `providers/sql/databend/sql-grammar.ts` for the source behind each fact.
databend: "established",
bigquery: "established",
// The Snowflake grammar row lands with the gate and the explain format, which flip this to established.
snowflake: "established",
spark: "established",
s3: "default",
// One kubectl statement, not SQL: no SQL grammar is established for it, and none is read.
kubernetes: "default",
mqtt: "default",
firestore: "default",
rabbitmq: "default",
// One JSON read request naming node ids, not SQL: no SQL grammar is established for it (#1677).
opcua: "default",
// One request line in a URL grammar, not SQL: no SQL grammar is established for it (#1722).
mtconnect: "default",
// One JSON read request, not SQL: no SQL grammar is established for it (#1723).
modbus: "default",
netconf: "default",
snmp: "default",
// One JSON read request, not SQL: no SQL grammar is established for it (#1720).
sparkplug: "default",
};
/**
* Db2 LUW, every fact probed 2026-10-03 on DB2/LINUXX8664 12.1.0.0 through db2-node 1.0.22, the
* driver the provider uses (#786). Each statement below answered as quoted.
*/
describe("db2", () => {
const grammar = resolveSqlGrammar("db2");
test("`#` is code: the rest of the line is not hidden", () => {
// `SELECT 1 AS a FROM SYSIBM.SYSDUMMY1 # note` and `SELECT 1 # 2 AS a FROM SYSIBM.SYSDUMMY1`
// are both SQLCODE -104, an unexpected token at the `#`.
expect(grammar.hash).toBe("code");
});
test("block comments nest", () => {
// `SELECT 1 AS a /* a /* b */ FROM SYSIBM.SYSDUMMY1 */ FROM SYSIBM.SYSDUMMY1` answers the
// row, and `SELECT 1 AS a /* a /* b */ FROM SYSIBM.SYSDUMMY1` is SQLCODE -104: the inner `*/`
// did not close the run.
expect(grammar.blockComment).toBe("nesting");
});
test("`q'…'` and `//` are not in the grammar", () => {
// `SELECT q'[x]' AS a FROM SYSIBM.SYSDUMMY1` and `SELECT 1 AS a FROM SYSIBM.SYSDUMMY1 // note`
// are both SQLCODE -104.
expect(grammar.alternateQuoting).toBe(false);
expect(grammar.doubleSlashComment).toBe(false);
});
test("`[…]` is left at the compatibility default", () => {
// `SELECT 1 AS [a] FROM SYSIBM.SYSDUMMY1` is SQLCODE -104, so it is no name quote, but SQL PL
// writes an array element as `a[1]` and no subscript reading was established for plain SQL.
expect(grammar.bracket).toBe(DEFAULT_SQL_GRAMMAR.bracket);
});
});
/**
* Apache DataFusion as InfluxDB 3.12.0 Core parses it, every fact probed through
* `POST /api/v3/query_sql` with `{"db":"home","q":<text>,"format":"jsonl"}`, the body the provider
* sends, and re-run 2026-10-04. Each statement below answered as quoted.
*/
describe("influxdb3", () => {
const grammar = resolveSqlGrammar("influxdb3");
test("`#` is code: the rest of the line is not hidden", () => {
// `SELECT 1 AS x # c` is HTTP 400, `ParserError("Expected: end of statement, found: # at Line: 1,
// Column: 15")`.
expect(grammar.hash).toBe("code");
});
test("`[…]` is a subscript and an array literal, never a name quote", () => {
// `SELECT [1,2][1] AS x` answers `{"x":1}`; `SELECT [[1,2],[3,4]][2][1] AS x` answers `{"x":3}`,
// so the run nests; `SELECT ['a]b'][1] AS x` answers `{"x":"a]b"}`, so a literal inside it is a
// literal; `SELECT [room] FROM home` answers an array column named `make_array(home.room)`, so
// the brackets were read through to an expression.
expect(grammar.bracket).toBe("subscript");
});
test("block comments nest", () => {
// `SELECT 1 /* a /* b */ c */ AS x` answers `{"x":1}`: the inner `*/` did not close the run.
expect(grammar.blockComment).toBe("nesting");
});
test("`q'…'` is not in the grammar", () => {
// `SELECT q'[x]' AS x` is HTTP 400, `ParserError("Expected: end of statement, found: AS at Line: 1,
// Column: 15")`.
expect(grammar.alternateQuoting).toBe(false);
});
test("`//` is not a comment", () => {
// `SELECT 1 AS x // c` is HTTP 400, `ParserError("Expected: end of statement, found: // at Line: 1,
// Column: 15")`.
expect(grammar.doubleSlashComment).toBe(false);
});
});
/**
* Databend, every fact read off its own lexer (`src/query/ast/src/parser/token.rs`) and parser, with the trailing
* clause measured on the pinned image (probe L3): there is no driver package to ask, and the lexer is the source the
* server itself runs.
*/
describe("databend", () => {
const grammar = resolveSqlGrammar("databend");
test("is a row of its own, not the compatibility default", () => {
expect(grammar).not.toBe(DEFAULT_SQL_GRAMMAR);
});
test("`#` is code: the lexer has `#`, `#>`, `#>>` and `#-` as operator tokens and no `#` comment", () => {
expect(grammar.hash).toBe("code");
});
test("`[…]` is a subscript and an array literal, never a name quote", () => {
// `[` and `]` are plain tokens; names are quoted with `"` or a backtick.
expect(grammar.bracket).toBe("subscript");
});
test("block comments do not nest: `lex_comment_block` stops at the first `*/`", () => {
expect(grammar.blockComment).toBe("flat");
});
test("`q'…'` and `//` are not in the grammar: `//` is an operator token", () => {
expect(grammar.alternateQuoting).toBe(false);
expect(grammar.doubleSlashComment).toBe(false);
});
test("a backslash always escapes inside `'…'` and `\"…\"`", () => {
// `'([^'\\]|\\.|'')*'` and its `"` twin, under every dialect setting.
expect(grammar.backslashAlwaysEscapes).toBe(true);
});
test("a script keeps the default: bodies are `$$` literals", () => {
expect(grammar.script).toBe(DEFAULT_SQL_GRAMMAR.script);
});
test("a trailing `FORMAT <name>` follows the row bound", () => {
// L3: `SELECT 1 FORMAT JSON LIMIT 5` is 1005 "unexpected `LIMIT`", and `SELECT 1 LIMIT 5 FORMAT JSON` succeeds.
expect(grammar.trailingLimitClauses).toHaveLength(1);
const [format] = grammar.trailingLimitClauses;
expect(format.test("SELECT number FROM numbers(10) FORMAT TabSeparated")).toBe(true);
expect(format.test("SELECT 1 format csv ")).toBe(true);
expect(format.test("SELECT * FROM (SELECT 1 FORMAT JSON) t")).toBe(false);
expect(format.test("SELECT note FROM t WHERE note = 'FORMAT CSV'")).toBe(false);
expect(format.test("SELECT format FROM t")).toBe(false);
});
});
/**
* How a script is cut (#1312). Each non-default row was measured in the end-to-end pass of
* 2026-10-03/04 before the fact existed, the cost quoted beside it.
*/
describe("script", () => {
test("Oracle reads PL/SQL units and SQL*Plus's `/` line, one statement per request", () => {
// A procedure cut at its inner `;` was stored INVALID (PLS-00103) on 26ai Free 23.26.3.
expect(resolveSqlGrammar("oracle").script).toEqual({ blocks: "pl-sql", separatorLine: "/", unit: "statement" });
});
test("SQL Server sends the batch between GO lines as one request", () => {
// `DECLARE @x INT = 5; SELECT @x * 2` as two requests was `Must declare the scalar
// variable "@x"` on 2025 RTM-CU9.
// A MERGE keeps its `;`, which the server requires (Msg 10713, SF-046).
expect(resolveSqlGrammar("mssql").script).toEqual({
blocks: "none",
separatorLine: "GO",
unit: "batch",
terminatedStatements: ["MERGE"],
});
});
test.each<DatabaseType>(["sqlite", "libsql"])("%s reads a trigger body", (type) => {
// A trigger cut at its inner `;` was "incomplete input" on SQLite 3.50.4 and "unexpected
// end of input" on sqld 0.24.33.
expect(resolveSqlGrammar(type).script).toEqual({ blocks: "trigger-body", separatorLine: null, unit: "statement" });
});
test.each<DatabaseType>(["postgres", "mysql", "db2", "duckdb", "clickhouse", "trino", "cassandra", "influxdb3"])(
"%s keeps the default: every code `;` ends a statement",
(type) => {
expect(resolveSqlGrammar(type).script).toBe(DEFAULT_SQL_GRAMMAR.script);
},
);
});
// Whether `\\` escapes inside every `'…'` and `"…"`. Databend is the one row that declares
// it: its lexer escapes with `\\.` in both quotes and no setting turns that off. MySQL is
// the dialect that escapes by default, and `NO_BACKSLASH_ESCAPES` in `sql_mode` turns that
// off per session, so its row cannot state it for every connection.
describe("backslashAlwaysEscapes", () => {
test("the compatibility default does not read a backslash as an escape", () => {
expect(DEFAULT_SQL_GRAMMAR.backslashAlwaysEscapes).toBe(false);
});
test.each(["databend", "bigquery", "snowflake", "spark", "couchbase"] as const)("%s declares true", (type) => {
expect(resolveSqlGrammar(type).backslashAlwaysEscapes).toBe(true);
});
test.each(
(Object.keys(GRAMMAR_COVERAGE) as DatabaseType[]).filter(
(type) =>
type !== "databend" && type !== "bigquery" && type !== "snowflake" && type !== "spark" && type !== "couchbase",
),
)("%s declares false", (type) => {
expect(resolveSqlGrammar(type).backslashAlwaysEscapes).toBe(false);
});
});
// Two opt-outs from readings every other row shares: whether `$tag$ … $tag$` is a string (`dollarTags`, absent means
// true) and what a backslash means inside `"…"` (`doubleQuoteBackslashEscapes`, absent means the reading
// `backslashAlwaysEscapes` gives both quotes).
describe("dollarTags and doubleQuoteBackslashEscapes", () => {
test("the compatibility default declares neither", () => {
expect(DEFAULT_SQL_GRAMMAR.dollarTags).toBeUndefined();
expect(DEFAULT_SQL_GRAMMAR.doubleQuoteBackslashEscapes).toBeUndefined();
});
test.each(["snowflake", "druid"] as const)("%s declares both false", (type) => {
expect(resolveSqlGrammar(type).dollarTags).toBe(false);
expect(resolveSqlGrammar(type).doubleQuoteBackslashEscapes).toBe(false);
});
test.each(
(Object.keys(GRAMMAR_COVERAGE) as DatabaseType[]).filter((type) => type !== "snowflake" && type !== "druid"),
)("%s declares neither", (type) => {
expect(resolveSqlGrammar(type).dollarTags).toBeUndefined();
expect(resolveSqlGrammar(type).doubleQuoteBackslashEscapes).toBeUndefined();
});
});
// Four facts Spark's lexer has, each absent (false) on every other row but one: raw strings (`rawStrings`), optimizer
// hints lexed as code (`hintsAreCode`), a `--` comment a backslash continues (`lineCommentBackslashContinues`) and one
// a lone carriage return ends (`lineCommentEndsAtCarriageReturn`). The SQL++ lexer and CQL's share the fourth; Druid's
// Calcite lexer shares the second and fourth.
describe("the reader facts Spark's row declares", () => {
test("the compatibility default declares none of the four", () => {
expect(DEFAULT_SQL_GRAMMAR.rawStrings).toBeUndefined();
expect(DEFAULT_SQL_GRAMMAR.hintsAreCode).toBeUndefined();
expect(DEFAULT_SQL_GRAMMAR.lineCommentBackslashContinues).toBeUndefined();
expect(DEFAULT_SQL_GRAMMAR.lineCommentEndsAtCarriageReturn).toBeUndefined();
});
test.each(
(Object.keys(GRAMMAR_COVERAGE) as DatabaseType[]).filter(
(type) => type !== "spark" && type !== "couchbase" && type !== "druid" && type !== "cassandra",
),
)("%s declares none of the four", (type) => {
expect(resolveSqlGrammar(type).rawStrings).toBeUndefined();
expect(resolveSqlGrammar(type).hintsAreCode).toBeUndefined();
expect(resolveSqlGrammar(type).lineCommentBackslashContinues).toBeUndefined();
expect(resolveSqlGrammar(type).lineCommentEndsAtCarriageReturn).toBeUndefined();
});
test("couchbase declares only that a carriage return ends a `--` comment", () => {
// The SQL++ line comment is `--[^\n\r]*`, so a carriage return ends it and a backslash continues nothing.
expect(resolveSqlGrammar("couchbase").rawStrings).toBeUndefined();
expect(resolveSqlGrammar("couchbase").hintsAreCode).toBeUndefined();
expect(resolveSqlGrammar("couchbase").lineCommentBackslashContinues).toBeUndefined();
expect(resolveSqlGrammar("couchbase").lineCommentEndsAtCarriageReturn).toBe(true);
});
test("cassandra declares only that a carriage return ends a `--` or `//` comment", () => {
// Measured on 5.0.9 (tests/fixtures/cassandra/5.0.9/agent-boundary/14-line-comment-carriage-return.txt): a clause
// after `-- x<CR>` or `// x<CR>` filters the row out, and a statement after one runs.
expect(resolveSqlGrammar("cassandra").rawStrings).toBeUndefined();
expect(resolveSqlGrammar("cassandra").hintsAreCode).toBeUndefined();
expect(resolveSqlGrammar("cassandra").lineCommentBackslashContinues).toBeUndefined();
expect(resolveSqlGrammar("cassandra").lineCommentEndsAtCarriageReturn).toBe(true);
});
test("druid declares that a hint is code and that a carriage return ends a line comment", () => {
// `/*+` is Calcite's `HINT_BEG` token, and its line comment is `("//"|"--")(~["\n","\r"])*`.
expect(resolveSqlGrammar("druid").rawStrings).toBeUndefined();
expect(resolveSqlGrammar("druid").hintsAreCode).toBe(true);
expect(resolveSqlGrammar("druid").lineCommentBackslashContinues).toBeUndefined();
expect(resolveSqlGrammar("druid").lineCommentEndsAtCarriageReturn).toBe(true);
});
test("spark declares all four", () => {
expect(resolveSqlGrammar("spark").rawStrings).toBe(true);
expect(resolveSqlGrammar("spark").hintsAreCode).toBe(true);
expect(resolveSqlGrammar("spark").lineCommentBackslashContinues).toBe(true);
expect(resolveSqlGrammar("spark").lineCommentEndsAtCarriageReturn).toBe(true);
});
});
describe("every database type has a recorded grammar decision", () => {
test.each(Object.entries(GRAMMAR_COVERAGE))("%s is %s", (type, expected) => {
const isDefault = resolveSqlGrammar(type as DatabaseType) === DEFAULT_SQL_GRAMMAR;
expect(isDefault ? "default" : "established").toBe(expected);
});
});
const SQL_TEXT_COVERAGE: Record<DatabaseType, boolean> = {
postgres: true,
mysql: true,
sqlite: true,
libsql: true,
// SQL, and the statement text IS what the editor sends: the provider extends
// SQLBaseProvider and hands the text to `runAndReadAll`.
duckdb: true,
oracle: true,
// SQL, and the statement text IS what the editor sends to the driver (#786).
db2: true,
mssql: true,
clickhouse: true,
couchbase: true,
druid: true,
libredb: true,
// SQL, measured: both answer a POSTed statement with columns and positional rows,
// and both providers extend SQLBaseProvider. Answering false here would switch the
// confirmation gate's SQL reading off for text that is SQL.
elasticsearch: true,
opensearch: true,
// SQL, and nothing but: the editor text is what goes to `POST /v1/statement`, and
// the provider extends SQLBaseProvider.
trino: true,
// CQL is SQL-SHAPED and the answer here is about SHAPE, not about vocabulary. What
// the editor holds is a statement built of SELECT/INSERT/UPDATE/DELETE keywords,
// `'…'` literals with doubled quotes, `"…"` quoted names and `--` / `/* */`
// comments - so the confirmation gate's span reader reads it correctly, and it is
// exactly that reading which finds a write hidden behind a comment. The keywords
// CQL LACKS (JOIN, OFFSET, EXPLAIN, subqueries - each measured as a syntax error on
// 5.0.9) are a smaller vocabulary, not a different notation; answering false here
// would switch the SQL checks off for text that is SQL, which is the mirror of the
// defect #297 fixed.
cassandra: true,
mongodb: false,
redis: false,
// A PromQL expression is not SQL text: its strings escape with a backslash, which a SQL span
// reader cannot follow (#1085).
prometheus: false,
// A Kafka read request is one JSON object, not SQL text: its strings escape with a backslash,
// which a SQL span reader cannot follow either (#1088).
kafka: false,
// An etcdctl command line is not SQL text: `commands.ts` reads its words by the shell's rules and a txn
// body by etcdctl's, neither of which a SQL span reader follows (#1089).
etcd: false,
// A Cypher statement is not SQL text: its strings escape with a backslash, `//` opens a comment and a
// backtick quotes a name, none of which a SQL span reader follows (Neo4j spec 5.5).
neo4j: false,
// A Milvus console request is a route line and one JSON body, not SQL text: its strings escape with a backslash,
// which a SQL span reader cannot follow (vector-family spec 5.4).
milvus: false,
// A Qdrant console request is a request line and one JSON body, not SQL text: its strings escape with a
// backslash and `//` opens a comment, which a SQL span reader cannot follow (vector-family spec 6.4).
qdrant: false,
weaviate: false,
// An InfluxQL statement is not SQL text: its strings escape with a backslash, `/.../` is a regex where the parser
// asks for one, and a `--` comment ends at a lone `\r`, none of which a SQL span reader follows (SPEC 5.7).
influxdb: false,
// SQL under the DataFusion grammar row: the provider extends SQLBaseProvider, so the SQL gate reads its text.
influxdb3: true,
// An `oxia client` read command is words split by POSIX shell rules, not SQL text: its quoting is the shell's,
// which a SQL span reader would report as unreadable (SB2-4.3).
oxia: false,
// SQL, and the statement text IS what the editor sends to `POST /v1/query`; the provider extends SQLBaseProvider.
databend: true,
bigquery: true,
// SQL, and the statement text IS what the provider submits; it extends SQLBaseProvider.
snowflake: true,
spark: true,
// AWS CLI words split by POSIX shell rules, not SQL text.
s3: false,
// A kubectl statement is words split by POSIX shell rules, not SQL text.
kubernetes: false,
mqtt: false,
// A JavaScript-shaped query chain, not SQL text.
firestore: false,
rabbitmq: false,
// A read request is a JSON document, not SQL text.
opcua: false,
// A request line is a URL, not SQL text.
mtconnect: false,
// A read request is JSON, not SQL text.
modbus: false,
netconf: false,
snmp: false,
sparkplug: false,
};
describe("readsSqlText", () => {
test.each(Object.entries(SQL_TEXT_COVERAGE))("%s writes SQL: %s", (type, expected) => {
expect(readsSqlText(type as DatabaseType)).toBe(expected);
});
test("a call that names no dialect is read as SQL", () => {
// The compatibility default once more. A caller that named nothing was reading
// SQL before this question existed, and the published dialog takes the type as
// a plain string from a host application - an unrecognised one must not turn
// the SQL checks off.
expect(readsSqlText()).toBe(true);
expect(readsSqlText("not-a-database" as DatabaseType)).toBe(true);
});
// A host calling from plain JavaScript can pass a value that is not a string but converts to a non-SQL type id.
// Such a value was never a member of the non-SQL set, so it reads as SQL, while `resolveSqlGrammar` keeps the row
// its property key names.
test.each<[string, unknown]>([
["a String object", new String("mongodb")],
["an array", ["mongodb"]],
["an object with toString", { toString: () => "redis" }],
])("%s naming a non-SQL type is read as SQL", (_label, value) => {
expect(readsSqlText(value as DatabaseType)).toBe(true);
});
});