csrd-migration 0.3.89__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.
- csrd_migration-0.3.89/.gitignore +217 -0
- csrd_migration-0.3.89/PKG-INFO +72 -0
- csrd_migration-0.3.89/README.md +60 -0
- csrd_migration-0.3.89/pyproject.toml +25 -0
- csrd_migration-0.3.89/src/csrd/migration/__init__.py +11 -0
- csrd_migration-0.3.89/src/csrd/migration/_migration.py +29 -0
- csrd_migration-0.3.89/src/csrd/migration/_runner.py +98 -0
- csrd_migration-0.3.89/src/csrd/migration/py.typed +0 -0
- csrd_migration-0.3.89/tests/test_migration.py +156 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
/env/
|
|
142
|
+
/venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
208
|
+
|
|
209
|
+
*.db
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Import linter cache
|
|
213
|
+
.import_linter_cache/
|
|
214
|
+
|
|
215
|
+
# IDE
|
|
216
|
+
.idea/
|
|
217
|
+
.idea/*
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: csrd-migration
|
|
3
|
+
Version: 0.3.89
|
|
4
|
+
Summary: Versioned schema migrations for csrd database adapters
|
|
5
|
+
Project-URL: Repository, https://github.com/csrd-api/fastapi-common
|
|
6
|
+
Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/migration
|
|
7
|
+
Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: csrd-repository
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# csrd-migration
|
|
14
|
+
|
|
15
|
+
Versioned, idempotent schema migrations for `csrd.repository` database adapters.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from csrd.migration import Migration, MigrationRunner
|
|
21
|
+
from csrd.repository import SQLiteAdapter
|
|
22
|
+
|
|
23
|
+
MIGRATIONS = [
|
|
24
|
+
Migration(
|
|
25
|
+
version="001",
|
|
26
|
+
description="create users table",
|
|
27
|
+
up="CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL)",
|
|
28
|
+
down="DROP TABLE users",
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
async def setup_db():
|
|
33
|
+
adapter = SQLiteAdapter("app.db")
|
|
34
|
+
await adapter.connect()
|
|
35
|
+
runner = MigrationRunner()
|
|
36
|
+
await runner.apply_all(adapter, MIGRATIONS)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
### `Migration`
|
|
42
|
+
|
|
43
|
+
Frozen dataclass representing a single schema change.
|
|
44
|
+
|
|
45
|
+
| Field | Type | Description |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `version` | `str` | Unique version identifier (e.g. `"001"`, `"002"`) |
|
|
48
|
+
| `description` | `str` | Human-readable description |
|
|
49
|
+
| `up` | `str` | SQL to apply the migration |
|
|
50
|
+
| `down` | `str` | SQL to revert the migration |
|
|
51
|
+
|
|
52
|
+
### `MigrationRunner`
|
|
53
|
+
|
|
54
|
+
Applies and tracks migrations against any `ABCDatabaseAdapter`.
|
|
55
|
+
|
|
56
|
+
| Method | Description |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `apply_all(adapter, migrations)` | Apply all pending migrations in order |
|
|
59
|
+
| `current_version(adapter)` | Return the latest applied version, or `None` |
|
|
60
|
+
| `pending(adapter, migrations)` | Return migrations not yet applied |
|
|
61
|
+
|
|
62
|
+
The runner creates a `_csrd_migrations` table automatically to track
|
|
63
|
+
applied versions. Re-running is idempotent — already-applied migrations
|
|
64
|
+
are skipped.
|
|
65
|
+
|
|
66
|
+
## Design
|
|
67
|
+
|
|
68
|
+
- **Adapter-agnostic** — works with `SQLiteAdapter`, `PGAdapter`, `MariaAdapter`
|
|
69
|
+
- **Idempotent** — safe to call on every startup
|
|
70
|
+
- **Ordered** — migrations are applied in list order
|
|
71
|
+
- **Future-ready** — the `Migration` dataclass is the stable interface for
|
|
72
|
+
a future model→SQL translation layer (see `docs/AUGMENT_PLAN.md`)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# csrd-migration
|
|
2
|
+
|
|
3
|
+
Versioned, idempotent schema migrations for `csrd.repository` database adapters.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from csrd.migration import Migration, MigrationRunner
|
|
9
|
+
from csrd.repository import SQLiteAdapter
|
|
10
|
+
|
|
11
|
+
MIGRATIONS = [
|
|
12
|
+
Migration(
|
|
13
|
+
version="001",
|
|
14
|
+
description="create users table",
|
|
15
|
+
up="CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL)",
|
|
16
|
+
down="DROP TABLE users",
|
|
17
|
+
),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
async def setup_db():
|
|
21
|
+
adapter = SQLiteAdapter("app.db")
|
|
22
|
+
await adapter.connect()
|
|
23
|
+
runner = MigrationRunner()
|
|
24
|
+
await runner.apply_all(adapter, MIGRATIONS)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## API
|
|
28
|
+
|
|
29
|
+
### `Migration`
|
|
30
|
+
|
|
31
|
+
Frozen dataclass representing a single schema change.
|
|
32
|
+
|
|
33
|
+
| Field | Type | Description |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `version` | `str` | Unique version identifier (e.g. `"001"`, `"002"`) |
|
|
36
|
+
| `description` | `str` | Human-readable description |
|
|
37
|
+
| `up` | `str` | SQL to apply the migration |
|
|
38
|
+
| `down` | `str` | SQL to revert the migration |
|
|
39
|
+
|
|
40
|
+
### `MigrationRunner`
|
|
41
|
+
|
|
42
|
+
Applies and tracks migrations against any `ABCDatabaseAdapter`.
|
|
43
|
+
|
|
44
|
+
| Method | Description |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `apply_all(adapter, migrations)` | Apply all pending migrations in order |
|
|
47
|
+
| `current_version(adapter)` | Return the latest applied version, or `None` |
|
|
48
|
+
| `pending(adapter, migrations)` | Return migrations not yet applied |
|
|
49
|
+
|
|
50
|
+
The runner creates a `_csrd_migrations` table automatically to track
|
|
51
|
+
applied versions. Re-running is idempotent — already-applied migrations
|
|
52
|
+
are skipped.
|
|
53
|
+
|
|
54
|
+
## Design
|
|
55
|
+
|
|
56
|
+
- **Adapter-agnostic** — works with `SQLiteAdapter`, `PGAdapter`, `MariaAdapter`
|
|
57
|
+
- **Idempotent** — safe to call on every startup
|
|
58
|
+
- **Ordered** — migrations are applied in list order
|
|
59
|
+
- **Future-ready** — the `Migration` dataclass is the stable interface for
|
|
60
|
+
a future model→SQL translation layer (see `docs/AUGMENT_PLAN.md`)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "csrd-migration"
|
|
3
|
+
version = "0.3.89"
|
|
4
|
+
description = "Versioned schema migrations for csrd database adapters"
|
|
5
|
+
license = { text = "MIT" }
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"csrd-repository",
|
|
9
|
+
]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
|
|
12
|
+
[tool.uv.sources]
|
|
13
|
+
csrd-repository = { workspace = true }
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/csrd-api/fastapi-common"
|
|
17
|
+
Documentation = "https://github.com/csrd-api/fastapi-common/tree/main/packages/migration"
|
|
18
|
+
Changelog = "https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/csrd"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Versioned schema migrations for csrd database adapters.
|
|
2
|
+
|
|
3
|
+
Public API::
|
|
4
|
+
|
|
5
|
+
from csrd.migration import Migration, MigrationRunner
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._migration import Migration
|
|
9
|
+
from ._runner import MigrationRunner
|
|
10
|
+
|
|
11
|
+
__all__ = ("Migration", "MigrationRunner")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Frozen dataclass representing a single versioned schema change."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Migration:
|
|
8
|
+
"""A single versioned database schema change.
|
|
9
|
+
|
|
10
|
+
Migrations are applied in list order by :class:`MigrationRunner`.
|
|
11
|
+
Each migration must have a unique ``version`` string.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
version
|
|
16
|
+
Unique version identifier (e.g. ``"001"``, ``"002"``).
|
|
17
|
+
Compared lexicographically — use zero-padded numbers.
|
|
18
|
+
description
|
|
19
|
+
Human-readable description of the change.
|
|
20
|
+
up
|
|
21
|
+
SQL statement(s) to apply the migration.
|
|
22
|
+
down
|
|
23
|
+
SQL statement(s) to revert the migration.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
version: str
|
|
27
|
+
description: str
|
|
28
|
+
up: str
|
|
29
|
+
down: str
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Migration runner — applies and tracks schema migrations."""
|
|
2
|
+
|
|
3
|
+
from csrd.repository import ABCDatabaseAdapter
|
|
4
|
+
|
|
5
|
+
from ._migration import Migration
|
|
6
|
+
|
|
7
|
+
_MIGRATIONS_TABLE = "_csrd_migrations"
|
|
8
|
+
|
|
9
|
+
_CREATE_TABLE_SQL = f"""
|
|
10
|
+
CREATE TABLE IF NOT EXISTS {_MIGRATIONS_TABLE} (
|
|
11
|
+
version TEXT PRIMARY KEY,
|
|
12
|
+
description TEXT NOT NULL,
|
|
13
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
14
|
+
)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# Postgres/MariaDB variant — uses CURRENT_TIMESTAMP instead of datetime('now')
|
|
18
|
+
_CREATE_TABLE_SQL_STANDARD = f"""
|
|
19
|
+
CREATE TABLE IF NOT EXISTS {_MIGRATIONS_TABLE} (
|
|
20
|
+
version VARCHAR(255) PRIMARY KEY,
|
|
21
|
+
description VARCHAR(1024) NOT NULL,
|
|
22
|
+
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
23
|
+
)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MigrationRunner:
|
|
28
|
+
"""Apply and track versioned schema migrations.
|
|
29
|
+
|
|
30
|
+
The runner creates a ``_csrd_migrations`` tracking table automatically.
|
|
31
|
+
Already-applied migrations are skipped (idempotent).
|
|
32
|
+
|
|
33
|
+
Usage::
|
|
34
|
+
|
|
35
|
+
runner = MigrationRunner()
|
|
36
|
+
await runner.apply_all(adapter, migrations)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
async def _ensure_table(self, adapter: ABCDatabaseAdapter) -> None:
|
|
40
|
+
"""Create the migrations tracking table if it doesn't exist."""
|
|
41
|
+
# Try SQLite-style first; fall back to standard SQL on failure
|
|
42
|
+
try:
|
|
43
|
+
await adapter.execute(_CREATE_TABLE_SQL)
|
|
44
|
+
except Exception:
|
|
45
|
+
await adapter.execute(_CREATE_TABLE_SQL_STANDARD)
|
|
46
|
+
|
|
47
|
+
async def _applied_versions(self, adapter: ABCDatabaseAdapter) -> set[str]:
|
|
48
|
+
"""Return the set of already-applied migration versions."""
|
|
49
|
+
rows = await adapter.fetch_all(f"SELECT version FROM {_MIGRATIONS_TABLE}")
|
|
50
|
+
return {row["version"] for row in rows}
|
|
51
|
+
|
|
52
|
+
async def apply_all(
|
|
53
|
+
self,
|
|
54
|
+
adapter: ABCDatabaseAdapter,
|
|
55
|
+
migrations: list[Migration],
|
|
56
|
+
) -> list[Migration]:
|
|
57
|
+
"""Apply all pending migrations in list order.
|
|
58
|
+
|
|
59
|
+
Returns the list of migrations that were actually applied
|
|
60
|
+
(empty if everything was already up-to-date).
|
|
61
|
+
"""
|
|
62
|
+
await self._ensure_table(adapter)
|
|
63
|
+
applied = await self._applied_versions(adapter)
|
|
64
|
+
|
|
65
|
+
newly_applied: list[Migration] = []
|
|
66
|
+
for migration in migrations:
|
|
67
|
+
if migration.version in applied:
|
|
68
|
+
continue
|
|
69
|
+
await adapter.execute(migration.up)
|
|
70
|
+
await adapter.execute(
|
|
71
|
+
f"INSERT INTO {_MIGRATIONS_TABLE} (version, description) "
|
|
72
|
+
"VALUES (:version, :description)",
|
|
73
|
+
{"version": migration.version, "description": migration.description},
|
|
74
|
+
)
|
|
75
|
+
newly_applied.append(migration)
|
|
76
|
+
|
|
77
|
+
return newly_applied
|
|
78
|
+
|
|
79
|
+
async def current_version(
|
|
80
|
+
self,
|
|
81
|
+
adapter: ABCDatabaseAdapter,
|
|
82
|
+
) -> str | None:
|
|
83
|
+
"""Return the latest applied migration version, or ``None``."""
|
|
84
|
+
await self._ensure_table(adapter)
|
|
85
|
+
row = await adapter.fetch_one(
|
|
86
|
+
f"SELECT version FROM {_MIGRATIONS_TABLE} ORDER BY version DESC LIMIT 1"
|
|
87
|
+
)
|
|
88
|
+
return row["version"] if row else None
|
|
89
|
+
|
|
90
|
+
async def pending(
|
|
91
|
+
self,
|
|
92
|
+
adapter: ABCDatabaseAdapter,
|
|
93
|
+
migrations: list[Migration],
|
|
94
|
+
) -> list[Migration]:
|
|
95
|
+
"""Return migrations not yet applied, preserving list order."""
|
|
96
|
+
await self._ensure_table(adapter)
|
|
97
|
+
applied = await self._applied_versions(adapter)
|
|
98
|
+
return [m for m in migrations if m.version not in applied]
|
|
File without changes
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Tests for csrd.migration — Migration dataclass and MigrationRunner."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from csrd.migration import Migration, MigrationRunner
|
|
6
|
+
from csrd.repository import SQLiteAdapter
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# Fixtures
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
async def adapter(tmp_path):
|
|
15
|
+
"""Provide a connected SQLiteAdapter backed by a temp file."""
|
|
16
|
+
db_path = str(tmp_path / "test.db")
|
|
17
|
+
adapter = SQLiteAdapter(db_path)
|
|
18
|
+
await adapter.connect()
|
|
19
|
+
yield adapter
|
|
20
|
+
await adapter.close()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MIGRATIONS = [
|
|
24
|
+
Migration(
|
|
25
|
+
version="001",
|
|
26
|
+
description="create users table",
|
|
27
|
+
up="CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL)",
|
|
28
|
+
down="DROP TABLE users",
|
|
29
|
+
),
|
|
30
|
+
Migration(
|
|
31
|
+
version="002",
|
|
32
|
+
description="add email column",
|
|
33
|
+
up="ALTER TABLE users ADD COLUMN email TEXT",
|
|
34
|
+
down=(
|
|
35
|
+
"CREATE TABLE users_backup (id INTEGER PRIMARY KEY, username TEXT NOT NULL);"
|
|
36
|
+
"INSERT INTO users_backup SELECT id, username FROM users;"
|
|
37
|
+
"DROP TABLE users;"
|
|
38
|
+
"ALTER TABLE users_backup RENAME TO users;"
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Migration dataclass
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestMigration:
|
|
50
|
+
def test_frozen(self):
|
|
51
|
+
m = Migration(version="001", description="test", up="SELECT 1", down="SELECT 1")
|
|
52
|
+
with pytest.raises(AttributeError):
|
|
53
|
+
m.version = "002" # type: ignore[misc]
|
|
54
|
+
|
|
55
|
+
def test_fields(self):
|
|
56
|
+
m = Migration(version="001", description="test", up="UP", down="DOWN")
|
|
57
|
+
assert m.version == "001"
|
|
58
|
+
assert m.description == "test"
|
|
59
|
+
assert m.up == "UP"
|
|
60
|
+
assert m.down == "DOWN"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# MigrationRunner
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestMigrationRunner:
|
|
69
|
+
async def test_apply_all_creates_table_and_applies(self, adapter):
|
|
70
|
+
runner = MigrationRunner()
|
|
71
|
+
applied = await runner.apply_all(adapter, MIGRATIONS)
|
|
72
|
+
|
|
73
|
+
assert len(applied) == 2
|
|
74
|
+
assert applied[0].version == "001"
|
|
75
|
+
assert applied[1].version == "002"
|
|
76
|
+
|
|
77
|
+
# Verify the users table was actually created with both columns
|
|
78
|
+
row = await adapter.fetch_one(
|
|
79
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name='users'"
|
|
80
|
+
)
|
|
81
|
+
assert row is not None
|
|
82
|
+
assert "email" in row["sql"]
|
|
83
|
+
|
|
84
|
+
async def test_apply_all_is_idempotent(self, adapter):
|
|
85
|
+
runner = MigrationRunner()
|
|
86
|
+
first = await runner.apply_all(adapter, MIGRATIONS)
|
|
87
|
+
second = await runner.apply_all(adapter, MIGRATIONS)
|
|
88
|
+
|
|
89
|
+
assert len(first) == 2
|
|
90
|
+
assert len(second) == 0 # nothing new to apply
|
|
91
|
+
|
|
92
|
+
async def test_apply_all_incremental(self, adapter):
|
|
93
|
+
runner = MigrationRunner()
|
|
94
|
+
|
|
95
|
+
# Apply only first migration
|
|
96
|
+
applied1 = await runner.apply_all(adapter, MIGRATIONS[:1])
|
|
97
|
+
assert len(applied1) == 1
|
|
98
|
+
|
|
99
|
+
# Now apply both — only second should be new
|
|
100
|
+
applied2 = await runner.apply_all(adapter, MIGRATIONS)
|
|
101
|
+
assert len(applied2) == 1
|
|
102
|
+
assert applied2[0].version == "002"
|
|
103
|
+
|
|
104
|
+
async def test_current_version_empty(self, adapter):
|
|
105
|
+
runner = MigrationRunner()
|
|
106
|
+
version = await runner.current_version(adapter)
|
|
107
|
+
assert version is None
|
|
108
|
+
|
|
109
|
+
async def test_current_version_after_apply(self, adapter):
|
|
110
|
+
runner = MigrationRunner()
|
|
111
|
+
await runner.apply_all(adapter, MIGRATIONS)
|
|
112
|
+
version = await runner.current_version(adapter)
|
|
113
|
+
assert version == "002"
|
|
114
|
+
|
|
115
|
+
async def test_current_version_partial(self, adapter):
|
|
116
|
+
runner = MigrationRunner()
|
|
117
|
+
await runner.apply_all(adapter, MIGRATIONS[:1])
|
|
118
|
+
version = await runner.current_version(adapter)
|
|
119
|
+
assert version == "001"
|
|
120
|
+
|
|
121
|
+
async def test_pending_all(self, adapter):
|
|
122
|
+
runner = MigrationRunner()
|
|
123
|
+
pending = await runner.pending(adapter, MIGRATIONS)
|
|
124
|
+
assert len(pending) == 2
|
|
125
|
+
|
|
126
|
+
async def test_pending_after_apply(self, adapter):
|
|
127
|
+
runner = MigrationRunner()
|
|
128
|
+
await runner.apply_all(adapter, MIGRATIONS[:1])
|
|
129
|
+
pending = await runner.pending(adapter, MIGRATIONS)
|
|
130
|
+
assert len(pending) == 1
|
|
131
|
+
assert pending[0].version == "002"
|
|
132
|
+
|
|
133
|
+
async def test_pending_none(self, adapter):
|
|
134
|
+
runner = MigrationRunner()
|
|
135
|
+
await runner.apply_all(adapter, MIGRATIONS)
|
|
136
|
+
pending = await runner.pending(adapter, MIGRATIONS)
|
|
137
|
+
assert len(pending) == 0
|
|
138
|
+
|
|
139
|
+
async def test_tracking_table_records(self, adapter):
|
|
140
|
+
runner = MigrationRunner()
|
|
141
|
+
await runner.apply_all(adapter, MIGRATIONS)
|
|
142
|
+
|
|
143
|
+
rows = await adapter.fetch_all(
|
|
144
|
+
"SELECT version, description FROM _csrd_migrations ORDER BY version"
|
|
145
|
+
)
|
|
146
|
+
assert len(rows) == 2
|
|
147
|
+
assert rows[0]["version"] == "001"
|
|
148
|
+
assert rows[0]["description"] == "create users table"
|
|
149
|
+
assert rows[1]["version"] == "002"
|
|
150
|
+
assert rows[1]["description"] == "add email column"
|
|
151
|
+
|
|
152
|
+
async def test_empty_migration_list(self, adapter):
|
|
153
|
+
runner = MigrationRunner()
|
|
154
|
+
applied = await runner.apply_all(adapter, [])
|
|
155
|
+
assert applied == []
|
|
156
|
+
assert await runner.current_version(adapter) is None
|