Skip to content

Enforce PRIMARY KEY NOT NULL and give every read path one row image - #310

Merged
farhan-syah merged 11 commits into
mainfrom
fix/primary-key-not-null-and-row-identity
Sep 9, 2026
Merged

Enforce PRIMARY KEY NOT NULL and give every read path one row image#310
farhan-syah merged 11 commits into
mainfrom
fix/primary-key-not-null-and-row-identity

Conversation

@farhan-syah

Copy link
Copy Markdown
Member

Fixes #293.

A declared PRIMARY KEY on the document (schemaless) and key-value engines accepted NULL and omitted values. Separately, IS NULL on the identity column returned different row sets on the scan path and the aggregate path, and DELETE ... WHERE id IS NULL matched every row.

Changes

  • Refuse a NULL or omitted primary key with SQLSTATE 23502 across insert, upsert, object-literal insert, INSERT ... SELECT, MERGE, UPDATE ... FROM, and UPDATE, on the document, key-value, and columnar engines.
  • Enforce the check inside the function that mints row identity, so a write path cannot resolve an identity without passing it.
  • Refuse a primary key that a non-literal right-hand side evaluates to NULL, at the point each update path builds its post-image.
  • Enforce the same rule on the native protocol, whose plan builders bypass SQL planning.
  • Require the row's identity at the shared predicate primitive, so every read path evaluates the same row image. Covers scan, aggregate, DELETE, UPDATE, point get, index fetch, materializing scan, transaction overlay, recursive CTE, UPDATE ... FROM, MERGE, write events, and the row-level-security write gate.
  • Read SQLSTATE from the constraint kind rather than answering 23505 for every constraint.

Behavior changes

Change Effect
id is injected on read paths that omitted it A schemaless row with no declared key surfaces its storage key as id. id IS NULL matches nothing.
ErrorDetails::ConstraintViolation gains constraint A constraint verdict keeps its kind across a node boundary. A remote not-null violation reports 23502, not 23505.
ReplicatedWrite::PointUpdate and BulkDml gain declared_primary_key The applier rebuilds its plan from the record, so the check reaches the apply path. BulkDml carries None on its delete arm.

Compatibility

The replicated write record gained a field. Pre-1.0, so no migration path is provided for existing WAL or replication logs.

Scope limit

The columnar engine does not enforce uniqueness of a declared non-id primary key. Two rows carrying the same value in that column both persist when no UNIQUE INDEX exists. Found while verifying this work, not fixed here. Tracked in #309.

Validation

Check Result
cargo nextest run --workspace --exclude nodedb-cluster-tests --all-features 16000/16000
cargo nextest run -p nodedb-cluster-tests --all-features 367/367
cargo clippy --workspace --all-targets -- -D warnings clean

Coverage added in sql_primary_key_nullability.rs, sql_null_predicate_parity.rs, native_primary_key_nullability.rs, and write_verdict_sqlstate.rs.

An absent field and an explicit NULL both satisfy IS NULL, but each
execution path resolves the field independently. Assert the scan,
aggregate, DELETE, and UPDATE paths agree on the same fixture, and
that IS NULL / IS NOT NULL counts partition the collection.
A declared PRIMARY KEY implies NOT NULL on every engine, since row
identity derives from the primary-key value at plan time. Assert
23502 (not_null_violation) across document schemaless/strict, KV,
and columnar, and across INSERT, UPSERT, object-literal, INSERT ...
SELECT, and UPDATE paths. Cover the empty-string counterpart, where
the value is present and a repeated insert collides as a duplicate
key instead.
A declared PRIMARY KEY implies NOT NULL, but identity derivation
stringified the key and read the empty string as "no identity", so a
NULL or omitted key minted a fresh surrogate and committed a row no
uniqueness check could see. Return a typed DocId that separates a
present key from an explicit NULL and from an absent column, and
refuse the latter two when the catalog records a declared key.

The guard reads the declared column, not the resolved one: schemaless,
columnar, and spatial collections resolve the key name to id by
convention, which says nothing about what the DDL declared.

Map RejectedConstraint to a SQLSTATE by its constraint kind. Both
mappers hardcoded unique_violation and ignored the field.
The key is a KV row's identity, so it carries the same NOT NULL
obligation a declared PRIMARY KEY does. An omitted key column was
substituted with an empty string, which stored a row under a key no
lookup names. Substitute NULL instead, and refuse a NULL key ahead of
the intent match so every write intent is covered.
UPDATE could null out a declared primary key, and the identity choke
point shared by INSERT ... SELECT, MERGE, and UPDATE ... FROM minted a
fresh surrogate whenever the target key was NULL or absent. Both stored
a row whose declared key no longer identifies it.

Refuse a literal NULL assignment to the declared key before the engine
dispatch, so every engine answers alike, and refuse a missing key value
at the shared assignment point. Carry the declared flag on TargetPk so
an undeclared id-by-convention column keeps minting as before.

Key an empty string like any other value there. Treating it as missing
let two rows share it.
A schemaless row's identity lives in the storage key when the body
carries no id field. Readers that materialize a row inject it, but
predicate evaluation did not, so id read as absent: SELECT ... WHERE
id IS NULL returned every row while count(*) over the same predicate
returned none, and DELETE ... WHERE id IS NULL emptied the collection.

Require the identity at the shared predicate primitive and inject it
there, so a caller cannot evaluate a row without one. The scan
primitives pass the row key to their predicates to supply it. Emit
rows through the same conversion, so a projection of id returns the
value the predicate matched on.

Route the update path's row image through the shared decode, so its
write gate judges the same document the delete path's gate does.
Recursive CTE predicates, the RLS check on a point get, and a MERGE
arm's AND condition each built a row image from the stored body alone,
so a schemaless row whose identity lives only in its storage key was
judged without one. A policy or condition naming id saw it as absent.

Build all three images the way every other reader does. The MERGE fix
also reaches the bodies the write gate and the commit-time expanders
consume.
A write event's payload, the plan-time row-level-security write image,
and the materializing scan each carried a schemaless body without the
identity that lives in its storage key. A trigger WHEN clause, a policy,
or an INSERT ... SELECT filter naming id judged a row that appeared to
have none.

Inject the storage key at each source, so every consumer reads the same
string a query returns. The write gate and the event payload use that
key rather than the plan's resolved document id, which for a minted
identity is a different encoding of the same surrogate.
A declared PRIMARY KEY implies NOT NULL, but a non-literal right-hand
side resolves only once the row is in hand, so the plan-time check
cannot see it. UPDATE t SET id = NULLIF(v, v) stored a NULL key.

Carry the declared key on the update plans and refuse a post-image
that nulls it, at each site that builds one. The key travels on the
replicated write record too: the apply rebuilds its plan from that
record, so a guard reading it off the plan alone never fires.

Strict collections need no guard. Their tuple encoding already refuses
a NULL for a non-nullable column, and a declared key is non-nullable.
A refusal names its constraint kind, and the pgwire mappers read that
to answer 23502 or 23505. The cross-node encoder flattened every
constraint refusal into a generic internal error carrying one numeric
code, so a not-null violation raised on a remote shard reached the
client as a unique violation, and the coordinator dropped the code
entirely.

Carry the collection and the kind on the typed cluster error and on
ErrorDetails, so a forwarded refusal reconstructs as the verdict the
refusing shard reached and the existing mappers answer correctly.

The Data Plane's own conversion passed the kind where the collection
belonged, naming the constraint as the collection.
Minting a row identity and refusing a NULL key were two calls, so a
write path that resolved identity without the separate guard reproduced
the hole. Fold the check into the function that mints, which reads the
declared column while identity keys on the resolved one.

Enforce it on the native protocol, whose plan builders bypass SQL
planning and carried no declared key, and on the point-update binary
merge path, which returns before the post-image guard and so admitted a
literal NULL.
@farhan-syah farhan-syah added the run-ci Opt this PR into the full test suite; re-add to force a re-run label Sep 9, 2026
@farhan-syah
farhan-syah merged commit dd2ed01 into main Sep 9, 2026
13 checks passed
@farhan-syah
farhan-syah deleted the fix/primary-key-not-null-and-row-identity branch September 9, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Opt this PR into the full test suite; re-add to force a re-run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Document and kv engines accept NULL primary keys; multiple NULL-pk rows coexist and IS NULL answers differ between scan and aggregate paths

1 participant