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