fix(sql): stop SCRIPT AS losing its closing parenthesis to a function call - #219
Merged
Conversation
… call `age INT SCRIPT AS (YEAR(CURRENT_DATE) - YEAR(birthdate))` — the example the documentation publishes — could not be parsed, and the error blamed a parenthesis: `')' expected but 'S' found` on the SCRIPT keyword. `identifierWithFunction` closes with `rep1(end)` — one or MORE parentheses — so a call to a name-only extractor (YEAR, MONTH, DAY, WEEK, QUARTER, EPOCHDAY, YEARDAY, HOUR, MINUTE, SECOND) consumed the `)` that belongs to the enclosing production. Measured: `scriptValue` fed `YEAR(birthdate))` consumes BOTH parens, while `WEEKDAY(birthdate))`, `ABS(salary))`, `DATE_DIFF(…))` and `birthdate + 1)` each leave one. `script` then failed and a column definition fell through to `optionalMultiFields`, which is where the misleading message came from. `SELECT YEAR(x)` was never affected — nothing encloses it there. Balancing that shared production is NOT the fix, and three attempts proved it on the `MAX(year(date_trunc(datetime_parse(…), MINUTE)))` fixture: bounding the close count with `repN`, adding a balanced `extractor_identifier`, and removing `extractor_function` from `sql_function` each dropped `Year` from `identifier.functions` while `.sql` still rendered it — visible only in the emitted painless script, and caught only by the bridge suite. `rep1sep(sql_function, start)` is ambiguous and that greed is what selects the correct nested parse. So the boundary is decided locally instead: `scriptBody` scans the balanced, quote- and escape-aware parenthesised body, and `script` re-parses just that text with `parseAll(scriptValue, …)`. Every other production is untouched. Its diagnostics are `Error`, not `Failure`: `SCRIPT AS` is already consumed, so no alternative is legitimate, and a `Failure` is discarded by both call sites — `column`'s `script | optionalMultiFields` keeps the alternative's Success, and `alterTable`'s `repsep` accepts zero statements so `phrase` overwrites the message. That left the scanner's own message unreachable and reported the very symptom being fixed. sql 488 x 2.12/2.13, core 737, macros 18, all five bridges 120, scalafmtCheckAll + headerCheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The grammar has always said so — `column` is `ident ~ extension_type ~ (script | optionalMultiFields)` — but `Table.merge` did not honour it: `ALTER COLUMN … SET SCRIPT AS` kept the sub-fields the column already had. `Table.sql` then rendered `name TEXT FIELDS (…) SCRIPT AS (…)`, which cannot be parsed back, and that rendering is what SHOW CREATE TABLE and the diff statements emit. Reachable from plain SQL: CREATE TABLE t (name TEXT FIELDS (raw KEYWORD)); ALTER TABLE t ALTER COLUMN name SET SCRIPT AS (UPPER(name)); Setting a script now clears the sub-fields, and declaring sub-fields (SET FIELDS or ADD FIELD) clears the script. `Table.validate` rejects a column holding both, because the SQL path is not the only way to build one: `IndexField.ddlColumn` fills `script` and `multiFields` independently from a live mapping. Failing loudly there beats emitting DDL that will not parse. sql 494 x 2.12/2.13, core 737, macros 18, all five bridges 120, scalafmtCheckAll + headerCheck clean. 5 of the 6 new tests fail without the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
age INT SCRIPT AS (YEAR(CURRENT_DATE) - YEAR(birthdate))— the example the documentation publishes — could not be parsed, and the error blamed a parenthesis somewhere else:')' expected but 'S' found, pointing at theSCRIPTkeyword.What was actually wrong
identifierWithFunctioncloses withrep1(end)— one or more parentheses — so a call to a name-only extractor consumed the)belonging to whatever enclosed it. Measured by feedingscriptValuethe text asscriptsees it, with the wrapper's paren still pending:YEAR(birthdate))WEEKDAY(birthdate)))✓ABS(salary)))✓DATE_DIFF(birthdate, CURRENT_DATE, YEAR)))✓birthdate + 1))✓scriptwasSCRIPT AS ~ start ~ scriptValue ~ end; with the closing paren gone itsendfound nothing,scriptfailed, and a column definition fell through tooptionalMultiFields— which is where the misleading message came from.It hits only the name-only extractors (
YEAR,MONTH,DAY,WEEK,QUARTER,EPOCHDAY,YEARDAY,HOUR,MINUTE,SECOND), because those are declared asYEAR.regex ^^ (_ => new Year)and reach the generic path.WEEKDAYescapes it —day_of_week_trconsumesNAME ( ident )with a singleendof its own — as doABS,UPPER,DATE_DIFFand arithmetic.SELECT YEAR(x)was never affected: nothing encloses it there. The documented SQL was correct all along.Why the fix is where it is
Balancing the shared production is not the fix, and three attempts proved it on this fixture:
Bounding the close count with
repN, adding a balancedextractor_identifier, and removingextractor_functionfromsql_functioneach produced the same regression:identifier.functionslostYear(andMAX$becameMaxAgg) while.sqlstill renderedMAX(YEAR(DATE_TRUNC(…))). The only visible symptom was the emitted painless script losing.get(ChronoField.YEAR)— the sql suite stayed green at 487 and only the bridge suite caught it.rep1sep(sql_function, start)is ambiguous, and the greed ofrep1(end)is what selects the correct nested parse; any change to the paren accounting reshuffles the winner.So the boundary is decided locally instead. A new
scriptBodyscans the balanced, quote- and escape-aware parenthesised body, andscriptre-parses just that text withparseAll(scriptValue, …).scriptValuenever sees the outer paren, and every other production is untouched. Both call sites benefit, sincealterColumnScriptdelegates toscript.Diagnostics
The scanner reports
Error, notFailure.SCRIPT ASis already consumed, so no alternative is legitimate — and aFailureis discarded by both call sites:column'sscript | optionalMultiFieldskeeps the alternative'sSuccessand drops the deeper failure, andalterTable'srepsepaccepts zero statements sophraseoverwrites the message. That left the scanner's own message unreachable and still reported the symptom this PR exists to remove. Now:CREATE TABLE t (a INT SCRIPT AS (b + 1')' expected but 'S' foundunbalanced parentheses in SCRIPT ASCREATE TABLE t (a INT SCRIPT AS b)')' expected but 'S' found'(' expected after SCRIPT ASALTER … SET SCRIPT AS (@@@)end of input expectedInvalid SCRIPT AS expression (@@@): …Verification
sql 488 on Scala 2.12 and 2.13 · core 737 · macrosTests 18 · bridge template and all four
es{6,7,8,9}bridges 120 each (es9 undersbt17) ·scalafmtCheckAll+headerCheckclean.Four of the six new tests fail without the fix; the "functions that were never affected" case stays green, as it must.
Also in this PR: a column is multi-field or script-defined, never both
Project decision, and the grammar already said it —
columnisident ~ extension_type ~ (script | optionalMultiFields).Table.mergedid not honour it:ALTER COLUMN … SET SCRIPT ASkept the sub-fields the column already had, soTable.sqlrenderedname TEXT FIELDS (…) SCRIPT AS (…), which cannot be parsed back — and that rendering is whatSHOW CREATE TABLEand the diff statements emit. Reachable from plain SQL:Setting a script now clears the sub-fields, and declaring sub-fields (
SET FIELDS/ADD FIELD) clears the script.Table.validaterejects a column holding both, because the SQL path is not the only way to build one —IndexField.ddlColumnfillsscriptandmultiFieldsindependently from a live mapping, and failing loudly there beats emitting DDL that will not parse.Not fixed here
SELECT ABS(YEAR(x))— wrapping a date-part extractor in a scalar function does not parse, on main too. Same unbalancedrep1(end)as above, in the general case rather than theSCRIPT ASone. Filed as Scalar function wrapping a date-part extractor does not parse: SELECT ABS(YEAR(x)) #220 with the measured boundary (aggregates work, scalars do not) and a warning about the three fixes that regress the nested fixture.SCRIPT AS ()reports an inner-alternation message (… '(?i)(LEAST)\b' expected …) rather than "empty script body".🤖 Generated with Claude Code