Skip to content

Commit d8155f6

Browse files
fix: update json attribute (#379)
* fix: update json attribute * fix: update json attribute * fix: update json attribute
1 parent e6776d0 commit d8155f6

3 files changed

Lines changed: 257 additions & 8 deletions

File tree

python/pydynox/attributes/special.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,24 +87,31 @@ def __init__(
8787
alias=alias,
8888
)
8989
self.model_class = model_class
90-
self._is_pydantic = model_class is not None and hasattr(model_class, "model_validate")
9190
self._is_dataclass = model_class is not None and dataclasses.is_dataclass(model_class)
91+
self._type_adapter = None
92+
if model_class is not None and not self._is_dataclass:
93+
try:
94+
from pydantic import TypeAdapter
95+
96+
self._type_adapter = TypeAdapter(model_class)
97+
except ImportError:
98+
pass
9299

93100
def _to_dict(self, value: Any) -> dict[str, Any] | list[Any]:
94101
"""Convert a typed model instance to a dict for JSON serialization."""
95-
if self._is_pydantic:
96-
return value.model_dump()
102+
if self._type_adapter is not None:
103+
return self._type_adapter.dump_python(value, mode="python")
97104
if self._is_dataclass:
98105
return dataclasses.asdict(value)
99106
return value
100107

101-
def _from_dict(self, data: dict[str, Any]) -> Any:
102-
"""Convert a dict to a typed model instance."""
108+
def _from_dict(self, data: Any) -> Any:
109+
"""Convert raw data to a typed model instance."""
110+
if self._type_adapter is not None:
111+
return self._type_adapter.validate_python(data)
103112
model_class = self.model_class
104113
if model_class is None:
105114
return data
106-
if self._is_pydantic:
107-
return model_class.model_validate(data) # ty: ignore[unresolved-attribute]
108115
return model_class(**data)
109116

110117
def serialize(self, value: Any | None) -> str | None:
@@ -148,7 +155,7 @@ def deserialize(self, value: Any) -> Any | None:
148155
data = value
149156
else:
150157
data = value
151-
if self.model_class is not None and isinstance(data, dict):
158+
if self.model_class is not None:
152159
return self._from_dict(data)
153160
return data
154161

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Async integration tests for JSONAttribute with RootModel and arbitrary types.
2+
3+
Regression tests for https://github.com/ferrumio/pydynox/issues/367
4+
"""
5+
6+
import uuid
7+
8+
import pytest
9+
from pydantic import BaseModel, RootModel
10+
from pydynox import Model, ModelConfig
11+
from pydynox.attributes import JSONAttribute, StringAttribute
12+
13+
14+
class Item(BaseModel):
15+
name: str
16+
qty: int
17+
18+
19+
class ItemList(RootModel[list[Item]]):
20+
pass
21+
22+
23+
class TagList(RootModel[list[str]]):
24+
pass
25+
26+
27+
class Inventory(Model):
28+
model_config = ModelConfig(table="test_table")
29+
pk = StringAttribute(partition_key=True)
30+
sk = StringAttribute(sort_key=True)
31+
items = JSONAttribute(ItemList)
32+
33+
34+
class TagModel(Model):
35+
model_config = ModelConfig(table="test_table")
36+
pk = StringAttribute(partition_key=True)
37+
sk = StringAttribute(sort_key=True)
38+
tags = JSONAttribute(TagList)
39+
40+
41+
class ScoreModel(Model):
42+
model_config = ModelConfig(table="test_table")
43+
pk = StringAttribute(partition_key=True)
44+
sk = StringAttribute(sort_key=True)
45+
scores = JSONAttribute(list[str])
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_rootmodel_list_save_and_get(dynamo):
50+
"""RootModel[list[...]] round-trips through save/get."""
51+
pk = f"RM_ASYNC#{uuid.uuid4().hex[:8]}"
52+
Inventory.model_config = ModelConfig(table="test_table", client=dynamo)
53+
54+
# GIVEN items as a RootModel list
55+
items = ItemList([Item(name="widget", qty=5), Item(name="gadget", qty=3)])
56+
inv = Inventory(pk=pk, sk="INV", items=items)
57+
58+
# WHEN saving and retrieving
59+
await inv.save()
60+
retrieved = await Inventory.get(pk=pk, sk="INV")
61+
62+
# THEN the RootModel is reconstructed
63+
assert isinstance(retrieved.items, ItemList)
64+
assert len(retrieved.items.root) == 2
65+
assert retrieved.items.root[0].name == "widget"
66+
assert retrieved.items.root[1].qty == 3
67+
68+
69+
@pytest.mark.asyncio
70+
async def test_rootmodel_list_update(dynamo):
71+
"""RootModel[list[...]] works with async update."""
72+
pk = f"RM_UPD_ASYNC#{uuid.uuid4().hex[:8]}"
73+
Inventory.model_config = ModelConfig(table="test_table", client=dynamo)
74+
75+
# GIVEN a saved inventory
76+
items = ItemList([Item(name="widget", qty=5)])
77+
inv = Inventory(pk=pk, sk="INV", items=items)
78+
await inv.save()
79+
80+
# WHEN updating with a new list
81+
new_items = ItemList([Item(name="gizmo", qty=10)])
82+
await inv.update(items=new_items)
83+
84+
# THEN the update is persisted
85+
retrieved = await Inventory.get(pk=pk, sk="INV")
86+
assert retrieved.items.root[0].name == "gizmo"
87+
assert retrieved.items.root[0].qty == 10
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_rootmodel_strings_save_and_get(dynamo):
92+
"""RootModel[list[str]] round-trips correctly."""
93+
pk = f"RM_STR_ASYNC#{uuid.uuid4().hex[:8]}"
94+
TagModel.model_config = ModelConfig(table="test_table", client=dynamo)
95+
96+
tags = TagList(["python", "rust", "dynamodb"])
97+
model = TagModel(pk=pk, sk="TAGS", tags=tags)
98+
await model.save()
99+
100+
retrieved = await TagModel.get(pk=pk, sk="TAGS")
101+
assert isinstance(retrieved.tags, TagList)
102+
assert retrieved.tags.root == ["python", "rust", "dynamodb"]
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_arbitrary_type_save_and_get(dynamo):
107+
"""JSONAttribute(list[str]) works with arbitrary types via TypeAdapter."""
108+
pk = f"ARB_ASYNC#{uuid.uuid4().hex[:8]}"
109+
ScoreModel.model_config = ModelConfig(table="test_table", client=dynamo)
110+
111+
model = ScoreModel(pk=pk, sk="SCORES", scores=["high", "medium", "low"])
112+
await model.save()
113+
114+
retrieved = await ScoreModel.get(pk=pk, sk="SCORES")
115+
assert retrieved.scores == ["high", "medium", "low"]
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Integration tests for JSONAttribute with RootModel and arbitrary types.
2+
3+
Regression tests for https://github.com/ferrumio/pydynox/issues/367
4+
"""
5+
6+
import uuid
7+
8+
from pydantic import BaseModel, RootModel
9+
from pydynox import Model, ModelConfig
10+
from pydynox.attributes import JSONAttribute, StringAttribute
11+
from pydynox.testing import MemoryBackend
12+
13+
14+
class Item(BaseModel):
15+
name: str
16+
qty: int
17+
18+
19+
class ItemList(RootModel[list[Item]]):
20+
pass
21+
22+
23+
class TagList(RootModel[list[str]]):
24+
pass
25+
26+
27+
class Inventory(Model):
28+
model_config = ModelConfig(table="test_table")
29+
pk = StringAttribute(partition_key=True)
30+
sk = StringAttribute(sort_key=True)
31+
items = JSONAttribute(ItemList)
32+
33+
34+
class TagModel(Model):
35+
model_config = ModelConfig(table="test_table")
36+
pk = StringAttribute(partition_key=True)
37+
sk = StringAttribute(sort_key=True)
38+
tags = JSONAttribute(TagList)
39+
40+
41+
class ScoreModel(Model):
42+
model_config = ModelConfig(table="test_table")
43+
pk = StringAttribute(partition_key=True)
44+
sk = StringAttribute(sort_key=True)
45+
scores = JSONAttribute(list[str])
46+
47+
48+
def test_rootmodel_list_save_and_get(dynamo):
49+
"""RootModel[list[...]] round-trips through save/get."""
50+
pk = f"RM#{uuid.uuid4().hex[:8]}"
51+
Inventory.model_config = ModelConfig(table="test_table", client=dynamo)
52+
53+
# GIVEN items as a RootModel list
54+
items = ItemList([Item(name="widget", qty=5), Item(name="gadget", qty=3)])
55+
inv = Inventory(pk=pk, sk="INV", items=items)
56+
57+
# WHEN saving and retrieving
58+
inv.sync_save()
59+
retrieved = Inventory.sync_get(pk=pk, sk="INV")
60+
61+
# THEN the RootModel is reconstructed
62+
assert isinstance(retrieved.items, ItemList)
63+
assert len(retrieved.items.root) == 2
64+
assert retrieved.items.root[0].name == "widget"
65+
assert retrieved.items.root[1].qty == 3
66+
67+
68+
def test_rootmodel_list_update(dynamo):
69+
"""RootModel[list[...]] works with sync_update."""
70+
pk = f"RM_UPD#{uuid.uuid4().hex[:8]}"
71+
Inventory.model_config = ModelConfig(table="test_table", client=dynamo)
72+
73+
# GIVEN a saved inventory
74+
items = ItemList([Item(name="widget", qty=5)])
75+
inv = Inventory(pk=pk, sk="INV", items=items)
76+
inv.sync_save()
77+
78+
# WHEN updating with a new list
79+
new_items = ItemList([Item(name="gizmo", qty=10)])
80+
inv.sync_update(items=new_items)
81+
82+
# THEN the update is persisted
83+
retrieved = Inventory.sync_get(pk=pk, sk="INV")
84+
assert retrieved.items.root[0].name == "gizmo"
85+
assert retrieved.items.root[0].qty == 10
86+
87+
88+
def test_rootmodel_strings_save_and_get(dynamo):
89+
"""RootModel[list[str]] round-trips correctly."""
90+
pk = f"RM_STR#{uuid.uuid4().hex[:8]}"
91+
TagModel.model_config = ModelConfig(table="test_table", client=dynamo)
92+
93+
tags = TagList(["python", "rust", "dynamodb"])
94+
model = TagModel(pk=pk, sk="TAGS", tags=tags)
95+
model.sync_save()
96+
97+
retrieved = TagModel.sync_get(pk=pk, sk="TAGS")
98+
assert isinstance(retrieved.tags, TagList)
99+
assert retrieved.tags.root == ["python", "rust", "dynamodb"]
100+
101+
102+
def test_arbitrary_type_save_and_get(dynamo):
103+
"""JSONAttribute(list[str]) works with arbitrary types via TypeAdapter."""
104+
pk = f"ARB#{uuid.uuid4().hex[:8]}"
105+
ScoreModel.model_config = ModelConfig(table="test_table", client=dynamo)
106+
107+
model = ScoreModel(pk=pk, sk="SCORES", scores=["high", "medium", "low"])
108+
model.sync_save()
109+
110+
retrieved = ScoreModel.sync_get(pk=pk, sk="SCORES")
111+
assert retrieved.scores == ["high", "medium", "low"]
112+
113+
114+
@MemoryBackend()
115+
def test_rootmodel_inplace_mutation_detected():
116+
"""In-place mutation on RootModel is detected on save."""
117+
items = ItemList([Item(name="widget", qty=5)])
118+
inv = Inventory(pk="INV#1", sk="INV", items=items)
119+
inv.sync_save()
120+
121+
loaded = Inventory.sync_get(pk="INV#1", sk="INV")
122+
loaded.items.root.append(Item(name="gadget", qty=2))
123+
loaded.sync_save()
124+
125+
result = Inventory.sync_get(pk="INV#1", sk="INV")
126+
assert len(result.items.root) == 2
127+
assert result.items.root[1].name == "gadget"

0 commit comments

Comments
 (0)