sqlalchemy-pydantic-json 0.0.1a1__tar.gz

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.
@@ -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.
@@ -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)