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
4 changes: 4 additions & 0 deletions src/postgrest/src/postgrest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
from .constants import DEFAULT_POSTGREST_CLIENT_HEADERS
from .exceptions import APIError
from .types import (
JSON,
CountMethod,
Filters,
Json,
RequestMethod,
ReturnMethod,
)
Expand Down Expand Up @@ -55,6 +57,8 @@
"APIError",
"CountMethod",
"Filters",
"JSON",
"Json",
"RequestMethod",
"ReturnMethod",
"Timeout",
Expand Down
47 changes: 42 additions & 5 deletions src/postgrest/src/postgrest/types.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from __future__ import annotations

import json
import sys
from collections.abc import Mapping, Sequence
from typing import Union
from typing import Any, Union

from httpx import AsyncClient, BasicAuth, Client, Headers, QueryParams
from pydantic import TypeAdapter
from typing_extensions import TypeAliasType
from pydantic import BeforeValidator, TypeAdapter
from typing_extensions import Annotated, TypeAliasType
from yarl import URL

if sys.version_info >= (3, 11):
Expand All @@ -21,6 +20,44 @@
JSONAdapter: TypeAdapter = TypeAdapter(JSON)


def _coerce_json(v: Any) -> Any:
"""Coerce raw JSON string to parsed object, or pass through if already deserialized."""
if isinstance(v, (str, bytes, bytearray)):
try:
return json.loads(v)
except Exception:
return v
return v


class _JsonType:
"""
Flexible Pydantic Json type that accepts both already-deserialized Python objects
(dicts, lists, scalars) and raw JSON strings.

Usage:
class Row(BaseModel):
json_col: Json # accepts dict, list, scalar, or json string
typed_col: Json[dict[str, int]] # parses string if needed, validates as dict[str, int]
model_col: Json[MySubModel] # parses string or dict into MySubModel
"""

def __getitem__(self, item: Any) -> Any:
return Annotated[item, BeforeValidator(_coerce_json)]

def __get_pydantic_core_schema__(self, source_type: Any, handler: Any) -> Any:
from pydantic_core import core_schema

schema = handler(Any)
return core_schema.no_info_before_validator_function(
_coerce_json,
schema,
)


Json = _JsonType()


class CountMethod(StrEnum):
exact = "exact"
planned = "planned"
Expand Down
99 changes: 99 additions & 0 deletions src/postgrest/tests/_sync/test_json_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
Tests for flexible Json type annotation (Issue #1597)
=====================================================
Verifies that Json fields in Pydantic models correctly accept both:
1. Already-deserialized Python objects (dicts, lists, scalars returned by PostgREST).
2. Raw JSON strings (parsing them automatically).
"""

from typing import Any, Dict, List
import json
import pytest
from pydantic import BaseModel, ValidationError
from postgrest.types import Json


class SimpleJsonModel(BaseModel):
id: int
data: Json


class SubModel(BaseModel):
name: str
count: int


class TypedJsonModel(BaseModel):
id: int
data: Json[SubModel]


class DictJsonModel(BaseModel):
id: int
data: Json[Dict[str, int]]


def test_unsubscripted_json_with_dict():
"""Verify Json accepts already-deserialized dict (PostgREST response)."""
input_data = {"id": 1, "data": {"foo": "bar", "num": 42}}
model = SimpleJsonModel.model_validate(input_data)
assert model.id == 1
assert model.data == {"foo": "bar", "num": 42}


def test_unsubscripted_json_with_list():
"""Verify Json accepts already-deserialized list."""
input_data = {"id": 2, "data": [1, 2, 3, 4]}
model = SimpleJsonModel.model_validate(input_data)
assert model.id == 2
assert model.data == [1, 2, 3, 4]


def test_unsubscripted_json_with_json_string():
"""Verify Json parses raw JSON string into Python object."""
json_str = json.dumps({"foo": "bar", "num": 42})
input_data = {"id": 3, "data": json_str}
model = SimpleJsonModel.model_validate(input_data)
assert model.id == 3
assert model.data == {"foo": "bar", "num": 42}


def test_subscripted_json_with_deserialized_dict():
"""Verify Json[SubModel] validates against deserialized dict."""
input_data = {"id": 4, "data": {"name": "TestItem", "count": 100}}
model = TypedJsonModel.model_validate(input_data)
assert model.id == 4
assert isinstance(model.data, SubModel)
assert model.data.name == "TestItem"
assert model.data.count == 100


def test_subscripted_json_with_json_string():
"""Verify Json[SubModel] parses JSON string and validates into SubModel."""
json_str = json.dumps({"name": "TestItem", "count": 100})
input_data = {"id": 5, "data": json_str}
model = TypedJsonModel.model_validate(input_data)
assert model.id == 5
assert isinstance(model.data, SubModel)
assert model.data.name == "TestItem"
assert model.data.count == 100


def test_subscripted_json_with_typed_dict():
"""Verify Json[Dict[str, int]] validates against dict and json string."""
dict_input = {"id": 6, "data": {"a": 1, "b": 2}}
model1 = DictJsonModel.model_validate(dict_input)
assert model1.data == {"a": 1, "b": 2}

str_input = {"id": 7, "data": '{"a": 3, "b": 4}'}
model2 = DictJsonModel.model_validate(str_input)
assert model2.data == {"a": 3, "b": 4}


def test_invalid_json_validation_error():
"""Verify invalid structure or json string raises ValidationError."""
with pytest.raises(ValidationError):
TypedJsonModel.model_validate({"id": 8, "data": "invalid json { string"})

with pytest.raises(ValidationError):
TypedJsonModel.model_validate({"id": 9, "data": {"name": "TestOnly"}}) # missing count
3 changes: 3 additions & 0 deletions src/supabase/src/supabase/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from postgrest import APIError as PostgrestAPIError
from postgrest import APIResponse as PostgrestAPIResponse
from postgrest.types import JSON, Json
from realtime import AuthorizationError, NotConnectedError
from storage3.utils import StorageException
from supabase_auth.errors import (
Expand Down Expand Up @@ -77,4 +78,6 @@
"ASupabaseException",
"AsyncSupabaseException",
"SyncSupabaseException",
"JSON",
"Json",
)
9 changes: 9 additions & 0 deletions src/supabase/src/supabase/types.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
from typing import TypedDict
from postgrest.types import JSON, Json


class RealtimeClientOptions(TypedDict, total=False):
auto_reconnect: bool
hb_interval: int
max_retries: int
initial_backoff: float


__all__ = [
"JSON",
"Json",
"RealtimeClientOptions",
]