Skip to content

Story 21.8 — conversion defects: CAST to BOOLEAN, temporal formats, and the DDL ingest path - #313

Merged
fupelaqu merged 3 commits into
mainfrom
feature/21.8
Sep 8, 2026
Merged

Story 21.8 — conversion defects: CAST to BOOLEAN, temporal formats, and the DDL ingest path#313
fupelaqu merged 3 commits into
mainfrom
feature/21.8

Conversation

@fupelaqu

@fupelaqu fupelaqu commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Story 21.8 of epic 21 (Core SQL front door). A conversion that cannot be performed is a bug, not a
no-op.

Closes #312

Lead-scoped to Parts A + B + E + G; C, D and F deferred. Four rulings were taken before any
code was written: scope, PD-1 (boolean semantics), OQ-3 (temporal formats), OQ-4 (unresolvable DDL
operand).

🔴 T0 refuted the story's own centrepiece

The spec shipped status: draft and said not to dev from it as written. Re-measuring every record
on current main moved two rows, and both reshaped the work.

Record 6 claimed four date functions emit uncompilable Painless — "the most serious" of the six —
and honestly flagged its blast-radius half as INFERRED.
Discharging that inference first was the
highest-value hour of the story. The malformed e0def form is produced only by the context-free
painless(None). Every production surface renders these through painless(Some(ctx)), whose arm is
correct; the painless(None) sites in shipped code (bucket_selector, bucket_script, geo
distance, top_hits script fields) either carry aggregates or drop the field. No measured
production surface emits it
⇒ Part C deferred.

A different, LIVE defect was found in the same four functions and folded in. includeTimeZone
appended " XXX" to the CALLER'S pattern — measured on real ES 8.18.3:

statement before after
DATETIME_FORMAT(ts, 'yyyy') "2025 Z" — silent wrong answer "2025"
DATETIME_PARSE('2025-01-10 10:00:00', 'yyyy-MM-dd HH:mm:ss') throws 2025-01-10T10:00Z

What changed

Part A — CAST(x AS BOOLEAN). Seven arms FROM boolean, none TO it, so every (_, Boolean) pair
fell to the identity fallback. C-style per PD-1: zero is false, Boolean.parseBoolean for strings.
Sources are ENUMERATED, not wildcarded, so (Boolean, Boolean) still reaches the identity arm.
canConvert is byte-identical and now pinned as deliberately disagreeing — it gates schema
evolution, a different contract.

Part B — the temporal format set. Painless has no expression-level try/catch (coerce
returns an expression embedded anywhere) and ofPattern's optional sections cannot express ISO's
variable-length fractional seconds, so a hand-written pattern would NARROW what parses. The set is
realised as a separator normalisation in front of a WIDER ISO formatter — one parse, strict
superset. TIMESTAMP moves to ISO_DATE_TIME.withZone(UTC), which fills a MISSING zone only: an
explicit +01:00 still wins (→ 13:30Z, measured).

Part G — the DDL ingest path. A computed column stored the UNCONVERTED operand into a correctly
mapped field. The spec designed two seams because ALTER … SET SCRIPT AS carries no column list —
true of the statement, false of the fix: Table.merge applies the ALTER to the table already
loaded from Elasticsearch, and CreateTable.schema builds its own, and both end at
Table.update()
. One seam, both sites, no I/O, nothing added to the cost-probed parse path. So
AC-G2 is fixed rather than excluded.

Part E — no code. Records 3 (epoch millis) and 4 (DECIMAL scale) confirmed CORRECT and closed
with written reasoning, because "the doc was wrong so the engine must be wrong" is the obvious and
incorrect inference.

%f (second commit). Mapped to SSS under a comment saying "microseconds". The obvious fix
would have been a regression: S is FIXED WIDTH in both directions, so SSSSSS makes the docs true
by breaking the parse of .123, and [.SSSSSS][.SSS] formats as .123456.123. That is an "except"
inside the rule Part B is justified by. DateTimeFormatterBuilder is whitelisted (measured), so
%f emits a variable-width appendFraction: parses .123, .123456, .123456789 and no
fraction at all
, and formats the value's real precision.

Verification

Every emission was EXECUTED on Elasticsearch before being pinned — a Painless claim is only
provable by Elasticsearch, and this project has paid for that rule twice. Part G is asserted on the
stored _source, never on the mapping and never on a read path: a correct mapping and a wrong
_source coexist silently.

  • Unit: sql 945 · core 924 · macrosTests 21 · bridge 197 · es6bridge 197, both
    Scala legs
  • Real ES: es6 rest 60/0/1 · es6 jest 60/0/1 · es7 61 · es8 61 · es9 61
  • Falsification: 7 mutations, 7 predicted REDs, control green after restore

Release notes

  • CAST(<n>|<string> AS BOOLEAN) now converts. ⚠️ CAST('1' AS BOOLEAN) is false — the numeric
    rule applies to a numeric operand, not to a numeric string. TRY_CAST to BOOLEAN can no longer
    yield NULL.
  • CAST to DATE also accepts yyyy/MM/dd; to TIMESTAMP/DATETIME also the space-separated spelling,
    defaulting a missing zone to UTC; to TIME now ISO_LOCAL_TIME.
  • DATETIME_FORMAT no longer appends a zone to the requested pattern and DATETIME_PARSE no longer
    demands one — values change for anyone relying on the old output.
  • %f is variable width: it formats a value's real precision and parses any number of digits.
  • A DDL computed column now CONVERTS on ingest. Existing indices keep their stored pipeline; the
    first ALTER after upgrading re-renders it.

Deferred, with local records (no new issues filed)

Parts C, D and F remain open in the spec. Two residuals were found and recorded locally:
date/time functions are unusable in a computed column (pre-existing, byte-identical after this
PR — that is AC-G3 holding — and on a published example), and it is routed to Part C, whose
family-wide guard should catch it. ⚠️ Not the same defect though: Part C is the context-free
assembly joining operand and call; the residual is the operand's runtime shape in processor context.
Part C's fix alone will not close it.

🤖 Generated with Claude Code

fupelaqu and others added 3 commits September 8, 2026 17:18
Story 21.8, scoped by the lead to Parts A + B + E + G; C, D and F deferred.

Part A - CAST(x AS BOOLEAN) was a silent no-op. `coerce` had seven arms FROM
boolean and none TO it, so every `(_, Boolean)` pair fell to the identity
fallback: `CAST(1 AS BOOLEAN)` emitted `1`. Lead ruling PD-1 is C-style -
zero is false, `Boolean.parseBoolean` for strings. Sources are ENUMERATED so
`(Boolean, Boolean)` still reaches the identity arm. `canConvert` untouched:
it gates schema evolution, not casts, and now deliberately disagrees.

Part B - the four `<string> -> <temporal>` arms hard-coded one format per
target, so `CAST('2025-01-10 14:30:00' AS TIMESTAMP)` raised while #276 had
already taught the WHERE path to accept that same literal. Lead ruling OQ-3
is an ordered format set; Painless has no expression-level try/catch and
`ofPattern`'s optional sections cannot express ISO's variable-length
fractional seconds, so it is realised as a separator normalisation in front
of a WIDER ISO formatter - one parse, a strict superset of what parsed
before. TIMESTAMP moves to `ISO_DATE_TIME.withZone(UTC)`, which fills a
MISSING zone only: an explicit offset still wins.

Also fixed, found by re-measuring record 6 on the production path and
confirmed on real ES: `includeTimeZone` appended " XXX" to the CALLER'S
pattern, so `DATETIME_FORMAT(ts, 'yyyy')` returned "2025 Z" and
`DATETIME_PARSE` threw on any input without a trailing offset.

Part G - the DDL path built ingest processors with NO schema, so a computed
column stored the UNCONVERTED operand into a correctly-mapped field: the
#205 silent-wrong-value family, invisible to every mapping-level check. The
spec expected two seams because `ALTER ... SET SCRIPT AS` carries no column
list - true of the statement, false of the fix. `Table.merge` applies the
ALTER to the LIVE table and `CreateTable.schema` builds its own, and both
end at `Table.update()`, so ONE seam covers both with no I/O and nothing
added to the parse path. An operand the schema cannot resolve stays silent
and emits the identity (lead ruling OQ-4).

Part E - records 3 (epoch millis) and 4 (DECIMAL scale) confirmed CORRECT and
closed with written reasoning and no code, because "the doc was wrong so the
engine must be wrong" is the obvious and incorrect inference.

Verification: every emission was EXECUTED on Elasticsearch 8.18.3 before
being pinned - a Painless claim is only provable by Elasticsearch. Part G is
asserted on the STORED _source, never on the mapping and never on a read
path. Green on real ES 6.8 rest+jest / 7.17 / 8.18 / 9.0; sql 943, core 924,
macrosTests 21, bridge 197, es6bridge 197, both Scala legs. Seven mutations
each produce the predicted RED.

Release notes (all user-visible):
- CAST(<n>|<string> AS BOOLEAN) now converts. `CAST('1' AS BOOLEAN)` is
  FALSE - the numeric rule applies to a numeric operand, not to a numeric
  string. TRY_CAST to BOOLEAN can no longer yield NULL.
- CAST to DATE also accepts `yyyy/MM/dd`; to TIMESTAMP/DATETIME also the
  space-separated spelling, defaulting a missing zone to UTC; to TIME now
  ISO_LOCAL_TIME, so `14:30` and fractional seconds parse.
- DATETIME_FORMAT no longer appends a zone to the requested pattern, and
  DATETIME_PARSE no longer demands one. Values change for anyone relying on
  the old output.
- A DDL computed column now CONVERTS on ingest. Existing indices keep their
  stored pipeline; the first ALTER after upgrading re-renders it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`%f` was mapped to the pattern letters `SSS` under a comment reading
"microseconds", so the engine contradicted its own comment, its published
documentation and the MySQL dialect it imitates.

MEASURED on real ES 8.18.3, `S` is FIXED WIDTH in BOTH directions, so no
letter substitution can be right:

  SSS     formats .123456789 as .123      parses .123 only
  SSSSSS  formats .123456789 as .123456   parses .123456 only
  [.SSSSSS][.SSS]                         formats as .123456.123

Mapping it to `SSSSSS` would have made the documentation true by BREAKING the
parse of a 3-digit fraction - narrowing one direction to widen the other,
which is an "except" inside the rule this same PR's temporal CAST arms are
justified by. `DateTimeFormatterBuilder` is whitelisted in Painless
(measured), so `%f` leaves the substitution map and `param` emits a
variable-width `appendFraction` instead.

A decimal point written immediately before `%f` is absorbed INTO the
fraction, which is what lets a value with no fractional part format as
`12:00:00` rather than `12:00:00.` and still parse.

Verified with the emitted script on ES 8.18.3: parses `.123` (as before),
`.123456`, `.123456789` and NO fraction at all; formats the value's real
precision. A strict SUPERSET in both directions - nothing that worked before
stopped working. The published annotation `'2025-01-10 12:00:00.123456'` is
now literally correct.

The builder is emitted only when a fraction is present, so every other format
stays on the plain `ofPattern` path and no other emission moves - pinned.

Green: sql 945, core 924, bridge 197, es6bridge 197, macrosTests 21; real ES
6.8 rest+jest / 7.17 / 8.18 / 9.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught the one defect in this story that reached a real cluster:
`CASE WHEN <comparison>` failed on all five clients with

  search_phase_execution_exception: all shards failed; compile error
  caused_by class_cast_exception:
    Cannot cast from [boolean] to [java.lang.Object].

`Criteria` fixed `out` to BOOLEAN but left `baseType` inherited from
`FunctionChain`, which walks the IDENTIFIER's chain -- so `1 = 1`
reported BIGINT and `descr = 'x'` reported the column's type. That has
always been wrong and always been inert: with no `(_, BOOLEAN)` arm in
`SQLTypeUtils.coerce`, every such pair fell to the identity fallback and
the comparison was emitted untouched.

Part A of this story added the arms, so `CASE WHEN 1 = 1` coerced an
already-boolean comparison FROM BIGINT and emitted

  def param1 = (1 == 1 != null ? (def)((1 == 1 != 0)) : null); ...

It cannot be fixed inside `coerce`: once an arm matches, a genuine
`CAST(1 AS BOOLEAN)` and this comparison are the same (BIGINT, BOOLEAN)
pair. The source type has to be right before `coerce` is called, and
`Criteria` is where both halves of that fact belong.

Emission is byte-for-byte restored to origin/main for every shape --
literal comparison, numeric column, varchar column -- so this pins a
restoration, not a new shape.

The whole unit estate stayed green while every live client failed: 945
sql + 924 core + 2 x 197 bridge tests, none of which renders a CASE
through a PainlessContext. `BooleanCastSpec` grows the production
renderer it was missing (`painless(None)` cannot see this branch at all)
plus the invariant itself, so the next `(_, BOOLEAN)` arm cannot re-open
the hole silently. Falsified: reverting the one-line fix turns exactly
those two tests red with the CI script byte-for-byte.

Verified on real Elasticsearch 6.8 (rest + jest), 7.17, 8.18.3 and 9.0:
GatewayApiSpec 72/72, 72/72, 73/73, 73/73, 73/73.

Story 21.8 / issue #312.

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

fupelaqu commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

CI caught a regression from part A — fixed in 70404137

The test job failed the same test on all five clients: GatewayApiIntegrationSpec"should answer the FROM-less connection handshake against the live cluster", at the SELECT CASE WHEN 1 = 1 THEN 'a' ELSE 'b' END row.

What Elasticsearch received:

def param1 = (1 == 1 != null ? (def)((1 == 1 != 0)) : null); param1 ? "a" : "b"
search_phase_execution_exception: all shards failed; compile error
caused_by class_cast_exception: Cannot cast from [boolean] to [java.lang.Object].

Cause. Criteria fixes out to BOOLEAN but leaves baseType inherited from FunctionChain, which walks the identifier's chain — so 1 = 1 reported BIGINT and descr = 'x' reported the column's type. That has always been wrong and always been inert: with no (_, BOOLEAN) arm in coerce, every such pair fell to the identity fallback and the comparison was emitted untouched. Part A added the arms, and the lie became live.

It cannot be repaired inside coerce — once an arm matches, a genuine CAST(1 AS BOOLEAN) and this comparison are the same (BIGINT, BOOLEAN) pair. The source type has to be right before coerce is called.

Fix. One line in Criteria, beside the out override it belongs with:

override def baseType: SQLType = SQLTypes.Boolean

Emission is byte-for-byte restored to origin/main for every shape (literal comparison, numeric column, varchar column), so this pins a restoration, not a new shape.

The part worth flagging

The whole unit estate stayed green while every live client failed — 945 sql + 924 core + 2 × 197 bridge tests, and not one of them renders a CASE through a PainlessContext. BooleanCastSpec's helper used painless(None), and Case renders an entirely different way without a context: only the context branch hoists conditions into def paramN = … bindings, and only that branch coerces the condition. A context-free assertion could not have seen this no matter what it pinned. Same distinction the story's own T0 refutation turned on.

So the spec grows the production renderer it was missing, four byte pins, and the invariant itself (cond.baseType shouldBe BOOLEAN) so the next (_, BOOLEAN) arm cannot re-open the hole silently.

Falsified: reverting the one-line fix turns exactly those two tests red, and the mutated output reproduces the CI script byte-for-byte.

Re-verified on real Elasticsearch, GatewayApiSpec per client:

ES suite
6.8 rest 72/72 (1 canceled)
6.8 jest 72/72 (1 canceled)
7.17 73/73
8.18.3 73/73
9.0 73/73

sql 947 · core 924 · bridge 197 · es6bridge 197 · scalafmtCheck · headerCheck · 2.12.20 sql/core Test/compile.

🤖 Generated with Claude Code

@fupelaqu
fupelaqu marked this pull request as ready for review September 8, 2026 17:38
@fupelaqu
fupelaqu merged commit e689113 into main Sep 8, 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.

Story 21.8 — conversion defects: CAST to BOOLEAN, the hard-coded temporal formats, the DDL ingest path

1 participant