From 4e7c4e9c2e979454bfdf92354aa24f1c54996b6e Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 3 Sep 2026 21:59:48 -0400 Subject: [PATCH 1/3] fix(postgrest): accept CLI-generated types in insert/upsert/update insert/upsert/update typed their body as JSON, so CLI-generated TypedDicts with datetime/date/time/UUID/Decimal fields failed type checking. Widening the alias alone is not enough: httpx serializes json= with stdlib json.dumps, which raises TypeError on those values at runtime. Add a JSONSerializable input alias (strict JSON type left unchanged for response validation) and normalize outbound bodies to JSON-safe primitives via pydantic in RequestConfig, keeping the httpx request path as-is. Fixes #1443 --- .../src/postgrest/_async/request_builder.py | 8 +- .../src/postgrest/_sync/request_builder.py | 8 +- .../src/postgrest/base_request_builder.py | 30 ++++-- src/postgrest/src/postgrest/types.py | 25 ++++- .../tests/_async/test_request_builder.py | 98 ++++++++++++++++++- .../tests/_sync/test_request_builder.py | 98 ++++++++++++++++++- 6 files changed, 248 insertions(+), 19 deletions(-) diff --git a/src/postgrest/src/postgrest/_async/request_builder.py b/src/postgrest/src/postgrest/_async/request_builder.py index f5c34e5f..6ee8e753 100644 --- a/src/postgrest/src/postgrest/_async/request_builder.py +++ b/src/postgrest/src/postgrest/_async/request_builder.py @@ -23,7 +23,7 @@ pre_upsert, ) from ..exceptions import APIError, APIErrorFromJSON, generate_default_error_message -from ..types import JSON, ReturnMethod +from ..types import JSONSerializable, ReturnMethod from ..utils import model_validate_json ReqConfig = RequestConfig[AsyncClient] @@ -330,7 +330,7 @@ def select( def insert( self, - json: JSON, + json: JSONSerializable, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -371,7 +371,7 @@ def insert( def upsert( self, - json: JSON, + json: JSONSerializable, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -416,7 +416,7 @@ def upsert( def update( self, - json: JSON, + json: JSONSerializable, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, diff --git a/src/postgrest/src/postgrest/_sync/request_builder.py b/src/postgrest/src/postgrest/_sync/request_builder.py index df502b69..22c66afd 100644 --- a/src/postgrest/src/postgrest/_sync/request_builder.py +++ b/src/postgrest/src/postgrest/_sync/request_builder.py @@ -23,7 +23,7 @@ pre_upsert, ) from ..exceptions import APIError, APIErrorFromJSON, generate_default_error_message -from ..types import JSON, ReturnMethod +from ..types import JSONSerializable, ReturnMethod from ..utils import model_validate_json ReqConfig = RequestConfig[Client] @@ -330,7 +330,7 @@ def select( def insert( self, - json: JSON, + json: JSONSerializable, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -371,7 +371,7 @@ def insert( def upsert( self, - json: JSON, + json: JSONSerializable, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -416,7 +416,7 @@ def upsert( def update( self, - json: JSON, + json: JSONSerializable, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, diff --git a/src/postgrest/src/postgrest/base_request_builder.py b/src/postgrest/src/postgrest/base_request_builder.py index 2a562859..6eac2546 100644 --- a/src/postgrest/src/postgrest/base_request_builder.py +++ b/src/postgrest/src/postgrest/base_request_builder.py @@ -2,6 +2,7 @@ import json import sys +from collections.abc import Mapping from json import JSONDecodeError from re import search from typing import ( @@ -39,7 +40,16 @@ from pydantic import validator as field_validator # type: ignore from .base_client import BasePostgrestClient -from .types import JSON, CountMethod, Filters, JSONAdapter, RequestMethod, ReturnMethod +from .types import ( + JSON, + CountMethod, + Filters, + JSONAdapter, + JSONSerializable, + RequestMethod, + ReturnMethod, + jsonable_encoder, +) from .utils import sanitize_param @@ -48,7 +58,7 @@ class QueryArgs(NamedTuple): method: RequestMethod params: QueryParams headers: Headers - json: JSON + json: JSONSerializable C = TypeVar("C", Client, AsyncClient) @@ -64,7 +74,7 @@ def __init__( headers: Headers, params: QueryParams, auth: BasicAuth | None, - json: JSON, + json: JSONSerializable, retry_enabled: bool = True, ) -> None: self.session: C = session @@ -72,7 +82,11 @@ def __init__( self.http_method = http_method self.headers = headers self.params = params - self.json = None if http_method in {"GET", "HEAD"} else json + # Normalize datetime/UUID/Decimal values to JSON-safe primitives so the + # httpx json= path (stdlib json.dumps) can serialize CLI-generated types. + self.json: JSON | None = ( + None if http_method in {"GET", "HEAD"} else jsonable_encoder(json) + ) self.auth = auth self.retry_enabled = retry_enabled @@ -104,7 +118,7 @@ def should_retry(self, response: RequestResponse, attempt_count: int) -> bool: return response.status_code == 503 or response.status_code == 520 -def _unique_columns(json: List[Dict[str, JSON]]): +def _unique_columns(json: List[Mapping[str, Any]]): unique_keys = {key for row in json for key in row.keys()} columns = ",".join([f'"{k}"' for k in unique_keys]) return columns @@ -141,7 +155,7 @@ def pre_select( def pre_insert( - json: JSON, + json: JSONSerializable, *, count: Optional[CountMethod], returning: ReturnMethod, @@ -164,7 +178,7 @@ def pre_insert( def pre_upsert( - json: JSON, + json: JSONSerializable, *, count: Optional[CountMethod], returning: ReturnMethod, @@ -190,7 +204,7 @@ def pre_upsert( def pre_update( - json: JSON, + json: JSONSerializable, *, count: Optional[CountMethod], returning: ReturnMethod, diff --git a/src/postgrest/src/postgrest/types.py b/src/postgrest/src/postgrest/types.py index 748f87e4..e575cb3f 100644 --- a/src/postgrest/src/postgrest/types.py +++ b/src/postgrest/src/postgrest/types.py @@ -2,7 +2,10 @@ import sys from collections.abc import Mapping, Sequence -from typing import Union +from datetime import date, datetime, time +from decimal import Decimal +from typing import Any, Union +from uuid import UUID from httpx import AsyncClient, BasicAuth, Client, Headers, QueryParams from pydantic import TypeAdapter @@ -20,6 +23,26 @@ ) JSONAdapter: TypeAdapter = TypeAdapter(JSON) +# Accepted input for write operations (insert/upsert/update). +# Supabase CLI-generated types include datetime/date/time/UUID/Decimal fields, +# which are not strict JSON but serialize to JSON cleanly. Kept separate from +# JSON so inbound response validation stays strict. +JSONSerializable = TypeAliasType( + "JSONSerializable", + "Union[None, bool, str, int, float, datetime, date, time, UUID, Decimal, Sequence[JSONSerializable], Mapping[str, JSONSerializable]]", +) + +_AnyAdapter: TypeAdapter = TypeAdapter(Any) + + +def jsonable_encoder(value: JSONSerializable) -> JSON: + """Convert datetime/date/time/UUID/Decimal values to JSON-safe primitives. + + Plain JSON passes through unchanged. Mirrors the outbound handling in v3 + (pydantic-based serialization) without changing the httpx request path. + """ + return _AnyAdapter.dump_python(value, mode="json") + class CountMethod(StrEnum): exact = "exact" diff --git a/src/postgrest/tests/_async/test_request_builder.py b/src/postgrest/tests/_async/test_request_builder.py index 9cdc0baa..ad697897 100644 --- a/src/postgrest/tests/_async/test_request_builder.py +++ b/src/postgrest/tests/_async/test_request_builder.py @@ -1,13 +1,19 @@ +import json +from datetime import date, datetime, time +from decimal import Decimal from typing import Any, AsyncIterable, Dict, List +from uuid import UUID import pytest from httpx import AsyncClient, Headers, QueryParams, Request, Response +from pydantic import TypeAdapter +from typing_extensions import TypedDict from yarl import URL from postgrest import AsyncRequestBuilder, AsyncSingleRequestBuilder from postgrest._async.request_builder import RequestConfig from postgrest.base_request_builder import APIResponse, SingleAPIResponse -from postgrest.types import JSON, CountMethod, ReturnMethod +from postgrest.types import JSON, CountMethod, JSONSerializable, ReturnMethod @pytest.fixture @@ -560,3 +566,93 @@ def test_single_with_csv_data( ) assert isinstance(result.data, str) assert result.data == csv_api_response + + +class MovieInsert(TypedDict): + """Mimics a Supabase CLI-generated insert type with non-strict-JSON fields.""" + + name: str + created_at: datetime + id: UUID + + +class TestWriteSerializableTypes: + """insert/upsert/update accept CLI-generated types (#1443).""" + + def test_generated_typeddict_validates_as_serializable(self): + TypeAdapter(JSONSerializable).validate_python( + { + "name": "foo", + "created_at": datetime(2024, 1, 2, 3, 4, 5), + "id": UUID("12345678-1234-5678-1234-567812345678"), + } + ) + + def test_insert_serializes_generated_types(self, request_builder): + builder = request_builder.insert( + MovieInsert( + name="foo", + created_at=datetime(2024, 1, 2, 3, 4, 5), + id=UUID("12345678-1234-5678-1234-567812345678"), + ) + ) + + assert builder.request.json == { + "name": "foo", + "created_at": "2024-01-02T03:04:05", + "id": "12345678-1234-5678-1234-567812345678", + } + # Previously raised TypeError inside httpx's stdlib json.dumps + assert json.loads(json.dumps(builder.request.json)) == builder.request.json + + def test_insert_serializes_date_time_decimal(self, request_builder): + builder = request_builder.insert( + { + "day": date(2024, 1, 2), + "at": time(3, 4, 5), + "amount": Decimal("1.5"), + } + ) + + assert builder.request.json == { + "day": "2024-01-02", + "at": "03:04:05", + "amount": "1.5", + } + assert json.loads(json.dumps(builder.request.json)) == builder.request.json + + def test_upsert_bulk_serializes_generated_types(self, request_builder): + builder = request_builder.upsert( + [ + { + "name": "foo", + "created_at": datetime(2024, 1, 2, 3, 4, 5), + } + ] + ) + + assert builder.request.json == [ + {"name": "foo", "created_at": "2024-01-02T03:04:05"} + ] + assert set(builder.request.params["columns"].split(",")) == set( + '"name","created_at"'.split(",") + ) + assert json.loads(json.dumps(builder.request.json)) == builder.request.json + + def test_update_serializes_generated_types(self, request_builder): + builder = request_builder.update({"created_at": datetime(2024, 1, 2, 3, 4, 5)}) + + assert builder.request.json == {"created_at": "2024-01-02T03:04:05"} + assert json.loads(json.dumps(builder.request.json)) == builder.request.json + + def test_plain_json_body_unchanged(self, request_builder): + body = { + "key1": "val1", + "n": 1, + "f": 1.5, + "b": True, + "z": None, + "l": [1, "a", {"k": "v"}], + } + + assert request_builder.insert(body).request.json == body diff --git a/src/postgrest/tests/_sync/test_request_builder.py b/src/postgrest/tests/_sync/test_request_builder.py index 435f8ab5..6944609a 100644 --- a/src/postgrest/tests/_sync/test_request_builder.py +++ b/src/postgrest/tests/_sync/test_request_builder.py @@ -1,13 +1,19 @@ +import json +from datetime import date, datetime, time +from decimal import Decimal from typing import Any, Dict, Iterable, List +from uuid import UUID import pytest from httpx import Client, Headers, QueryParams, Request, Response +from pydantic import TypeAdapter +from typing_extensions import TypedDict from yarl import URL from postgrest import SyncRequestBuilder, SyncSingleRequestBuilder from postgrest._async.request_builder import RequestConfig from postgrest.base_request_builder import APIResponse, SingleAPIResponse -from postgrest.types import JSON, CountMethod, ReturnMethod +from postgrest.types import JSON, CountMethod, JSONSerializable, ReturnMethod @pytest.fixture @@ -560,3 +566,93 @@ def test_single_with_csv_data( ) assert isinstance(result.data, str) assert result.data == csv_api_response + + +class MovieInsert(TypedDict): + """Mimics a Supabase CLI-generated insert type with non-strict-JSON fields.""" + + name: str + created_at: datetime + id: UUID + + +class TestWriteSerializableTypes: + """insert/upsert/update accept CLI-generated types (#1443).""" + + def test_generated_typeddict_validates_as_serializable(self): + TypeAdapter(JSONSerializable).validate_python( + { + "name": "foo", + "created_at": datetime(2024, 1, 2, 3, 4, 5), + "id": UUID("12345678-1234-5678-1234-567812345678"), + } + ) + + def test_insert_serializes_generated_types(self, request_builder): + builder = request_builder.insert( + MovieInsert( + name="foo", + created_at=datetime(2024, 1, 2, 3, 4, 5), + id=UUID("12345678-1234-5678-1234-567812345678"), + ) + ) + + assert builder.request.json == { + "name": "foo", + "created_at": "2024-01-02T03:04:05", + "id": "12345678-1234-5678-1234-567812345678", + } + # Previously raised TypeError inside httpx's stdlib json.dumps + assert json.loads(json.dumps(builder.request.json)) == builder.request.json + + def test_insert_serializes_date_time_decimal(self, request_builder): + builder = request_builder.insert( + { + "day": date(2024, 1, 2), + "at": time(3, 4, 5), + "amount": Decimal("1.5"), + } + ) + + assert builder.request.json == { + "day": "2024-01-02", + "at": "03:04:05", + "amount": "1.5", + } + assert json.loads(json.dumps(builder.request.json)) == builder.request.json + + def test_upsert_bulk_serializes_generated_types(self, request_builder): + builder = request_builder.upsert( + [ + { + "name": "foo", + "created_at": datetime(2024, 1, 2, 3, 4, 5), + } + ] + ) + + assert builder.request.json == [ + {"name": "foo", "created_at": "2024-01-02T03:04:05"} + ] + assert set(builder.request.params["columns"].split(",")) == set( + '"name","created_at"'.split(",") + ) + assert json.loads(json.dumps(builder.request.json)) == builder.request.json + + def test_update_serializes_generated_types(self, request_builder): + builder = request_builder.update({"created_at": datetime(2024, 1, 2, 3, 4, 5)}) + + assert builder.request.json == {"created_at": "2024-01-02T03:04:05"} + assert json.loads(json.dumps(builder.request.json)) == builder.request.json + + def test_plain_json_body_unchanged(self, request_builder): + body = { + "key1": "val1", + "n": 1, + "f": 1.5, + "b": True, + "z": None, + "l": [1, "a", {"k": "v"}], + } + + assert request_builder.insert(body).request.json == body From 9fe023ec16c84acd1669bc19e6cef8eff0414589 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 14 Sep 2026 21:04:03 -0400 Subject: [PATCH 2/3] fix(postgrest): type-check TypedDict rows in insert/upsert/update mypy and pyright reject a TypedDict where Mapping[str, ] is expected, so the JSONSerializable alias still failed the pattern from the issue. Add a write-only JSONSerializableInput alias that also accepts TypedDict classes via their __required_keys__/__optional_keys__ markers; plain mappings keep the strict JSONSerializable value types. Also cast the encoder result to JSON so the package's mypy target passes. Static regression helpers in the request-builder tests fail on the previous annotations and pass here. --- .../src/postgrest/_async/request_builder.py | 8 +++---- .../src/postgrest/_sync/request_builder.py | 8 +++---- .../src/postgrest/base_request_builder.py | 12 +++++----- src/postgrest/src/postgrest/types.py | 22 ++++++++++++++++--- .../tests/_async/test_request_builder.py | 18 +++++++++++++++ .../tests/_sync/test_request_builder.py | 18 +++++++++++++++ 6 files changed, 69 insertions(+), 17 deletions(-) diff --git a/src/postgrest/src/postgrest/_async/request_builder.py b/src/postgrest/src/postgrest/_async/request_builder.py index 6ee8e753..84d2b386 100644 --- a/src/postgrest/src/postgrest/_async/request_builder.py +++ b/src/postgrest/src/postgrest/_async/request_builder.py @@ -23,7 +23,7 @@ pre_upsert, ) from ..exceptions import APIError, APIErrorFromJSON, generate_default_error_message -from ..types import JSONSerializable, ReturnMethod +from ..types import JSONSerializableInput, ReturnMethod from ..utils import model_validate_json ReqConfig = RequestConfig[AsyncClient] @@ -330,7 +330,7 @@ def select( def insert( self, - json: JSONSerializable, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -371,7 +371,7 @@ def insert( def upsert( self, - json: JSONSerializable, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -416,7 +416,7 @@ def upsert( def update( self, - json: JSONSerializable, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, diff --git a/src/postgrest/src/postgrest/_sync/request_builder.py b/src/postgrest/src/postgrest/_sync/request_builder.py index 22c66afd..86c7c0f8 100644 --- a/src/postgrest/src/postgrest/_sync/request_builder.py +++ b/src/postgrest/src/postgrest/_sync/request_builder.py @@ -23,7 +23,7 @@ pre_upsert, ) from ..exceptions import APIError, APIErrorFromJSON, generate_default_error_message -from ..types import JSONSerializable, ReturnMethod +from ..types import JSONSerializableInput, ReturnMethod from ..utils import model_validate_json ReqConfig = RequestConfig[Client] @@ -330,7 +330,7 @@ def select( def insert( self, - json: JSONSerializable, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -371,7 +371,7 @@ def insert( def upsert( self, - json: JSONSerializable, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -416,7 +416,7 @@ def upsert( def update( self, - json: JSONSerializable, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, diff --git a/src/postgrest/src/postgrest/base_request_builder.py b/src/postgrest/src/postgrest/base_request_builder.py index 6eac2546..bdf5ffb1 100644 --- a/src/postgrest/src/postgrest/base_request_builder.py +++ b/src/postgrest/src/postgrest/base_request_builder.py @@ -45,7 +45,7 @@ CountMethod, Filters, JSONAdapter, - JSONSerializable, + JSONSerializableInput, RequestMethod, ReturnMethod, jsonable_encoder, @@ -58,7 +58,7 @@ class QueryArgs(NamedTuple): method: RequestMethod params: QueryParams headers: Headers - json: JSONSerializable + json: JSONSerializableInput C = TypeVar("C", Client, AsyncClient) @@ -74,7 +74,7 @@ def __init__( headers: Headers, params: QueryParams, auth: BasicAuth | None, - json: JSONSerializable, + json: JSONSerializableInput, retry_enabled: bool = True, ) -> None: self.session: C = session @@ -155,7 +155,7 @@ def pre_select( def pre_insert( - json: JSONSerializable, + json: JSONSerializableInput, *, count: Optional[CountMethod], returning: ReturnMethod, @@ -178,7 +178,7 @@ def pre_insert( def pre_upsert( - json: JSONSerializable, + json: JSONSerializableInput, *, count: Optional[CountMethod], returning: ReturnMethod, @@ -204,7 +204,7 @@ def pre_upsert( def pre_update( - json: JSONSerializable, + json: JSONSerializableInput, *, count: Optional[CountMethod], returning: ReturnMethod, diff --git a/src/postgrest/src/postgrest/types.py b/src/postgrest/src/postgrest/types.py index e575cb3f..b94ab29e 100644 --- a/src/postgrest/src/postgrest/types.py +++ b/src/postgrest/src/postgrest/types.py @@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence from datetime import date, datetime, time from decimal import Decimal -from typing import Any, Union +from typing import Any, ClassVar, Protocol, Union, cast from uuid import UUID from httpx import AsyncClient, BasicAuth, Client, Headers, QueryParams @@ -32,16 +32,32 @@ "Union[None, bool, str, int, float, datetime, date, time, UUID, Decimal, Sequence[JSONSerializable], Mapping[str, JSONSerializable]]", ) + +class _TypedDictLike(Protocol): + # Every TypedDict class defines these attributes and plain mappings don't. + # Needed because neither mypy nor pyright accepts a TypedDict where a + # Mapping with concrete value types is expected. + __required_keys__: ClassVar[frozenset[str]] + __optional_keys__: ClassVar[frozenset[str]] + + +# Write inputs additionally accept TypedDict rows, while plain mappings still +# have to satisfy the strict JSONSerializable value types above. +JSONSerializableInput = TypeAliasType( + "JSONSerializableInput", + "Union[None, bool, str, int, float, datetime, date, time, UUID, Decimal, Sequence[JSONSerializableInput], Mapping[str, JSONSerializableInput], _TypedDictLike]", +) + _AnyAdapter: TypeAdapter = TypeAdapter(Any) -def jsonable_encoder(value: JSONSerializable) -> JSON: +def jsonable_encoder(value: JSONSerializableInput) -> JSON: """Convert datetime/date/time/UUID/Decimal values to JSON-safe primitives. Plain JSON passes through unchanged. Mirrors the outbound handling in v3 (pydantic-based serialization) without changing the httpx request path. """ - return _AnyAdapter.dump_python(value, mode="json") + return cast(JSON, _AnyAdapter.dump_python(value, mode="json")) class CountMethod(StrEnum): diff --git a/src/postgrest/tests/_async/test_request_builder.py b/src/postgrest/tests/_async/test_request_builder.py index ad697897..35471017 100644 --- a/src/postgrest/tests/_async/test_request_builder.py +++ b/src/postgrest/tests/_async/test_request_builder.py @@ -576,6 +576,24 @@ class MovieInsert(TypedDict): id: UUID +def _generated_typeddict_writes_type_check(builder: AsyncRequestBuilder) -> None: + row = MovieInsert( + name="foo", + created_at=datetime(2024, 1, 2, 3, 4, 5), + id=UUID("12345678-1234-5678-1234-567812345678"), + ) + builder.insert(row) + builder.upsert([row]) + builder.update(row) + + +def _reject_unserializable_mapping(builder: AsyncRequestBuilder) -> None: + # A plain mapping with object values must stay a type error; an unused + # ignore here means the strict JSON alias regressed. + body: dict[str, object] = {"name": object()} + builder.update(body) # type: ignore[arg-type] + + class TestWriteSerializableTypes: """insert/upsert/update accept CLI-generated types (#1443).""" diff --git a/src/postgrest/tests/_sync/test_request_builder.py b/src/postgrest/tests/_sync/test_request_builder.py index 6944609a..feedca17 100644 --- a/src/postgrest/tests/_sync/test_request_builder.py +++ b/src/postgrest/tests/_sync/test_request_builder.py @@ -576,6 +576,24 @@ class MovieInsert(TypedDict): id: UUID +def _generated_typeddict_writes_type_check(builder: SyncRequestBuilder) -> None: + row = MovieInsert( + name="foo", + created_at=datetime(2024, 1, 2, 3, 4, 5), + id=UUID("12345678-1234-5678-1234-567812345678"), + ) + builder.insert(row) + builder.upsert([row]) + builder.update(row) + + +def _reject_unserializable_mapping(builder: SyncRequestBuilder) -> None: + # A plain mapping with object values must stay a type error; an unused + # ignore here means the strict JSON alias regressed. + body: dict[str, object] = {"name": object()} + builder.update(body) # type: ignore[arg-type] + + class TestWriteSerializableTypes: """insert/upsert/update accept CLI-generated types (#1443).""" From 923515c256db775fa03c334d3c4a52797e0854fc Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 14 Sep 2026 21:04:06 -0400 Subject: [PATCH 3/3] fix(postgrest): keep non-finite floats instead of encoding them as null dump_python(mode="json") turns nan/inf into null, so a request that used to fail client-side (httpx uses allow_nan=False) would silently write SQL NULL. Use pydantic_core.to_jsonable_python with inf_nan_mode "constants" so datetime/UUID/Decimal normalization is unchanged while non-finite floats keep the pre-existing behavior. Covered in both test suites. --- src/postgrest/src/postgrest/types.py | 15 +++++++++------ .../tests/_async/test_request_builder.py | 11 +++++++++++ src/postgrest/tests/_sync/test_request_builder.py | 11 +++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/postgrest/src/postgrest/types.py b/src/postgrest/src/postgrest/types.py index b94ab29e..101d6cbd 100644 --- a/src/postgrest/src/postgrest/types.py +++ b/src/postgrest/src/postgrest/types.py @@ -4,9 +4,10 @@ from collections.abc import Mapping, Sequence from datetime import date, datetime, time from decimal import Decimal -from typing import Any, ClassVar, Protocol, Union, cast +from typing import ClassVar, Protocol, Union, cast from uuid import UUID +import pydantic_core from httpx import AsyncClient, BasicAuth, Client, Headers, QueryParams from pydantic import TypeAdapter from typing_extensions import TypeAliasType @@ -48,16 +49,18 @@ class _TypedDictLike(Protocol): "Union[None, bool, str, int, float, datetime, date, time, UUID, Decimal, Sequence[JSONSerializableInput], Mapping[str, JSONSerializableInput], _TypedDictLike]", ) -_AnyAdapter: TypeAdapter = TypeAdapter(Any) - def jsonable_encoder(value: JSONSerializableInput) -> JSON: """Convert datetime/date/time/UUID/Decimal values to JSON-safe primitives. - Plain JSON passes through unchanged. Mirrors the outbound handling in v3 - (pydantic-based serialization) without changing the httpx request path. + Plain JSON passes through unchanged, including non-finite floats, which are + kept as-is instead of silently becoming null. Mirrors the outbound handling + in v3 (pydantic-based serialization) without changing the httpx request path. """ - return cast(JSON, _AnyAdapter.dump_python(value, mode="json")) + return cast( + JSON, + pydantic_core.to_jsonable_python(value, inf_nan_mode="constants"), + ) class CountMethod(StrEnum): diff --git a/src/postgrest/tests/_async/test_request_builder.py b/src/postgrest/tests/_async/test_request_builder.py index 35471017..88deb9ed 100644 --- a/src/postgrest/tests/_async/test_request_builder.py +++ b/src/postgrest/tests/_async/test_request_builder.py @@ -1,4 +1,5 @@ import json +import math from datetime import date, datetime, time from decimal import Decimal from typing import Any, AsyncIterable, Dict, List @@ -674,3 +675,13 @@ def test_plain_json_body_unchanged(self, request_builder): } assert request_builder.insert(body).request.json == body + + def test_non_finite_floats_pass_through(self, request_builder): + body = {"nan": float("nan"), "inf": float("inf"), "ninf": float("-inf")} + + encoded = request_builder.insert(body).request.json + + assert math.isnan(encoded["nan"]) + assert encoded["inf"] == float("inf") + assert encoded["ninf"] == float("-inf") + assert json.dumps(encoded) == json.dumps(body) diff --git a/src/postgrest/tests/_sync/test_request_builder.py b/src/postgrest/tests/_sync/test_request_builder.py index feedca17..d5be1bec 100644 --- a/src/postgrest/tests/_sync/test_request_builder.py +++ b/src/postgrest/tests/_sync/test_request_builder.py @@ -1,4 +1,5 @@ import json +import math from datetime import date, datetime, time from decimal import Decimal from typing import Any, Dict, Iterable, List @@ -674,3 +675,13 @@ def test_plain_json_body_unchanged(self, request_builder): } assert request_builder.insert(body).request.json == body + + def test_non_finite_floats_pass_through(self, request_builder): + body = {"nan": float("nan"), "inf": float("inf"), "ninf": float("-inf")} + + encoded = request_builder.insert(body).request.json + + assert math.isnan(encoded["nan"]) + assert encoded["inf"] == float("inf") + assert encoded["ninf"] == float("-inf") + assert json.dumps(encoded) == json.dumps(body)