Story 21.7 — Parser.ident uniformity: quoted names for every DML/DDL statement kind - #311
Merged
Conversation
`Parser.ident` (`[a-zA-Z_][a-zA-Z0-9_.]*`) was the third name surface of this dialect and the only one stories 21.1 and 21.2 left untouched, so `INSERT`, `UPDATE`, `CREATE`, `DROP`, `ALTER`, `TRUNCATE`, `COPY INTO` and every `SHOW`/`DESCRIBE` disagreed with `SELECT` -- and with each other -- about what a name may look like. Measured on the unmodified tree: 40 of 110 probe statements were rejected on the QUOTING alone, including every Tableau capability probe and the two qualified DDL examples `joins.md` already publishes. Two productions replace `ident` at every one of its call sites but two. `identParts` / `identRef` take an object reference through 21.2's `tableParts`, so a qualifier is captured and never interpreted, joined or dropped; `identName` takes a column, an option key or a struct-entry key through 21.1's `qualifiedName`, so both quote styles are accepted, the delimiters stripped and the case preserved. Neither adds a lexer. The legacy `ident` regex survives as the LAST alternative of both. That is not belt-and-braces: `tableParts` reaches its name through `bareFirstPart`, which carries the reserved-word lookahead `identifier` needs, and `ident` never had it -- so `CREATE TABLE t (count INTEGER)`, `DROP TABLE order` and roughly 130 further bare names parse today and would have stopped. It is also what makes the normalising render of a column name a fixed point for every name the old surface could spell. Renders follow suit. An object reference carries `parts` and re-emits through the shared `renderName`, so `DROP TABLE IF EXISTS `#Tableau_sid_1_Connect_Chec`` renders a text that reads back as itself. A name held inside a `List[String]` / `ListMap[String, _]` has nowhere to put a bit, so `renderColumnName` decides by shape -- bare when the bare name surface can spell it, ANSI-quoted otherwise -- which leaves every existing rendering byte-identical and closes the lossy render story 21.2 exists to remove. `parts` is `Nil` for a single bare part, because such a reference IS its name: the AST and the render of every bare-spelled statement are what they were before this story, and a programmatically built node still compares equal to a parsed one. Measured: 40 of 110 probe rows flip REJECT -> PARSE and 70 are unchanged; 21 of the corpus's 24 DDL probes now parse while the 3 `CREATE LOCAL TEMPORARY TABLE` rows still reject on their own merits; `sql` 912 and `core` 924 on both Scala legs, bridge 197, es6bridge 197, macrosTests 21. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both new name productions put the richer alternative first, and `|` commits
to the first alternative that SUCCEEDS, not to the one that consumes most.
On a dangling dot the rich alternative succeeds on a PREFIX -- `a` out of
`a.`, `a` out of `a..b` -- leaving the rest unconsumed, so the enclosing
sequence fails and a name `ident` used to swallow whole stopped parsing:
OPTIONS (a. = 1) parsed on main, key `a.` -> rejected
OPTIONS (a..b = 1) parsed on main, key `a..b` -> rejected
DROP TABLE a. parsed on main, index `a.` -> rejected
DROP TABLE a..b parsed on main, index `a..b` -> rejected
The first two were UNDECLARED. They live on `identName`, which no test
covered for a malformed key, and only a differential probe against a parser
built from origin/main could see them -- the branch's own suite was green
over all four. The second two were declared as a deliberate narrowing.
Lead ruling: 21.7 narrows nothing, anywhere. Nobody means `a.` as an index
name or an option key, but "nobody means it" is not a reason to stop
accepting it inside a release -- rejecting it is a breaking change and
belongs to whoever schedules one. A rule justified by "no regression in
existing parsing" cannot hold for option keys and not for table names, so
both productions take the same guard.
`<~ not(".")` makes the rich alternative DECLINE exactly where it would
otherwise commit to a prefix, handing the lexeme to `ident` unchanged. It
consumes nothing and cannot affect a well-formed name: every accepted shape
ends at a delimiter, not at a dot. `logs-2025.03`, `"sch".tbl` and
`"elastic" . bi_events` (AD-8's whitespace-tolerant qualifier dot) are
untouched, and the FROM surface never sees this production at all.
Measured over 28 option/struct shapes against origin/main: 25 byte-identical,
3 widened, 0 narrowed. The three widenings stay -- a backtick option key, a
backtick struct-entry key, and a hyphenated key such as `Content-Type`, which
`ident`'s charset could never spell even though `Headers(ListMap(...))` has
always been buildable programmatically.
The three narrowing pins are RETARGETED rather than deleted, per the rule
that a pin recording a contract outlives the contract's value, and the option
keys gain the coverage whose absence hid the defect.
sql 914 (2.13.16 and 2.12.20) - core 924 - bridge 197 - es6bridge 197 -
macrosTests 21.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fupelaqu
marked this pull request as ready for review
September 8, 2026 14:00
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Story 21.7 of epic 21 (Core SQL front door).
Parser.ident— the third name surface — now has zero statement call sites: DQL, DML and DDL respect the same name rules.Closes #310
What changed
Two productions replace
identat every one of its 78 call sites but two. Neither adds a lexer:identParts/identRefare built on 21.2'stableParts,identNameon 21.1'squalifiedName.identRef(35 sites)tablePartsparts: Seq[NamePart] = Nil, renders through the sharedrenderNameidentName(41 sites)qualifiedNameINSERTcolumn lists,UPDATE SETkeys,PRIMARY KEY, conflict target,PARTITION BY, enrichFROM/ON/ENRICH, option and struct-entry keys, watcher input/action namesrenderColumnName)ident(2 sites)processorType,fileFormat— deliberate non-conversionsTable.rendernow delegates to the sharedsql.renderName, so there is ONE derivation of the rendering rather than two.Task 0 — baseline, re-measured on
origin/main9f6726b2The spec's baseline (
ac54a079) is many merges stale, so it was re-measured. Every row the spec records as REJECT still rejected; both controls parsed. The split's premise is intact.9f6726b2INSERT INTO `prod_eu`.dest SELECT a FROM srcregex '[a-zA-Z_][a-zA-Z0-9_.]*' expected but '`' foundINSERT INTO "prod_eu".dest SELECT a FROM src"foundUPDATE `orders` SET a = 1 WHERE id = 1CREATE TABLE "dest" ("c" INTEGER)"foundINSERT INTO tbl ("c") VALUES ('a')regex '(?i)(SELECT)\b' expected but '(' foundUPDATE tbl SET "a" = 1 WHERE id = 1end of input expected(differs from the spec's expectation; recorded as measured)CREATE TABLE `#Tableau_sid_1_Connect_Chec` (`COL` INTEGER)DROP TABLE IF EXISTS `#Tableau_sid_1_Connect_Chec`CREATE TABLE dest (COL INTEGER)CREATE LOCAL TEMPORARY TABLE dest (COL INTEGER)regex '(?i)OR\b' expected but 'L' found(control)DELETE FROM "elastic".orders WHERE id = 1FromParser.table)Task 1.1 — classification of every
identsite80
\bident\btokens inParser.scalaon9f6726b2= 78 code call sites + the definition + one prose mention. (The spec's "76" was measured atac54a079; the count moved by 2 between those commits, as the spec warned it might.) 35 + 41 + 2 = 78, exactly.Object reference →
identRef(35), node carriesparts(29 nodes)createOrReplaceTable,createTable,showTable,showCreateTable,describeTable,dropTable,truncateTable,alterTablecreateOrReplaceMaterializedView,createMaterializedView,dropMaterializedView,refreshMaterializedView,showMaterializedView,showMaterializedViewStatus,showCreateMaterializedView,describeMaterializedViewcreateOrReplacePipeline,createPipeline,dropPipeline,showPipeline,showCreatePipeline,describePipeline,alterPipelinecreateEnrichPolicy,createOrReplaceEnrichPolicy,executeEnrichPolicy,dropEnrichPolicy,showEnrichPolicycreateOrReplaceWatcher,createWatcher(name only),showWatcherStatus,dropWatcherinsert,copy,updateColumn / key →
identName(41):column,primaryKey,partitionBy,dropColumn,renameColumn(×2), the 15alterColumn*column names plusDROP OPTION/DROP FIELDoperands,DROP MAPPING/DROP SETTING/DROP ALIASkeys,dropProcessorcolumn, the watcherWHENfield,chainInputandwatcherActionnames, enrichFROM/ON/ENRICH(×2 forms),conflictTarget, theINSERTcolumn list, theUPDATE … SETleft-hand sides,optionkeys andstruct_entrykeys.Deliberate non-conversions (2), documented in source:
processorTypeandfileFormatselect an ENUM, not a name —IngestProcessor.sqlrenders an uppercase keyword, andfileFormatalready accepts every quoted spelling throughliteral.Design decisions
AD-1 —
identis retained as the LAST alternative of both new productions.tablePartsreaches its name throughbareFirstPart, which carries a reserved-word negative lookaheadidentnever had. A straight swap would newly reject ~130 bare names in every DDL/DML position —CREATE TABLE t (count INTEGER),DROP TABLE order,INSERT INTO t (min) …— a customer-visible regression with no migration path inside the release, which AC-5 forbids. Residual, stated:FROM countstays rejected whileDROP TABLE countis accepted. That asymmetry is PRE-EXISTING and is not made worse; narrowing DDL onto DQL's reserved-word rule is a breaking change that needs scheduling, not a grammar clean-up.AD-2 — one renderer.
sql.renderName(parts, name)is the single implementation andTable.renderdelegates to it.AD-3 — carry-or-pin, decided per node by measurement. The story's headline acceptance (
#Tableau_sid_1_Connect_Chec) is exactly a name the bare surface cannot spell, so object references must carryparts. A name held inside aList[String]/ListMap[String, _]has nowhere to put a bit, sorenderColumnNamedecides by shape — bare when the name matchesbareNameRegex(which is nowParser.ident's own regex, its single owner), ANSI-quoted otherwise. Exact, not heuristic, precisely because of AD-1's fallback; and no reserved-word list is duplicated outsideParser.AD-4 —
partsisNilfor a single BARE part, normalised insideidentRef. Such a reference IS its name, so recording it would make every parsed DDL node unequal to the programmatic one —TableDiff,IndicesApiandAlterTableRoundTripSpecall buildAlterTable("dest", …)with no parts and compare against a re-parse.AD-5 (OQ-1) — option and struct-entry keys ACCEPT quoting but render exactly as before. Measured on the unmodified tree: the double-quoted spelling of an OPTION key ALREADY parsed, through
TypeParser.literal, and already gave the content as the key — so foroptionthis is a backtick-only widening with a byte-identical AST.struct_entryhad noliteralalternative, so both spellings are new there.AD-6 (post-review, lead ruling) — the story narrows NOTHING, on any surface. See below.
🔴 AD-6 — the reversal, and the defect it caught
The first implementation shipped one declared narrowing (
DROP TABLE a./a..b/SHOW TABLE a.) and, undeclared, the same narrowing on option and struct-entry keys:origin/mainOPTIONS (a. = 1)a.OPTIONS (a..b = 1)a..bNo test covered a malformed option key, so the branch's own 912-test suite was green over it. Only a parser built from
origin/mainand run against the same inputs could see it.Cause, one shape in two places.
identName = (qualifiedName ^^ (_._1)) | identandidentParts = tableParts | (ident …)both put the richer alternative first, and|commits to the first alternative that succeeds, not the one that consumes most. On a dangling dot the rich alternative succeeds on a PREFIX (aout ofa.), leaving the rest unconsumed, so the enclosing sequence fails.Ruling: fix both. A rule justified by "no regression in existing parsing" cannot hold for option keys and not for table names in the same story. Nobody means
a.as an index name, but "nobody means it" is not a reason to stop accepting it inside a release — rejecting it is a breaking change and belongs to whoever schedules one.Fix:
<~ not(".")on both productions, so the rich alternative DECLINES exactly where it would commit to a prefix andidenttakes the lexeme whole. It consumes nothing and cannot affect a well-formed name: every accepted shape ends at a delimiter, not at a dot.logs-2025.03,"sch".tbland"elastic" . bi_events(21.2 AD-8's whitespace-tolerant qualifier dot) are untouched, and the FROM surface never reaches these productions. The three narrowing pins were retargeted, never deleted, and the option keys gained the coverage whose absence hid the defect.Differential probe, 28 option/struct shapes against
origin/main: 25 byte-identical, 3 widened, 0 narrowed.🔴 The durable lesson: a production's alternation ORDER is a contract about what it DECLINES, not only about what it accepts. Swapping a first alternative for a richer one silently narrows every input the richer one matches a PREFIX of — and a branch-only suite cannot see it, because the inputs it breaks are exactly the ones nobody wrote a test for.
Verification
Regression measured, not claimed. 2,049 statements harvested from the repo's own test sources, pushed through parsers compiled from BOTH trees (the branch, and
origin/mainrestored into the same committed worktree — a genuine single-variable control):NOT <op>/INfix(sql): make ALTER COLUMN ... SET|ADD FIELD parse and actually apply #218 family, 21.5's arrayDEFAULTrender, ALTER PIPELINE's processor-field AST and ORDER BY resolution. Zero introduced.AC-6, measured on the real corpus. Replaying the 24 DDL rows of
epic-19-bi-corpus.csvgives exactly the split 21.6's attribution predicts: the 21capability_openrows parse, and the 3rejected_pending_policyrows (CREATE LOCAL TEMPORARY TABLE) still reject withregex '(?i)OR\b' expected but 'L' found. Recorded here for 21.6 to consume; these rows are never scored as wins and no headline number is published from them — 21.6 owns the number. Whether to honour a temp-table capability probe remains a separate product decision aboutCREATE TABLEsemantics, tracked outside this epic.OQ-2 — what a real Elasticsearch actually did, not what was expected. A disposable Elasticsearch 8.18.3 container, both probes through
GatewayApi.run:CREATE TABLE `#Tableau_sid_1_Connect_Chec` (`COL` INTEGER)ElasticFailure, status 400,operation=Some("schema"),Invalid index: Index name must be lowercaseDROP TABLE IF EXISTS `#Tableau_sid_1_Connect_Chec`ElasticFailure, status 400,operation=Some("drop"), same messageCREATE TABLE `#tableau_probe_lower` (`COL` INTEGER)(isolating the variable)Invalid index: Index name contains invalid characters: /, *, ?, ", <, >, |, space, comma, #CREATE TABLE plain_probe_control (COL INTEGER)/DROP TABLE IF EXISTS …The expectation ("
#is not a legal ES index-name character") was right about the outcome and wrong about the mechanism: the refusal is client-side, in SoftClient4ES's own index-name validator, before the request reaches Elasticsearch, and the first reason reported is the uppercase rule. Not wired into CI.Documentation. Every SQL example added to
documentation/sql/dql_statements.md, and the twodocumentation/sql/joins.md:250-265DDL examples that only become true here, were parse-probed against the built parser and round-tripped.Suites.
sql914 andcore924 on both Scala legs, bridge 197, es6bridge 197, macrosTests 21,DialectCensusSpec16 unmoved,scalafmtCheckAllgreen.++ 2.12.20 sql/Test/compilegreen. Parse cost main 5,935 ms → branch 5,799 ms over 2,049 statements (−2.3 %, overlapping ranges), well inside the #269 gate.Not run, deliberately:
sql/Test/headerCheck(RED on main; newsqltest files take no header) and the Docker/ES suites (nothing here reaches a client — OQ-2 was verified once by hand).Behaviour changes
INSERT INTO `prod_eu`.dest/INSERT INTO "prod_eu".dest— targetdest, qualifier captured inparts, never interpretedUPDATE `orders` SET …,CREATE TABLE "dest" ("c" INTEGER), and the same forDROP/TRUNCATE/ALTER/COPY INTO/SHOW/DESCRIBE/ MV / pipeline / watcher / enrich-policy namesINSERT INTO tbl ("c") VALUES ('a'),UPDATE tbl SET "a" = 1,ALTER TABLE … ALTER COLUMN "c" …CREATE TABLE logs-2025.03,DROP TABLE my-index,INSERT INTO logs-2025.03.SELECTandDELETEalready accepted them; this closes the last surface that did notTypeParser.literal— byte-identical AST, measured. A hyphenated key (Content-Type) was never spellable in SQL at allNo narrowings. Nothing that parses on
origin/mainstops parsing.Pre-existing, unchanged, and NOT introduced here (recorded so it is not mistaken for a regression):
OPTIONS ('my key' = 1)rendersOPTIONS (my key = 1)onorigin/mainand on this branch — a key the bare surface cannot spell renders bare and does not re-parse. That is the #218 render family.Release note
INSERT/UPDATE/CREATE/DROP/TRUNCATE/ALTER/COPY INTOand everySHOW/DESCRIBEnow accept quoted table AND column names (both quote styles) with the same qualifier semantics asSELECT, and a hyphenated name (logs-2025.03,my-index) is spellable there too — a pure widening; bare spellings parse to a byte-identical AST and render. Backticked option keys and quoted struct-entry keys are accepted; keys still render exactly as before. 29 DDL/DML AST nodes gained a defaultedpartsfield: binary-incompatible (case-class arity — the same rebuild 21.1/21.2 already owe on this train) and source-incompatible forcase <Node>(…)destructuring.Standing product questions (neither blocking, neither introduced here)
FROM countis rejected whileDROP TABLE countis accepted — pre-existing, not made worse. Closing it breaks ~130 bare names with no migration path, so it is a scheduled deprecation.OPTIONSclause with such a key.🤖 Generated with Claude Code