sqlalchemy-pydantic-json 0.0.1a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlalchemy_pydantic_json/__init__.py +5 -0
- sqlalchemy_pydantic_json/_model.py +377 -0
- sqlalchemy_pydantic_json/alembic.py +102 -0
- sqlalchemy_pydantic_json/py.typed +0 -0
- sqlalchemy_pydantic_json-0.0.1a1.dist-info/METADATA +376 -0
- sqlalchemy_pydantic_json-0.0.1a1.dist-info/RECORD +8 -0
- sqlalchemy_pydantic_json-0.0.1a1.dist-info/WHEEL +4 -0
- sqlalchemy_pydantic_json-0.0.1a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic models stored in SQLAlchemy JSON columns, with full change tracking.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
|
|
6
|
+
class Address(EmbeddedPydanticModel):
|
|
7
|
+
city: str = "Helsinki"
|
|
8
|
+
|
|
9
|
+
class Settings(EmbeddedPydanticModel):
|
|
10
|
+
tags: set[str] = set()
|
|
11
|
+
address: Address = Address()
|
|
12
|
+
|
|
13
|
+
class User(Base):
|
|
14
|
+
...
|
|
15
|
+
settings: Mapped[Settings] = mapped_column(Settings.column(), default=Settings)
|
|
16
|
+
extra: Mapped[Settings | None] = mapped_column(Settings.column())
|
|
17
|
+
|
|
18
|
+
Any change anywhere inside `user.settings` (attributes, list/dict/set
|
|
19
|
+
mutations, nested models) marks `user` dirty. Use EmbeddedPydanticModel as the base
|
|
20
|
+
for the column's model *and* for all of its submodels.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import functools
|
|
26
|
+
import weakref
|
|
27
|
+
from collections.abc import Callable, Iterable
|
|
28
|
+
from typing import Any, Protocol, Self, SupportsIndex, TypeVar, cast, overload
|
|
29
|
+
|
|
30
|
+
from pydantic import AliasChoices, AliasPath, BaseModel, PrivateAttr
|
|
31
|
+
from pydantic.fields import FieldInfo
|
|
32
|
+
from sqlalchemy import JSON, Dialect
|
|
33
|
+
from sqlalchemy.ext.mutable import Mutable, MutableDict, MutableList, MutableSet
|
|
34
|
+
from sqlalchemy.orm.attributes import flag_modified
|
|
35
|
+
from sqlalchemy.types import TypeDecorator, TypeEngine
|
|
36
|
+
|
|
37
|
+
__all__ = ["EmbeddedPydanticModel", "PydanticJSON"]
|
|
38
|
+
|
|
39
|
+
_M = TypeVar("_M", bound=BaseModel)
|
|
40
|
+
_T = TypeVar("_T")
|
|
41
|
+
_KT = TypeVar("_KT")
|
|
42
|
+
_VT = TypeVar("_VT")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------------
|
|
46
|
+
# Column type
|
|
47
|
+
# --------------------------------------------------------------------------
|
|
48
|
+
class PydanticJSON(TypeDecorator[_M]):
|
|
49
|
+
"""
|
|
50
|
+
JSON column type converting to/from a Pydantic model.
|
|
51
|
+
|
|
52
|
+
Don't use it directly; use ``Model.column()``, which also enables change
|
|
53
|
+
tracking (a bare PydanticJSON column would not notice in-place changes).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
impl: TypeEngine[Any] | type[TypeEngine[Any]] = JSON
|
|
57
|
+
cache_ok = True
|
|
58
|
+
|
|
59
|
+
def __init__(self, model: type[_M], json_type: type[JSON] | JSON = JSON) -> None:
|
|
60
|
+
# Replaces TypeDecorator.__init__, which would build `impl` from the class attribute.
|
|
61
|
+
self.model = model
|
|
62
|
+
self.json_type = json_type(none_as_null=True) if isinstance(json_type, type) else json_type
|
|
63
|
+
self.impl = self.json_type
|
|
64
|
+
|
|
65
|
+
def process_bind_param(self, value: Any, dialect: Dialect) -> Any:
|
|
66
|
+
if value is None:
|
|
67
|
+
return None
|
|
68
|
+
return _validate(self.model, value).model_dump(mode="json", by_alias=True)
|
|
69
|
+
|
|
70
|
+
def process_result_value(self, value: Any, dialect: Dialect) -> _M | None:
|
|
71
|
+
return None if value is None else _validate(self.model, value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# The JSON is stored with the models' aliases (like Pydantic's own `by_alias=True`). Loading also
|
|
75
|
+
# accepts field names, e.g. in rows stored before an alias was added.
|
|
76
|
+
def _validate(model: type[_M], value: Any) -> _M:
|
|
77
|
+
return model.model_validate(value, by_alias=True, by_name=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Marks a model_post_init that sets up the tracking. Pydantic wraps model_post_init in every
|
|
81
|
+
# subclass (because of the private attribute) with functools.wraps, which copies the mark along.
|
|
82
|
+
_LINKS_FIELDS = "_sqlalchemy_pydantic_json_links_fields"
|
|
83
|
+
_F = TypeVar("_F", bound=Callable[..., Any])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _marked_as_linking(post_init: _F) -> _F:
|
|
87
|
+
setattr(post_init, _LINKS_FIELDS, True)
|
|
88
|
+
return post_init
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _links_fields(post_init: object) -> bool:
|
|
92
|
+
"""Whether `post_init`, or a function it wraps (functools.wraps), sets up the tracking."""
|
|
93
|
+
while post_init is not None:
|
|
94
|
+
if getattr(post_init, _LINKS_FIELDS, False):
|
|
95
|
+
return True
|
|
96
|
+
post_init = getattr(post_init, "__wrapped__", None)
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _linking_post_init(
|
|
101
|
+
post_init: Callable[[EmbeddedPydanticModel, Any], None],
|
|
102
|
+
) -> Callable[[EmbeddedPydanticModel, Any], None]:
|
|
103
|
+
@functools.wraps(post_init)
|
|
104
|
+
def model_post_init(self: EmbeddedPydanticModel, context: Any, /) -> None:
|
|
105
|
+
post_init(self, context)
|
|
106
|
+
self._link_fields() # linking again (if it also called super()) is harmless
|
|
107
|
+
|
|
108
|
+
return _marked_as_linking(model_post_init)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check_aliases_round_trip(model: type[BaseModel]) -> None:
|
|
112
|
+
"""Raise if a field is stored under a name it can't be loaded from again."""
|
|
113
|
+
for name, field in model.model_fields.items():
|
|
114
|
+
stored = field.serialization_alias or name
|
|
115
|
+
if stored not in _loadable_names(name, field):
|
|
116
|
+
raise TypeError(
|
|
117
|
+
f"{model.__name__}.{name} is stored as {stored!r} (its serialization alias), "
|
|
118
|
+
f"but can't be loaded from that name (validation alias: "
|
|
119
|
+
f"{field.validation_alias!r}). Use the same alias for both, or include "
|
|
120
|
+
f"{stored!r} in the validation alias with AliasChoices."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _loadable_names(name: str, field: FieldInfo) -> set[str]:
|
|
125
|
+
names = {name}
|
|
126
|
+
aliases = field.validation_alias
|
|
127
|
+
choices = aliases.choices if isinstance(aliases, AliasChoices) else [aliases]
|
|
128
|
+
for choice in choices:
|
|
129
|
+
if isinstance(choice, str):
|
|
130
|
+
names.add(choice)
|
|
131
|
+
elif isinstance(choice, AliasPath) and len(choice.path) == 1:
|
|
132
|
+
names.add(str(choice.path[0]))
|
|
133
|
+
return names
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------------------------------------------------------------
|
|
137
|
+
# Parent links (supports several parents; stale links are pruned lazily)
|
|
138
|
+
# --------------------------------------------------------------------------
|
|
139
|
+
class _Parent(Protocol):
|
|
140
|
+
"""A model or container that can hold tracked values."""
|
|
141
|
+
|
|
142
|
+
def _notify(self) -> None: ...
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _ParentLinks:
|
|
146
|
+
__slots__ = ("_refs",)
|
|
147
|
+
|
|
148
|
+
def __init__(self) -> None:
|
|
149
|
+
self._refs: dict[int, weakref.ref[_Parent]] = {}
|
|
150
|
+
|
|
151
|
+
def add(self, parent: _Parent) -> None:
|
|
152
|
+
self._refs[id(parent)] = weakref.ref(parent)
|
|
153
|
+
|
|
154
|
+
def notify(self, child: object) -> None:
|
|
155
|
+
for key, ref in list(self._refs.items()):
|
|
156
|
+
parent = ref()
|
|
157
|
+
# a parent that was garbage collected, or no longer holds the
|
|
158
|
+
# child (popped, replaced, ...), is dropped instead of notified
|
|
159
|
+
if parent is None or not _holds(parent, child):
|
|
160
|
+
self._refs.pop(key, None)
|
|
161
|
+
continue
|
|
162
|
+
parent._notify()
|
|
163
|
+
|
|
164
|
+
# Links aren't part of a model's value; Pydantic's `==` compares private attributes too, so
|
|
165
|
+
# without this no two models would ever be equal.
|
|
166
|
+
def __eq__(self, other: object) -> bool:
|
|
167
|
+
return isinstance(other, _ParentLinks) or NotImplemented
|
|
168
|
+
|
|
169
|
+
__hash__ = None # type: ignore[assignment]
|
|
170
|
+
|
|
171
|
+
# copies (copy.copy and copy.deepcopy use this too) and pickles start out unlinked
|
|
172
|
+
def __reduce__(self) -> tuple[type[_ParentLinks], tuple[()]]:
|
|
173
|
+
return (_ParentLinks, ())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _holds(parent: _Parent, child: object) -> bool:
|
|
177
|
+
if isinstance(parent, EmbeddedPydanticModel):
|
|
178
|
+
d = vars(parent)
|
|
179
|
+
return any(d.get(name) is child for name in type(parent).model_fields)
|
|
180
|
+
if isinstance(parent, _TrackedDict):
|
|
181
|
+
return any(v is child for v in cast("_TrackedDict[Any, Any]", parent).values())
|
|
182
|
+
# the only other parents are lists (set items are never linked)
|
|
183
|
+
return any(v is child for v in cast("_TrackedList[Any]", parent))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _link(value: Any, parent: _Parent) -> Any:
|
|
187
|
+
"""Return `value` with tracking enabled, linked to `parent`."""
|
|
188
|
+
tracked: _TrackedList[Any] | _TrackedDict[Any, Any] | _TrackedSet[Any]
|
|
189
|
+
if isinstance(value, EmbeddedPydanticModel):
|
|
190
|
+
value._links.add(parent) # add, not replace: the same instance may live in several places
|
|
191
|
+
return value
|
|
192
|
+
if isinstance(value, _TrackedList | _TrackedDict | _TrackedSet):
|
|
193
|
+
tracked = cast("_TrackedList[Any] | _TrackedDict[Any, Any] | _TrackedSet[Any]", value)
|
|
194
|
+
elif isinstance(value, list):
|
|
195
|
+
# a new container has no parents yet, so filling it (which links each item to it)
|
|
196
|
+
# notifies nobody
|
|
197
|
+
tracked = _TrackedList()
|
|
198
|
+
tracked.extend(cast("list[Any]", value))
|
|
199
|
+
elif isinstance(value, dict):
|
|
200
|
+
tracked = _TrackedDict()
|
|
201
|
+
tracked.update(cast("dict[Any, Any]", value))
|
|
202
|
+
elif isinstance(value, set): # set items are hashable, so never models/lists/dicts
|
|
203
|
+
tracked = _TrackedSet(cast("set[Any]", value))
|
|
204
|
+
else:
|
|
205
|
+
return value
|
|
206
|
+
tracked._links.add(parent)
|
|
207
|
+
return tracked
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# --------------------------------------------------------------------------
|
|
211
|
+
# Tracked containers (SQLAlchemy's Mutable* already hook every mutating method)
|
|
212
|
+
# --------------------------------------------------------------------------
|
|
213
|
+
class _TrackedContainer:
|
|
214
|
+
@property
|
|
215
|
+
def _links(self) -> _ParentLinks:
|
|
216
|
+
try:
|
|
217
|
+
links: _ParentLinks = self.__dict__["_links_"]
|
|
218
|
+
except KeyError:
|
|
219
|
+
links = self.__dict__["_links_"] = _ParentLinks()
|
|
220
|
+
return links
|
|
221
|
+
|
|
222
|
+
def changed(self) -> None:
|
|
223
|
+
self._links.notify(self)
|
|
224
|
+
|
|
225
|
+
def _notify(self) -> None: # a child inside this container changed
|
|
226
|
+
self.changed()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# MutableList's and MutableSet's in-place operators (__iadd__, __ior__, ...) don't match list's and
|
|
230
|
+
# set's; that comes from SQLAlchemy.
|
|
231
|
+
class _TrackedList(_TrackedContainer, MutableList[_T]): # ty: ignore[invalid-method-override]
|
|
232
|
+
def __setitem__(self, index: SupportsIndex | slice, value: _T | Iterable[_T]) -> None:
|
|
233
|
+
if isinstance(index, slice):
|
|
234
|
+
value = [_link(x, self) for x in cast("Iterable[_T]", value)]
|
|
235
|
+
else:
|
|
236
|
+
value = _link(value, self)
|
|
237
|
+
super().__setitem__(index, value)
|
|
238
|
+
|
|
239
|
+
def append(self, x: _T) -> None:
|
|
240
|
+
super().append(_link(x, self))
|
|
241
|
+
|
|
242
|
+
def extend(self, x: Iterable[_T]) -> None:
|
|
243
|
+
super().extend([_link(v, self) for v in x])
|
|
244
|
+
|
|
245
|
+
def insert(self, i: SupportsIndex, x: _T) -> None:
|
|
246
|
+
super().insert(i, _link(x, self))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _TrackedDict(_TrackedContainer, MutableDict[_KT, _VT]):
|
|
250
|
+
def __setitem__(self, key: _KT, value: _VT) -> None:
|
|
251
|
+
super().__setitem__(key, _link(value, self))
|
|
252
|
+
|
|
253
|
+
# same overloads as MutableDict.setdefault
|
|
254
|
+
@overload
|
|
255
|
+
def setdefault(
|
|
256
|
+
self: _TrackedDict[_KT, _T | None], key: _KT, value: None = None
|
|
257
|
+
) -> _T | None: ...
|
|
258
|
+
|
|
259
|
+
@overload
|
|
260
|
+
def setdefault(self, key: _KT, value: _VT) -> _VT: ...
|
|
261
|
+
|
|
262
|
+
def setdefault(self, key: _KT, value: object = None) -> object:
|
|
263
|
+
return super().setdefault(key, _link(value, self))
|
|
264
|
+
|
|
265
|
+
def update(self, *a: Any, **kw: _VT) -> None:
|
|
266
|
+
super().update({k: _link(v, self) for k, v in dict(*a, **kw).items()})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _TrackedSet(_TrackedContainer, MutableSet[_T]): # ty: ignore[invalid-method-override]
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# --------------------------------------------------------------------------
|
|
274
|
+
# The base model
|
|
275
|
+
# --------------------------------------------------------------------------
|
|
276
|
+
class EmbeddedPydanticModel(Mutable, BaseModel):
|
|
277
|
+
"""Base class for models stored in a JSON column, and for their submodels."""
|
|
278
|
+
|
|
279
|
+
_links: _ParentLinks = PrivateAttr(default_factory=_ParentLinks)
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
|
|
283
|
+
super().__pydantic_init_subclass__(**kwargs)
|
|
284
|
+
_check_aliases_round_trip(cls)
|
|
285
|
+
# A model_post_init of the subclass's own also sets up tracking, whether or not it calls
|
|
286
|
+
# super().model_post_init().
|
|
287
|
+
own_post_init = cls.__dict__.get("model_post_init")
|
|
288
|
+
if own_post_init is not None and not _links_fields(own_post_init):
|
|
289
|
+
setattr(cls, "model_post_init", _linking_post_init(own_post_init)) # noqa: B010
|
|
290
|
+
|
|
291
|
+
@_marked_as_linking
|
|
292
|
+
def model_post_init(self, context: Any, /) -> None:
|
|
293
|
+
self._link_fields()
|
|
294
|
+
|
|
295
|
+
def _link_fields(self) -> None:
|
|
296
|
+
d = vars(self)
|
|
297
|
+
for name in type(self).model_fields:
|
|
298
|
+
d[name] = _link(d[name], self)
|
|
299
|
+
|
|
300
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
301
|
+
super().__setattr__(name, value)
|
|
302
|
+
if name in type(self).model_fields:
|
|
303
|
+
d = vars(self)
|
|
304
|
+
d[name] = _link(d[name], self)
|
|
305
|
+
self._notify()
|
|
306
|
+
|
|
307
|
+
def __delattr__(self, name: str) -> None:
|
|
308
|
+
super().__delattr__(name)
|
|
309
|
+
self._notify()
|
|
310
|
+
|
|
311
|
+
def _notify(self) -> None:
|
|
312
|
+
self.changed()
|
|
313
|
+
self._links.notify(self)
|
|
314
|
+
|
|
315
|
+
def changed(self) -> None:
|
|
316
|
+
"""
|
|
317
|
+
Flag every ORM row that currently holds this instance as modified.
|
|
318
|
+
|
|
319
|
+
Like Mutable.changed(), but skips (and forgets) rows that no longer hold
|
|
320
|
+
this exact instance, e.g. expired after a commit or since reassigned.
|
|
321
|
+
"""
|
|
322
|
+
for state, key in list(self._parents.items()):
|
|
323
|
+
obj = state.obj()
|
|
324
|
+
if obj is None or state.dict.get(key) is not self:
|
|
325
|
+
self._parents.pop(state, None)
|
|
326
|
+
continue
|
|
327
|
+
flag_modified(obj, key)
|
|
328
|
+
|
|
329
|
+
# --- copies are independent: not linked to the original's parents/rows ---
|
|
330
|
+
def __copy__(self) -> Self:
|
|
331
|
+
new = super().__copy__()
|
|
332
|
+
d = vars(new)
|
|
333
|
+
for name in type(new).model_fields: # a shallow copy shares containers; give it its own
|
|
334
|
+
v = d[name]
|
|
335
|
+
if isinstance(v, list | dict | set):
|
|
336
|
+
d[name] = cast("list[Any] | dict[Any, Any] | set[Any]", v).copy()
|
|
337
|
+
return new._detached()
|
|
338
|
+
|
|
339
|
+
def __deepcopy__(self, memo: dict[int, Any] | None = None) -> Self:
|
|
340
|
+
return super().__deepcopy__(memo)._detached()
|
|
341
|
+
|
|
342
|
+
def _detached(self) -> Self:
|
|
343
|
+
vars(self).pop("_parents", None)
|
|
344
|
+
self._links = _ParentLinks()
|
|
345
|
+
self._link_fields()
|
|
346
|
+
return self
|
|
347
|
+
|
|
348
|
+
def __getstate__(self) -> dict[Any, Any]:
|
|
349
|
+
state = super().__getstate__()
|
|
350
|
+
state["__dict__"] = {k: v for k, v in state["__dict__"].items() if k != "_parents"}
|
|
351
|
+
return state
|
|
352
|
+
|
|
353
|
+
def __setstate__(self, state: dict[Any, Any]) -> None:
|
|
354
|
+
super().__setstate__(state)
|
|
355
|
+
self._link_fields()
|
|
356
|
+
|
|
357
|
+
# --- SQLAlchemy Mutable hooks ---
|
|
358
|
+
@classmethod
|
|
359
|
+
def coerce(cls, key: str, value: Any) -> Self | None:
|
|
360
|
+
if value is None or isinstance(value, cls):
|
|
361
|
+
return value
|
|
362
|
+
if isinstance(value, dict):
|
|
363
|
+
return _validate(cls, value)
|
|
364
|
+
return cast("Self | None", super().coerce(key, value))
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def column(cls, json_type: type[JSON] | JSON = JSON) -> PydanticJSON[Self]:
|
|
368
|
+
"""
|
|
369
|
+
Column type for ``mapped_column()``, with change tracking enabled.
|
|
370
|
+
|
|
371
|
+
`json_type` is the underlying column type: the generic ``JSON`` by default, or e.g.
|
|
372
|
+
PostgreSQL's ``JSONB``. A class is created with ``none_as_null=True``, so that ``None`` is
|
|
373
|
+
stored as SQL ``NULL``. An instance is used as is, e.g. JSONB on PostgreSQL only::
|
|
374
|
+
|
|
375
|
+
JSON(none_as_null=True).with_variant(JSONB(none_as_null=True), "postgresql")
|
|
376
|
+
"""
|
|
377
|
+
return cast("PydanticJSON[Self]", cls.as_mutable(PydanticJSON(cls, json_type)))
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Alembic support: render `PydanticJSON` columns in migrations as their plain JSON type.
|
|
3
|
+
|
|
4
|
+
Without this, autogenerate writes
|
|
5
|
+
``sqlalchemy_pydantic_json._model.PydanticJSON(none_as_null=True)`` into the migration, which fails
|
|
6
|
+
when it runs. With it, the migration gets ``sa.JSON(none_as_null=True)``, and depends on neither
|
|
7
|
+
this package nor your models.
|
|
8
|
+
|
|
9
|
+
In ``env.py``, pass the result of `make_render_item()` to ``context.configure()`` (in both the
|
|
10
|
+
offline and the online function)::
|
|
11
|
+
|
|
12
|
+
from sqlalchemy_pydantic_json.alembic import make_render_item
|
|
13
|
+
|
|
14
|
+
context.configure(..., render_item=make_render_item())
|
|
15
|
+
|
|
16
|
+
If you already have a ``render_item`` function of your own, let it handle everything else::
|
|
17
|
+
|
|
18
|
+
context.configure(..., render_item=make_render_item(wrap=my_render_item))
|
|
19
|
+
|
|
20
|
+
This module doesn't import Alembic at runtime.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import warnings
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from typing import TYPE_CHECKING, Any, Literal, Never, cast
|
|
28
|
+
|
|
29
|
+
from sqlalchemy_pydantic_json._model import PydanticJSON
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from alembic.autogenerate.api import AutogenContext
|
|
33
|
+
from sqlalchemy.types import TypeEngine
|
|
34
|
+
|
|
35
|
+
__all__ = ["make_render_item"]
|
|
36
|
+
|
|
37
|
+
# the signature Alembic expects for ``render_item``
|
|
38
|
+
_RenderItem = Callable[[str, Any, "AutogenContext"], "str | Literal[False]"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _render_item(type_: str, obj: Any, autogen_context: AutogenContext) -> str | Literal[False]:
|
|
42
|
+
"""
|
|
43
|
+
Render `PydanticJSON` column types as their underlying JSON type.
|
|
44
|
+
|
|
45
|
+
Returns ``False`` for everything else, which tells Alembic to use its default rendering.
|
|
46
|
+
"""
|
|
47
|
+
if type_ != "type" or not isinstance(obj, PydanticJSON):
|
|
48
|
+
return False
|
|
49
|
+
impl = obj.impl_instance
|
|
50
|
+
# Alembic's own type rendering (private API): renders the JSON type exactly as if the column
|
|
51
|
+
# were declared with it directly, incl. dialect imports, JSONB's `astext_type` and
|
|
52
|
+
# `with_variant()`. Only used while generating migrations; tested in CI.
|
|
53
|
+
from alembic.autogenerate import render
|
|
54
|
+
|
|
55
|
+
repr_type = cast(
|
|
56
|
+
"Callable[[TypeEngine[Any], AutogenContext], str] | None", vars(render).get("_repr_type")
|
|
57
|
+
)
|
|
58
|
+
if repr_type is None:
|
|
59
|
+
warnings.warn(
|
|
60
|
+
"alembic.autogenerate.render._repr_type is not available in this Alembic version; "
|
|
61
|
+
"falling back to simple rendering. Check the generated migration.",
|
|
62
|
+
stacklevel=2,
|
|
63
|
+
)
|
|
64
|
+
return _simple_repr_type(impl, autogen_context)
|
|
65
|
+
return repr_type(impl, autogen_context)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _simple_repr_type(impl: TypeEngine[Any], autogen_context: AutogenContext) -> str:
|
|
69
|
+
"""Fallback rendering; doesn't handle `with_variant()` or nested types like JSONB's."""
|
|
70
|
+
module = type(impl).__module__
|
|
71
|
+
if module.startswith("sqlalchemy.dialects."):
|
|
72
|
+
# e.g. sqlalchemy.dialects.postgresql.json -> postgresql.JSONB(...)
|
|
73
|
+
dialect = module.split(".")[2]
|
|
74
|
+
autogen_context.imports.add(f"from sqlalchemy.dialects import {dialect}")
|
|
75
|
+
return f"{dialect}.{impl!r}"
|
|
76
|
+
prefix = autogen_context.opts.get("sqlalchemy_module_prefix", "sa.") or ""
|
|
77
|
+
return f"{prefix}{impl!r}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def make_render_item(*args: Never, wrap: _RenderItem | None = None) -> _RenderItem:
|
|
81
|
+
"""
|
|
82
|
+
Return a ``render_item`` function for Alembic's ``context.configure()``.
|
|
83
|
+
|
|
84
|
+
It renders `PydanticJSON` column types as their underlying JSON type. Everything else is passed
|
|
85
|
+
on to `wrap` if given, or otherwise left to Alembic's default rendering.
|
|
86
|
+
"""
|
|
87
|
+
# `*args` only exists to give a clear error when the function itself is passed to Alembic
|
|
88
|
+
# (``render_item=make_render_item``), which then calls it with three positional arguments.
|
|
89
|
+
if args: # ty: ignore[redundant-condition] # only reachable when called untyped, by Alembic
|
|
90
|
+
raise TypeError(
|
|
91
|
+
"make_render_item() takes no positional arguments. Pass its result to Alembic: "
|
|
92
|
+
"context.configure(render_item=make_render_item()), or "
|
|
93
|
+
"make_render_item(wrap=my_render_item) to combine it with your own function."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def render_item(type_: str, obj: Any, autogen_context: AutogenContext) -> str | Literal[False]:
|
|
97
|
+
rendered = _render_item(type_, obj, autogen_context)
|
|
98
|
+
if rendered is not False or wrap is None:
|
|
99
|
+
return rendered
|
|
100
|
+
return wrap(type_, obj, autogen_context)
|
|
101
|
+
|
|
102
|
+
return render_item
|
|
File without changes
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlalchemy-pydantic-json
|
|
3
|
+
Version: 0.0.1a1
|
|
4
|
+
Summary: Pydantic v2 models in SQLAlchemy JSON columns with automatic mutation tracking
|
|
5
|
+
Keywords: sqlalchemy,pydantic,json,jsonb,mutable,mutation-tracking,orm
|
|
6
|
+
Author: Joakim Nordling
|
|
7
|
+
Author-email: Joakim Nordling <joakim.nordling@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Pydantic :: 2
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Dist: sqlalchemy>=2.0.14
|
|
23
|
+
Requires-Dist: pydantic>=2.11
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Project-URL: Homepage, https://github.com/joakimnordling/sqlalchemy-pydantic-json
|
|
26
|
+
Project-URL: Documentation, https://github.com/joakimnordling/sqlalchemy-pydantic-json#readme
|
|
27
|
+
Project-URL: Changelog, https://github.com/joakimnordling/sqlalchemy-pydantic-json/blob/main/CHANGELOG.md
|
|
28
|
+
Project-URL: Issues, https://github.com/joakimnordling/sqlalchemy-pydantic-json/issues
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# sqlalchemy-pydantic-json
|
|
32
|
+
|
|
33
|
+
Store Pydantic models in SQLAlchemy JSON columns, and just change them in place: every change,
|
|
34
|
+
however deeply nested, is saved when you commit.
|
|
35
|
+
|
|
36
|
+
No `flag_modified()` calls, no event listeners in your code, and full type-checker support
|
|
37
|
+
(mypy, pyright and ty).
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
A JSON column is a convenient place for structured data that doesn't deserve its own tables:
|
|
42
|
+
settings, preferences, metadata. With plain SQLAlchemy you get dicts and lists back, and changing
|
|
43
|
+
them in place isn't noticed: `user.settings["theme"] = "dark"` is silently lost unless you also call
|
|
44
|
+
`flag_modified(user, "settings")`. SQLAlchemy's `MutableDict` helps for one level, but not for
|
|
45
|
+
nested structures, and not for Pydantic models.
|
|
46
|
+
|
|
47
|
+
This package gives you real Pydantic models in the column (validation, defaults, types,
|
|
48
|
+
autocompletion) and tracks every change inside them: fields, lists, dicts, sets and nested models,
|
|
49
|
+
however deep.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install sqlalchemy-pydantic-json
|
|
55
|
+
# or
|
|
56
|
+
uv add sqlalchemy-pydantic-json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Requires Python 3.11+, SQLAlchemy 2.0.14+ and Pydantic 2.11+. Tested with SQLite, PostgreSQL and
|
|
60
|
+
MariaDB, with both `Session` and `AsyncSession`.
|
|
61
|
+
|
|
62
|
+
**Using Alembic?** Then also do the [one-time Alembic setup](#alembic-setup) below. Without it,
|
|
63
|
+
autogenerated migrations fail.
|
|
64
|
+
|
|
65
|
+
## Quick start
|
|
66
|
+
|
|
67
|
+
Use `EmbeddedPydanticModel` as the base class for the column's model **and for every model inside
|
|
68
|
+
it**, and declare the column with `Model.column()`:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from sqlalchemy import create_engine, select
|
|
72
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
|
|
73
|
+
|
|
74
|
+
from sqlalchemy_pydantic_json import EmbeddedPydanticModel
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Visit(EmbeddedPydanticModel):
|
|
78
|
+
page: str = "/"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Address(EmbeddedPydanticModel):
|
|
82
|
+
city: str = "Helsinki"
|
|
83
|
+
lines: list[str] = []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Settings(EmbeddedPydanticModel):
|
|
87
|
+
theme: str = "light"
|
|
88
|
+
tags: set[str] = set()
|
|
89
|
+
address: Address = Address()
|
|
90
|
+
history: list[Visit] = []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Base(DeclarativeBase):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class User(Base):
|
|
98
|
+
__tablename__ = "users"
|
|
99
|
+
|
|
100
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
101
|
+
settings: Mapped[Settings] = mapped_column(Settings.column(), default=Settings)
|
|
102
|
+
extra: Mapped[Settings | None] = mapped_column(Settings.column()) # nullable
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
engine = create_engine("sqlite://")
|
|
106
|
+
Base.metadata.create_all(engine)
|
|
107
|
+
|
|
108
|
+
with Session(engine) as session:
|
|
109
|
+
session.add(User(id=1))
|
|
110
|
+
session.commit()
|
|
111
|
+
|
|
112
|
+
user = session.get(User, 1)
|
|
113
|
+
user.settings.theme = "dark"
|
|
114
|
+
user.settings.tags.add("admin")
|
|
115
|
+
user.settings.address.lines.append("Mannerheimintie 1")
|
|
116
|
+
user.settings.history.append(Visit(page="/home"))
|
|
117
|
+
user.settings.history[0].page = "/start"
|
|
118
|
+
assert user in session.dirty # every change above marks the row as changed
|
|
119
|
+
session.commit()
|
|
120
|
+
|
|
121
|
+
with Session(engine) as session:
|
|
122
|
+
user = session.get(User, 1)
|
|
123
|
+
assert user.settings.address.lines == ["Mannerheimintie 1"]
|
|
124
|
+
assert user.settings.history[0].page == "/start"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
You can also assign a whole model, or a dict (it's validated into the model), or `None` for a
|
|
128
|
+
nullable column:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
with Session(engine) as session:
|
|
132
|
+
user = session.get(User, 1)
|
|
133
|
+
user.settings = Settings(theme="blue")
|
|
134
|
+
user.extra = {"theme": "green"}
|
|
135
|
+
assert isinstance(user.extra, Settings)
|
|
136
|
+
user.extra = None # stored as SQL NULL
|
|
137
|
+
session.commit()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## PostgreSQL: JSON or JSONB
|
|
141
|
+
|
|
142
|
+
`Model.column()` uses SQLAlchemy's generic `JSON` type, which works on every database. On
|
|
143
|
+
PostgreSQL that creates a `json` column. For `jsonb` (binary, indexable, more operators), pass
|
|
144
|
+
`json_type`:
|
|
145
|
+
|
|
146
|
+
<!-- readme-test: skip -->
|
|
147
|
+
```python
|
|
148
|
+
from sqlalchemy import JSON
|
|
149
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
150
|
+
|
|
151
|
+
# always JSONB (PostgreSQL only)
|
|
152
|
+
settings: Mapped[Settings] = mapped_column(Settings.column(json_type=JSONB), default=Settings)
|
|
153
|
+
|
|
154
|
+
# JSONB on PostgreSQL, JSON elsewhere (e.g. SQLite in tests)
|
|
155
|
+
settings: Mapped[Settings] = mapped_column(
|
|
156
|
+
Settings.column(
|
|
157
|
+
json_type=JSON(none_as_null=True).with_variant(JSONB(none_as_null=True), "postgresql")
|
|
158
|
+
),
|
|
159
|
+
default=Settings,
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A type *class* such as `JSONB` automatically gets `none_as_null=True`, so that `None` is stored as
|
|
164
|
+
SQL `NULL`. A type *instance* is used as is, so pass `none_as_null=True` yourself, as above.
|
|
165
|
+
|
|
166
|
+
## Querying inside the JSON
|
|
167
|
+
|
|
168
|
+
The column keeps SQLAlchemy's JSON operators, so you can filter on values inside the model:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
with Session(engine) as session:
|
|
172
|
+
blue = session.scalars(select(User).where(User.settings["theme"].as_string() == "blue")).all()
|
|
173
|
+
in_helsinki = session.scalars(
|
|
174
|
+
select(User).where(User.settings[("address", "city")].as_string() == "Helsinki")
|
|
175
|
+
).all()
|
|
176
|
+
assert [u.id for u in blue] == [1]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
See SQLAlchemy's [JSON type documentation](https://docs.sqlalchemy.org/en/20/core/type_basics.html#sqlalchemy.types.JSON)
|
|
180
|
+
for the operators, and what each database supports.
|
|
181
|
+
|
|
182
|
+
## Aliases (e.g. camelCase)
|
|
183
|
+
|
|
184
|
+
Pydantic aliases decide the key names in the stored JSON. For camelCase, make your own base class
|
|
185
|
+
with an alias generator, and use it for all of your models:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from pydantic import ConfigDict, Field
|
|
189
|
+
from pydantic.alias_generators import to_camel
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class CamelModel(EmbeddedPydanticModel):
|
|
193
|
+
model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Profile(CamelModel):
|
|
197
|
+
display_name: str = "anon" # stored as "displayName"
|
|
198
|
+
tax_id: str | None = Field(default=None, alias="TIN") # an explicit alias wins: "TIN"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Member(Base):
|
|
202
|
+
__tablename__ = "members"
|
|
203
|
+
|
|
204
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
205
|
+
profile: Mapped[Profile] = mapped_column(Profile.column(), default=Profile)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
Base.metadata.create_all(engine)
|
|
209
|
+
|
|
210
|
+
with Session(engine) as session:
|
|
211
|
+
session.add(Member(id=1, profile=Profile(display_name="Jocke", TIN="123")))
|
|
212
|
+
session.commit() # stored as {"displayName": "Jocke", "TIN": "123"}
|
|
213
|
+
|
|
214
|
+
query = select(Member.id).where(Member.profile["displayName"].as_string() == "Jocke")
|
|
215
|
+
assert session.scalars(query).all() == [1]
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- The JSON is stored with the aliases, as by Pydantic's `model_dump(by_alias=True)`. Loading
|
|
219
|
+
accepts both the aliases and the field names, so rows stored before you added an alias still
|
|
220
|
+
load. They're stored with the aliases the next time they're saved.
|
|
221
|
+
- Queries into the JSON use the stored names: `Member.profile["displayName"]`.
|
|
222
|
+
- `validate_by_name=True` lets your Python code use field names. Type checkers know the field names
|
|
223
|
+
of generated aliases (`display_name=`), but only the alias of an explicit `Field(alias="TIN")`
|
|
224
|
+
(`TIN=`), so write it that way. Also pass `Field(default=...)` as a keyword: type checkers treat
|
|
225
|
+
a positional default as a required field.
|
|
226
|
+
- A field must be loadable from the name it's stored under. If its `serialization_alias` differs
|
|
227
|
+
from its `validation_alias`, defining the model raises a `TypeError` (include the stored name
|
|
228
|
+
with `AliasChoices` if you need both).
|
|
229
|
+
|
|
230
|
+
## Default values
|
|
231
|
+
|
|
232
|
+
These all work:
|
|
233
|
+
|
|
234
|
+
<!-- readme-test: skip -->
|
|
235
|
+
```python
|
|
236
|
+
# a new model for each row (recommended)
|
|
237
|
+
settings: Mapped[Settings] = mapped_column(Settings.column(), default=Settings)
|
|
238
|
+
|
|
239
|
+
# a dict, validated into a new model for each row
|
|
240
|
+
settings: Mapped[Settings] = mapped_column(Settings.column(), default={"theme": "dark"})
|
|
241
|
+
|
|
242
|
+
# a default in the database; the model's own field defaults fill in the rest when loaded
|
|
243
|
+
settings: Mapped[Settings] = mapped_column(Settings.column(), server_default=text("'{}'"))
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Don't use a model *instance* as the default (`default=Settings()`): SQLAlchemy then puts that same
|
|
247
|
+
object into every new row, so changing one row's settings changes all of them.
|
|
248
|
+
|
|
249
|
+
## Alembic setup
|
|
250
|
+
|
|
251
|
+
Alembic's autogenerate can't write the column type into a migration by itself: it would write
|
|
252
|
+
`sqlalchemy_pydantic_json._model.PydanticJSON(...)`, which fails when the migration runs. Tell it to
|
|
253
|
+
write the plain JSON type instead. In your `env.py`, pass `render_item` to **both**
|
|
254
|
+
`context.configure()` calls (offline and online):
|
|
255
|
+
|
|
256
|
+
<!-- readme-test: skip -->
|
|
257
|
+
```python
|
|
258
|
+
from sqlalchemy_pydantic_json.alembic import make_render_item
|
|
259
|
+
|
|
260
|
+
context.configure(
|
|
261
|
+
...,
|
|
262
|
+
render_item=make_render_item(),
|
|
263
|
+
)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
If you already have a `render_item` function of your own, wrap it:
|
|
267
|
+
|
|
268
|
+
<!-- readme-test: skip -->
|
|
269
|
+
```python
|
|
270
|
+
context.configure(..., render_item=make_render_item(wrap=my_render_item))
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Migrations then contain `sa.JSON(none_as_null=True)` (or `postgresql.JSONB(...)`, or the variant),
|
|
274
|
+
and depend on neither this package nor your models, so they keep working as your models change.
|
|
275
|
+
|
|
276
|
+
Changing the *model* doesn't change the database schema, so Alembic has nothing to generate for
|
|
277
|
+
it. Existing rows must still validate against the new model, though: after adding a required field
|
|
278
|
+
or renaming one, say, either make the model accept the old data, or update the stored JSON yourself
|
|
279
|
+
(for example in a hand-written data migration). Changing the column's type between JSON and JSONB
|
|
280
|
+
is detected like any other type change.
|
|
281
|
+
|
|
282
|
+
## Using with SQLModel
|
|
283
|
+
|
|
284
|
+
Declare the column with `sa_column`:
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
from sqlalchemy import Column
|
|
288
|
+
from sqlmodel import Field, SQLModel, col, select
|
|
289
|
+
from sqlmodel import Session as SQLModelSession
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class Player(SQLModel, table=True):
|
|
293
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
294
|
+
settings: Settings = Field(
|
|
295
|
+
default_factory=Settings,
|
|
296
|
+
sa_column=Column(Settings.column(), nullable=False),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
SQLModel.metadata.create_all(engine)
|
|
301
|
+
|
|
302
|
+
with SQLModelSession(engine) as session:
|
|
303
|
+
session.add(Player(id=1))
|
|
304
|
+
session.commit()
|
|
305
|
+
|
|
306
|
+
player = session.get(Player, 1)
|
|
307
|
+
player.settings.tags.add("captain") # tracked, as with SQLAlchemy models
|
|
308
|
+
assert player in session.dirty
|
|
309
|
+
session.commit()
|
|
310
|
+
|
|
311
|
+
query = select(Player.id).where(col(Player.settings)["theme"].as_string() == "light")
|
|
312
|
+
assert session.exec(query).all() == [1]
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Rules and gotchas
|
|
316
|
+
|
|
317
|
+
- **Every model inside the column should inherit `EmbeddedPydanticModel`,** not
|
|
318
|
+
`pydantic.BaseModel`. A plain `BaseModel` still loads and saves correctly, and replacing it as a
|
|
319
|
+
whole is tracked, but changes *inside* it aren't: they're lost unless something else in the row
|
|
320
|
+
changes too. So plain models are fine only if they're never changed in place, for example frozen
|
|
321
|
+
ones (`model_config = ConfigDict(frozen=True)`).
|
|
322
|
+
- **Values are validated every time a row is loaded,** against the current model. When you change
|
|
323
|
+
a model, existing rows must still validate: give new fields a default (or update the stored
|
|
324
|
+
rows), and handle renamed or removed fields, for example with a `model_validator(mode="before")`
|
|
325
|
+
or a hand-written data migration.
|
|
326
|
+
- **Bulk and Core statements bypass tracking,** as with any SQLAlchemy attribute:
|
|
327
|
+
`session.execute(update(User).values(...))` writes what you give it, and doesn't know about
|
|
328
|
+
in-place changes.
|
|
329
|
+
- **Values you keep across an expiring commit are no longer tracked.** With the default
|
|
330
|
+
`expire_on_commit=True`, `commit()` expires the row; the next access loads a fresh model. Changing
|
|
331
|
+
the old model you kept a reference to does nothing (and doesn't raise). Read the value from the row
|
|
332
|
+
again after committing.
|
|
333
|
+
- **Shallow copies share nested models,** as in Pydantic: changing a nested model in a
|
|
334
|
+
`model_copy()` or `copy.copy()` also changes it in the original. Use `model_copy(deep=True)` or
|
|
335
|
+
`copy.deepcopy()` for an independent copy. Copies (and pickled models) aren't attached to any row
|
|
336
|
+
until you assign them.
|
|
337
|
+
- **Thread safety** is the same as for SQLAlchemy sessions: don't share one between threads.
|
|
338
|
+
|
|
339
|
+
## How it works
|
|
340
|
+
|
|
341
|
+
- `PydanticJSON` is a SQLAlchemy `TypeDecorator` over `JSON`: it validates the model on load and
|
|
342
|
+
dumps it with `model_dump(mode="json")` on save. On its own it doesn't track anything.
|
|
343
|
+
- `EmbeddedPydanticModel` combines Pydantic's `BaseModel` with SQLAlchemy's `Mutable`, and
|
|
344
|
+
`Model.column()` is `Model.as_mutable(PydanticJSON(Model))`.
|
|
345
|
+
- Whenever a field is set, lists, dicts and sets are wrapped in tracked versions of SQLAlchemy's
|
|
346
|
+
`MutableList`, `MutableDict` and `MutableSet`, and nested models are linked to their parent.
|
|
347
|
+
- Each model or container keeps weak references to all of its parents. A change is passed up from
|
|
348
|
+
parent to parent until it reaches the model in the column, which marks the row as changed. A
|
|
349
|
+
parent that no longer holds the value (after a `pop()` or reassignment, say) is skipped and
|
|
350
|
+
forgotten, so values can be moved around and shared freely.
|
|
351
|
+
|
|
352
|
+
## Alternatives
|
|
353
|
+
|
|
354
|
+
- [sqlalchemy-json](https://github.com/edelooff/sqlalchemy-json): nested change tracking for plain
|
|
355
|
+
dicts and lists, without Pydantic models.
|
|
356
|
+
- [SQLAlchemy-Nested-Mutable](https://github.com/wonderbeyond/sqlalchemy-nested-mutable): nested
|
|
357
|
+
tracking including Pydantic models, but for Pydantic v1 only.
|
|
358
|
+
- [SQLModel](https://sqlmodel.tiangolo.com/): Pydantic and SQLAlchemy in one model class, but no
|
|
359
|
+
built-in change tracking for Pydantic models in JSON columns. This package adds it
|
|
360
|
+
([see above](#using-with-sqlmodel)).
|
|
361
|
+
- [activemodel](https://github.com/iloveitaly/activemodel): an ActiveRecord-style framework on top
|
|
362
|
+
of SQLModel. Its `PydanticJSONMixin` also tracks changes in Pydantic models in JSON columns, by
|
|
363
|
+
comparing snapshots of the JSON when the session commits. It requires SQLModel, and a change
|
|
364
|
+
isn't visible to flushes (including autoflush before a query) until then.
|
|
365
|
+
|
|
366
|
+
This package needs only SQLAlchemy 2.0 and Pydantic. It works with SQLAlchemy's declarative models
|
|
367
|
+
and with SQLModel, and notices every change the moment it's made.
|
|
368
|
+
|
|
369
|
+
## Contributing
|
|
370
|
+
|
|
371
|
+
See [CONTRIBUTING.md](https://github.com/joakimnordling/sqlalchemy-pydantic-json/blob/main/CONTRIBUTING.md).
|
|
372
|
+
Changes are listed in the [changelog](https://github.com/joakimnordling/sqlalchemy-pydantic-json/blob/main/CHANGELOG.md).
|
|
373
|
+
|
|
374
|
+
## License
|
|
375
|
+
|
|
376
|
+
[MIT](https://github.com/joakimnordling/sqlalchemy-pydantic-json/blob/main/LICENSE)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
sqlalchemy_pydantic_json/__init__.py,sha256=tMX1uWNicv4g0PZUjL82Vs2ItuTC5gu8MMtDtEaZ36I,226
|
|
2
|
+
sqlalchemy_pydantic_json/_model.py,sha256=xW3u2X6iqDmK71LTCM1VI7_sokt7Dq7kyXO0lLEeiDs,14661
|
|
3
|
+
sqlalchemy_pydantic_json/alembic.py,sha256=_6l6jOYJjPTGyDkH9Nybm0n634R3WXRO1G5Mu7BHRZ8,4360
|
|
4
|
+
sqlalchemy_pydantic_json/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
sqlalchemy_pydantic_json-0.0.1a1.dist-info/licenses/LICENSE,sha256=NO_zyIgDivri9_xYthj7rkpolyvSNXPpUBBu56uIRGY,1072
|
|
6
|
+
sqlalchemy_pydantic_json-0.0.1a1.dist-info/WHEEL,sha256=R1d3uUTbmXM1FHXH_itQashbrqrOSVj-hvBCpmkIIGE,81
|
|
7
|
+
sqlalchemy_pydantic_json-0.0.1a1.dist-info/METADATA,sha256=gi5e3-IRC3nP3CTXtpuWr9cN3akc3CgeTx2KvX8ivWM,15274
|
|
8
|
+
sqlalchemy_pydantic_json-0.0.1a1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joakim Nordling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|