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 JSONSerializableInput, 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: JSONSerializableInput,
*,
count: Optional[CountMethod] = None,
returning: ReturnMethod = ReturnMethod.representation,
Expand Down Expand Up @@ -371,7 +371,7 @@ def insert(

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

def update(
self,
json: JSON,
json: JSONSerializableInput,
*,
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 JSONSerializableInput, 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: JSONSerializableInput,
*,
count: Optional[CountMethod] = None,
returning: ReturnMethod = ReturnMethod.representation,
Expand Down Expand Up @@ -371,7 +371,7 @@ def insert(

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

def update(
self,
json: JSON,
json: JSONSerializableInput,
*,
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,
JSONSerializableInput,
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: JSONSerializableInput


C = TypeVar("C", Client, AsyncClient)
Expand All @@ -64,15 +74,19 @@ def __init__(
headers: Headers,
params: QueryParams,
auth: BasicAuth | None,
json: JSON,
json: JSONSerializableInput,
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: JSONSerializableInput,
*,
count: Optional[CountMethod],
returning: ReturnMethod,
Expand All @@ -164,7 +178,7 @@ def pre_insert(


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


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