Skip to content

Commit 917808f

Browse files
committed
✨ Automatically map dictionaries to JSON
1 parent a85de91 commit 917808f

File tree

4 files changed

+155
-10
lines changed

4 files changed

+155
-10
lines changed

sqlmodel/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
from sqlalchemy.orm.decl_api import DeclarativeMeta
5454
from sqlalchemy.orm.instrumentation import is_instrumented
5555
from sqlalchemy.sql.schema import MetaData
56-
from sqlalchemy.sql.sqltypes import LargeBinary, Time, Uuid
56+
from sqlalchemy.sql.sqltypes import JSON, LargeBinary, Time, Uuid
5757
from typing_extensions import Literal, TypeAlias, deprecated, get_origin
5858

5959
from ._compat import ( # type: ignore[attr-defined]
@@ -700,6 +700,8 @@ def get_sqlalchemy_type(field: Any) -> Any:
700700
)
701701
if issubclass(type_, uuid.UUID):
702702
return Uuid
703+
if issubclass(type_, dict):
704+
return JSON
703705
raise ValueError(f"{type_} has no matching SQLAlchemy type")
704706

705707

tests/test_dict.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from typing import Dict, Optional
2+
3+
from sqlmodel import Field, Session, SQLModel, create_engine
4+
from typing_extensions import TypedDict
5+
6+
from .conftest import needs_pydanticv2
7+
8+
pytestmark = needs_pydanticv2
9+
10+
11+
def test_dict_maps_to_json(clear_sqlmodel):
12+
class Resource(SQLModel, table=True):
13+
id: Optional[int] = Field(default=None, primary_key=True)
14+
name: str
15+
data: dict
16+
17+
engine = create_engine("sqlite://")
18+
SQLModel.metadata.create_all(engine)
19+
20+
resource = Resource(name="test", data={"key": "value", "num": 42})
21+
22+
with Session(engine) as session:
23+
session.add(resource)
24+
session.commit()
25+
session.refresh(resource)
26+
27+
assert resource.data["key"] == "value"
28+
assert resource.data["num"] == 42
29+
30+
31+
def test_typing_dict_maps_to_json(clear_sqlmodel):
32+
"""Test if typing.Dict type annotation works without explicit sa_type"""
33+
34+
class Resource(SQLModel, table=True):
35+
id: Optional[int] = Field(default=None, primary_key=True)
36+
name: str
37+
data: Dict[str, int]
38+
39+
engine = create_engine("sqlite://")
40+
SQLModel.metadata.create_all(engine)
41+
42+
resource = Resource(name="test", data={"count": 100})
43+
44+
with Session(engine) as session:
45+
session.add(resource)
46+
session.commit()
47+
session.refresh(resource)
48+
49+
assert resource.data["count"] == 100
50+
51+
52+
class Metadata(TypedDict):
53+
name: str
54+
email: str
55+
56+
57+
def test_typeddict_automatic_json_mapping(clear_sqlmodel):
58+
"""
59+
Test that TypedDict fields automatically map to JSON type.
60+
61+
This fixes the original error:
62+
ValueError: <class 'app.models.NeonMetadata'> has no matching SQLAlchemy type
63+
"""
64+
65+
class ConnectedResource(SQLModel, table=True):
66+
id: Optional[int] = Field(default=None, primary_key=True)
67+
name: str
68+
neon_metadata: Metadata
69+
70+
engine = create_engine("sqlite://")
71+
SQLModel.metadata.create_all(engine)
72+
73+
resource = ConnectedResource(
74+
name="my-resource",
75+
neon_metadata={"name": "John Doe", "email": "[email protected]"},
76+
)
77+
78+
with Session(engine) as session:
79+
session.add(resource)
80+
session.commit()
81+
session.refresh(resource)
82+
83+
assert resource.neon_metadata["name"] == "John Doe"
84+
assert resource.neon_metadata["email"] == "[email protected]"

tests/test_sqlalchemy_type_errors.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict, List, Optional, Union
1+
from typing import List, Optional, Union
22

33
import pytest
44
from sqlmodel import Field, SQLModel
@@ -12,14 +12,6 @@ class Hero(SQLModel, table=True):
1212
tags: List[str]
1313

1414

15-
def test_type_dict_breaks() -> None:
16-
with pytest.raises(ValueError):
17-
18-
class Hero(SQLModel, table=True):
19-
id: Optional[int] = Field(default=None, primary_key=True)
20-
tags: Dict[str, Any]
21-
22-
2315
def test_type_union_breaks() -> None:
2416
with pytest.raises(ValueError):
2517

tests/test_type_mappings.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Test that Python type annotations map to correct SQLAlchemy column types"""
2+
3+
from typing import Dict, Optional
4+
5+
from sqlalchemy import JSON, Boolean, Date, DateTime, Float, Integer
6+
from sqlalchemy.sql.sqltypes import LargeBinary
7+
from sqlmodel import Field, SQLModel
8+
from sqlmodel.sql.sqltypes import AutoString
9+
from typing_extensions import TypedDict
10+
11+
from .conftest import needs_pydanticv2
12+
13+
14+
def test_type_mappings(clear_sqlmodel):
15+
from datetime import date, datetime
16+
17+
class Model(SQLModel, table=True):
18+
id: Optional[int] = Field(default=None, primary_key=True)
19+
name: str
20+
count: int
21+
price: float
22+
active: bool
23+
created: datetime
24+
birth_date: date
25+
data_bytes: bytes
26+
27+
# Verify the type mappings
28+
assert isinstance(Model.name.type, AutoString) # type: ignore
29+
assert isinstance(Model.count.type, Integer) # type: ignore
30+
assert isinstance(Model.price.type, Float) # type: ignore
31+
assert isinstance(Model.active.type, Boolean) # type: ignore
32+
assert isinstance(Model.created.type, DateTime) # type: ignore
33+
assert isinstance(Model.birth_date.type, Date) # type: ignore
34+
assert isinstance(Model.data_bytes.type, LargeBinary) # type: ignore
35+
36+
37+
@needs_pydanticv2
38+
def test_dict_maps_to_json(clear_sqlmodel):
39+
"""Test that plain dict annotation maps to JSON column type"""
40+
41+
class Model(SQLModel, table=True):
42+
id: Optional[int] = Field(default=None, primary_key=True)
43+
data: dict
44+
45+
assert isinstance(Model.data.type, JSON) # type: ignore
46+
47+
48+
@needs_pydanticv2
49+
def test_typing_dict_maps_to_json(clear_sqlmodel):
50+
class Model(SQLModel, table=True):
51+
id: Optional[int] = Field(default=None, primary_key=True)
52+
data: Dict[str, int]
53+
54+
assert isinstance(Model.data.type, JSON) # type: ignore
55+
56+
57+
@needs_pydanticv2
58+
def test_typeddict_maps_to_json(clear_sqlmodel):
59+
class MyDict(TypedDict):
60+
name: str
61+
count: int
62+
63+
class Model(SQLModel, table=True):
64+
id: Optional[int] = Field(default=None, primary_key=True)
65+
data: MyDict
66+
67+
assert isinstance(Model.data.type, JSON) # type: ignore

0 commit comments

Comments
 (0)