fix(sql): make ALTER COLUMN ... SET|ADD FIELD parse and actually apply - #218
Merged
Conversation
`ALTER COLUMN c SET FIELD raw KEYWORD` is documented but only `ADD` ever worked, and even that changed nothing. Parse: `multiFields` carried its own `| success(Nil)` fallback, so every consumer became optional. `alterColumnFields` is `ALTER COLUMN c SET <multiFields>`, which therefore matched a bare `SET` with an empty field list and left `FIELD raw KEYWORD` unconsumed — discarded in silence before #213 made trailing input an error, a parse failure after. The fallback now lives in `optionalMultiFields`, whose only caller is the column definition that legitimately allows a column with no sub-fields. A bare `ALTER COLUMN c SET` is consequently rejected instead of parsing as a statement that did nothing. Apply: `Table.merge`'s single-field branches computed their `copy` and threw it away, returning the unchanged table — value discarding, same family as the #205 bridge chain. `SET|ADD FIELD` and `DROP FIELD` thus produced an EMPTY diff, so `GatewayApi.run` (merge -> diff -> push) sent nothing to Elasticsearch and still reported success. Both branches now write the updated column back, mirroring the `AlterColumnFields` branch they were modelled on (`field.update(Some(c))` + `updateStruct()`). Round-trip: `AlterColumnField.sql` renders `SET FIELD`, so the AST could not re-parse its own rendering while `SET FIELD` was unparseable; that closes with the parse fix. This is the path `TableDiff` uses for FieldAdded/FieldAltered. The renderer now interpolates `${field.sql}` rather than relying on `Token.toString`. Docs: ddl_statements.md — table-level MAPPING/SETTING take a bare `key = value` (the parentheses in the old text belong to an object VALUE); the undocumented SET|ADD ALIAS / DROP ALIAS are listed; and the CREATE TABLE `OPTIONS (...)` section replaces a `WITH (...)` clause the grammar never had (the enrich walkthrough's silently-dropped default_pipeline). Every example was parse-probed through the parser. 5 ParserSpec tests + a new AlterTableMergeSpec (6). Verified to fail without their fix: 3 parser tests, 5 of the 6 merge tests (the plural SET FIELDS control stays green, as it must). sql 464 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>
Follow-up to the SET|ADD FIELD fix, covering the rest of the family — an engine that renders SQL its own grammar rejects. These are live: MaterializedViewExtension executes `client.run(alter.sql)` and writes the same string into a user-runnable .sql artifact. - SET DEFAULT _ingest.timestamp could be rendered but not parsed: `alterColumnDefault` took a bare `value` while the column-level `defaultVal` allowed the ingest placeholders. `option` had the same gap, which is how the mirrored `_meta.columns.<c>.default_value` is written. Both accept them now, and the two parsers moved next to `value` in TypeParser so the object and the trait can both see them. - String literals rendered unescaped: a comment or default holding an apostrophe emitted `SET COMMENT 'it's here'`, which cannot be parsed back. `StringValue.sql` and the two plain-String comment renderers go through the new `escapeStringLiteral`, reversing exactly what the grammar's `'([^'\\]|\\.)*'` unescapes. - Nine `throw new Exception(...)` inside combinator actions became `err`. `Parser.apply` is typed `Either[ParserError, Statement]`; a raw exception escaped that signature and only GatewayApi happened to wrap the call in `ElasticResult.attempt`. - `Table.merge` resolved columns against `this` rather than the fold accumulator, so a statement could not touch a column an earlier statement in the same ALTER had just added: `ALTER TABLE users (ADD COLUMN profile VARCHAR, ALTER COLUMN profile SET FIELDS (...))` threw ColumnNotFound. - `ObjectValue.set`/`remove` descended to the LEAF's parent and then re-attached it under the FIRST key, collapsing every level in between: setting `_meta.columns.<c>.default_value` replaced the whole of `_meta` with its innermost object. Depth 1 and 2 were correct, which is why it survived — the metadata paths the schema writes are the deeper ones. Left alone this would have turned the ingest-default fix above into a silent corruption: the statement now parses, so it would have run. Recursion is on the head, one level at a time; removing an absent path is a no-op instead of overwriting its head. - The `terms` include/exclude derived its value from `v.sql` — the SQL rendering. That carried the quote delimiters (stripped for EQ/NE but NOT for LIKE/RLIKE/MATCH, which embedded `'` in the regex) and, once string literals are escaped, would have sent `a\\b` to Elasticsearch for a value holding one backslash: an include matching nothing. It now uses the value itself, which also drops the stray quotes. - help JSON shipped `SET TYPE`, which the grammar rejects; the same file's own example already used `SET DATA TYPE`. Guards, because this family has been found one member at a time: AlterTableRoundTripSpec enumerates the ALTER surface (33 statements, ALTER PIPELINE, quote/backslash cases, and the guards that must return Left rather than throw); ObjectValuePathSpec covers depth-3 set/remove; BucketIncludesSpec pins the ES include/exclude derivation; AlterTableMergeSpec gains the accumulator cases and a convergence test — applying the ALTER a diff produced must leave no residual diff, which is what the MV reconcile loop depends on. All verified to fail without their fix. sql 482 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>
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.
ALTER COLUMN c SET FIELD raw KEYWORDis documented, but onlyADDever parsed — and evenADD FIELDchanged nothing. Two independent bugs stacked on the same statement, both silent.The parse
multiFieldscarried its own| success(Nil)fallback, so every consumer became optional.alterColumnFieldsis literallyALTER COLUMN c SET <multiFields>, so it matched a bareSETwith an empty field list and leftFIELD raw KEYWORDunconsumed:ALTER COLUMN c SET FIELD raw KEYWORDSET+ nothing; the rest silently discarded pre-#213, parse error afterALTER COLUMN c ADD FIELD raw KEYWORDALTER COLUMN c SETAlterColumnFields(c, Nil)— a statement that did nothingALTER COLUMN c SET FIELDS (…)The fallback moved to
optionalMultiFields, whose only caller is the column definition that legitimately allows a column without sub-fields.(?i)FIELDS\bcannot matchFIELD rawand(?i)FIELD\bcannot matchFIELDS (, so both statements stay reachable regardless of alternation order.The apply — found by the adversarial review, and the reason this is not a one-line PR
Making the statement parse would have been worse than leaving it broken:
Table.merge's single-field branches computed theircopyand threw it away, returning the unchanged table.Value discarding, the same family as the #205 bridge chain.
GatewayApi.rundoes merge → diff → push, so an unchanged merge means an empty diff: nothing reached Elasticsearch and the DDL still reported success.DROP FIELDhad the identical bug. Both now write the column back, mirroring theAlterColumnFieldsbranch they were modelled on (field.update(Some(c))+updateStruct()).Round-trip
AlterColumnField.sqlrendersSET FIELD, so whileSET FIELDwas unparseable the AST could not re-parse its own rendering — the pathTableDiffuses forFieldAdded/FieldAltered. Fixed by the parse change; the renderer now interpolates${field.sql}instead of relying onToken.toString.Docs
documentation/sql/ddl_statements.md, every example parse-probed through the real parser:MAPPING/SETTINGtake a barekey = value— the parentheses in the old text belong to an object value (SET MAPPING _meta = (owner = 'analytics')), and the documented form never parsedSET|ADD ALIAS/DROP ALIASexist in the grammar and were undocumentedCREATE TABLE … OPTIONS (…)section replaces aWITH (…)clause the grammar never had — the enrich walkthrough'sdefault_pipelinewas silently dropped, so following that guide produced a table whose inserts never went through the pipeline the walkthrough exists to demonstratesoftclient4es-webcarries the matchingSET MAPPING/SET SETTINGcorrection on the existing sync branch (PR #37).Verification
sql 464 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.Verified to fail without their fix: 3 of the 5 new ParserSpec tests, and 5 of the 6 new
AlterTableMergeSpectests — the pluralSET FIELDScontrol stays green, as it must.ALTER COLUMN … SET|ADD FIELDandDROP FIELDnow change the index. They previously parsed (or not) and did nothing while reporting success, so a schema that "already had" those statements applied will now actually receive them.Second pass — the rest of the family
The remaining render/parse asymmetries, plus two defects the review of this PR turned up.
AlterColumnType(SET TYPEvsSET DATA TYPE) and theAlterTable/AlterPipelineIF EXISTSposition were fixed by @smanciot; the round-trip spec below covers them.SET DEFAULT _ingest.timestamprenderable but not parseable_last_updatedcolumn breaks when it already exists;optionhad the same gap, which is how_meta.columns.<c>.default_valueis writtenSET COMMENT 'it's here'cannot be parsed backthrowinside combinator actionsParser.apply'sEither[ParserError, Statement]signatureTable.mergeresolved againstthis, not the fold accumulatorALTER TABLE users (ADD COLUMN profile VARCHAR, ALTER COLUMN profile SET FIELDS (…))threwColumnNotFoundObjectValue.set/removecollapsed paths of depth ≥ 3_meta.columns.<c>.default_valuereplaced the whole of_metawith its innermost objecttermsinclude/exclude derived fromv.sqla\\bto ES for a one-backslash valueSET TYPEThe last two are why this pass matters beyond tidiness. The
ObjectValuebug is pre-existing, but the ingest-default fix above would have turned it from a loud failure into a silent corruption — the statement now parses, so it would have run and quietly emptied_meta. Measured before the fix: applying the ALTER thatTableDiffitself produced left a non-empty residual diff (MappingRemoved(_meta.not_null),_meta.data_type,_meta.default_value), i.e. an MV reconcile loop that never converges. The include/exclude one is a regression this PR's own escaping change introduced into a live ES query path, caught by the review.Guards
This family has been found one member at a time, so the tests enumerate rather than sample:
AlterTableRoundTripSpec— 33 ALTER TABLE statements + ALTER PIPELINE + quote/backslash cases + the guards that must returnLeftrather than throwObjectValuePathSpec— depth-3set/remove, and that removing an absent path is a no-opBucketIncludesSpec— the ES include/exclude derivationAlterTableMergeSpec— the accumulator cases and a convergence test: applying the ALTER a diff produced must leave no residual diffAll verified to fail without their fix.
Verification
sql 482 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.ALTER COLUMN … SET|ADD FIELD/DROP FIELDnow change the index; they previously reported success and did nothing.HAVING <grouped field> LIKE|RLIKE '…'no longer embeds the quote characters in the generatedtermsregex, so an include that never matched now matches.Still open, deliberately not fixed here
YEAR(…)insideSCRIPT ASdoes not parse at all —ALTER COLUMN age SET SCRIPT AS (YEAR(CURRENT_DATE) - YEAR(birthdate))and the same expression in aCREATE TABLEcolumn both fail, whileDATE_DIFF(birthdate, CURRENT_DATE, YEAR)works. That exact expression is a published example in the web docs.(routing = _id)renders(routing = "_id")). Left as is: the_metaJSON read-back already reconstructs aStringValue, so quoting matches how Elasticsearch stores it.CharValue.sqlis still unescaped; unreachable from the parser.🤖 Generated with Claude Code