Skip to content

fix(datagrid): enable editing tables without a primary key - #600

Merged
debba merged 3 commits into
mainfrom
fix/keyless-table-editing
Aug 5, 2026
Merged

fix(datagrid): enable editing tables without a primary key#600
debba merged 3 commits into
mainfrom
fix/keyless-table-editing

Conversation

@debba

@debba debba commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #598

Problem

Cells of a table without a primary key could not be edited at all, and the UI gave no feedback about why:

  • double clicking a cell did not enter edit mode
  • the sidebar editor accepted input but never showed the "Submit changes" button (Ctrl+S did nothing)
  • "Set NULL", "Set EMPTY" and "Delete Row" were silent no-ops
  • "Duplicate Row" worked, since inserts do not need to address an existing row

The reporter's prenotazioni table has no primary key and no unique index, which is exactly the case that triggered this. The behavior was not a regression of v0.18.0: row addressing has always required pkColumns, and every code path that needs it bailed out silently when the table had none.

How it is fixed

Row identity fallback (frontend)

New util src/utils/rowIdentity.ts:

  • resolveRowIdentity(pkColumns, columnMetadata, resultColumns) returns the primary key columns when the table has them (unchanged behavior), and otherwise falls back to identifying rows by the values of all comparable physical columns. The fallback only activates when the result set exposes every physical column of the table, so a partial SELECT a, b FROM t on a keyless table stays non-editable (it could not distinguish rows that differ only in the omitted columns).
  • isComparableColumn excludes binary, geometric, json, hstore and floating-point columns from the identity. Binary/geometric/json/hstore values live in the grid as display/wire representations that would not match in an equality comparison, and approximate numerics (FLOAT, DOUBLE, REAL) are not exactly representable, so comparing them against the grid's decimal representation may silently match nothing. Excluded columns stay editable, they just don't take part in addressing the row.
  • buildKeylessUpdatePlan plans the updates for one keyless row. Without a primary key each UPDATE invalidates the previous value of the column it just changed, and that value is part of the next WHERE clause, so updates for the same row run sequentially with the already-applied values threaded into each step's WHERE map. Values whose stored result is unknown client side (the DEFAULT sentinel) are ordered last and dropped from the WHERE maps of any later step, so applying DEFAULT to two columns of the same row works instead of failing halfway through.

Editor.tsx derives this identity once per tab and uses it everywhere rows are addressed: pending-change tracking, selection filtering, delete-selected, rollback, submit, and the pkColumns prop of DataGrid. On submit, keyless rows are updated per row in plan order, and a zero affected-rows result raises a clear error instead of silently doing nothing (the row no longer matches its original values, e.g. the data changed underneath).

Keyless deletions get the same treatment. All grid rows sharing an identity are marked for deletion together (pending deletions are keyed by the serialized identity), so each DELETE is repeated until the number of copies the grid showed is gone: MySQL removes one copy per statement (LIMIT 1) and loops, PostgreSQL/SQLite sweep them in one statement, and every driver ends up in the state the grid displayed. A zero affected-rows result raises a clear error here too (dataGrid.keylessDeleteNotFound, translated in all 11 locales).

When no safe identity exists (keyless table with a partial result), the double click now shows an explanatory alert (dataGrid.noRowIdentity, translated in all 11 locales) instead of being ignored.

Driver support (backend)

The pk_map sent to update_record / delete_record can now legitimately contain NULLs and booleans, since it may carry a full row:

  • MySQL/MariaDB: new push_pk_condition helper renders col IS NULL for NULL entries (previously rejected with "Unsupported PK type") and binds booleans, on both the prepared and the text (bastion) protocol. update_record and delete_record now append LIMIT 1: a no-op when the WHERE is a real primary key match, and a guard against sweeping duplicate rows when the identity is all-columns.
  • PostgreSQL: build_pk_predicate renders IS NULL without consuming a placeholder index and binds booleans natively.
  • SQLite: sqlite_push_pk_where gets the same IS NULL and boolean handling.

PostgreSQL and SQLite do not support LIMIT on UPDATE/DELETE, so on those drivers rows that are entirely identical are updated together; the identity collapses to the same key for such rows in the grid as well, which is consistent. For deletions the frontend loop described above makes all drivers converge, so the per-driver difference no longer leaks into the outcome.

Regression safety

  • Tables with a primary key take exactly the same code paths as before: resolveRowIdentity returns the PK columns unchanged, submits stay parallel, and the MySQL LIMIT 1 cannot change the outcome of a PK-addressed statement.
  • Public function signatures are unchanged except build_pk_predicate (internal, pub(super), single caller) whose param became optional to express "no bound parameter" for IS NULL.
  • Full suites pass: 3528 frontend tests (25 new for rowIdentity), 1108 Rust tests (18 new across the three drivers' WHERE builders), tsc, eslint.

Testing

  • Unit tests cover the identity resolution (PK present, keyless fallback, partial results, non-comparable column exclusion including floating-point types, case-insensitive matching), the keyless update plan (ordering, WHERE threading, DEFAULT-sentinel columns dropped from later WHERE maps, no caller mutation), and the drivers' NULL/bool/escaping behavior including placeholder-index continuity on PostgreSQL.
  • Verified live against a MariaDB 11 container loaded with the dump from the issue: cell edits, multi-column edits on the same row, Set NULL, Set EMPTY and Delete Row all work on prenotazioni; a subset SELECT shows the explanatory alert instead of editing.
  • The duplicate-row and stale-row semantics were verified directly against MySQL 8.4, PostgreSQL 16 and SQLite with a seeded keyless table: IS NULL addressing matches the intended row, LIMIT 1 touches exactly one of two identical copies (PostgreSQL/SQLite sweep both), and a WHERE built from stale values reports zero affected rows, which now surfaces as an error for both updates and deletions.

Tables with no primary key silently refused every cell edit: double
click did nothing, the sidebar editor never showed the submit button,
and Set NULL / Set EMPTY / Delete Row were no-ops.

Rows of keyless tables are now identified by the values of all their
comparable columns (binary, geometric, json and hstore columns are
excluded), but only when the result set exposes every physical column
of the table. Updates on such rows run sequentially per row, threading
already-applied values into each WHERE clause.

Driver support: NULL entries in the pk map render as IS NULL instead of
being rejected, booleans bind natively, and MySQL/MariaDB updates and
deletes by pk map carry LIMIT 1 so duplicate rows are never swept by a
single statement. When no safe identity exists the grid now explains
why editing is unavailable instead of ignoring the double click.
debba added 2 commits August 5, 2026 09:19
- exclude approximate numeric columns (float, double, real) from the
  fallback row identity: stored floats may not equality-match the
  grid's decimal representation, which made updates fail with a
  misleading error
- drop DEFAULT-sentinel columns from subsequent WHERE maps in the
  keyless update plan instead of matching the literal sentinel, so
  setting DEFAULT on two columns of the same row no longer fails
  after partial application
- check the affected count of keyless deletions and repeat each
  DELETE until every duplicate the grid marked is gone: MySQL
  (LIMIT 1) and PostgreSQL/SQLite (full sweep) now converge on the
  state the grid showed, and a stale row raises a clear error
  (new dataGrid.keylessDeleteNotFound key, all 11 locales) instead
  of silently doing nothing
@debba
debba merged commit 6fe4230 into main Aug 5, 2026
2 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.

[Bug]: can't edit table cells

1 participant