diff --git a/src/postgrest/src/postgrest/_async/request_builder.py b/src/postgrest/src/postgrest/_async/request_builder.py index f5c34e5f..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 JSON, 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: JSON, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -371,7 +371,7 @@ def insert( def upsert( self, - json: JSON, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -416,7 +416,7 @@ def upsert( def update( self, - json: JSON, + 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 df502b69..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 JSON, 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: JSON, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -371,7 +371,7 @@ def insert( def upsert( self, - json: JSON, + json: JSONSerializableInput, *, count: Optional[CountMethod] = None, returning: ReturnMethod = ReturnMethod.representation, @@ -416,7 +416,7 @@ def upsert( def update( self, - json: JSON, + 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 2a562859..bdf5ffb1 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, + JSONSerializableInput, + 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: JSONSerializableInput C = TypeVar("C", Client, AsyncClient) @@ -64,7 +74,7 @@ def __init__( headers: Headers, params: QueryParams, auth: BasicAuth | None, - json: JSON, + json: JSONSerializableInput, 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: JSONSerializableInput, *, count: Optional[CountMethod], returning: ReturnMethod, @@ -164,7 +178,7 @@ def pre_insert( def pre_upsert( - json: JSON, + json: JSONSerializableInput, *, count: Optional[CountMethod], returning: ReturnMethod, @@ -190,7 +204,7 @@ def pre_upsert( def pre_update( - json: JSON, + json: JSONSerializableInput, *, count: Optional[CountMethod], returning: ReturnMethod, diff --git a/src/postgrest/src/postgrest/types.py b/src/postgrest/src/postgrest/types.py index 748f87e4..101d6cbd 100644 --- a/src/postgrest/src/postgrest/types.py +++ b/src/postgrest/src/postgrest/types.py @@ -2,8 +2,12 @@ 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 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 @@ -20,6 +24,44 @@ ) 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]]", +) + + +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]", +) + + +def jsonable_encoder(value: JSONSerializableInput) -> JSON: + """Convert datetime/date/time/UUID/Decimal values to JSON-safe primitives. + + 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, + pydantic_core.to_jsonable_python(value, inf_nan_mode="constants"), + ) + 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..88deb9ed 100644 --- a/src/postgrest/tests/_async/test_request_builder.py +++ b/src/postgrest/tests/_async/test_request_builder.py @@ -1,13 +1,20 @@ +import json +import math +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 +567,121 @@ 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 + + +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).""" + + 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 + + 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 435f8ab5..d5be1bec 100644 --- a/src/postgrest/tests/_sync/test_request_builder.py +++ b/src/postgrest/tests/_sync/test_request_builder.py @@ -1,13 +1,20 @@ +import json +import math +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 +567,121 @@ 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 + + +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).""" + + 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 + + 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)