Skip to content

Painless emission: the nine defects surfaced by #370, to fix in ONE PR #373

Description

@fupelaqu

Follow-up to #370 (PR pending). The lead's decision: all of these go into ONE PR — not one issue each. Every item below is pre-existing on main, was surfaced by executing the emissions of #370's shapes on real Elasticsearch, and was deliberately left out of #370 to keep it to parameter identity. None was made silent by #370; three went from silently wrong to loud.

Wrong-answer or loud, measured on ES 8.18.3

  1. Mixed LocalDate / ZonedDateTime comparison — a DATE-literal narrowing makes one side a LocalDate while the other side stays the raw ZonedDateTime: CAST(d AS DATE) - INTERVAL 3 DAY > d (Cannot cast ZonedDateTime to ChronoLocalDate), DATE_TRUNC(d, MONTH) = CAST('2025-01-01' AS DATE) AND DATE_TRUNC(d, MONTH) < d rendered as ONE script (CASE, Criteria.painless), no-schema CAST(d AS DATE) = d (silently no rows). Six time functions emit a statement-sequence Painless — broken in predicates (silent zero rows for IN), and LAST_DAY fails even in SELECT #367's rule 8 for a COLUMN right operand. Before A column compared with a function of itself collapses onto one Painless parameter — silent wrong answer #370 these were x < x, silently false.
  2. LocalTime has no isEqual — CAST(d AS TIME) = CAST('00:00:00' AS TIME) fails dynamic method [java.time.LocalTime, isEqual/1] not found; the = spelling must be equals for a LocalTime.
  3. CASE-expression WHEN has no null guard on its RIGHT operand — CASE <expr> WHEN lastSeen THEN … over a document without lastSeen: NullPointerException: ChronoLocalDate.toEpochDay() because "other" is null.
  4. Column-vs-column temporal comparison translated as a range with the other column's NAME as date math — WHERE d > DATE_TRUNC(ts, MONTH) becomes {"range":{"d":{"gt":"ts||/M"}}} → parse_exception: failed to parse date field [ts]. Never reaches Painless; needs the script path.
  5. Arithmetic over date functions drops the functions — WHERE YEAR(d) * 100 + MONTH(d) = 202501 renders param2 * 100 + param1 over the RAW doc-values (query context), and SCRIPT AS (YEAR(d) * 100 + MONTH(d)) does the same in an ingest processor (ArithmeticExpression.toPainless reads ctx.get(left) before the chain is applied).
  6. Processor / Transform contexts still collapse — identifiers register as LiteralParam(processParamName), so contextKey never applies: SCRIPT AS (CASE WHEN YEAR(d) > MONTH(d) …) emits ….get(ChronoField.YEAR).get(ChronoField.MONTH_OF_YEAR) on ONE parameter; GREATEST(YEAR(d), MONTH(d)) likewise. Extensions' FieldAnalyzer renders through PainlessContext(Processor), so materialized-view enrichment inherits it.
  7. Arg-coercion path re-narrows an already narrowed parameter — DATE_FORMAT(CAST(d AS DATE), …) / YEAR(CAST(d AS DATE)) inside a CASE keyed on the same CAST: function/package.scala appends painlessUtcLocalDate onto a parameter that already rendersLocalDate.
  8. A second identifier INSTANCE's narrowing is lost when it shares a registered parameter — the order of two predicates in one script decides whether the shared parameter carries the .toLocalDate() (loud in one order, loud differently in the other). Carrying a method from a later instance onto the registered object is the fix.
  9. Duplicated guarded expression — DATE_ADD(DATE_PARSE(name, …), INTERVAL 1 DAY) > d renders the guarded operand twice inside a != null ? … : null (valid, but every function in the chain is evaluated twice per document).

Also owed

Found 2026-09-19 while fixing #370; each item was executed on real Elasticsearch, before and after.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions