From 6eda4508a71dee0dd4e6622a49e396ac37bf32b8 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Sun, 11 Oct 2026 15:40:52 +0200 Subject: [PATCH] fix(contract): refuse floating-point columns as upsert primary keys A `DOUBLE PRECISION` primary-key column is unsafe: IEEE-754 treats `0.0` and `-0.0` as equal, and the warehouse collapses them to one key, so `ensure_unique_key` would reject such rows mid-run. `DOUBLE_PRECISION` is the only approximate type in the contract grammar (NUMERIC is exact decimal128), so refusing it at validation moves that failure out of the write path and keeps an unwritable upsert out of the contract. Signed-off-by: Simone Carolini Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0137E9XmyUXhwPTRrtNfivDw --- CHANGELOG.md | 5 ++++- continuo_python_runtime/contract/loader.py | 10 +++++++++- tests/contract/test_loader.py | 10 ++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e2d13..8e66a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Contracts may set `mode_type` — `replace` (the default), `append` or `upsert` — and, for `upsert` only, `primary_key`: a non-empty list of output columns, each spelled exactly as in `output_columns` and declared - `nullable: false`, names unique case-insensitively. + `nullable: false`, names unique case-insensitively, and none a + floating-point (`DOUBLE PRECISION`) column — the warehouse collapses `0.0` + and `-0.0`, so a float key is rejected at validation rather than failing at + write time. `python-csv` nodes allow only `replace`. `continuo-runtime validate`, `merge` and `run` enforce these rules; the harness's write path for the new modes follows in a later entry. diff --git a/continuo_python_runtime/contract/loader.py b/continuo_python_runtime/contract/loader.py index c5f8927..b37fd6b 100644 --- a/continuo_python_runtime/contract/loader.py +++ b/continuo_python_runtime/contract/loader.py @@ -155,7 +155,10 @@ def _validate_primary_key( output column (postgres quotes identifiers, so ``Rate_Date`` is not ``rate_date``) declared ``nullable: false``, because a NULL key never matches an existing row. Repeats are refused case-insensitively too, so a - key cannot name two columns that differ only in case. + key cannot name two columns that differ only in case. A floating-point + column is refused as well: the warehouse treats ``0.0`` and ``-0.0`` as + one key, so ``ensure_unique_key`` would reject the rows at write time -- + failing here instead keeps an unwritable upsert out of the contract. """ if mode_type != "upsert": if "primary_key" in raw: @@ -194,6 +197,11 @@ def _validate_primary_key( raise ContractError( f"{label}: 'primary_key' column {name!r} must be declared nullable: false" ) + if parse_sql_type(column.type).base == "DOUBLE_PRECISION": + raise ContractError( + f"{label}: 'primary_key' column {name!r} is floating-point " + f"({column.type}); a floating-point column cannot be a primary key" + ) return tuple(raw_key) diff --git a/tests/contract/test_loader.py b/tests/contract/test_loader.py index 94475ef..675461f 100644 --- a/tests/contract/test_loader.py +++ b/tests/contract/test_loader.py @@ -853,6 +853,16 @@ def test_primary_key_column_must_be_not_null(): parse_node(_upsert_entry(primary_key=["rate_date", "rate"]), "c.yml") +def test_primary_key_column_cannot_be_floating_point(): + """ensure_unique_key rejects float keys at write time; refuse them at validation.""" + entry = _upsert_entry(primary_key=["rate_date", "score"]) + entry["output_columns"].append({"name": "score", "type": "DOUBLE PRECISION", "nullable": False}) + with pytest.raises( + ContractError, match="'primary_key' column 'score' is floating-point" + ): + parse_node(entry, "c.yml") + + def test_primary_key_names_must_match_output_columns_exactly(): """Review Focus 2: postgres quotes identifiers, so Rate_Date is not rate_date.""" with pytest.raises(