fix(21.8 Part C): a date function must emit Painless that RUNS, in every context - #315
Merged
Conversation
…ery context
Story 21.8 Part C, lead-scoped to "everything": the deferred assembly defect, the
routed ingest residual, and three more the real-index rule turned up.
## The assembly (AD-1 / AC-5)
`TransformFunction.toPainless` appended the function's own rendering to the operand
on the assumption that it is a method CHAINED onto it. True of most of the date
family, false of the four whose call is STANDALONE -- those already carry the operand
in their own arguments, so it was emitted twice and two tokens fused:
DATE_PARSE('2025-01-10','yyyy-MM-dd')
-> "2025-01-10"LocalDate.parse("2025-01-10", ...)
DATE_PARSE(name,'yyyy-MM-dd')
-> ... e0 != null ? e0def arg0 = (doc['name']...
Fixed at the assembly, not in the four `toPainlessCall`s: the context-bearing branch
beside it already made this decision with its `else p`, so the two renderings now
agree by construction. The record's OWED confirmation is discharged -- measured on
ES 8.18.3, both pre-fix strings are `script_exception :: compile error`.
The fusion also PROPAGATED outward (`YEAR(DATE_PARSE(name, fmt))` carried it), which
the record did not have.
## Three more, all LIVE on the production path and all pre-existing
Found by executing the assembly's own output, which is what AC-4's real-index rule is
for. Each has a different cause; none is the assembly.
- `DATE_FORMAT('<string>', fmt)` and `DATETIME_FORMAT(<keyword col>, fmt)`:
`Cannot cast from [java.lang.String] to [TemporalAccessor]`. The operand is now
parsed through the same `coerce` arms a `::DATE` cast uses, so the two spellings
cannot accept different format sets. The `SQLTypes.Varchar` case-object test that
should have caught it was dead for every real column -- the same defect story 21.5
fixed in `coerce`.
- `YEAR(DATE_PARSE(col, fmt))`, `MONTH(...)`: `Cannot cast from [int] to
[java.lang.Object]`. A primitive-returning method landed OUTSIDE the parse's null
guard, where Painless cannot unify it with `null`. Neither boxing the whole
expression nor `?.` fixes it -- Painless types `cond ? null : X` as `Object` -- so
the guarded expression is bound to a name and the method applied inside.
- `DATE_TRUNC(DATE_PARSE(col, fmt), MONTH)`: `member method [java.time.LocalDate,
truncatedTo/1] not found`. A date-only value has no time to truncate.
## The ingest path (the routed residual)
`ctx.<field>` is the RAW JSON value of the document, so `YEAR`, `MONTH`, `DATE_TRUNC`,
`DATE_ADD`/`DATE_SUB`, `DATE_FORMAT` and `DATE_DIFF` threw, `ignore_failure: true`
swallowed it, and the computed column was silently ABSENT.
- the shape is decided at RUNTIME (`instanceof`), per the lead's ruling: Elasticsearch
accepts BOTH an ISO string and epoch millis into a `date` field, so neither guess
was right. The previous code hard-coded `from = SQLTypes.BigInt`, untested, and was
wrong for every documented example;
- the ingest CLOCK was `ctx['_ingest']['timestamp']`, which is NULL on ES 6.8.23,
7.17.29, 8.18.3 AND 9.0.3 -- verified on REAL indices, not `_simulate`. So
`CURRENT_DATE`/`CURRENT_TIMESTAMP`/`NOW`/`TODAY` in `SCRIPT AS` had never worked on
any version. Not a version split: naming `metadata()` fails COMPILATION on ES 6/7
even in a branch never taken. `System.currentTimeMillis()` is whitelisted on all
four and is already the unit the surrounding `Instant.ofEpochMilli(...)` expects;
- a processor collapses to ONE temporal type, `ZonedDateTime`: `ChronoUnit.between`
refuses mixed types and Elasticsearch refuses a `LocalDate` in `ctx` at all. Same
collapse `SQLTypeUtils.runtimeType` already applies to a query.
The published `DATE_DIFF(birthdate, CURRENT_DATE, YEAR)` now stores `age: 36` for
`{"birthdate":"1990-05-20"}` and `{"birthdate":643161600000}` on all four majors.
## A bridge fixture was pinning a script Elasticsearch REJECTS
`SQLQuerySpec`'s `datetime_parse` expectation returns `class_cast_exception` on
ES 8.18.3. Nothing ever executed it. Updated in both bridge copies, and the same
statement is now a unit test whose emission was executed.
## Release notes
- `DATE_PARSE`/`DATETIME_PARSE`/`DATE_FORMAT`/`DATETIME_FORMAT` produce valid Painless
in the context-free rendering; `DATE_FORMAT`/`DATETIME_FORMAT` over a STRING operand
and `YEAR`/`MONTH`/`DATE_TRUNC` over a parse result now execute instead of failing
the shard.
- ⚠️ Date and time functions in a computed column now WORK. A table created on an
earlier version keeps its old pipeline: re-run its `CREATE TABLE` (or `ALTER TABLE
... ALTER COLUMN ... SET SCRIPT AS`) and reindex documents whose computed column is
absent. `documentation/sql/ddl_statements.md` carried a limitation note that is now
false; it is replaced.
- ⚠️ `CURRENT_DATE` in an INGEST script is a `ZonedDateTime`, not a `LocalDate`. A
query is unaffected.
- Unchanged: a document missing the source field still leaves the computed column
absent.
- Downstream repos pinning generated Painless need updating for the date family.
## Verification
sql 993 / core 944 / bridge 197 / es6bridge 197 / macrosTests 21, both Scala legs.
Six mutations, six predicted REDs, restore byte-identical -- and a seventh whose
mutation did not apply was re-run rather than counted as green.
Emissions executed on real Elasticsearch 6.8.23, 7.17.29, 8.18.3 and 9.0.3: script
fields for the query path, ingest pipelines for the DDL path, both document shapes.
Records: docs/issues/local-21.8-date-parse-emits-malformed-painless.md ·
docs/issues/local-21.8-ingest-temporal-function-unusable.md (both closed, local-only).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fupelaqu
marked this pull request as ready for review
September 8, 2026 19:03
⚠️ Caught by CI, not locally, and the reason is worth recording: `sbt scalafmtAll` followed by `scalafmtCheckAll` BOTH passed in the worktree the files were written in, while `sql/Test/scalafmtCheck` on a fresh checkout reported them unformatted. scalafmt's per-project cache agreed with the run that populated it. A fresh worktree reproduces what CI sees; the same worktree does not. Line wrapping only -- no assertion, expectation or emission changes. `sql` 993/993 green after the reformat, and `headerCheck scalafmtSbtCheck scalafmtCheck test:scalafmtCheck` (the exact CI lint command) is clean on a fresh checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's integration leg failed on all five clients, and it was the right kind of failure: `GatewayApiIntegrationSpec`'s "record what an ingest script sees for a DATE column" asserted `Option(days) shouldBe None` — it was written to RECORD that the computed column did not compute, with the note that "a change in either direction is loud". Part C changed it, so it was loud. That is the test earning its keep, not a regression. The half of its reading that was right is kept: `ctx.d` IS the raw JSON value and not the temporal object a query sees, which is still why `SQLTypeUtils.coerce` guards its temporal arms on `isProcessorContext`. The conclusion drawn from it was the wrong half — that `DATEDIFF(d, CURRENT_DATE, DAY)` therefore CANNOT compute at ingest. It can, once the operand is parsed first.⚠️ The expected value is COMPUTED, not pinned: `days` is a distance from `now`, so a literal would have been correct for exactly one day. It is derived the way the ingest script derives it, with ±1 tolerance for an ingest and an assertion that straddle UTC midnight. Verified on real Elasticsearch 8.18 via the Docker testkit: the named test passes, and the whole `JavaClientGatewayApiSpec` suite is 73/73. One testkit edit covers all five clients (`copyTestkit`).⚠️ The two `ctx['_ingest']['timestamp']` strings elsewhere in this file are deliberately untouched: they are hand-written INPUT SQL for `CREATE OR REPLACE PIPELINE`, not engine output, and a supplied processor source is kept as written. 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.
Story 21.8 Part C, lead-scoped to "everything": the deferred assembly defect, the routed ingest
residual, and three more that the real-index rule turned up.
What was actually wrong
The assembly (AD-1 / AC-5).
TransformFunction.toPainlessappended a function's rendering toits operand assuming the call is a method CHAINED onto it. A STANDALONE call already carries the
operand in its own arguments, so it was emitted twice and two tokens fused —
"2025-01-10"LocalDate.parse(…),e0def arg0 = …. Fixed at the assembly, not in the fourtoPainlessCalls: the context-bearingbranch beside it already made this decision with its
else p, so the two renderings now agree byconstruction. The record's owed confirmation is discharged — measured on ES 8.18.3, both pre-fix
strings are
script_exception :: compile error. The fusion also propagated outward(
YEAR(DATE_PARSE(name, fmt))carried it), which the record did not have.Three more, all LIVE on the production path and all pre-existing. Found by executing the
assembly's own output. Each has a different cause; none is the assembly:
DATE_FORMAT('<string>', fmt),DATETIME_FORMAT(<keyword col>, fmt)Cannot cast from [java.lang.String] to [TemporalAccessor]YEAR(DATE_PARSE(col, fmt)),MONTH(...)Cannot cast from [int] to [java.lang.Object]DATE_TRUNC(DATE_PARSE(col, fmt), MONTH)member method [java.time.LocalDate, truncatedTo/1] not foundThe documented spelling
DATE_FORMAT('2025-01-10'::DATE, '%Y-%m-%d')escaped the first only becausethe CAST inserted the parse — which is why the operand is now parsed through the same
coercearms, so the two spellings cannot accept different format sets.
The ingest path (the routed residual).
ctx.<field>is the raw JSON value of the document, soYEAR/MONTH/DATE_TRUNC/DATE_ADD/DATE_FORMAT/DATE_DIFFthrew,ignore_failure: trueswallowed it, and the computed column was silently absent. Three things had to be true andonly the first was known:
instanceof), per your ruling — Elasticsearch accepts BOTH anISO string and epoch millis into a
datefield, so neither guess was right. The previous codehard-coded
from = SQLTypes.BigInt, untested, and was wrong for every documented example;ctx['_ingest']['timestamp']throwson ES 6.8.23, 7.17.29, 8.18.3 AND 9.0.3 — verified on real indices, not
_simulate— soCURRENT_DATE/CURRENT_TIMESTAMP/NOW/TODAYinSCRIPT AShad never worked anywhere.Not a version split: naming
metadata()fails COMPILATION on ES 6/7 even in a branch nevertaken.
System.currentTimeMillis()is whitelisted on all four and is already the unit thesurrounding
Instant.ofEpochMilli(...)expects;ZonedDateTime—ChronoUnit.betweenrefuses mixedtypes and Elasticsearch refuses a
LocalDateinctxat all. The same collapseSQLTypeUtils.runtimeTypealready applies to a query.The published
DATE_DIFF(birthdate, CURRENT_DATE, YEAR)now storesage: 36for{"birthdate":"1990-05-20"}and{"birthdate":643161600000}on all four majors.🔴 A bridge fixture was pinning a script Elasticsearch REJECTS
SQLQuerySpec'sdatetime_parseexpectation returnsclass_cast_exceptionon ES 8.18.3. Nothingever executed it. Updated in both bridge copies, and the same statement is now a unit test whose
emission was executed. Story 21.8's AC-G3 "byte for byte" ingest pin is deliberately gone for
the same reason: it proved the bytes had not moved and could not notice they did not run.
Release notes
DATE_PARSE/DATETIME_PARSE/DATE_FORMAT/DATETIME_FORMATproduce valid Painless in thecontext-free rendering;
DATE_FORMAT/DATETIME_FORMATover a STRING operand andYEAR/MONTH/DATE_TRUNCover a parse result now execute instead of failing the shard.version keeps its old pipeline: re-run its
CREATE TABLE(orALTER TABLE … SET SCRIPT AS) andreindex documents whose computed column is absent.
documentation/sql/ddl_statements.mdcarried alimitation note that is now false; it is replaced.
CURRENT_DATEin an INGEST script is aZonedDateTime, not aLocalDate. A query isunaffected.
Verification
legs;
scalafmtCheckAllandheaderCheckclean.apply (two occurrences, one replaced) was re-run rather than counted as green.
the query path, ingest pipelines for the DDL path, both document shapes and the null path.
Not covered
The five-CLIENT integration suites were not run; verification went straight to the four ES servers
over HTTP, which is stronger for these emissions but does not exercise the client stacks. Worth a
run before merge.
Records (local-only, per your standing rule):
docs/issues/local-21.8-date-parse-emits-malformed-painless.md·docs/issues/local-21.8-ingest-temporal-function-unusable.md— both closed.🤖 Generated with Claude Code