Story 21.1 — quoted identifiers: one lexing surface (backtick + double quote) - #283
Merged
Conversation
…sition (story 21.1) Story 21.1 (#252 part 1). A column name or alias may now be written quoted, in either the ANSI SQL-92 double-quote or the MySQL backtick spelling, everywhere an identifier is accepted: the SELECT list, WHERE, GROUP BY, HAVING, ORDER BY, every function / aggregate / conversion operand, PARTITION BY, window arguments, UNNEST, SCRIPT AS bodies, all four alias positions, and each part of a qualified name. Quoting is folded into `identifier` itself rather than sprinkled over the ~35 sites that end in `| identifier`, so every existing production inherits it with a zero-line diff. `quotedIdentifier` survives as a strict subset, which is what keeps `quotedIdentifier`-first ordering load-bearing in the four clause-level productions (delete it and `SELECT "category"` flips from a column to a string literal). The AST records ONE bit -- `quoted: Boolean`, appended last and defaulted on `GenericIdentifier` and `Alias` -- and `Identifier.sql` re-emits with one canonical delimiter, the ANSI double quote, quoting each dot-separated part. The bit never reaches Elasticsearch: term keys, aggregation fields, sort keys, `_source.includes` and Painless `doc[...]` accesses stay the bare name, pinned in both bridge trees. AD-13 -- the story's one deviation from the spec, and the reason for it. The spec's PD-3 kept a bare double-quoted lexeme a STRING in operand position. That is incompatible with its own AC-5: because the render normalises to one delimiter, MAX(`amount`) rendered MAX("amount") and re-parsed as MAX('amount'). Six round-trip rows failed and one re-parsed into a hard rejection. MaterializedViewExtension persists that render and runs client.run(alter.sql), so it was a live silent corruption, not cosmetics. Switching the canonical delimiter to the backtick also fixes it, in one line, and was refuted: DuckDB accepts only the double quote and arrow's JOIN planner feeds identifier.sql into a DuckDB SELECT list. So `quotedIdentifier` is inserted before `identifierWithValue` in `identifierWithIntervalFunction` -- one token, no alternation order moved. It subsumes AD-10, so `qualifiedQuotedIdentifier` was never shipped. Measured consequence: a double-quoted operand inside a function, aggregate, conversion or arithmetic expression now names a COLUMN. Value positions are untouched. Zero existing tests depended on the old reading. The three quote-aware scanners learn the backtick (Parser.normalize, Parser.scriptBody, GatewayApi.splitStatements), each with the backslash-escape branch suppressed for it -- without them a `--`, a `)` or a `;` inside a backticked name silently truncates, closes or splits a valid statement. Part 1 of 2 for #252 -- closed by the 21.2 PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fupelaqu
marked this pull request as ready for review
September 5, 2026 15:07
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.
Part 1 of 2 for #252 — closed by the 21.2 PR.
Story 21.1 (
_bmad-output/implementation-artifacts/21-1-quoted-identifier-lexing-surface.md).A column name or alias may now be written quoted, in either the ANSI SQL-92 double-quote or the
MySQL backtick spelling, everywhere an identifier is accepted. Quoting is folded into
identifieritself, so the ~35 sites ending in
| identifierinherit it with a zero-line diff — noSelectParser/GroupByParser/OrderByParser/WhereParser/function/**edits, which isthe point of AD-2.
🔴 The one deviation that matters: AD-13 reverses PD-3
The spec was internally inconsistent, and only the round-trip suite could show it. PD-3 kept a
bare double-quoted lexeme a STRING in operand position; AC-5 requires the render to be a fixed
point. Because
Identifier.sqlnormalises to ONE canonical delimiter, those cannot both hold:Six AC-5 matrix rows failed this way (
MAX,CAST,TRY_CAST,CONVERT×2,DATE_TRUNC,EXTRACT,HAVING COUNT(id)), andSELECT (amount+ 1)re-parsed into a hard type mismatchrejection.
MaterializedViewExtensionpersists that render and runsclient.run(alter.sql)— sothis was a live silent corruption, not cosmetics.
Switching the canonical delimiter to the backtick also fixes it, in one line, and was refuted:
DuckDB's Keywords and Identifiers documentation accepts
"and only", andsoftclient4es-arrow'sJoinPlanner:297feedsfield.identifier.sqlstraight into a DuckDB SELECTlist. Emitting backticks would break the sidecar's JOIN path for every quoted column.
Fix — one token:
quotedIdentifierinserted immediately beforeidentifierWithValueinidentifierWithIntervalFunction. No alternation ORDER moves (AD-3 intact). It subsumes AD-10 —the ten Tableau SQL-92 corpus shapes are a strict subset — so
qualifiedQuotedIdentifierwas nevershipped.
Measured blast radius.
MAX("salary"),CAST("col" AS BIGINT),DATE_TRUNC("event_ts", MONTH),UPPER("abc"),ABS("amount")andDATE_PARSE's first operand now read the double-quoted lexeme asa COLUMN. Every genuine VALUE position is untouched (
WHERE a = "x",>,BETWEEN,IN,LIKE). Zero existing tests depended on the old reading — 604/605 green immediately after theflip, the single failure being an unrelated deliberate widening. One-line revert if the lead
disagrees, at the cost of AC-5.
Task 0 — baseline re-measured on
66cfbcff(11 of 12 rows exactly as specced)SELECT UPPER("abc") AS u FROM tUPPER('abc');name == ""SELECT MAX("salary") AS m FROM tMAX('salary')SELECT CAST("amount" AS BIGINT) AS a FROM tCAST('amount' AS BIGINT)SELECT "category" FROM tname == "category"SELECT e."category" FROM bi_events ename == "e.",fieldAlias == Some("category")SELECT t.from FROM x tname == "from",tableAlias == Some("t")SELECT a.[0] FROM tname == "a.[0]"SELECT "" AS e FROM tname == "", functions EMPTYSELECT "a\b" FROM tname == "a\b"(one backslash)SELECT "amount" + 1 AS a FROM tend of input expectedSELECT a . b FROM tSELECT id FROM bi_events "e"Row 10's verdict matches; its stated cause does not, and the real one is load-bearing — see the
|-commits limitation below.Task 5.2 — ordering audit re-run at dev HEAD: ONE spec row was wrong
The audit recorded
date_parse/datetime_parseas keeping the string reading "because they listliteralbeforeidentifier". True, and not decisive:literalsits at slot 4, BEHINDidentifierWithIntervalFunctionat slot 2. They flip with the rest of the operand family(
DATE_PARSE("2024-01-01", …)is now the column named2024-01-01; the documented single-quotedspelling is unaffected). Corrected in-source and pinned. Every other row was measured and is
unchanged. No alternation ORDER moved anywhere.
AD-9 parse cost — the patched tree is FASTER, ranges non-overlapping
sql/testOnly *ParserSpec, 11 runs per tree in one sbt session, first discarded as warm-up:66cfbcfffeature/21.1−19.5 %, and patched-max < baseline-min. Plausibly the shorter first-part regex plus packrat
memoisation on smaller parts. Well inside the ~20 % escalation budget and in the good direction; not
over-claimed —
ParserSpecis a suite, not a parse microbenchmark.AD-11 — cross-story merge contract with 21.5 / #274
One shared file,
sql/.../sql/package.scala. 21.1 landed FIRST and makes the ONLY executablescanner edits (the backtick joins the quote set in all three scanners, with the backslash-escape
branch suppressed for it).
''doubling needs no scanner change, so 21.5 has no competing logichunk — it owns the merge and must re-run
core/testOnly *SplitStatementsSpec,sql/testOnly *ScriptFunctionParenSpecandsql/testOnly *QuotedIdentifierSpec.unquoteNamestays identifier-only; whoever lands second folds itsq == '"'interior onto 21.5'sunescapeStringLiteral(content, delimiter)and leaves the backtick arm local (doubling only).🔴 AD-13 strengthens 21.5's coherence argument:
"x"is now an identifier in many more places,so the single quote is the only unambiguous string delimiter in operand position.
AD-12 — interaction with 21.3
The
bucketNamesdigit-in-name crash is pinned, not fixed, for BOTH quoted spellings, inrejectsInternallyshape (LeftcarryingParser.InternalParseFailure+IndexOutOfBounds, via21.4's boundary catch). 21.3 RETARGETS those two rows to its own rejection message — never
deletes them. And
GROUP BY `1`is the column named1(name == "1", no functions), which iswhat 21.3's ordinal predicate separates by construction.
21.4 hand-off re-check (dev-findings item 11)
identifierWithArithmeticExpression's default arm is a harderrand alternative #2 of 9 inWhereParser.any_identifier. Verdict: still unreachable. Its arms coverArithmeticExpression | Identifier | FunctionWithIdentifier | Function, and everyvalueExpralternative including AD-13's yields an
Identifier(GenericIdentifieris the sealed trait's onlyimplementor). The
errwas NOT softened tofailure.Deliberate behaviour changes, all pinned
SELECT a..b,SELECT a.→ rejected.SELECT e."category" FROM bi_events ewas a live silent corruption (renderedSELECT e AS category FROM bi_events AS e); now the columne.category.SELECT ""is the empty string literal, not a nameless column."a\b"unescapes toab, consistent with what the regex always accepted.SELECT a . b,"a"::BIGINT/`a`::BIGINT, and a quoted FROM table alias.SELECT a.[0]still parses.Two residual limits, pinned and documented (not fixed)
SELECT `amount` + 1andSELECT "amount" + 1are both REJECTED (onmaintoo);SELECT amount + 1,SELECT (`amount` + 1)andSELECT MAX(`amount` + 1)all work. Cause:|COMMITS, andquotedIdentifieris first inSelectParser.field— the ordering that makesSELECT "category"a column. Needs a lookahead, not a reorder.
rep("." ~> part)skips whitespace):ORDER BY b. DESC→ORDER BY "b.DESC" ASC, direction silently lost. Both inputs are malformedSQL whose old parse was equally nonsense. Hand-off to 21.2 / 21.7, which touch these
productions.
PD-4 — release notes owed by
0.23.0"x"inside a function, aggregate, conversion or arithmetic operand now means the COLUMNx, not the stringx. Use single quotes for a literal there..sqlrenderings change for any statement using a quoted identifier or alias — they gain canonicalANSI double quotes. Anything downstream pinning generated SQL text must look.
GenericIdentifierandAliasgain a field ⇒ binary-incompatible (case-class arity) ⇒downstream rebuild on the next core bump. Source-compatible (defaulted, appended last; no
case Alias(x) =>pattern exists anywhere).CREATE OR REPLACE MATERIALIZED VIEW's "definition unchanged" short-circuit compares a storedrender, so a view whose definition quotes a name rebuilds once at
version + 1.Verification
sql654 ·core868 (both green on 2.13.16 and 2.12.20) ·softclient4es-sql-bridge129 ·
es6bridge129 ·softclient4es7-sql-bridge129 ·softclient4es8-sql-bridge129 ·
macrosTests19. Named gates, each run alone:ParserSpec305 ·ParserTotalitySpec35 ·
AlterTableRoundTripSpec4 ·ScriptFunctionParenSpec12 ·SQLKeywordsSpec9 ·DialectCensusSpec16 (source-derived; did not move) ·SplitStatementsSpec18.scalafmtCheckAllandheaderCheckgreen.+ sql/compile/+ core/compileand++ 2.12.20 sql|core/Test/compileclean.softclient4es9-sql-bridge/testwas not executed: it dies withUnsupportedClassVersionErroron the default JDK (elastic4s for es9 is Java-17 bytecode — 21.4's dev-findings item 20) and this run
is constrained to plain
sbt. It compiles, and the identicalcopyBridgetemplate ran 129 greenon the template, es6, es7 and es8 legs. CI runs temurin 17.
Also reproduced twice: es7 and es8 each printed
[success]with noTests:line on their firstinvocation (stale generated tree) and 129 on the second.
Notes
softclient4es-webMDX twin ofdql_statements.mdis deferred to the 0.23.0 release docsweep — the site documents released behaviour and this ships on
0.23.0-SNAPSHOT.build.sbt:23verified at0.23.0-SNAPSHOTand left alone —feature/21.4carried the bump.Post-merge record — lead sign-off and the two issues this PR did not fix (2026-09-06)
Added after the merge, because both items were still open when it landed.
AD-13 — CONFIRMED by the lead (2026-09-06)
AD-13 reverses PD-3, a product decision that had already been reviewed: a double-quoted lexeme in a
function / CAST / aggregate / arithmetic operand position now means the COLUMN, where it used to
mean the string. The lead confirmed AD-13 and declined the one-line revert.
The grounds, restated so the reversal is a decision and not a discovery: PD-3 and AC-5 are mutually
incompatible once the render normalises to one canonical delimiter.
MAX(amount)rendersMAX("amount")and, under PD-3, re-parses asMAX('amount')— an aggregate silently becoming anaggregate over a constant.
MaterializedViewExtensionpersists that render and runsclient.run(alter.sql), so it was a live corruption, not a cosmetic round-trip failure.Measured costs, accepted:
MAX("salary"),CAST("col" AS BIGINT),DATE_TRUNC("event_ts", MONTH),UPPER("abc")andDATE_PARSE("2024-01-01", 'fmt')all flip from string to column. Value positionsare untouched —
WHERE a = "x",>,BETWEEN,INandLIKEeach listliteralahead of theidentifier in their own alternation.
--draft) and merged before the lead had ruled on AD-13. The approval above is retrospective.Nothing needs undoing, but a reversal of a lead-reviewed decision should block at the design gate in
future stories rather than ship and flag.
Filed as separate issues — NOT fixed here, deliberately
Both are pinned by tests and documented in
documentation/sql/known_limitations.md, so they arerecorded decisions. Neither is fixable inside 21.1 without moving an alternation order AD-3 forbids.
SELECT `amount` + 1is rejected while
SELECT (`amount` + 1)andSELECT MAX(`amount` + 1)work.|commits tothe first succeeding alternative and
quotedIdentifieris first inSelectParser.field/WhereParser.any_identifier— the very ordering that makesSELECT "category"a column. Needs alookahead, not a reorder. Pre-existing for the double-quoted spelling; 21.1 makes the backtick
spelling inherit it.
ORDER BY b. DESCparses as
ORDER BY "b.DESC" ASC, silently losing the sort direction. Both this andSELECT a. AS xare malformed inputs whose pre-21.1 parse was equally nonsense, so it trades onebroken reading for another. Closing it means a whitespace-free dot+part regex, which also reverts
the deliberate
SELECT a . bwidening this PR pins. Hand-off to 21.2 / 21.7, which both touchthese productions.
#252 stays OPEN — verified after the merge.
Closes #252rides 21.2's PR, as specced.🤖 Generated with Claude Code