Skip to content

Fix keyless-table numeric/temporal WHERE binding (port of tabularis#618) - #4

Merged
aesslinger merged 1 commit into
mainfrom
port/pg618-keyless-table-binding
Aug 11, 2026
Merged

Fix keyless-table numeric/temporal WHERE binding (port of tabularis#618)#4
aesslinger merged 1 commit into
mainfrom
port/pg618-keyless-table-binding

Conversation

@aesslinger

@aesslinger aesslinger commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ports a parity fix from the built-in PostgreSQL driver, flagged by @debba on PR #3: TabularisDB/tabularis#618 fixed a bug where updating or deleting a row in a table with no primary key failed with:

ERROR: operator does not exist: numeric = text (SQLSTATE 42883)

Keyless tables identify rows by every column, so the WHERE predicate can target numeric/temporal columns. Their values reach the driver as JSON strings (numeric serializes as string to preserve arbitrary precision), and bind_pk_value bound them as plain TEXT — PostgreSQL has no implicit numeric = text (or timestamp = text) operator and rejects the statement.

This plugin has the exact same bug shape: bind_pg_string (used for SET binding on insert_record/update_record) already had the numeric/temporal coercion cascade, but bind_pk_value (used for WHERE predicates via build_pk_map_predicate — shared by update_record, delete_record, save_blob_to_file, and fetch_blob_as_data_url) never routed through it.

Fix

Factored the numeric/temporal coercion out of bind_pg_string into two shared functions — bind_pg_numeric_string / bind_pg_temporal_string, matching the builtin driver's own extracted-helper naming — and call them from both bind_pg_string and bind_pk_value. Unknown/non-numeric/non-temporal column types keep the existing shape heuristics unchanged.

TDD — full red→green trail

Per this project's standing discipline for parity fixes, wrote the tests before touching binding.rs:

  1. Added 4 tests to src/binding_tests.rs's bind_pk_value_tests module, mirroring #618's own upstream test cases exactly:
    • numeric_column_string_value_casts_to_numeric"1500.00" against a numeric column
    • double_precision_column_string_value_casts_to_double"1.5" against a double precision column
    • numeric_column_unparsable_string_is_rejected"abc" against a numeric column must error, not silently bind as text
    • timestamp_column_string_value_casts_through_text"2024-05-01 10:30:00" against a timestamp column
  2. Confirmed REDcargo test bind_pk_value_tests before the fix:
    thread '...numeric_column_string_value_casts_to_numeric' panicked:
    assertion `left == right` failed
      left: "$2"
     right: "CAST($2 AS numeric)"
    
    thread '...double_precision_column_string_value_casts_to_double' panicked:
      left: "$1"
     right: "CAST($1 AS double precision)"
    
    thread '...numeric_column_unparsable_string_is_rejected' panicked:
    assertion failed: bind_pk_value(&json!("abc"), 1, Some("numeric")).is_err()
    
    thread '...timestamp_column_string_value_casts_through_text' panicked:
      left: "$3"
     right: "CAST($3 AS timestamp)"
    
    test result: FAILED. 6 passed; 4 failed
    
    All four failed exactly the way the missing coercion predicts: bound as a bare placeholder with no CAST, or (for the unparsable case) silently accepted instead of rejected.
  3. Implemented the fix (the two shared functions + wiring bind_pk_value through them).
  4. Confirmed GREENcargo test bind_pk_value_tests after: test result: ok. 10 passed; 0 failed. Full suite: cargo test --lib --bins82 passed; 0 failed (78 previous + 4 new), cargo clippy --all-targets -- -D warnings clean, cargo fmt --all -- --check clean, no Cargo.lock drift.

Functional verification against a live PostgreSQL instance

Unit tests prove the binding logic in isolation; this proves the fix closes the actual reported bug end-to-end, including a genuine before/after reproduction:

  1. Reproduced the exact upstream bug first, against a real database (the local Podman pg-tabularis-test container, postgres:16):
    CREATE TABLE public.keyless_numeric_test (amount numeric, label text);
    INSERT INTO public.keyless_numeric_test VALUES (1500.00, 'row-a'), (250.50, 'row-b');
  2. git stash'd the fix to rebuild the binary at main's current (pre-fix) state, then sent the exact request a real update_record call would make, identifying the row by its amount column (no PK exists):
    {"method":"update_record","params":{...},"table":"keyless_numeric_test","schema":"public","pk_map":{"amount":"1500.00"},"col_name":"label","new_val":"should-fail"}
    Result: {"error":{"code":-32603,"message":"Prepare failed: db error"}} — confirmed via psql that this is precisely numeric = text (ran SELECT 1500.00::numeric = '1500.00'::text directly, got the identical operator does not exist error PostgreSQL raises).
  3. git stash pop'd the fix, rebuilt, and sent the identical request: {"result":1} — and SELECT * FROM keyless_numeric_test confirmed the row was actually updated (label changed to the new value), not just a fake success response.
  4. Repeated for delete_record against the same table (amount = 250.50) — row correctly removed.
  5. Repeated for a keyless table with a timestamp column (CREATE TABLE keyless_temporal_test (imported_at timestamp, label text)), identifying the row by imported_atupdate_record succeeded and the row was correctly updated.
  6. Cleaned up all scratch tables afterward.

Cross-repo parity gate

This change touches a shared binding path used by update_record, delete_record, save_blob_to_file, and fetch_blob_as_data_url — every method that identifies a row by primary key — so a regression here could be broad. Rebuilt the release binary and re-ran tabularis's existing, unmodified 82-test parity suite against it via POSTGRES_PLUGIN_BIN (same command used for every prior fix in this migration): 82/82 GREEN, confirming zero regression.

Test plan

  • TDD: 4 new tests written first, confirmed RED, then GREEN after the fix (see full transcript above)
  • cargo test --lib --bins: 82/82 unit tests pass (78 previous + 4 new)
  • cargo clippy --all-targets -- -D warnings, cargo fmt --all -- --check: clean
  • git diff --exit-code Cargo.lock: no drift
  • Functional live-database reproduction: bug confirmed present pre-fix (exact 42883/"operator does not exist: numeric = text" failure), confirmed fixed post-fix, for both update_record and delete_record, across numeric and temporal keyless-identifying columns
  • Cross-repo parity gate: 82/82 GREEN against tabularis's unmodified parity suite

debba flagged (PR #3, comment) that the builtin PostgreSQL driver had a bug
fixed upstream in TabularisDB/tabularis#618: updating or deleting a row in
a table with no primary key failed with "operator does not exist: numeric
= text" (SQLSTATE 42883). Keyless tables identify rows by every column, so
the WHERE predicate can target numeric/temporal columns — whose values
arrive as JSON strings (numeric serializes as string to preserve arbitrary
precision) — but bind_pk_value bound them as plain TEXT with no coercion.

This plugin shares the exact same bug shape: bind_pg_string (used for SET
binding) already had the numeric/temporal coercion cascade, but
bind_pk_value (used for WHERE predicates via build_pk_map_predicate — the
shared path for update_record, delete_record, save_blob_to_file, and
fetch_blob_as_data_url) never routed through it.

TDD, per the project's standing instruction to keep following it for
parity fixes: wrote 4 tests mirroring #618's own upstream test cases
(numeric cast, double precision cast, unparsable-numeric rejection,
timestamp cast-through-TEXT) against bind_pk_value first, confirmed all 4
failed (RED — bound as plain "$N" with no CAST, exactly the missing
coercion). Fixed by factoring the numeric/temporal coercion out of
bind_pg_string into two shared functions (bind_pg_numeric_string /
bind_pg_temporal_string, matching the builtin's own extracted-helper
naming) and calling them from both bind_pg_string and bind_pk_value.
Confirmed GREEN: 82/82 unit tests (78 previous + 4 new).

Functionally verified against a live PostgreSQL instance (the local Podman
pg-tabularis-test container), not just unit tests: created a keyless table
with a numeric column, confirmed update_record failed with the exact
42883 error on the pre-fix binary (git stash to rebuild main's current
state), then confirmed the identical request succeeds and persists
correctly on the fixed binary. Repeated for delete_record and a keyless
temporal-column table (timestamp). Re-ran tabularis's unmodified 82-test
cross-repo parity suite against the rebuilt release binary afterward —
still 82/82 GREEN, zero regression.
@aesslinger
aesslinger merged commit c1af0fe into main Aug 11, 2026
5 checks passed
@aesslinger
aesslinger deleted the port/pg618-keyless-table-binding branch August 11, 2026 14:33
aesslinger added a commit that referenced this pull request Aug 11, 2026
Version scheme reset from 0.1.0 to 1.0.0-beta.1. The actual target is
1.0.0 -- Phase 1 byte-for-byte parity is complete per tabularis PR #577's
own "CP-4 Gate Met" status -- not an independent 0.x development line, so
SemVer's own prerelease mechanism (1.0.0-beta.1 -> -beta.2 -> -rc.1 ->
1.0.0) expresses that relationship natively, which a bare 0.1.0 cannot.

Applied retroactively at this point in history (pre-#4) so the
v1.0.0-beta.1 tag/release's own manifest matches the tag name, rather than
tagging a commit that still says "0.1.0" inside its .tabularium/Cargo.toml.

Verified: .tabularium re-validated clean against the live registry schema
via @tabularium/cli validate; cargo build/test (78/78)/clippy/fmt all
pass; markdownlint clean.
aesslinger added a commit that referenced this pull request Aug 11, 2026
…18) (#4)

debba flagged (PR #3, comment) that the builtin PostgreSQL driver had a bug
fixed upstream in TabularisDB/tabularis#618: updating or deleting a row in
a table with no primary key failed with "operator does not exist: numeric
= text" (SQLSTATE 42883). Keyless tables identify rows by every column, so
the WHERE predicate can target numeric/temporal columns — whose values
arrive as JSON strings (numeric serializes as string to preserve arbitrary
precision) — but bind_pk_value bound them as plain TEXT with no coercion.

This plugin shares the exact same bug shape: bind_pg_string (used for SET
binding) already had the numeric/temporal coercion cascade, but
bind_pk_value (used for WHERE predicates via build_pk_map_predicate — the
shared path for update_record, delete_record, save_blob_to_file, and
fetch_blob_as_data_url) never routed through it.

TDD, per the project's standing instruction to keep following it for
parity fixes: wrote 4 tests mirroring #618's own upstream test cases
(numeric cast, double precision cast, unparsable-numeric rejection,
timestamp cast-through-TEXT) against bind_pk_value first, confirmed all 4
failed (RED — bound as plain "$N" with no CAST, exactly the missing
coercion). Fixed by factoring the numeric/temporal coercion out of
bind_pg_string into two shared functions (bind_pg_numeric_string /
bind_pg_temporal_string, matching the builtin's own extracted-helper
naming) and calling them from both bind_pg_string and bind_pk_value.
Confirmed GREEN: 82/82 unit tests (78 previous + 4 new).

Functionally verified against a live PostgreSQL instance (the local Podman
pg-tabularis-test container), not just unit tests: created a keyless table
with a numeric column, confirmed update_record failed with the exact
42883 error on the pre-fix binary (git stash to rebuild main's current
state), then confirmed the identical request succeeds and persists
correctly on the fixed binary. Repeated for delete_record and a keyless
temporal-column table (timestamp). Re-ran tabularis's unmodified 82-test
cross-repo parity suite against the rebuilt release binary afterward —
still 82/82 GREEN, zero regression.
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.

1 participant