Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/postgrest/src/postgrest/_async/request_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -330,7 +330,7 @@ def select(

def insert(
self,
json: JSON,
json: JSONSerializable,
*,
count: Optional[CountMethod] = None,
returning: ReturnMethod = ReturnMethod.representation,
Expand Down Expand Up @@ -371,7 +371,7 @@ def insert(

def upsert(
self,
json: JSON,
json: JSONSerializable,
*,
count: Optional[CountMethod] = None,
returning: ReturnMethod = ReturnMethod.representation,
Expand Down Expand Up @@ -416,7 +416,7 @@ def upsert(

def update(
self,
json: JSON,
json: JSONSerializable,
*,
count: Optional[CountMethod] = None,
returning: ReturnMethod = ReturnMethod.representation,
Expand Down
8 changes: 4 additions & 4 deletions src/postgrest/src/postgrest/_sync/request_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -330,7 +330,7 @@ def select(

def insert(
self,
json: JSON,
json: JSONSerializable,
*,
count: Optional[CountMethod] = None,
returning: ReturnMethod = ReturnMethod.representation,
Expand Down Expand Up @@ -371,7 +371,7 @@ def insert(

def upsert(
self,
json: JSON,
json: JSONSerializable,
*,
count: Optional[CountMethod] = None,
returning: ReturnMethod = ReturnMethod.representation,
Expand Down Expand Up @@ -416,7 +416,7 @@ def upsert(

def update(
self,
json: JSON,
json: JSONSerializable,
*,
count: Optional[CountMethod] = None,
returning: ReturnMethod = ReturnMethod.representation,
Expand Down
30 changes: 22 additions & 8 deletions src/postgrest/src/postgrest/base_request_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import sys
from collections.abc import Mapping
from json import JSONDecodeError
from re import search
from typing import (
Expand Down Expand Up @@ -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


Expand All @@ -48,7 +58,7 @@ class QueryArgs(NamedTuple):
method: RequestMethod
params: QueryParams
headers: Headers
json: JSON
json: JSONSerializable


C = TypeVar("C", Client, AsyncClient)
Expand All @@ -64,15 +74,19 @@ def __init__(
headers: Headers,
params: QueryParams,
auth: BasicAuth | None,
json: JSON,
json: JSONSerializable,
retry_enabled: bool = True,
) -> None:
self.session: C = session
self.path = path
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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -141,7 +155,7 @@ def pre_select(


def pre_insert(
json: JSON,
json: JSONSerializable,
*,
count: Optional[CountMethod],
returning: ReturnMethod,
Expand All @@ -164,7 +178,7 @@ def pre_insert(


def pre_upsert(
json: JSON,
json: JSONSerializable,
*,
count: Optional[CountMethod],
returning: ReturnMethod,
Expand All @@ -190,7 +204,7 @@ def pre_upsert(


def pre_update(
json: JSON,
json: JSONSerializable,
*,
count: Optional[CountMethod],
returning: ReturnMethod,
Expand Down
25 changes: 24 additions & 1 deletion src/postgrest/src/postgrest/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
98 changes: 97 additions & 1 deletion src/postgrest/tests/_async/test_request_builder.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading