From a97bcf9029229661a9b3b040de66874993c04d07 Mon Sep 17 00:00:00 2001 From: Adrian Ehrsam Date: Tue, 28 Jul 2026 14:44:57 +0200 Subject: [PATCH 1/2] Add complex_helper support to pg_update_dict/pg_update pg_update_dict had no way to adapt composite/enum column values the way pg_insert/pg_upsert_dict already do via ComplexHelper, so updating an enum-typed column through it fails with a type mismatch unless the caller hand-writes the UPDATE with an explicit ::type cast. New complex_helper kwarg defaults to None, preserving existing call sites exactly. --- pgdevkit/db/crud.py | 17 +++++++++-- pyproject.toml | 2 +- tests/db/test_crud.py | 67 +++++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/pgdevkit/db/crud.py b/pgdevkit/db/crud.py index f8f05fa..56728f4 100644 --- a/pgdevkit/db/crud.py +++ b/pgdevkit/db/crud.py @@ -157,8 +157,15 @@ async def pg_update_dict( table_name: tuple[str, str], data: dict, primary_keys: Sequence[str], + *, + complex_helper: ComplexHelper | None = None, ) -> Any | None: - """Update a row identified by primary_keys. Returns the raw row tuple.""" + """Update a row identified by primary_keys. Returns the raw row tuple. + + Pass `complex_helper` when the table has composite/enum columns among the + values being set — omitted, this behaves exactly as before (plain values + passed straight through to psycopg).""" + data = await _convert_complex_values(con, table_name, data, complex_helper) set_parts = [ SQL("{col} = {val}").format(col=Identifier(k), val=Placeholder(k)) for k in data if k not in primary_keys ] @@ -173,9 +180,13 @@ async def pg_update_dict( return await cur.fetchone() -async def pg_update(con: AsyncConnection, data: T, data_type: type[T]) -> Any | None: +async def pg_update( + con: AsyncConnection, data: T, data_type: type[T], *, complex_helper: ComplexHelper | None = None +) -> Any | None: """Update a typed model instance.""" - return await pg_update_dict(con, data_type.get_table_name(), data.model_dump(), data_type.get_primary_key()) + return await pg_update_dict( + con, data_type.get_table_name(), data.model_dump(), data_type.get_primary_key(), complex_helper=complex_helper + ) async def pg_upsert_dict( diff --git a/pyproject.toml b/pyproject.toml index 58c3222..1b86fb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ packages = ["pgdevkit"] [project] name = "pgdevkit" -version = "0.3.0" +version = "0.3.1" description = "A helper for developing with Postgres" readme = "README.md" requires-python = ">=3.14" diff --git a/tests/db/test_crud.py b/tests/db/test_crud.py index a0992e0..073ee33 100644 --- a/tests/db/test_crud.py +++ b/tests/db/test_crud.py @@ -16,6 +16,7 @@ pg_retrieve, pg_retrieve_many, pg_update, + pg_update_dict, pg_upsert, pg_upsert_many, pg_upsert_many_dict, @@ -60,6 +61,20 @@ def get_primary_key() -> Sequence[str]: return ["id"] +class Gadget(PostgresTableModel): + model_config = ConfigDict(from_attributes=True) + id: int + status: str + + @staticmethod + def get_table_name() -> tuple[str, str]: + return ("public", "gadget") + + @staticmethod + def get_primary_key() -> Sequence[str]: + return ["id"] + + @pytest.fixture async def pool(monkeypatch: pytest.MonkeyPatch): ensure_container() @@ -70,6 +85,8 @@ async def pool(monkeypatch: pytest.MonkeyPatch): con.execute("CREATE TABLE widget (id serial PRIMARY KEY, name text NOT NULL)") con.execute("CREATE TYPE label_pair AS (en text, de text)") con.execute("CREATE TABLE gizmo (id serial PRIMARY KEY, label label_pair NOT NULL)") + con.execute("CREATE TYPE gadget_status AS ENUM ('active', 'inactive')") + con.execute("CREATE TABLE gadget (id serial PRIMARY KEY, status gadget_status NOT NULL)") monkeypatch.setenv(f"{ENV_PREFIX}HOST", constants.HOST) monkeypatch.setenv(f"{ENV_PREFIX}PORT", str(constants.PORT)) @@ -184,3 +201,53 @@ def backfill_de(value: dict) -> dict: fetched = await pg_retrieve(con, Gizmo, {"id": gizmo_id}, complex_helper=helper) assert fetched is not None assert fetched.label == {"en": "Hello", "de": "[Hello]"} + + +@requires_podman +async def test_update_dict_without_complex_helper_still_works_on_plain_columns(pool: PgPool): + # complex_helper is a new optional kwarg -- omitting it (the pre-existing call + # shape) must behave exactly as before for tables with no composite/enum columns. + async with pool.connection() as con: + inserted = await pg_insert(con, ("public", "widget"), {"name": "sprocket"}) + widget_id = inserted["id"] + + await pg_update_dict(con, ("public", "widget"), {"id": widget_id, "name": "renamed"}, ["id"]) + + fetched = await pg_retrieve(con, Widget, {"id": widget_id}) + assert fetched is not None + assert fetched.name == "renamed" + + +@requires_podman +async def test_update_enum_column_requires_complex_helper(pool: PgPool): + # Without a ComplexHelper, a plain str targeting an enum column fails -- + # psycopg has no registered adapter for the custom enum type, so Postgres + # rejects the untyped parameter against the enum-typed column. + async with pool.connection() as con: + inserted = await pg_insert(con, ("public", "gadget"), {"status": "active"}, complex_helper=ComplexHelper(con)) + gadget_id = inserted["id"] + + with pytest.raises(psycopg.errors.DatatypeMismatch): + await pg_update_dict(con, ("public", "gadget"), {"id": gadget_id, "status": "inactive"}, ["id"]) + + +@requires_podman +async def test_update_dict_and_update_convert_enum_column_with_complex_helper(pool: PgPool): + async with pool.connection() as con: + helper = ComplexHelper(con) + inserted = await pg_insert(con, ("public", "gadget"), {"status": "active"}, complex_helper=helper) + gadget_id = inserted["id"] + + await pg_update_dict( + con, ("public", "gadget"), {"id": gadget_id, "status": "inactive"}, ["id"], complex_helper=helper + ) + fetched = await pg_retrieve(con, Gadget, {"id": gadget_id}, complex_helper=helper) + assert fetched is not None + assert fetched.status == "inactive" + + # pg_update (typed-model variant) threads complex_helper through the same way. + fetched.status = "active" + await pg_update(con, fetched, Gadget, complex_helper=helper) + refetched = await pg_retrieve(con, Gadget, {"id": gadget_id}, complex_helper=helper) + assert refetched is not None + assert refetched.status == "active" diff --git a/uv.lock b/uv.lock index c610b87..acd2bab 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "pgdevkit" -version = "0.3.0" +version = "0.3.1" source = { editable = "." } dependencies = [ { name = "docker" }, From d6f82fe9923cfe0801fa6f39fc5f6f0bd0e48536 Mon Sep 17 00:00:00 2001 From: Adrian Ehrsam Date: Tue, 28 Jul 2026 14:51:53 +0200 Subject: [PATCH 2/2] Fix incorrect enum-update assertion in pg_update_dict tests test_update_enum_column_requires_complex_helper asserted that updating an enum column without a ComplexHelper raises -- verified against a real Postgres that this is false: psycopg already binds a plain str to an enum-typed UPDATE target fine on its own. Replaced it with a composite-column test, which is the actual case that fails without complex_helper (psycopg has no adapter for a plain dict against a composite type). Kept the enum test as a plain round-trip check instead of a failure assertion. --- tests/db/test_crud.py | 45 +++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/db/test_crud.py b/tests/db/test_crud.py index 073ee33..5556070 100644 --- a/tests/db/test_crud.py +++ b/tests/db/test_crud.py @@ -219,20 +219,12 @@ async def test_update_dict_without_complex_helper_still_works_on_plain_columns(p @requires_podman -async def test_update_enum_column_requires_complex_helper(pool: PgPool): - # Without a ComplexHelper, a plain str targeting an enum column fails -- - # psycopg has no registered adapter for the custom enum type, so Postgres - # rejects the untyped parameter against the enum-typed column. - async with pool.connection() as con: - inserted = await pg_insert(con, ("public", "gadget"), {"status": "active"}, complex_helper=ComplexHelper(con)) - gadget_id = inserted["id"] - - with pytest.raises(psycopg.errors.DatatypeMismatch): - await pg_update_dict(con, ("public", "gadget"), {"id": gadget_id, "status": "inactive"}, ["id"]) - - -@requires_podman -async def test_update_dict_and_update_convert_enum_column_with_complex_helper(pool: PgPool): +async def test_update_dict_converts_enum_column_with_complex_helper(pool: PgPool): + # psycopg already binds a plain str to an enum-typed UPDATE target column + # fine on its own (Postgres resolves it via the usual assignment cast), so + # this isn't a failing-without/passing-with case the way composite columns + # are below -- it just confirms complex_helper doesn't break the enum path + # and round-trips correctly end to end. async with pool.connection() as con: helper = ComplexHelper(con) inserted = await pg_insert(con, ("public", "gadget"), {"status": "active"}, complex_helper=helper) @@ -251,3 +243,28 @@ async def test_update_dict_and_update_convert_enum_column_with_complex_helper(po refetched = await pg_retrieve(con, Gadget, {"id": gadget_id}, complex_helper=helper) assert refetched is not None assert refetched.status == "active" + + +@requires_podman +async def test_update_dict_composite_column_requires_complex_helper(pool: PgPool): + # Unlike enums, psycopg has no built-in way to adapt a plain Python dict + # to a composite column type -- this is the real case pg_update_dict's + # complex_helper support fixes. + async with pool.connection() as con: + helper = ComplexHelper(con) + inserted = await pg_insert(con, ("public", "gizmo"), {"label": {"en": "Hello", "de": "Hallo"}}, complex_helper=helper) + gizmo_id = inserted["id"] + + with pytest.raises(psycopg.ProgrammingError): + await pg_update_dict(con, ("public", "gizmo"), {"id": gizmo_id, "label": {"en": "Bye", "de": "Tschuess"}}, ["id"]) + + await pg_update_dict( + con, + ("public", "gizmo"), + {"id": gizmo_id, "label": {"en": "Bye", "de": "Tschuess"}}, + ["id"], + complex_helper=helper, + ) + fetched = await pg_retrieve(con, Gizmo, {"id": gizmo_id}, complex_helper=helper) + assert fetched is not None + assert fetched.label == {"en": "Bye", "de": "Tschuess"}