Skip to content

Group, de-duplicate and sort a map's null and a missing key as one null, and merge a CALL's UNION ALL marks from every part, as drivine4j does - #322

Merged
jasperblues merged 3 commits into
mainfrom
one-null
Nov 1, 2026
Merged

jasperblues merged 3 commits into
mainfrom
one-null

Conversation

@jasperblues

Copy link
Copy Markdown
Member

Fixes #314
Fixes #321

Port of liberation-data/drivine4j#500 and liberation-data/drivine4j#512 (drivine4j PRs #503 and #514, review-pass commits included). Drivine for TypeScript has the first in liberation-data/drivine#501.

What changes

A map's null and a missing key are one null (#314). On Postgres and Oracle, a null read out of a map or a list is JSON's null, and a missing key is SQL's null. The two grouped, de-duplicated and sorted apart, where Neo4j has one null. A Json value now carries json_null where it may be JSON's null. That is:

  • a property or an item read out of a map or a list that isn't known to be free of nulls;
  • an entry of a parameter;
  • an unwound item of such a list;
  • head() and last() of such a list;
  • a null literal;
  • a reduce();
  • a CASE that may give any of these.

Where it is set:

  • Grouping and DISTINCT: a projection that groups or keeps its rows distinct compares a flagged item by grouped_null: NULLIF(x, 'null'::jsonb) on Postgres, and NVL(x, JSON('null')) on Oracle, as in drivine4j's review. The GROUP BY key and the DISTINCT column both read it that way. An order term that is such an item sorts by the same column. _held_first checks the compared form for a subquery.
  • UNION without ALL: a part's single flagged item is given as SQL's null (compared_null). This applies to a top-level UNION, a subquery's UNION, and a CALL's UNION. Under a CALL's UNION parts (_distinct_parts, set and restored by _comparing_parts), grouping also compares by SQL's null, so CALL { RETURN null AS v UNION RETURN {}.a AS v } doesn't split.
  • Oracle order: json_null_sort_key sorts by [rank, grouped_null(x)], so the next term decides between the two nulls.
  • Unchanged: a property read from the store is never flagged, so its SQL is the same as before.

A CALL's UNION ALL marks (#321). _called_union used to take its marks from the first part only. Now:

  • the merged column may be JSON's null where any part's value may be;
  • it is a list, a list of lists, or flat only where every part's value is, as text/texts already were.

Cypher spec

Synced to v0.20.0 with scripts/sync_cypher_spec.py v0.20.0. It adds two steps to "A null in a map and a missing key are one null" (liberation-data/drivine-cypher-spec#45). All of #314's holds are removed: the Postgres and Oracle holds on that case in queries.yaml, and the SQL hold on "A null in a written row and a missing key are one null" in writes.yaml. #321's two new steps pass with no hold.

Tests

These are in tests/integration/test_cypher_compiled.py, and in its sync copy generated by scripts/unasync.py:

SQL comparison

I compiled every statement of spec v0.20.0 (1,496) and of the openCypher TCK corpus (4,119) on origin/main (79f5d87, with #320) and on this branch, for Postgres and Oracle.

  • Refusals: none changed in either direction. No statement went from compiled to refused or back.
  • SQL that changed: 36 statements on Postgres and 59 on Oracle.
  • Why the rest is safe: every one of these reads a value that may be JSON's null where the rows are compared or sorted, which is what drivine4j's fix flags too. All of them pass the spec on both stores. One unit test pinned the old Oracle order of UNWIND [2, 'b', null] … ORDER BY t DESC. It now checks that a list without a null sorts as before, and that one holding a null sorts by NVL(…, JSON('null')).

Differences from drivine4j and Drivine for TypeScript

…ll, and merge a CALL's UNION ALL marks from every part, as drivine4j does, at Cypher spec v0.20.0

On Postgres and Oracle a null read out of a map or a list is JSON's null and a missing key SQL's, so the two grouped,
de-duplicated and sorted apart where Neo4j has one null (#314, port of liberation-data/drivine4j#500). A JSON value now
carries `json_null` where it may be JSON's null: a property or an item of a map or a list not known free of nulls, a
parameter's entry, an unwound item, head() and last() of such a list, a null written, a reduce(), and a CASE that may
give one. A projection that groups or keeps its rows distinct compares such an item with the two nulls as one
(`grouped_null`, on Oracle `NVL(x, JSON('null'))`), an order term that is the item sorts by the same column, a part of
a UNION without ALL gives it as SQL's null (`compared_null`), and Oracle's order ties the two nulls so the next term
decides (`json_null_sort_key`). A property read from the store is never flagged, so its SQL is unchanged.

A CALL's UNION ALL merged its parts' marks from the first part only (#321, port of liberation-data/drivine4j#512): the
merged column now may be JSON's null where any part's may be, and is a list, a list of lists or flat only where every
part's is.

The Cypher spec moves to v0.20.0, which adds two steps to "A null in a map and a missing key are one null"; the #314
holds are taken out.

Fixes #314
Fixes #321
@jasperblues
jasperblues merged commit d098b1d into main Nov 1, 2026
7 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

1 participant