pydantic-table 0.3.0__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.
- pydantic_table-0.3.0/LICENSE +21 -0
- pydantic_table-0.3.0/PKG-INFO +343 -0
- pydantic_table-0.3.0/README.md +325 -0
- pydantic_table-0.3.0/pyproject.toml +32 -0
- pydantic_table-0.3.0/src/pydantic_table/__init__.py +4 -0
- pydantic_table-0.3.0/src/pydantic_table/__version__.py +3 -0
- pydantic_table-0.3.0/src/pydantic_table/alembic/__init__.py +0 -0
- pydantic_table-0.3.0/src/pydantic_table/alembic/archive.py +162 -0
- pydantic_table-0.3.0/src/pydantic_table/alembic/exceptions.py +5 -0
- pydantic_table-0.3.0/src/pydantic_table/alembic/op.py +241 -0
- pydantic_table-0.3.0/src/pydantic_table/logger.py +136 -0
- pydantic_table-0.3.0/src/pydantic_table/py.typed +0 -0
- pydantic_table-0.3.0/src/pydantic_table/sqlalchemy/__init__.py +3 -0
- pydantic_table-0.3.0/src/pydantic_table/sqlalchemy/base.py +57 -0
- pydantic_table-0.3.0/src/pydantic_table/sqlalchemy/column.py +97 -0
- pydantic_table-0.3.0/src/pydantic_table/sqlalchemy/table.py +18 -0
- pydantic_table-0.3.0/src/pydantic_table/table_model/exceptions.py +6 -0
- pydantic_table-0.3.0/src/pydantic_table/table_model/field.py +62 -0
- pydantic_table-0.3.0/src/pydantic_table/table_model/internal_attr.py +21 -0
- pydantic_table-0.3.0/src/pydantic_table/table_model/meta.py +89 -0
- pydantic_table-0.3.0/src/pydantic_table/table_model/model.py +164 -0
- pydantic_table-0.3.0/src/pydantic_table/utils.py +16 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mariia Redchuk
|
|
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,343 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pydantic-table
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary:
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Author: mariia
|
|
7
|
+
Author-email: mariia.redchuk@iconservizi.com
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: alembic (>=1.19.1,<2.0.0)
|
|
14
|
+
Requires-Dist: dotenv (>=0.9.9,<0.10.0)
|
|
15
|
+
Requires-Dist: pydantic (>=2.13.4,<3.0.0)
|
|
16
|
+
Requires-Dist: sqlalchemy (>=2.0.52,<3.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# pydantic-table
|
|
20
|
+
|
|
21
|
+
Use pydantic model as single source of truth for SQLAlchemy table definition and alembic migrations.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
pip install https://github.com/sagitta42/pydantic-table.git@v0.3.0
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## `TableModel` and `ColumnField`
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# tables.py
|
|
31
|
+
from pydantic_table import ColumnField, TableModel
|
|
32
|
+
|
|
33
|
+
class ExampleTable(TableModel, table_name="examples"):
|
|
34
|
+
id: int = ColumnField(description="ID", primary_key=True)
|
|
35
|
+
name: str = ColumnField(description="Name")
|
|
36
|
+
value: float = ColumnField(description="Value", nullable=True)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
>>> ExampleTable.column_fields()
|
|
41
|
+
{
|
|
42
|
+
'id': ColumnFieldInfo(annotation=int, required=True, primary_key=True, nullable=False),
|
|
43
|
+
'name': ColumnFieldInfo(annotation=str, required=True, primary_key=False, nullable=False),
|
|
44
|
+
'value': ColumnFieldInfo(annotation=float, required=True, primary_key=False, nullable=True)
|
|
45
|
+
}
|
|
46
|
+
>>> row = ExampleTable(id=42, name="Alice", value=1.618)
|
|
47
|
+
>>> row.model_dump()
|
|
48
|
+
{'table_name__': 'examples', 'missing_columns__': [], 'extra_columns__': {'new_column': 'foo'}, 'id': 42, 'name': 'Alice', 'value': 1.618}
|
|
49
|
+
>>> row.column_dump()
|
|
50
|
+
{'id': 42, 'name': 'Alice', 'value': 1.618}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- `TableModel` is a pydantic `BaseModel`
|
|
54
|
+
- model field name = column name
|
|
55
|
+
- model field annotation = column data type
|
|
56
|
+
- `TableModel.column_fields()` returns `dict[str, ColumnFieldInfo]`
|
|
57
|
+
- `ColumnFieldInfo` is `FieldInfo` with extra properties `primary_key` and `nullable`
|
|
58
|
+
- `model_dump()` returns all fields including special internal fields - see [alembic][#alembic] section on the roles of `missing_columns__` and `extra_columns__`
|
|
59
|
+
- `column_dump()` returns actual columns
|
|
60
|
+
|
|
61
|
+
- **One source of truth**:
|
|
62
|
+
- class defines **table schema**
|
|
63
|
+
|
|
64
|
+
- instance represents a **data row**
|
|
65
|
+
|
|
66
|
+
- **validation** via pydantic at earliest stage (e.g. before alembic migrations, in-app DB calls etc.)
|
|
67
|
+
|
|
68
|
+
- defines **payload** for API
|
|
69
|
+
|
|
70
|
+
- **adaptors** for `sa.Column` and `sa.Table` from `sqlalchemy` in `pydantic_table.sqlalchemy`
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
sa_column: sa.Column = Column("id", column_field_info, foreign_key="another_table.name")
|
|
74
|
+
sa_table: sa.Table = Table(ExampleTable, autoload_with=op.get_bind())
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **adaptor** for `DeclarativeBase` from `sqlalchemy` via **BaseMeta** in `pydantic_table.sqlalchemy` - see [sqlalchemy][#sqlalchemy] section
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
class ExampleTableBase(Base, metaclass=BaseMeta, model=ExampleTable)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- **Schema changes** easily tracked
|
|
84
|
+
|
|
85
|
+
- update to `TableModel` child class is auto-reflected in payload and DB Base **at the same time**
|
|
86
|
+
- **backwards compatibility** via alembic adaptors in `pydantic_table.alembic.op`: `ExampleTable` can be updated directly by adding/removing column fields, followed by an add/drop column migration; previous migrations are not broken if `op.drop_column()` and `op.drop_table()` adaptors are used - see [alembic][#alembic] section
|
|
87
|
+
|
|
88
|
+
## sqlalchemy
|
|
89
|
+
|
|
90
|
+
### `sa.Column` and `sa.Table` adaptors
|
|
91
|
+
|
|
92
|
+
Get `sa.Table` from your `TableModel`:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import pydantic_table.sqlalchemy as sap
|
|
96
|
+
from tables import ExampleTable
|
|
97
|
+
|
|
98
|
+
table = sap.Table(ExampleTable, autoload_with=op.get_bind())
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Get single `sa.Column` from your `ColumnFieldInfo`:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
column_info = ExampleTable.column_fields()["id"]
|
|
105
|
+
sap.Column("id", column_info, foreign_key="another_table.name")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### `Base` adaptor
|
|
109
|
+
|
|
110
|
+
Translate `TableModel` into `DeclarativeBase` :
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# models.py
|
|
114
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
115
|
+
from pydantic_table.sqlalchemy import BaseMeta
|
|
116
|
+
|
|
117
|
+
from tables import ExampleTable
|
|
118
|
+
|
|
119
|
+
class Base(DeclarativeBase):
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
class ExampleTableBase(Base, metaclass=BaseMeta, model=ExampleTable):
|
|
123
|
+
pass
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Convenience: **shared definition** between **db models** and **payload schemas**
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
# schemas.py
|
|
130
|
+
from tables import ExampleTable
|
|
131
|
+
|
|
132
|
+
class MyPayload(BaseModel):
|
|
133
|
+
row: ExampleTable
|
|
134
|
+
other_stuff: 42
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# db.py
|
|
139
|
+
from collections.abc import Generator
|
|
140
|
+
from sqlalchemy import create_engine
|
|
141
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
142
|
+
|
|
143
|
+
engine = create_engine(<get_url()>, pool_pre_ping=True)
|
|
144
|
+
SessionLocal = sessionmaker(
|
|
145
|
+
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
|
|
146
|
+
)
|
|
147
|
+
def get_db() -> Generator[Session, None, None]:
|
|
148
|
+
db = SessionLocal()
|
|
149
|
+
try:
|
|
150
|
+
yield db
|
|
151
|
+
finally:
|
|
152
|
+
db.close()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# routes/router.py
|
|
157
|
+
from fastapi import APIRouter, Depends
|
|
158
|
+
from sqlalchemy.orm import Session
|
|
159
|
+
|
|
160
|
+
from db import get_db
|
|
161
|
+
from schemas import MyPayload
|
|
162
|
+
|
|
163
|
+
router = APIRouter()
|
|
164
|
+
|
|
165
|
+
@router.post("/add_data", response_model=MyResponseModel)
|
|
166
|
+
def add_data(
|
|
167
|
+
payload: MyPayload, session: Session = Depends(get_db)
|
|
168
|
+
) -> MyResponseModel:
|
|
169
|
+
|
|
170
|
+
db_row = ExampleTableBase(**payload.row.model_dump())
|
|
171
|
+
session.add(db_row)
|
|
172
|
+
session.commit()
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Once model under **tables** is updated, there is no need to update either **schemas** or **models** - seamless update to router endpoint payload and DB interaction.
|
|
176
|
+
|
|
177
|
+
## alembic
|
|
178
|
+
|
|
179
|
+
### create/delete table
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from alembic import op
|
|
183
|
+
|
|
184
|
+
from pydantic_table.alembic import op as opp # avoid conflict with op
|
|
185
|
+
from tables import ExampleTable
|
|
186
|
+
|
|
187
|
+
def upgrade() -> None:
|
|
188
|
+
"""Upgrade schema."""
|
|
189
|
+
opp.create_table(ExampleTable)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def downgrade() -> None:
|
|
193
|
+
"""Downgrade schema."""
|
|
194
|
+
opp.drop_table(ExampleTable)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Backwards compatibility:
|
|
198
|
+
|
|
199
|
+
- If **new column fields** are added to or **old column fields are dropped** from `ExampleTable` at the future point, the old revision above **is not broken**,
|
|
200
|
+
|
|
201
|
+
- If this revision is "re-migrated" via downgrade followed by upgrade, `opp` will detect said schema change comparing it to the table schema at drop moment, and **archive a snapshot of table model** in this revision under `versions/.archive`.
|
|
202
|
+
|
|
203
|
+
- The re-upgrade will re-create table based on archived `TableModel` rather than the changed one
|
|
204
|
+
- **Make sure** to **NOT DELETE** the archive `*.json` revision files - keep track of them the same way the `*.py` revision files are managed
|
|
205
|
+
|
|
206
|
+
### insert/remove data
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
from alembic import op
|
|
210
|
+
|
|
211
|
+
from pydantic_table.alembic import op as opp
|
|
212
|
+
from tables import ExampleTable
|
|
213
|
+
|
|
214
|
+
data = ExampleTable(id=42, name="Alice", value=2.718)
|
|
215
|
+
def upgrade() -> None:
|
|
216
|
+
"""Upgrade schema."""
|
|
217
|
+
# id will be stored under extra columns even if dropped from ExampleTable in the future
|
|
218
|
+
opp.insert(data) # table name is already stored in ExampleTable
|
|
219
|
+
|
|
220
|
+
def downgrade() -> None:
|
|
221
|
+
"""Downgrade schema."""
|
|
222
|
+
# getter will extract "id" from extra columns data even if column is removed in the future
|
|
223
|
+
opp.delete_where(ExampleTable, id=data.get("id"))
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Backwards compatibility:
|
|
227
|
+
|
|
228
|
+
- If new column fields are added to `ExampleTable` in the future, they will be ignored as they are not provided in the migration's data, and are not present in the table at that revision
|
|
229
|
+
- If column fields are removed from `ExampleTable` in the future, this will not break the migration as `TableModel` will store the extra field values internally, and retrieve them by detecting those columns in database at that revision
|
|
230
|
+
- The getter `data.get("id")` extracts "id" value from extra columns even if "id" is eventually removed from `ExampleTable` in a future schema update. Alternatively, use `id=42`, `ExampleTable(id=id, ...)` and `opp.delete_where(..., id=id)`
|
|
231
|
+
|
|
232
|
+
### add column
|
|
233
|
+
|
|
234
|
+
Update `ExampleTable` schema:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
# tables.py
|
|
238
|
+
class ExampleTable(TableModel, table_name="examples"):
|
|
239
|
+
id: int = ColumnField(description="ID", primary_key=True)
|
|
240
|
+
name: str = ColumnField(description="Name")
|
|
241
|
+
value: float = ColumnField(description="Value")
|
|
242
|
+
new_column: str = ColumnField(default="", description="new_column") # NEW
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Column has default or is nullable:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
def upgrade() -> None:
|
|
249
|
+
"""Upgrade schema."""
|
|
250
|
+
# column has default or is nullable, so can be added without data
|
|
251
|
+
opp.add_column(ExampleTable, "new_column")
|
|
252
|
+
# column is not nullable and does not have a default so cannot be added without data
|
|
253
|
+
# row or list of rows with values for new column
|
|
254
|
+
# and values for other columns for where condition as column=value
|
|
255
|
+
data = ExampleTable(id=42, new_column="foo")
|
|
256
|
+
opp.add_column(ExampleTable, "new_column", data=data)
|
|
257
|
+
|
|
258
|
+
def downgrade() -> None:
|
|
259
|
+
"""Downgrade schema."""
|
|
260
|
+
opp.drop_column(ExampleTable, "new_column")
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### remove column
|
|
264
|
+
|
|
265
|
+
Update `ExampleTable` schema:
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
# tables.py
|
|
269
|
+
class ExampleTable(TableModel, table_name="examples"):
|
|
270
|
+
id: int = ColumnField(description="ID", primary_key=True)
|
|
271
|
+
name: str = ColumnField(description="Name")
|
|
272
|
+
# value: float = ColumnField(description="Value") <-- remove column
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Same as in adding a column, re-add column with data if not nullable and does not have default:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
def upgrade() -> None:
|
|
279
|
+
"""Upgrade schema."""
|
|
280
|
+
# pydantic_table.alembic.op will detect column present in table but not in TableModel
|
|
281
|
+
# will archive field information to be able to re-add column in downgrade
|
|
282
|
+
opp.drop_column(ExampleTable, "value")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def downgrade() -> None:
|
|
286
|
+
"""Downgrade schema."""
|
|
287
|
+
# TODO: auto-read field info and data from archive
|
|
288
|
+
opp.add_column(ExampleTable, "value")
|
|
289
|
+
# if column does not have default, so must be added with data
|
|
290
|
+
# data = ExampleTable(id=42, value=2.718)
|
|
291
|
+
# opp.add_column(ExampleTable, "value", data=data)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Backwards compatibility:
|
|
295
|
+
|
|
296
|
+
Here downgrade is possible even though information on properties of `"value"` column such as primary key, nullable etc. are not present in `ExampleTable` anymore because at `drop_column()` its absence will be detected by `opp`, and an archive of column field info will be saved in the revision archive, similar to deleting table.
|
|
297
|
+
|
|
298
|
+
### fallback to alembic op
|
|
299
|
+
|
|
300
|
+
Easy fallback to standard alembic op by using `sap.Column` and `sap.Table` adaptors described in the [sqlalchemy][#sqlalchemy] section, and performing standard `op` operations "manually".
|
|
301
|
+
|
|
302
|
+
## utils/validation
|
|
303
|
+
|
|
304
|
+
Reading data for migration from a `.csv` file, `.json` or other? `TableModel` will auto-validate it for you. Example util based on `pydantic_table`:
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
def df2model(df: pd.DataFrame, model: Type[TableModel]) -> list[TableModel]:
|
|
308
|
+
"""
|
|
309
|
+
Convert DataFrame to list of Model rows
|
|
310
|
+
"""
|
|
311
|
+
records: list[dict[str, Any]] = df.to_dict(orient="records")
|
|
312
|
+
ret = []
|
|
313
|
+
|
|
314
|
+
for rec in records:
|
|
315
|
+
ret.append(model(**rec))
|
|
316
|
+
|
|
317
|
+
return ret
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
With this, you can migrate with
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
from models import ExampleTable
|
|
324
|
+
|
|
325
|
+
from alembic_migrations.utils import df2model
|
|
326
|
+
|
|
327
|
+
def upgrade() -> None:
|
|
328
|
+
"""Upgrade schema."""
|
|
329
|
+
rows = df2model(df, ExampleTable)
|
|
330
|
+
opp.insert(rows)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def downgrade() -> None:
|
|
334
|
+
"""Downgrade schema."""
|
|
335
|
+
opp.delete_where(ExampleTable, column1=value1, column2=value2, ...) # you need to know your condition
|
|
336
|
+
# opp.deep_delete(rows) <- will delete where column=value for each column for each row
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
-----
|
|
343
|
+
*Made with [poetic](https://github.com/sagitta42/poetic)*
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# pydantic-table
|
|
2
|
+
|
|
3
|
+
Use pydantic model as single source of truth for SQLAlchemy table definition and alembic migrations.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
pip install https://github.com/sagitta42/pydantic-table.git@v0.3.0
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## `TableModel` and `ColumnField`
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
# tables.py
|
|
13
|
+
from pydantic_table import ColumnField, TableModel
|
|
14
|
+
|
|
15
|
+
class ExampleTable(TableModel, table_name="examples"):
|
|
16
|
+
id: int = ColumnField(description="ID", primary_key=True)
|
|
17
|
+
name: str = ColumnField(description="Name")
|
|
18
|
+
value: float = ColumnField(description="Value", nullable=True)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
>>> ExampleTable.column_fields()
|
|
23
|
+
{
|
|
24
|
+
'id': ColumnFieldInfo(annotation=int, required=True, primary_key=True, nullable=False),
|
|
25
|
+
'name': ColumnFieldInfo(annotation=str, required=True, primary_key=False, nullable=False),
|
|
26
|
+
'value': ColumnFieldInfo(annotation=float, required=True, primary_key=False, nullable=True)
|
|
27
|
+
}
|
|
28
|
+
>>> row = ExampleTable(id=42, name="Alice", value=1.618)
|
|
29
|
+
>>> row.model_dump()
|
|
30
|
+
{'table_name__': 'examples', 'missing_columns__': [], 'extra_columns__': {'new_column': 'foo'}, 'id': 42, 'name': 'Alice', 'value': 1.618}
|
|
31
|
+
>>> row.column_dump()
|
|
32
|
+
{'id': 42, 'name': 'Alice', 'value': 1.618}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- `TableModel` is a pydantic `BaseModel`
|
|
36
|
+
- model field name = column name
|
|
37
|
+
- model field annotation = column data type
|
|
38
|
+
- `TableModel.column_fields()` returns `dict[str, ColumnFieldInfo]`
|
|
39
|
+
- `ColumnFieldInfo` is `FieldInfo` with extra properties `primary_key` and `nullable`
|
|
40
|
+
- `model_dump()` returns all fields including special internal fields - see [alembic][#alembic] section on the roles of `missing_columns__` and `extra_columns__`
|
|
41
|
+
- `column_dump()` returns actual columns
|
|
42
|
+
|
|
43
|
+
- **One source of truth**:
|
|
44
|
+
- class defines **table schema**
|
|
45
|
+
|
|
46
|
+
- instance represents a **data row**
|
|
47
|
+
|
|
48
|
+
- **validation** via pydantic at earliest stage (e.g. before alembic migrations, in-app DB calls etc.)
|
|
49
|
+
|
|
50
|
+
- defines **payload** for API
|
|
51
|
+
|
|
52
|
+
- **adaptors** for `sa.Column` and `sa.Table` from `sqlalchemy` in `pydantic_table.sqlalchemy`
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
sa_column: sa.Column = Column("id", column_field_info, foreign_key="another_table.name")
|
|
56
|
+
sa_table: sa.Table = Table(ExampleTable, autoload_with=op.get_bind())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- **adaptor** for `DeclarativeBase` from `sqlalchemy` via **BaseMeta** in `pydantic_table.sqlalchemy` - see [sqlalchemy][#sqlalchemy] section
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
class ExampleTableBase(Base, metaclass=BaseMeta, model=ExampleTable)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- **Schema changes** easily tracked
|
|
66
|
+
|
|
67
|
+
- update to `TableModel` child class is auto-reflected in payload and DB Base **at the same time**
|
|
68
|
+
- **backwards compatibility** via alembic adaptors in `pydantic_table.alembic.op`: `ExampleTable` can be updated directly by adding/removing column fields, followed by an add/drop column migration; previous migrations are not broken if `op.drop_column()` and `op.drop_table()` adaptors are used - see [alembic][#alembic] section
|
|
69
|
+
|
|
70
|
+
## sqlalchemy
|
|
71
|
+
|
|
72
|
+
### `sa.Column` and `sa.Table` adaptors
|
|
73
|
+
|
|
74
|
+
Get `sa.Table` from your `TableModel`:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import pydantic_table.sqlalchemy as sap
|
|
78
|
+
from tables import ExampleTable
|
|
79
|
+
|
|
80
|
+
table = sap.Table(ExampleTable, autoload_with=op.get_bind())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Get single `sa.Column` from your `ColumnFieldInfo`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
column_info = ExampleTable.column_fields()["id"]
|
|
87
|
+
sap.Column("id", column_info, foreign_key="another_table.name")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### `Base` adaptor
|
|
91
|
+
|
|
92
|
+
Translate `TableModel` into `DeclarativeBase` :
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# models.py
|
|
96
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
97
|
+
from pydantic_table.sqlalchemy import BaseMeta
|
|
98
|
+
|
|
99
|
+
from tables import ExampleTable
|
|
100
|
+
|
|
101
|
+
class Base(DeclarativeBase):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
class ExampleTableBase(Base, metaclass=BaseMeta, model=ExampleTable):
|
|
105
|
+
pass
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Convenience: **shared definition** between **db models** and **payload schemas**
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
# schemas.py
|
|
112
|
+
from tables import ExampleTable
|
|
113
|
+
|
|
114
|
+
class MyPayload(BaseModel):
|
|
115
|
+
row: ExampleTable
|
|
116
|
+
other_stuff: 42
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# db.py
|
|
121
|
+
from collections.abc import Generator
|
|
122
|
+
from sqlalchemy import create_engine
|
|
123
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
124
|
+
|
|
125
|
+
engine = create_engine(<get_url()>, pool_pre_ping=True)
|
|
126
|
+
SessionLocal = sessionmaker(
|
|
127
|
+
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
|
|
128
|
+
)
|
|
129
|
+
def get_db() -> Generator[Session, None, None]:
|
|
130
|
+
db = SessionLocal()
|
|
131
|
+
try:
|
|
132
|
+
yield db
|
|
133
|
+
finally:
|
|
134
|
+
db.close()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# routes/router.py
|
|
139
|
+
from fastapi import APIRouter, Depends
|
|
140
|
+
from sqlalchemy.orm import Session
|
|
141
|
+
|
|
142
|
+
from db import get_db
|
|
143
|
+
from schemas import MyPayload
|
|
144
|
+
|
|
145
|
+
router = APIRouter()
|
|
146
|
+
|
|
147
|
+
@router.post("/add_data", response_model=MyResponseModel)
|
|
148
|
+
def add_data(
|
|
149
|
+
payload: MyPayload, session: Session = Depends(get_db)
|
|
150
|
+
) -> MyResponseModel:
|
|
151
|
+
|
|
152
|
+
db_row = ExampleTableBase(**payload.row.model_dump())
|
|
153
|
+
session.add(db_row)
|
|
154
|
+
session.commit()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Once model under **tables** is updated, there is no need to update either **schemas** or **models** - seamless update to router endpoint payload and DB interaction.
|
|
158
|
+
|
|
159
|
+
## alembic
|
|
160
|
+
|
|
161
|
+
### create/delete table
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from alembic import op
|
|
165
|
+
|
|
166
|
+
from pydantic_table.alembic import op as opp # avoid conflict with op
|
|
167
|
+
from tables import ExampleTable
|
|
168
|
+
|
|
169
|
+
def upgrade() -> None:
|
|
170
|
+
"""Upgrade schema."""
|
|
171
|
+
opp.create_table(ExampleTable)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def downgrade() -> None:
|
|
175
|
+
"""Downgrade schema."""
|
|
176
|
+
opp.drop_table(ExampleTable)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Backwards compatibility:
|
|
180
|
+
|
|
181
|
+
- If **new column fields** are added to or **old column fields are dropped** from `ExampleTable` at the future point, the old revision above **is not broken**,
|
|
182
|
+
|
|
183
|
+
- If this revision is "re-migrated" via downgrade followed by upgrade, `opp` will detect said schema change comparing it to the table schema at drop moment, and **archive a snapshot of table model** in this revision under `versions/.archive`.
|
|
184
|
+
|
|
185
|
+
- The re-upgrade will re-create table based on archived `TableModel` rather than the changed one
|
|
186
|
+
- **Make sure** to **NOT DELETE** the archive `*.json` revision files - keep track of them the same way the `*.py` revision files are managed
|
|
187
|
+
|
|
188
|
+
### insert/remove data
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from alembic import op
|
|
192
|
+
|
|
193
|
+
from pydantic_table.alembic import op as opp
|
|
194
|
+
from tables import ExampleTable
|
|
195
|
+
|
|
196
|
+
data = ExampleTable(id=42, name="Alice", value=2.718)
|
|
197
|
+
def upgrade() -> None:
|
|
198
|
+
"""Upgrade schema."""
|
|
199
|
+
# id will be stored under extra columns even if dropped from ExampleTable in the future
|
|
200
|
+
opp.insert(data) # table name is already stored in ExampleTable
|
|
201
|
+
|
|
202
|
+
def downgrade() -> None:
|
|
203
|
+
"""Downgrade schema."""
|
|
204
|
+
# getter will extract "id" from extra columns data even if column is removed in the future
|
|
205
|
+
opp.delete_where(ExampleTable, id=data.get("id"))
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Backwards compatibility:
|
|
209
|
+
|
|
210
|
+
- If new column fields are added to `ExampleTable` in the future, they will be ignored as they are not provided in the migration's data, and are not present in the table at that revision
|
|
211
|
+
- If column fields are removed from `ExampleTable` in the future, this will not break the migration as `TableModel` will store the extra field values internally, and retrieve them by detecting those columns in database at that revision
|
|
212
|
+
- The getter `data.get("id")` extracts "id" value from extra columns even if "id" is eventually removed from `ExampleTable` in a future schema update. Alternatively, use `id=42`, `ExampleTable(id=id, ...)` and `opp.delete_where(..., id=id)`
|
|
213
|
+
|
|
214
|
+
### add column
|
|
215
|
+
|
|
216
|
+
Update `ExampleTable` schema:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
# tables.py
|
|
220
|
+
class ExampleTable(TableModel, table_name="examples"):
|
|
221
|
+
id: int = ColumnField(description="ID", primary_key=True)
|
|
222
|
+
name: str = ColumnField(description="Name")
|
|
223
|
+
value: float = ColumnField(description="Value")
|
|
224
|
+
new_column: str = ColumnField(default="", description="new_column") # NEW
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Column has default or is nullable:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
def upgrade() -> None:
|
|
231
|
+
"""Upgrade schema."""
|
|
232
|
+
# column has default or is nullable, so can be added without data
|
|
233
|
+
opp.add_column(ExampleTable, "new_column")
|
|
234
|
+
# column is not nullable and does not have a default so cannot be added without data
|
|
235
|
+
# row or list of rows with values for new column
|
|
236
|
+
# and values for other columns for where condition as column=value
|
|
237
|
+
data = ExampleTable(id=42, new_column="foo")
|
|
238
|
+
opp.add_column(ExampleTable, "new_column", data=data)
|
|
239
|
+
|
|
240
|
+
def downgrade() -> None:
|
|
241
|
+
"""Downgrade schema."""
|
|
242
|
+
opp.drop_column(ExampleTable, "new_column")
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### remove column
|
|
246
|
+
|
|
247
|
+
Update `ExampleTable` schema:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
# tables.py
|
|
251
|
+
class ExampleTable(TableModel, table_name="examples"):
|
|
252
|
+
id: int = ColumnField(description="ID", primary_key=True)
|
|
253
|
+
name: str = ColumnField(description="Name")
|
|
254
|
+
# value: float = ColumnField(description="Value") <-- remove column
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Same as in adding a column, re-add column with data if not nullable and does not have default:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
def upgrade() -> None:
|
|
261
|
+
"""Upgrade schema."""
|
|
262
|
+
# pydantic_table.alembic.op will detect column present in table but not in TableModel
|
|
263
|
+
# will archive field information to be able to re-add column in downgrade
|
|
264
|
+
opp.drop_column(ExampleTable, "value")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def downgrade() -> None:
|
|
268
|
+
"""Downgrade schema."""
|
|
269
|
+
# TODO: auto-read field info and data from archive
|
|
270
|
+
opp.add_column(ExampleTable, "value")
|
|
271
|
+
# if column does not have default, so must be added with data
|
|
272
|
+
# data = ExampleTable(id=42, value=2.718)
|
|
273
|
+
# opp.add_column(ExampleTable, "value", data=data)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Backwards compatibility:
|
|
277
|
+
|
|
278
|
+
Here downgrade is possible even though information on properties of `"value"` column such as primary key, nullable etc. are not present in `ExampleTable` anymore because at `drop_column()` its absence will be detected by `opp`, and an archive of column field info will be saved in the revision archive, similar to deleting table.
|
|
279
|
+
|
|
280
|
+
### fallback to alembic op
|
|
281
|
+
|
|
282
|
+
Easy fallback to standard alembic op by using `sap.Column` and `sap.Table` adaptors described in the [sqlalchemy][#sqlalchemy] section, and performing standard `op` operations "manually".
|
|
283
|
+
|
|
284
|
+
## utils/validation
|
|
285
|
+
|
|
286
|
+
Reading data for migration from a `.csv` file, `.json` or other? `TableModel` will auto-validate it for you. Example util based on `pydantic_table`:
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
def df2model(df: pd.DataFrame, model: Type[TableModel]) -> list[TableModel]:
|
|
290
|
+
"""
|
|
291
|
+
Convert DataFrame to list of Model rows
|
|
292
|
+
"""
|
|
293
|
+
records: list[dict[str, Any]] = df.to_dict(orient="records")
|
|
294
|
+
ret = []
|
|
295
|
+
|
|
296
|
+
for rec in records:
|
|
297
|
+
ret.append(model(**rec))
|
|
298
|
+
|
|
299
|
+
return ret
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
With this, you can migrate with
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
from models import ExampleTable
|
|
306
|
+
|
|
307
|
+
from alembic_migrations.utils import df2model
|
|
308
|
+
|
|
309
|
+
def upgrade() -> None:
|
|
310
|
+
"""Upgrade schema."""
|
|
311
|
+
rows = df2model(df, ExampleTable)
|
|
312
|
+
opp.insert(rows)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def downgrade() -> None:
|
|
316
|
+
"""Downgrade schema."""
|
|
317
|
+
opp.delete_where(ExampleTable, column1=value1, column2=value2, ...) # you need to know your condition
|
|
318
|
+
# opp.deep_delete(rows) <- will delete where column=value for each column for each row
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
-----
|
|
325
|
+
*Made with [poetic](https://github.com/sagitta42/poetic)*
|