From 9ea2cdda374c37c5692358d52fdb529c9b058fbf Mon Sep 17 00:00:00 2001 From: Sanjeev888 Date: Mon, 31 Aug 2026 12:26:57 +0530 Subject: [PATCH] fix: add flexible Json type annotation for Pydantic models (#1597) --- src/postgrest/src/postgrest/__init__.py | 4 + src/postgrest/src/postgrest/types.py | 47 ++++++++-- src/postgrest/tests/_sync/test_json_type.py | 99 +++++++++++++++++++++ src/supabase/src/supabase/__init__.py | 3 + src/supabase/src/supabase/types.py | 9 ++ 5 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 src/postgrest/tests/_sync/test_json_type.py diff --git a/src/postgrest/src/postgrest/__init__.py b/src/postgrest/src/postgrest/__init__.py index edb87f2e..7d42ff9a 100644 --- a/src/postgrest/src/postgrest/__init__.py +++ b/src/postgrest/src/postgrest/__init__.py @@ -26,8 +26,10 @@ from .constants import DEFAULT_POSTGREST_CLIENT_HEADERS from .exceptions import APIError from .types import ( + JSON, CountMethod, Filters, + Json, RequestMethod, ReturnMethod, ) @@ -55,6 +57,8 @@ "APIError", "CountMethod", "Filters", + "JSON", + "Json", "RequestMethod", "ReturnMethod", "Timeout", diff --git a/src/postgrest/src/postgrest/types.py b/src/postgrest/src/postgrest/types.py index 748f87e4..510dbeea 100644 --- a/src/postgrest/src/postgrest/types.py +++ b/src/postgrest/src/postgrest/types.py @@ -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): @@ -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" diff --git a/src/postgrest/tests/_sync/test_json_type.py b/src/postgrest/tests/_sync/test_json_type.py new file mode 100644 index 00000000..6b3485ac --- /dev/null +++ b/src/postgrest/tests/_sync/test_json_type.py @@ -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 diff --git a/src/supabase/src/supabase/__init__.py b/src/supabase/src/supabase/__init__.py index 2abfed2b..9caff07c 100644 --- a/src/supabase/src/supabase/__init__.py +++ b/src/supabase/src/supabase/__init__.py @@ -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 ( @@ -77,4 +78,6 @@ "ASupabaseException", "AsyncSupabaseException", "SyncSupabaseException", + "JSON", + "Json", ) diff --git a/src/supabase/src/supabase/types.py b/src/supabase/src/supabase/types.py index 4f774531..840d8a57 100644 --- a/src/supabase/src/supabase/types.py +++ b/src/supabase/src/supabase/types.py @@ -1,4 +1,5 @@ from typing import TypedDict +from postgrest.types import JSON, Json class RealtimeClientOptions(TypedDict, total=False): @@ -6,3 +7,11 @@ class RealtimeClientOptions(TypedDict, total=False): hb_interval: int max_retries: int initial_backoff: float + + +__all__ = [ + "JSON", + "Json", + "RealtimeClientOptions", +] +