fix(sql): close exactly the parentheses a function call opened (#220) - #221
Merged
Conversation
`identifierWithFunction` closed with `rep1(end)` — one or MORE — so a call to a name-only extractor swallowed the `)` of whatever enclosed it, and `SELECT ABS(YEAR(x)) FROM t` failed with `')' expected but 'F' found` at the FROM. It now closes one `)` per `rep1sep` separator plus the optional leading `(`, and rejects a zero count so a bare function name is not read as a call. Balancing this routes `MAX(YEAR(x))` through the same window-aggregate production as `MAX(x)`, which exposed two silent losses in `Field.update`: it rebuilt the chain from the window's identifier alone, dropping that identifier's head (so `MAX(YEAR(DATE_TRUNC(x, MINUTE)))` emitted a painless script without `.get(ChronoField.YEAR)`) and everything wrapping the window (so `MAX(x)::STRING` came back un-cast). Both ends are now preserved. Behaviour changes: `AGG(...)::TYPE` is rejected rather than silently ignoring the cast — `MAX(YEAR(x))::STRING` was already rejected on main, and keeping the leading end is what stops this becoming a silent wrong answer. Aggregations are emitted in SELECT order, matching what the equivalent query with a parenthesis-balanced inner function already did. Closes #220 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 10, 2026
A cast cannot wrap an aggregate result — the aggregate has to be first in a column's function chain. CAST / TRY_CAST / CONVERT have always rejected it; this PR makes `::` agree instead of silently dropping the cast, so the restriction now needs stating, along with the equivalent form that works: `MAX(salary::BIGINT)` rather than `MAX(salary)::BIGINT`. Also corrects the cast target list, which was measured against the parser rather than trusted: DECIMAL(p,s) / NUMERIC(p,s), TEXT and BOOL were all advertised as cast targets and none of them parse (DECIMAL/NUMERIC are already recorded as unsupported in known_limitations.md), while CHAR works and was missing. The three examples that omitted FROM were fixed too — every statement requires one, so they could not have run as written. Companion web page: softclient4es-web#38. Co-Authored-By: Claude Opus 5 (1M context) <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.
SELECT ABS(YEAR(createdAt)) FROM tdid not parse, and the error named a parenthesis at theFROM:')' expected but 'F' found.What was wrong
identifierWithFunctionparses a nested call as a chain of bare function names separated by(—rep1sep(sql_function, start)— then the innermost identifier, then the closing parens. It closed withrep1(end): one or MORE. Nothing tied the number of)consumed to the number of(opened, so a call to a name-only extractor ate the parenthesis belonging to whatever enclosed it.It now closes exactly what it opened — one
)perrep1sepseparator, plus the optional one introducing the innermost identifier:Zero is now an arithmetically valid count, so it has to be rejected explicitly — the old
rep1refused it as a side effect of demanding a), and without the guardSELECT MAX FROM twould parse as a function applied to nothing.failurerather thanerr, so an alternative stays free to read the token as an ordinary column:SELECT YEAR FROM tis a column namedyear.Only the name-only extractors were affected (
YEAR,MONTH,DAY,WEEK,QUARTER,EPOCHDAY,YEARDAY,HOUR,MINUTE,SECOND), because they are declared as the bare name and so reach the generic path.DATE_TRUNC,DATE_DIFF,WEEKDAY,ABS,UPPERconsume their own parentheses and never were. That is why the failure looked arbitrary — and whySELECT YEAR(x)always worked: nothing encloses it there.This is the general case of the boundary #219 fixed locally for
SCRIPT AS.The shape change, and the two latent bugs it exposed
With the parentheses balanced,
MAX(YEAR(x))reaches the same window-aggregate production asMAX(x)and yieldsMaxAgg, where it used to fall through to the generic chain and yield the plainMaxobject. The two only ever differed because one argument happened to be parenthesis-balanced and the other was not.That newly-taken path was carrying two silent bugs, both in
Field.update, which rebuilt the chain from the window's identifier alone. A field's chain is<applied after the window> :: window :: <applied before it>and both ends were lost:windowFunction +: windowFunction.identifier.functions.taildrops a head that is only the window function when the list is the field's own. Since747553d0the list has been the window's identifier's, whose head is the innermost transform.MAX(YEAR(DATE_TRUNC(x, MINUTE)))lostYEARand was scripted without.get(ChronoField.YEAR).CastOperator :: MaxAgg :: …, soMAX(salary)::STRINGcame back un-cast, the round trip quietly shorter than the statement.AGG(…)::TYPEis now rejected instead of silently ignoring the cast. The engine requires the aggregate to be first in the chain, and a postfix cast puts something ahead of it —MAX(YEAR(x))::STRINGwas already rejected on main for exactly that reason. Preserving the leading end is what keeps it rejected; without it this PR would have downgraded that loud error into a silently un-cast column. The same error now also coversMAX(x)::TandMAX(DATE_TRUNC(x))::T, which previously parsed and dropped the cast — returning a number where the user asked for a string. Nothing in the test corpus used the shape.Aggregations now come out in SELECT order.
bridge'sdatetime_parseexpectation moved from{lastSeen, ct}to{ct, lastSeen}; itsdate_parsesibling — the same query with a balanced inner function — already expected SELECT order, so this removes an inconsistency rather than creating one. Measured in both directions and for both balanced and unbalanced inner functions. Elasticsearch does not care aboutaggskey order, but any downstream consumer pinning generated JSON for an aggregate over a bare-name extractor needs the two keys swapped. The three sibling repos were checked: none pins aggregation JSON, and the two places touching this AST (RequiredField.aggregation: Option[AggregateFunction],JoinPlanner's empty-chainSELECT *test) are unaffected.Not fixed here
SELECT ABS(YEAR(x) + 1)still fails — but so doesSELECT ABS(salary + 1). A scalar function's argument isvalueExpr, which has no arithmetic alternative; separate, pre-existing, unrelated to this boundary.MAX()still parses as a call with no argument. The new guard only rejects a bare function name; an empty argument list is a different, pre-existing acceptance.STDDEV(YEAR(x))emitsextended_statsover the raw date with no script) and HAVING / ORDER BY on an aggregate over a transform emit a malformed aggregation (double transform + doc[] in bucket_selector; empty agg name) #223 (HAVING MAX(YEAR(x)) > …emits abucket_selectorwith the transform applied twice and an illegaldoc[…]reference;ORDER BY MAX(ABS(x))names the aggregation""). Both were verified byte-identical against the pre-fix sources, so neither is caused by this change.Documentation
The last commit documents the cast restriction in
documentation/sql/type_conversion.mdandfunctions_type_conversion.md. It also corrects the cast target list, which was measured against the parser rather than trusted:DECIMAL(p,s)/NUMERIC(p,s),TEXTandBOOLwere all advertised as cast targets and none of them parse, whileCHARworks and was missing. Three examples that omittedFROMwere fixed too — every statement requires one, so they could not have run as written.Companion public page: softclient4es-web#38.
Verification
sql 500 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) · Docker integration on real ES6/7/8/9: ES8 284 passed / 3 canceled across 19 suites, ES6 55/1, ES7 56, ES9 56 ·scalafmtCheckAll+headerCheckclean.The new tests assert the emitted painless script and the function chain, not the
.sqlround trip — the round trip is blind to this entire family, which is how three earlier rebalancing attempts passedsql/testat 487 while droppingYEAR.Closes #220
🤖 Generated with Claude Code