Skip to content

UNION ALL matches branch columns BY NAME, not by position: column 2 comes back NULL when the branches name their columns differently #354

Description

@fupelaqu

Summary

A UNION ALL matches its branches' columns by name instead of by position, on every
execution route. SQL-92 7.10 says the result's column NAMES come from the first branch and the
branches are matched positionally; the engine instead looks each of the first branch's names up
in the leg's row, null-fills what it does not find, and appends the rest.

So a branch that projects the right shape with different names loses its values. HTTP 200
throughout. Same family as #205 / #209 / #253: the query runs, returns rows, and the rows are wrong.

Mechanism: multiSearch hands requests.head's output names to parseResponseTree, which applies
ElasticConversion.rowNormalizer - a by-name lookup. SearchApi.unionAllRowNormalizer applies the
same function on the per-leg and licensed-cap routes, so all three routes share the behaviour.

Reproduction (measured on real Elasticsearch 8.18)

Two indices, mapping { id: keyword, category: keyword, tag: keyword, amount: integer }.

1. Same column names, different positions - the guards ADMIT it and column 2 is NULL

SELECT a, b FROM x UNION ALL SELECT a, a FROM y

Branch arity matches and the types match, so neither parse-time guard rejects it. Branch 2's rows
come back (category, R_CAT_n), (tag, null) - column 2 is null where SQL says it holds a's value.
Reproduced on all three routes (one-shot _msearch, per-leg, licensed cap).

2. An alias per branch - the most visible instance, and the first branch is affected too

SELECT id AS x FROM rc_left LIMIT 5 UNION ALL SELECT id AS y FROM rc_right LIMIT 5

Fixture: 1 shard / 0 replicas, two documents each, L_id_1, L_id_2, R_id_1, R_id_2.
Measured at b7f40955 (the merge of #352, before story 22.6):

spelling rows
LIMIT 5 on both legs (one-shot _msearch) {x -> null, y -> L_id_1} {x -> null, y -> L_id_2} {x -> null, y -> R_id_1} {x -> null, y -> R_id_2}
no LIMIT (also one-shot at b7f40955) identical

Note branch 1's own rows: the column the analyst asked for is null, and its value appears under
the other branch's alias.

3. A duplicate output name lets a leg's extra columns through

SELECT amount, amount FROM bt_a UNION ALL SELECT amount, MAX(amount) AS m FROM bt_b

A duplicate requested name sends rowNormalizer down its legacy per-row path with a requested-name
SET, so the leg's other columns - an internal aggregate key max#m included - survive into
user-visible rows, and the routes disagree:

cap route      List(amount) ; List(amount, m, max#m)
per-leg route  List(amount) ; List(amount, m, max#m)
one-shot       List(amount) ; List(amount, max#m)

Status and scope

  • Pre-existing - case 2 is reproduced at b7f40955 above, and nothing in story 22.6 changed the
    one-shot path.
  • Where the branches agree on column names, which is the overwhelmingly common case and every
    shape any captured BI workload emits, every route agrees and is correct.
  • Story 22.6 gave the per-leg and licensed-cap routes the same normaliser the one-shot route already
    used, so the three routes now agree except on case 2's first branch (the one-shot route is the
    wrong one there) and case 3. Making them agree by copying the one-shot behaviour would have meant
    shipping a wrong answer on purpose, so it was deliberately not done.

Suggested fix

Match branches POSITIONALLY, as SQL-92 does, with the first branch supplying the names - i.e. apply
each leg's own output-name mapping first and then zip positionally against the first branch's names,
rather than looking the first branch's names up in a leg that never used them. The duplicate-name
case falls out of the same change.

This touches the ES-native UNION ALL fast path (epic 22 AD-6: "one _msearch when every leg is
bounded, byte-for-byte today's request") and the shared row contract all three routes go through, so
it wants its own change with its own real-Elasticsearch coverage across the three routes.

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