Skip to content

fix(sql): a predicate over a function-wrapped column dropped the function - #372

Merged
fupelaqu merged 2 commits into
mainfrom
fix/367-predicate-drops-function-transform
Sep 19, 2026
Merged

fupelaqu merged 2 commits into
mainfrom
fix/367-predicate-drops-function-transform

Conversation

@fupelaqu

@fupelaqu fupelaqu commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

What was wrong

Expression.painless compared the Painless parameter name whenever the operand was registered on
the context. That is the whole operand only where the function chain folds into the parameter's own
declaration as a method — YEAR(d) becomes def param1 = doc['d']….get(ChronoField.YEAR). A chain
that renders as an expression cannot fold, so the parameter held the untransformed doc-value and
the function vanished:

WHERE WEEKDAY(d) = 0
  def param1 = (doc['d'].size() == 0 ? null : doc['d'].value.toInstant().atZone(ZoneId.of('Z')));
  param1 == null ? false : (param1 == 0)

A ZonedDateTime against an int. Painless does not reject that — it evaluates it as false,
which is precisely the reported symptom: = and IN answered zero rows with HTTP 200, NOT IN
answered every row, and > / BETWEEN / a string compareTo failed the shard. The SELECT list was
correct all along because a script_field renders the operand itself and never enters this method.

🔴 The issue's own diagnosis was wrong, and that is worth recording. It reported the malformed
statement-sequence Painless as "not the cause" and the obvious fix as "ruled out" — both conclusions
came from painless(None), a rendering path production never takes for a WHERE. The review of #366
that reported "compares a ZonedDateTime to an integer" was right.

Population — derived, not listed

A census over every function spelling the grammar accepts × operand shapes × right-hand sides
convicts 283 statements: the six date functions the issue names, plus every
CAST/CONVERT/TRY_CAST/SAFE_CAST and ISNULL/ISNOTNULL.

The eight repairs, each forced by a measurement

Repair Measured failure it removes
1 The parameter shortcut applies only when the parameter IS the chain rendering the dropped transform above
2 The coercion reads the type the chain RENDERS (new Identifier.chainType) schema-resolved WEEKDAY(d) claimed TIMESTAMP, so the extracted int was converted as a date
3 A temporal chain over a temporal column keeps the column's Java type WHERE LAST_DAY(d) = ts → dynamic method [java.time.ZonedDateTime, atStartOfDay/1] not found, where it had WORKED before
4 Numeric-vs-numeric comparisons skip the widening ((long) <guarded ternary>) → script_exception: compile error
5 A safe cast's try/catch is hoisted into the prologue, QUERY context only def left1 = try { … }; → compile error; persisted processor/transform bytes unchanged
6 IN over a numeric operand compares with == [9,12].contains(<Long>) and <Double> matched NOTHING; <Integer> matched — List.contains is Java equals
7 COALESCE renders balanced at every arity 1 arg → String.valueOf( : param1)); ≥3 args → no_viable_alt_exception, in EVERY venue including a plain SELECT
8 The .toLocalDate() narrowing respects the column's type, and fires on both sides or neither .toLocalDate() on a keyword column failed the shard; narrowing one side gave Cannot cast java.time.ZonedDateTime to java.time.chrono.ChronoLocalDate

Both operands, not one. check chose the comparison spelling from the right-hand identifier's
out — its column's type — so YEAR(d) = YEAR(ts) compared two ints with isEqual
(dynamic method [java.lang.Integer, isEqual/1] not found) and CAST(num AS INT) = CAST(num2 AS INT)
stringified the left and lost every match, silently. One derivation, valueType, now answers for the
right what chainType answers for the left.

Verification

Two guards that measure different things.

PredicateTransformSurvivalSpec (sql, unit) generates its population from SQLKeywords.functionWords
— 18,273 predicates — and asks three questions, no allow-list:

  • is the operand's transform still in the emission (two detectors, because each is blind where the
    other sees: a signature diff misses DATE_PARSE, whose LocalDate.parse also appears on the right,
    and ISNULL, whose transform contains no call at all);
  • is every method the emission calls declared on the receiver the pipeline hands it — walking the
    method CHAIN and re-typing at each step from the JDK's own return types;
  • do the delimiters balance.

PredicateFunctionResultSpec (testkit, 5 clients) executes the predicates over a real index and
asserts ROW SETS, because comparing a ZonedDateTime to an int is not an error but a wrong answer,
and no byte pin can see that.

Results

  • Execution spec: 23/23 on ES 7.17, 8.18 and 9.0; 19/23 + 4 recorded skips on 6.8 (rest and jest).
  • Falsifying: 11 of 15 rows were RED on unfixed code, with the issue's exact symptoms (Set() for
    = 0, all seven rows for NOT IN).
  • Both regression pins and the receiver check were mutation-verified — each one was green over a
    live defect until the mutation proved it fires.
  • sql 1386 · core 1129 · macros 30 · licensing 153 · all four bridges 208 — green. Lint clean
    (headerCheck scalafmtSbtCheck scalafmtCheck test:scalafmtCheck), Scala 2.12 compiles.

Blast radius outside predicates: 36 of 2,211 statements, over SELECT script fields, sorts,
buckets, date math, transform and ingest renderings — 24 COALESCE (every one an emission
Elasticsearch previously rejected) and 12 TRY_CAST/SAFE_CAST (the query-context hoist). The
persisted ingest renderings that change are the three broken COALESCE ones; no TRY_CAST processor
or transform byte moves.

Release notes

  • Predicates over a function-wrapped column return different rows — the right ones. Anything
    built on the old answers (a saved filter, a dashboard, a materialized view whose WHERE used one of
    these functions) changes behaviour.
  • IN over a numeric operand emits <param> == v1 || <param> == v2 instead of [v1,v2].contains(…).
  • TRY_CAST / SAFE_CAST in a query render a hoisted try/catch; COALESCE renders balanced at
    every arity. Downstream fixtures pinning generated Painless need updating.
  • Additive API only: Identifier.chainType, PainlessContext.bindLocalWith, and two protected
    members on Expression. No existing signature changed, so the sql module stays
    binary-compatible.
  • ES 6.8 keeps two documented limitations, tracked as ES 6.8: a raw date doc-value reaches Painless un-normalised — DATE_FORMAT/DATETIME_FORMAT in every venue, and any temporal expression compared with a date column #371, with the rows assume-skipped and the
    verbatim Elasticsearch errors recorded in the spec.

Found and NOT fixed, each recorded in the spec with its measurement

Documentation — the Elasticsearch 6.8 limit of DATE_FORMAT / DATETIME_FORMAT (#371)

A second commit documents the limit rather than fixing it, and the measurement narrows the claim a
long way: on 6.8 only a bare date column fails. Measured on real 6.8.23 over
created_at = 2025-01-10:

Operand 6.8
DATE_FORMAT('2025-01-10'::DATE, '%Y-%m-%d') — every published example works
DATE_FORMAT(CAST(created_at AS DATE), '%Y-%m-%d') works
DATE_FORMAT(DATE_TRUNC(created_at, DAY), '%Y-%m-%d') works
DATE_FORMAT(created_at, '%Y-%m-%d') refused

So the docs give a one-edit workaround, not just a warning, and record that no published example is
affected. The note lands in the function reference (the shape STDDEV/VARIANCE's version limit
already uses), in the known-limitations page, and in the REPL help corpus for both functions.

⚠️ Why documented and not fixed. The one-line normalisation was written, measured and REVERTED: it
repairs the bare-column case and breaks five shapes whose operand is already a LocalDate
(DATE_TRUNC, DATE_ADD, CAST(d AS DATE), CURRENT_DATE, LAST_DAY) — LocalDate has no
toInstant(). Instrumenting the call site found the real mechanism: the narrowing is already
REQUESTED (identifier.painlessMethods holds .toInstant().atZone(Z).toLocalDate()) and never reaches
the parameter declaration, because PainlessParam identity is the doc-value STRING, so the object
whose method list is mutated is not the object the prologue renders. That is #370's defect, and one
fix in parameter-declaration rendering serves both.

This commit also adds EmissionCostProbe, the companion of ParseCostProbe for the other half of what
a request pays before Elasticsearch sees it — schema resolution and script emission. Same protocol,
and it asserts no timing, for the reason #269/#270 recorded.

Closes #367

🤖 Generated with Claude Code

…tion

`Expression.painless` compared the Painless PARAMETER NAME whenever the operand
was registered on the context. That is the whole operand only where the function
chain folds into the parameter's own declaration as a method — `YEAR(d)` becomes
`def param1 = doc['d']....get(ChronoField.YEAR)`. A chain that renders as an
EXPRESSION cannot fold, so the parameter held the untransformed doc-value and the
function VANISHED: `WHERE WEEKDAY(d) = 0` emitted

    def param1 = (doc['d'].size() == 0 ? null : doc['d'].value...);
    param1 == null ? false : (param1 == 0)

a `ZonedDateTime` against an `int`. Painless does not reject that, it evaluates
it as FALSE — so `=` and `IN` answered ZERO rows with HTTP 200 and `NOT IN`
answered EVERY row, while `>` / `BETWEEN` / a string `compareTo` failed the
shard. The SELECT list was correct all along, which is why it survived: a
`script_field` renders the operand itself and never enters this method.

The population is every function whose Painless is not a suffix method chain,
and it is wider than the six the issue names: WEEKDAY/DAYOFWEEK, LAST_DAY,
DATE_FORMAT, DATETIME_FORMAT, DATE_PARSE, DATETIME_PARSE with their aliases,
plus every CAST/CONVERT/TRY_CAST/SAFE_CAST and ISNULL/ISNOTNULL — 283 statements
of a 4,209-predicate census.

Eight repairs, each forced by a measurement on real Elasticsearch:

1. the parameter shortcut is taken only when the parameter IS the chain rendering
2. the coercion reads the type the chain RENDERS (new `Identifier.chainType`),
   not the column's — the schema-resolved `WEEKDAY(d)` claimed to be a TIMESTAMP
3. a TEMPORAL chain over a TEMPORAL column keeps the column's Java type; coercing
   it emitted `.atStartOfDay(...)`, a `LocalDate` method, onto a `ZonedDateTime`
4. numeric-vs-numeric comparisons skip the widening: `((long) <guarded ternary>)`
   is a compile error, and Painless compares boxed numerics itself
5. a safe cast's `try`/`catch` is hoisted into the script prologue in a QUERY
   context, so it can be an operand at all; the PERSISTED processor/transform
   renderings keep their bytes
6. `IN` over a numeric operand compares with `==`: `List.contains` is Java
   `equals`, which is false across boxed types, so any non-Integer answered
   nothing
7. `COALESCE` at one argument emitted `String.valueOf( : param1))` and at three or
   more an unbalanced expression Elasticsearch cannot parse, in every venue
8. the `.toLocalDate()` narrowing no longer lands on a non-temporal column, and
   fires on both sides of a comparison or on neither

The RIGHT operand had the same lie: `check` chose the comparison spelling from
the right-hand identifier's `out`, i.e. its column's type, so `YEAR(d) =
YEAR(ts)` compared two `int`s with `isEqual` and `CAST(num AS INT) = CAST(num2 AS
INT)` stringified the left and lost every match. One derivation, `valueType`,
now answers for the right what `chainType` answers for the left.

Two guards, and they measure different things. `PredicateTransformSurvivalSpec`
generates its population from `SQLKeywords.functionWords` and asks three
questions with no allow-list: is the transform still there, is every method the
emission calls declared on the receiver the pipeline hands it (walking the chain
and re-typing at each step, the JDK deciding), and do the delimiters balance.
`PredicateFunctionResultSpec` EXECUTES the predicates over a real index and
asserts ROW SETS, because comparing a `ZonedDateTime` to an `int` is not an error
but a wrong answer, and no byte pin can see that.

Closes #367

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ME_FORMAT

On 6.8 a `date` doc-value reaches Painless as a `JodaCompatibleZonedDateTime`
while `DateTimeFormatter.format` requires a `java.time.TemporalAccessor`, and the
operand of these two functions is passed as an ARGUMENT, so it never reaches the
UTC normalisation the CHAINED date functions get. The query is refused with a
script error there and works on 7, 8 and 9.

MEASURED on real 6.8.23 over `created_at = 2025-01-10`, which narrows the claim
considerably — only a BARE date column fails:

    DATE_FORMAT('2025-01-10'::DATE, '%Y-%m-%d')          works
    DATE_FORMAT(CAST(created_at AS DATE), '%Y-%m-%d')    works
    DATE_FORMAT(DATE_TRUNC(created_at, DAY), '%Y-%m-%d') works
    DATE_FORMAT(created_at, '%Y-%m-%d')                  refused

So the documentation carries a one-edit workaround rather than a bare warning,
and records that every formatting example already published uses a cast and is
therefore unaffected. The note goes in the function reference (in the same shape
the `STDDEV`/`VARIANCE` version limit already uses), in the known-limitations
page, and in the REPL help corpus for both functions.

⚠️ This documents the limit instead of fixing it, deliberately. The one-line
normalisation that fixes the bare-column case was written, measured and REVERTED:
it breaks five shapes whose operand is already a `LocalDate` (`DATE_TRUNC`,
`DATE_ADD`, `CAST(d AS DATE)`, `CURRENT_DATE`, `LAST_DAY`), since `LocalDate` has
no `toInstant()`. Instrumenting the call site found why: the narrowing is already
REQUESTED — `identifier.painlessMethods` holds
`.toInstant().atZone(Z).toLocalDate()` — and never reaches the declaration, which
renders as the bare doc-value, because `PainlessParam` identity is the doc-value
STRING and the mutated object is not the rendered one. That is the same defect as
issue #370, and one fix in how a parameter's declaration is rendered serves both.

Also adds `EmissionCostProbe`, the companion of `ParseCostProbe` for the other
half of what a request pays before Elasticsearch sees it: schema resolution and
script emission. Same protocol, same refusal to assert a timing.

Refs #371, #370

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fupelaqu
fupelaqu merged commit 650345f into main Sep 19, 2026
3 of 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.

Six time functions emit a statement-sequence Painless — broken in predicates (silent zero rows for IN), and LAST_DAY fails even in SELECT

1 participant