Skip to content

fix(sql): set operations take DuckDB's types and answer one class per column; aggregates without GROUP BY answer one row - #420

Merged
fupelaqu merged 1 commit into
mainfrom
fix/set-operations-and-global-aggregates
Oct 8, 2026
Merged

fupelaqu merged 1 commit into
mainfrom
fix/set-operations-and-global-aggregates

Conversation

@fupelaqu

@fupelaqu fupelaqu commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

What

Set operations type their columns as DuckDB does. DuckDB is the engine that runs UNION, INTERSECT, EXCEPT and every UNION ALL the relational engine (arrow) plans, so core's own UNION ALL now answers the same types, the same values and the same refusals.

One rule types every depth alike: a column, each field of an object, each element of a list. Each node of DuckDB's operator tree is typed over all of its branches at once:

  • a run of one union kind is one node, in any order, even parenthesised;
  • INTERSECT / EXCEPT are binary.
Branches Type
numbers, BOOLEAN the widest (TINYINT < … < DOUBLE)
DATE and TIMESTAMP TIMESTAMP
text with any other type VARCHAR, each value spelled as DuckDB spells it (250000000000.0, 1e-05, 2024-01-31 10:30:00)
VARBINARY with text only VARBINARY (DuckDB's BLOB)
NULL literal the other branches' type
DATE / TIMESTAMP with a number or BOOLEAN, no text and no VARBINARY branch refused by name (the relational engine fails on it at runtime)
other pairs DuckDB gives no type (VARBINARY or TIME with a number or a date) VARCHAR
a list, an object or a point beside another family refused by name
lists a list of the element type these rules give
objects and points DuckDB's merged STRUCT: every field in order of first appearance, each typed by these rules; points stay GEO_POINT, normalised to {lat, lon}

One class per position. A position whose branches' types differ converts every branch's value to its type, at every depth. JDBC and Arrow, which read the type from the first value, then report it right.

  • A value is first read as its declared type, as Elasticsearch indexed it. A number stored as text ("5") is a number.
  • A date is read with its field's mapping format, as the cluster's Elasticsearch major reads it. This was measured letter by letter and name by name on 6.8.23, 7.17.29 and 8.18.3.
  • A date whose reading would depend on the client's JDK or time-zone data fails the statement, naming the field and the format, never a wrong date. That covers zone names and region ids, and forms that resolve differently across JDKs.

What answers differently from 0.23.0:

  • n UNION ALL x answers a DOUBLE column, d UNION ALL ts a TIMESTAMP column, and id UNION ALL n a VARCHAR column, one class each. 0.23.0 mixed the classes.
  • Refused by name: a date beside a number (d UNION ALL n). This now also applies inside objects and lists, where 0.23.0 answered each object as stored. That is a deliberate change: DuckDB fails on it, Unimplemented type for cast (INTEGER -> DATE).
  • Refused by name: a list or an object beside another family.

Also in this PR:

  • A calculation over aggregates without GROUP BY answers one row (MAX(n) - MIN(n), COUNT(*) * 2, MAX(d) + 1). Elasticsearch refused these searches before.
  • A DATE / TIMESTAMP cast over a per-group date calculation applies: (MAX(d) + 1)::DATE is the day. It failed in Elasticsearch before.
  • For the relational engine: private[elastic] MultiSearch.columnTypes(requests, operators) and branchTypes(requests, operators) give the type and the verdict over the operator tree (arrow will call them).
  • Licences: DuckDB's text for doubles and temporals comes from a port of {fmt} 6.1.2 and code translated from DuckDB 1.5.5, each with its MIT notice. The notices are in THIRD-PARTY-NOTICES, in META-INF/THIRD-PARTY-NOTICES of the core jar, and in the CLI bundles.

Measured

API changes (rebuild downstream modules)

  • ElasticConversion.jsonToRows takes a new last parameter, converters: binary incompatible with code compiled against 0.23.0.
  • LegProjection gains converters (four fields to five).
  • New:
    • MultiSearch.columnTypes, branchTypes, SetOperationColumn, SetOperationType, StructField;
    • SingleSearch.wholeTableCalculation;
    • ElasticAggregation.wholeTableCalculationAggregation;
    • an overload of ElasticConversion.rowProjector.

Follow-ups (not in this PR)

🤖 Generated with Claude Code

… column; aggregates without GROUP BY answer one row

- One rule types each column of a UNION ALL, UNION, INTERSECT or EXCEPT,
  each field of an object and each element of a list, over every branch of
  a node of DuckDB's operator tree. The type is DuckDB's where DuckDB gives
  one, else VARCHAR, each value spelled as DuckDB spells it. Refused by
  name: only what the relational engine fails on when the statement runs,
  i.e. a date beside a number (with no text or binary branch) and a list or
  object beside another family.
- Every branch of a position whose branches' types differ is converted to
  its type, at every depth, so the position holds one class. A value is
  first read as its declared type, as Elasticsearch indexed it:
  - numbers stored as text;
  - dates with their field's mapping format, as each Elasticsearch major
    reads it (measured on 6.8.23, 7.17.29 and 8.18.3).
  A date whose reading would depend on the client's JDK or time-zone data
  fails, naming the field and the format.
- DuckDB's text for doubles and temporals comes from a port of {fmt} 6.1.2
  and code translated from DuckDB 1.5.5, each with its MIT notice. The
  notices are in THIRD-PARTY-NOTICES, in META-INF of the core jar, and in
  the CLI bundles.
- A calculation over aggregates without GROUP BY answers one row, the
  whole table being one group (#413):
  - COUNT(*) reads the bucket count;
  - counts and sums over no document are 0 on 7.17 and later;
  - HAVING COUNT(*) = 0 keeps its row.
- A DATE or TIMESTAMP cast over a per-group date calculation applies.
- New private[elastic] MultiSearch.columnTypes / branchTypes over the
  operator tree, for the relational engine.
- Binary incompatible: ElasticConversion.jsonToRows and LegProjection gain
  a parameter.

Closes #413

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@fupelaqu
fupelaqu marked this pull request as ready for review October 8, 2026 06:45
@fupelaqu
fupelaqu merged commit fc3558f into main Oct 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.

A calculation over aggregates without GROUP BY fails in Elasticsearch: SELECT MAX(n) - MIN(n) FROM t

1 participant