Skip to content

A coercion-driven narrowing rewrites a Painless parameter other instances of the same column read #378

Description

@fupelaqu

Split out of #373 (item 8), which is otherwise complete. Measured on ES 8.18.3 at 80f76b1a.

A coercion-driven narrowing mutates a Painless parameter that other instances of the same column read, so an unrelated predicate changes the value they see.

Silent

SELECT CASE WHEN n > 0 THEN d ELSE d END AS c FROM t
  -> def param2 = (doc['d'].size() == 0 ? null : doc['d'].value);
     param3 ? param2 : param2                         -- 2025-01-05T13:45:00.000Z, correct

SELECT CASE WHEN d = CAST('2025-01-05' AS DATE) THEN d ELSE d END AS c FROM t
  -> def param1 = (doc['d'].size() == 0 ? null : doc['d'].value.toLocalDate());
     param3 ? param1 : param1                         -- 2025-01-05, the TIME IS GONE

An unrelated CAST(… AS DATE) comparison truncates the projected value of the same column. HTTP 200, a plausible date, wrong.

Loud, and in both directions

CASE WHEN d = CAST('2025-01-05' AS DATE) THEN 1
     WHEN d > CAST('2025-01-05T00:00:00' AS TIMESTAMP) THEN 2 ELSE 0 END
  -> param1 is a LocalDate; param1.isAfter(<ZonedDateTime>)   class_cast_exception

Reversing the two WHEN arms reverses the failure — the narrowing is lost instead of imposed (Cannot cast java.time.LocalDate to …ChronoZonedDateTime). Whichever predicate registers first decides the shared parameter's rendering.

Cause

contextKey (#370) is the column plus the folded CHAIN, which is what the SQL says. A narrowing can also arrive from a coercion — a comparison against a DATE literal, or a CAST — and that is invisible to the chain. Two BARE instances of one column therefore have identical keys by construction while needing different renderings, and addParam stores the parameter OBJECT, so narrowing it after registration rewrites the declaration every other instance reads.

Two approaches tried and REFUTED — do not repeat them

Both were implemented, measured, and reverted.

1. Apply the narrowing BEFORE registering, with contextKey covering it. Fixes all three shapes and the control diff moves exactly those three — but leaves a dead parameter elsewhere:

LAST_DAY(d) = CAST('2025-01-31' AS DATE)
  -> def param1 = (… doc['d'].value.toLocalDate());          -- never referenced
     def param2 = (… doc['d'].value.toInstant().atZone(Z).toLocalDate());

painless() prepends the UTC normalisation during rendering, so a key frozen at registration does not describe what is finally emitted and the operand registers a second time. (main emits one parameter here; the branch emitted two.)

2. Make contextKey a recomputed def instead of a lazy val, keeping the original ordering so re-keying happens on mutation. Emits two declarations named param1 — invalid Painless. A parameter's name is assigned at registration, and re-keying desynchronises the lookup from the stored declaration.

The constraint any fix must satisfy

The final rendering is not known at registration time — the UTC normalisation arrives later. Identity therefore cannot be settled when the parameter is created, which is what defeated both attempts.

The place where the rendering is known is render time. The suggested direction: when an identifier renders and finds a registered parameter carrying methods it did not ask for, it takes its own parameter instead of reusing that one.

Ready-made regression truths

  • CASE WHEN n > 0 THEN d ELSE d END must project the full timestamp.
  • CASE WHEN d = CAST('2025-01-05' AS DATE) THEN d ELSE d END must project the full timestamp too.
  • Both WHEN orders of the loud shape must execute and agree.
  • LAST_DAY(d) = CAST('2025-01-31' AS DATE) must keep emitting exactly ONE doc-value parameter.

Related

DATE_TRUNC(d, MONTH) = CAST('2025-01-01' AS DATE) AND DATE_TRUNC(d, MONTH) < d fails for this reason, and is characterised in MixedTemporalComparisonSpec as belonging here rather than to #373 item 1. ParameterIdentitySpec's "the identity key should not read the narrowing off the object" pins the current two-parameter shape and its comment records that BOTH orders fail the shard — that expectation will move when this is fixed.

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