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