Skip to content

fix(sql): make ALTER COLUMN ... SET|ADD FIELD parse and actually apply - #218

Merged
fupelaqu merged 3 commits into
mainfrom
fix/alter-column-set-field
Aug 10, 2026
Merged

fix(sql): make ALTER COLUMN ... SET|ADD FIELD parse and actually apply#218
fupelaqu merged 3 commits into
mainfrom
fix/alter-column-set-field

Conversation

@fupelaqu

@fupelaqu fupelaqu commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

ALTER COLUMN c SET FIELD raw KEYWORD is documented, but only ADD ever parsed — and even ADD FIELD changed nothing. Two independent bugs stacked on the same statement, both silent.

The parse

multiFields carried its own | success(Nil) fallback, so every consumer became optional. alterColumnFields is literally ALTER COLUMN c SET <multiFields>, so it matched a bare SET with an empty field list and left FIELD raw KEYWORD unconsumed:

SQL as written Before After
ALTER COLUMN c SET FIELD raw KEYWORD matched SET + nothing; the rest silently discarded pre-#213, parse error after parses
ALTER COLUMN c ADD FIELD raw KEYWORD parses parses (same AST — SET/ADD are synonyms)
ALTER COLUMN c SET parsed as AlterColumnFields(c, Nil) — a statement that did nothing rejected
ALTER COLUMN c SET FIELDS (…) parses parses (unchanged)

The fallback moved to optionalMultiFields, whose only caller is the column definition that legitimately allows a column without sub-fields. (?i)FIELDS\b cannot match FIELD raw and (?i)FIELD\b cannot match FIELDS (, 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 their copy and threw it away, returning the unchanged table.

val updatedFields = c.multiFields.filterNot(_.name == field.name) :+ field
c.copy(multiFields = updatedFields)   // result discarded
table                                  // unchanged table returned

Value discarding, the same family as the #205 bridge chain. GatewayApi.run does merge → diff → push, so an unchanged merge means an empty diff: nothing reached Elasticsearch and the DDL still reported success. DROP FIELD had the identical bug. Both now write the column back, mirroring the AlterColumnFields branch they were modelled on (field.update(Some(c)) + updateStruct()).

Round-trip

AlterColumnField.sql renders SET FIELD, so while SET FIELD was unparseable the AST could not re-parse its own rendering — the path TableDiff uses for FieldAdded/FieldAltered. Fixed by the parse change; the renderer now interpolates ${field.sql} instead of relying on Token.toString.

Docs

documentation/sql/ddl_statements.md, every example parse-probed through the real parser:

  • table-level MAPPING/SETTING take a bare key = value — the parentheses in the old text belong to an object value (SET MAPPING _meta = (owner = 'analytics')), and the documented form never parsed
  • SET|ADD ALIAS / DROP ALIAS exist in the grammar and were undocumented
  • CREATE TABLE … OPTIONS (…) section replaces a WITH (…) clause the grammar never had — the enrich walkthrough's default_pipeline was silently dropped, so following that guide produced a table whose inserts never went through the pipeline the walkthrough exists to demonstrate

softclient4es-web carries the matching SET MAPPING/SET SETTING correction 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 under sbt17) · scalafmtCheckAll + headerCheck clean.

Verified to fail without their fix: 3 of the 5 new ParserSpec tests, and 5 of the 6 new AlterTableMergeSpec tests — the plural SET FIELDS control stays green, as it must.

⚠️ Release-note item

ALTER COLUMN … SET|ADD FIELD and DROP FIELD now 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 TYPE vs SET DATA TYPE) and the AlterTable/AlterPipeline IF EXISTS position were fixed by @smanciot; the round-trip spec below covers them.

Defect Effect
SET DEFAULT _ingest.timestamp renderable but not parseable the MV _last_updated column breaks when it already exists; option had the same gap, which is how _meta.columns.<c>.default_value is written
string literals rendered unescaped SET COMMENT 'it's here' cannot be parsed back
nine throw inside combinator actions escaped Parser.apply's Either[ParserError, Statement] signature
Table.merge resolved against this, not the fold accumulator ALTER TABLE users (ADD COLUMN profile VARCHAR, ALTER COLUMN profile SET FIELDS (…)) threw ColumnNotFound
ObjectValue.set/remove collapsed paths of depth ≥ 3 setting _meta.columns.<c>.default_value replaced the whole of _meta with its innermost object
terms include/exclude derived from v.sql quote delimiters embedded in LIKE/RLIKE regexes; escaped literals would have sent a\\b to ES for a one-backslash value
help JSON SET TYPE shipped syntax the grammar rejects

The last two are why this pass matters beyond tidiness. The ObjectValue bug 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 that TableDiff itself 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 return Left rather than throw
  • ObjectValuePathSpec — depth-3 set/remove, and that removing an absent path is a no-op
  • BucketIncludesSpec — the ES include/exclude derivation
  • AlterTableMergeSpec — the accumulator cases and a convergence test: applying the ALTER a diff produced must leave no residual diff

All 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 under sbt17) · scalafmtCheckAll + headerCheck clean.

⚠️ Release-note items

  • ALTER COLUMN … SET|ADD FIELD / DROP FIELD now change the index; they previously reported success and did nothing.
  • HAVING <grouped field> LIKE|RLIKE '…' no longer embeds the quote characters in the generated terms regex, so an include that never matched now matches.

Still open, deliberately not fixed here

  • YEAR(…) inside SCRIPT AS does not parse at all — ALTER COLUMN age SET SCRIPT AS (YEAR(CURRENT_DATE) - YEAR(birthdate)) and the same expression in a CREATE TABLE column both fail, while DATE_DIFF(birthdate, CURRENT_DATE, YEAR) works. That exact expression is a published example in the web docs.
  • Nested ingest placeholders coerce to strings on a round trip ((routing = _id) renders (routing = "_id")). Left as is: the _meta JSON read-back already reconstructs a StringValue, so quoting matches how Elasticsearch stores it.
  • CharValue.sql is still unescaped; unreachable from the parser.

🤖 Generated with Claude Code

fupelaqu and others added 3 commits August 10, 2026 16:45
`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>
@fupelaqu
fupelaqu merged commit f180590 into main Aug 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant