iceaxe 0.7.1__cp313-cp313-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of iceaxe might be problematic. Click here for more details.
- iceaxe/__init__.py +20 -0
- iceaxe/__tests__/__init__.py +0 -0
- iceaxe/__tests__/benchmarks/__init__.py +0 -0
- iceaxe/__tests__/benchmarks/test_bulk_insert.py +45 -0
- iceaxe/__tests__/benchmarks/test_select.py +114 -0
- iceaxe/__tests__/conf_models.py +133 -0
- iceaxe/__tests__/conftest.py +204 -0
- iceaxe/__tests__/docker_helpers.py +208 -0
- iceaxe/__tests__/helpers.py +268 -0
- iceaxe/__tests__/migrations/__init__.py +0 -0
- iceaxe/__tests__/migrations/conftest.py +36 -0
- iceaxe/__tests__/migrations/test_action_sorter.py +237 -0
- iceaxe/__tests__/migrations/test_generator.py +140 -0
- iceaxe/__tests__/migrations/test_generics.py +91 -0
- iceaxe/__tests__/mountaineer/__init__.py +0 -0
- iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
- iceaxe/__tests__/mountaineer/dependencies/test_core.py +76 -0
- iceaxe/__tests__/schemas/__init__.py +0 -0
- iceaxe/__tests__/schemas/test_actions.py +1264 -0
- iceaxe/__tests__/schemas/test_cli.py +25 -0
- iceaxe/__tests__/schemas/test_db_memory_serializer.py +1525 -0
- iceaxe/__tests__/schemas/test_db_serializer.py +398 -0
- iceaxe/__tests__/schemas/test_db_stubs.py +190 -0
- iceaxe/__tests__/test_alias.py +83 -0
- iceaxe/__tests__/test_base.py +52 -0
- iceaxe/__tests__/test_comparison.py +383 -0
- iceaxe/__tests__/test_field.py +11 -0
- iceaxe/__tests__/test_helpers.py +9 -0
- iceaxe/__tests__/test_modifications.py +151 -0
- iceaxe/__tests__/test_queries.py +605 -0
- iceaxe/__tests__/test_queries_str.py +173 -0
- iceaxe/__tests__/test_session.py +1511 -0
- iceaxe/__tests__/test_text_search.py +287 -0
- iceaxe/alias_values.py +67 -0
- iceaxe/base.py +350 -0
- iceaxe/comparison.py +560 -0
- iceaxe/field.py +250 -0
- iceaxe/functions.py +906 -0
- iceaxe/generics.py +140 -0
- iceaxe/io.py +107 -0
- iceaxe/logging.py +91 -0
- iceaxe/migrations/__init__.py +5 -0
- iceaxe/migrations/action_sorter.py +98 -0
- iceaxe/migrations/cli.py +228 -0
- iceaxe/migrations/client_io.py +62 -0
- iceaxe/migrations/generator.py +404 -0
- iceaxe/migrations/migration.py +86 -0
- iceaxe/migrations/migrator.py +101 -0
- iceaxe/modifications.py +176 -0
- iceaxe/mountaineer/__init__.py +10 -0
- iceaxe/mountaineer/cli.py +74 -0
- iceaxe/mountaineer/config.py +46 -0
- iceaxe/mountaineer/dependencies/__init__.py +6 -0
- iceaxe/mountaineer/dependencies/core.py +67 -0
- iceaxe/postgres.py +133 -0
- iceaxe/py.typed +0 -0
- iceaxe/queries.py +1455 -0
- iceaxe/queries_str.py +294 -0
- iceaxe/schemas/__init__.py +0 -0
- iceaxe/schemas/actions.py +864 -0
- iceaxe/schemas/cli.py +30 -0
- iceaxe/schemas/db_memory_serializer.py +705 -0
- iceaxe/schemas/db_serializer.py +346 -0
- iceaxe/schemas/db_stubs.py +525 -0
- iceaxe/session.py +860 -0
- iceaxe/session_optimized.c +12035 -0
- iceaxe/session_optimized.cpython-313-darwin.so +0 -0
- iceaxe/session_optimized.pyx +212 -0
- iceaxe/sql_types.py +148 -0
- iceaxe/typing.py +73 -0
- iceaxe-0.7.1.dist-info/METADATA +261 -0
- iceaxe-0.7.1.dist-info/RECORD +75 -0
- iceaxe-0.7.1.dist-info/WHEEL +6 -0
- iceaxe-0.7.1.dist-info/licenses/LICENSE +21 -0
- iceaxe-0.7.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client interface functions to introspect the client migrations and import them appropriately
|
|
3
|
+
into the current runtime.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.util import module_from_spec, spec_from_file_location
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from iceaxe.migrations.migration import MigrationRevisionBase
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fetch_migrations(migration_base: Path):
|
|
15
|
+
"""
|
|
16
|
+
Fetch all migrations from the migration base directory. Instead of using importlib.import_module,
|
|
17
|
+
we manually create the module dependencies - this provides some flexibility in the future to avoid
|
|
18
|
+
importing the whole client application just to fetch the migrations.
|
|
19
|
+
|
|
20
|
+
We enforce that all migration files have the prefix 'rev_'.
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
migrations: list[MigrationRevisionBase] = []
|
|
24
|
+
for file in migration_base.glob("rev_*.py"):
|
|
25
|
+
module_name = file.stem
|
|
26
|
+
if module_name.isidentifier() and not module_name.startswith("_"):
|
|
27
|
+
spec = spec_from_file_location(module_name, str(file))
|
|
28
|
+
if spec and spec.loader:
|
|
29
|
+
module = module_from_spec(spec)
|
|
30
|
+
sys.modules[module_name] = module
|
|
31
|
+
spec.loader.exec_module(module)
|
|
32
|
+
migrations.extend(
|
|
33
|
+
attribute()
|
|
34
|
+
for attribute_name in dir(module)
|
|
35
|
+
if isinstance(attribute := getattr(module, attribute_name), type)
|
|
36
|
+
and issubclass(attribute, MigrationRevisionBase)
|
|
37
|
+
and attribute is not MigrationRevisionBase
|
|
38
|
+
)
|
|
39
|
+
return migrations
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sort_migrations(migrations: list[MigrationRevisionBase]):
|
|
43
|
+
"""
|
|
44
|
+
Sort migrations by their (down_revision, up_revision) dependencies. We start with down=current_revision
|
|
45
|
+
which should be the original migration.
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
migration_dict = {mig.down_revision: mig for mig in migrations}
|
|
49
|
+
sorted_revisions: list[MigrationRevisionBase] = []
|
|
50
|
+
|
|
51
|
+
next_revision = None
|
|
52
|
+
while next_revision in migration_dict:
|
|
53
|
+
next_migration = migration_dict[next_revision]
|
|
54
|
+
sorted_revisions.append(next_migration)
|
|
55
|
+
next_revision = next_migration.up_revision
|
|
56
|
+
|
|
57
|
+
if len(sorted_revisions) != len(migrations):
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"There are gaps in the migration sequence or unresolved dependencies."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return sorted_revisions
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from dataclasses import asdict, is_dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from inspect import ismodule
|
|
6
|
+
from json import dumps as json_dumps
|
|
7
|
+
from time import time
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Any, Callable, Sequence, Type
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from iceaxe.migrations.migration import MigrationRevisionBase
|
|
14
|
+
from iceaxe.migrations.migrator import Migrator
|
|
15
|
+
from iceaxe.schemas.actions import DatabaseActions, DryRunAction, DryRunComment
|
|
16
|
+
from iceaxe.schemas.db_memory_serializer import DatabaseMemorySerializer
|
|
17
|
+
from iceaxe.schemas.db_stubs import DBObject, DBObjectPointer
|
|
18
|
+
|
|
19
|
+
MIGRATION_TEMPLATE = """
|
|
20
|
+
{header_imports}
|
|
21
|
+
|
|
22
|
+
class MigrationRevision(MigrationRevisionBase):
|
|
23
|
+
\"""
|
|
24
|
+
Migration auto-generated on {timestamp}.
|
|
25
|
+
|
|
26
|
+
Context: {user_message}
|
|
27
|
+
|
|
28
|
+
\"""
|
|
29
|
+
up_revision: str = {rev}
|
|
30
|
+
down_revision: str | None = {prev_rev}
|
|
31
|
+
|
|
32
|
+
async def up(self, migrator: Migrator):
|
|
33
|
+
{up_code}
|
|
34
|
+
|
|
35
|
+
async def down(self, migrator: Migrator):
|
|
36
|
+
{down_code}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MigrationGenerator:
|
|
41
|
+
"""
|
|
42
|
+
Generates Python migration files for database schema changes.
|
|
43
|
+
This class handles the automatic generation of migration code by comparing
|
|
44
|
+
the current database state with the desired schema defined in code.
|
|
45
|
+
|
|
46
|
+
The generator creates both 'up' and 'down' migration methods, allowing for
|
|
47
|
+
bidirectional schema changes. It automatically handles:
|
|
48
|
+
- Table creation and deletion
|
|
49
|
+
- Column additions, modifications, and removals
|
|
50
|
+
- Constraint management
|
|
51
|
+
- Type creation and updates
|
|
52
|
+
- Import tracking for required dependencies
|
|
53
|
+
|
|
54
|
+
```python {{sticky: True}}
|
|
55
|
+
# Generate a new migration
|
|
56
|
+
generator = MigrationGenerator()
|
|
57
|
+
code, revision = await generator.new_migration(
|
|
58
|
+
down_objects=current_db_state,
|
|
59
|
+
up_objects=desired_schema,
|
|
60
|
+
down_revision="previous_migration_id",
|
|
61
|
+
user_message="Add user preferences table"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# The generated code will look like:
|
|
65
|
+
'''
|
|
66
|
+
class MigrationRevision(MigrationRevisionBase):
|
|
67
|
+
up_revision: str = "20240101120000"
|
|
68
|
+
down_revision: str | None = "previous_migration_id"
|
|
69
|
+
|
|
70
|
+
async def up(self, migrator: Migrator):
|
|
71
|
+
await migrator.actor.add_table(...)
|
|
72
|
+
await migrator.actor.add_column(...)
|
|
73
|
+
|
|
74
|
+
async def down(self, migrator: Migrator):
|
|
75
|
+
await migrator.actor.drop_table(...)
|
|
76
|
+
'''
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self):
|
|
80
|
+
"""
|
|
81
|
+
Initialize a new MigrationGenerator instance.
|
|
82
|
+
Sets up the import tracking system and database serializer.
|
|
83
|
+
"""
|
|
84
|
+
self.import_tracker: defaultdict[str, set[str]] = defaultdict(set)
|
|
85
|
+
self.serializer = DatabaseMemorySerializer()
|
|
86
|
+
|
|
87
|
+
async def new_migration(
|
|
88
|
+
self,
|
|
89
|
+
down_objects_with_dependencies: Sequence[
|
|
90
|
+
tuple[DBObject, Sequence[DBObject | DBObjectPointer]]
|
|
91
|
+
],
|
|
92
|
+
up_objects_with_dependencies: Sequence[
|
|
93
|
+
tuple[DBObject, Sequence[DBObject | DBObjectPointer]]
|
|
94
|
+
],
|
|
95
|
+
down_revision: str | None,
|
|
96
|
+
user_message: str | None,
|
|
97
|
+
) -> tuple[str, str]:
|
|
98
|
+
"""
|
|
99
|
+
Generate a new migration file by comparing two database states.
|
|
100
|
+
|
|
101
|
+
:param down_objects_with_dependencies: Current database state with object dependencies
|
|
102
|
+
:param up_objects_with_dependencies: Desired database state with object dependencies
|
|
103
|
+
:param down_revision: ID of the previous migration this one builds upon
|
|
104
|
+
:param user_message: Optional description of the migration's purpose
|
|
105
|
+
:return: A tuple of (generated migration code, new revision ID)
|
|
106
|
+
|
|
107
|
+
```python {{sticky: True}}
|
|
108
|
+
# Generate migration for schema change
|
|
109
|
+
generator = MigrationGenerator()
|
|
110
|
+
code, revision = await generator.new_migration(
|
|
111
|
+
down_objects_with_dependencies=[(
|
|
112
|
+
DBTable(table_name="users"),
|
|
113
|
+
[]
|
|
114
|
+
)],
|
|
115
|
+
up_objects_with_dependencies=[(
|
|
116
|
+
DBTable(
|
|
117
|
+
table_name="users",
|
|
118
|
+
columns=[DBColumn(name="email", type=ColumnType.VARCHAR)]
|
|
119
|
+
),
|
|
120
|
+
[]
|
|
121
|
+
)],
|
|
122
|
+
down_revision="20240101",
|
|
123
|
+
user_message="Add email column to users table"
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
"""
|
|
127
|
+
self.import_tracker.clear()
|
|
128
|
+
revision = str(int(time()))
|
|
129
|
+
|
|
130
|
+
# Import requirements for every file. We need to explicitly provide the location
|
|
131
|
+
# to the dependencies, since this is a synthetic module and not an actual class where
|
|
132
|
+
# we can track the module.
|
|
133
|
+
self.track_import(Migrator)
|
|
134
|
+
self.track_import(MigrationRevisionBase)
|
|
135
|
+
|
|
136
|
+
next_objects = [obj for obj, _ in up_objects_with_dependencies]
|
|
137
|
+
previous_objects = [obj for obj, _ in down_objects_with_dependencies]
|
|
138
|
+
|
|
139
|
+
next_objects_ordering = self.serializer.order_db_objects(
|
|
140
|
+
up_objects_with_dependencies
|
|
141
|
+
)
|
|
142
|
+
previous_objects_ordering = self.serializer.order_db_objects(
|
|
143
|
+
down_objects_with_dependencies
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Convert to their respective DBObjects, with dependencies
|
|
147
|
+
up_actor = DatabaseActions()
|
|
148
|
+
up_actions = await self.serializer.build_actions(
|
|
149
|
+
up_actor,
|
|
150
|
+
previous_objects,
|
|
151
|
+
previous_objects_ordering,
|
|
152
|
+
next_objects,
|
|
153
|
+
next_objects_ordering,
|
|
154
|
+
)
|
|
155
|
+
up_code = self.actions_to_code(up_actions)
|
|
156
|
+
|
|
157
|
+
down_actor = DatabaseActions()
|
|
158
|
+
down_actions = await self.serializer.build_actions(
|
|
159
|
+
down_actor,
|
|
160
|
+
next_objects,
|
|
161
|
+
next_objects_ordering,
|
|
162
|
+
previous_objects,
|
|
163
|
+
previous_objects_ordering,
|
|
164
|
+
)
|
|
165
|
+
down_code = self.actions_to_code(down_actions)
|
|
166
|
+
|
|
167
|
+
imports: list[str] = []
|
|
168
|
+
for module, classes in self.import_tracker.items():
|
|
169
|
+
if classes:
|
|
170
|
+
classes_list = ", ".join(sorted(classes))
|
|
171
|
+
imports.append(f"from {module} import {classes_list}")
|
|
172
|
+
|
|
173
|
+
code = MIGRATION_TEMPLATE.strip().format(
|
|
174
|
+
migrator_import=DatabaseMemorySerializer.__module__,
|
|
175
|
+
rev=json_dumps(revision),
|
|
176
|
+
prev_rev=json_dumps(down_revision) if down_revision else "None",
|
|
177
|
+
up_code=self.indent_code(up_code, 2),
|
|
178
|
+
down_code=self.indent_code(down_code, 2),
|
|
179
|
+
header_imports="\n".join(imports),
|
|
180
|
+
timestamp=datetime.now().isoformat(),
|
|
181
|
+
user_message=user_message or "None",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return code, revision
|
|
185
|
+
|
|
186
|
+
def actions_to_code(self, actions: list[DryRunAction | DryRunComment]) -> list[str]:
|
|
187
|
+
"""
|
|
188
|
+
Convert a list of database actions into executable Python code.
|
|
189
|
+
Handles both actual database operations and comments.
|
|
190
|
+
|
|
191
|
+
:param actions: List of actions to convert to code
|
|
192
|
+
:return: List of Python code lines
|
|
193
|
+
|
|
194
|
+
```python {{sticky: True}}
|
|
195
|
+
generator = MigrationGenerator()
|
|
196
|
+
code_lines = generator.actions_to_code([
|
|
197
|
+
DryRunAction(
|
|
198
|
+
fn=actor.add_column,
|
|
199
|
+
kwargs={
|
|
200
|
+
"table_name": "users",
|
|
201
|
+
"column_name": "email",
|
|
202
|
+
"explicit_data_type": ColumnType.VARCHAR
|
|
203
|
+
}
|
|
204
|
+
),
|
|
205
|
+
DryRunComment(
|
|
206
|
+
text="Add email verification field",
|
|
207
|
+
previous_line=False
|
|
208
|
+
)
|
|
209
|
+
])
|
|
210
|
+
# Results in:
|
|
211
|
+
# ['# Add email verification field',
|
|
212
|
+
# 'await migrator.actor.add_column(table_name="users", column_name="email", explicit_data_type=ColumnType.VARCHAR)']
|
|
213
|
+
```
|
|
214
|
+
"""
|
|
215
|
+
code_lines: list[str] = []
|
|
216
|
+
|
|
217
|
+
for action in actions:
|
|
218
|
+
if isinstance(action, DryRunAction):
|
|
219
|
+
# All the actions should be callables attached to the migrator
|
|
220
|
+
migrator_signature = action.fn.__name__
|
|
221
|
+
|
|
222
|
+
# Format the kwargs as python native types since the code has to be executable
|
|
223
|
+
kwargs = ", ".join(
|
|
224
|
+
[f"{k}={self.format_arg(v)}" for k, v in action.kwargs.items()]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Format the dependencies
|
|
228
|
+
code_lines.append(
|
|
229
|
+
f"await migrator.actor.{migrator_signature}({kwargs})"
|
|
230
|
+
)
|
|
231
|
+
elif isinstance(action, DryRunComment):
|
|
232
|
+
if action.previous_line:
|
|
233
|
+
# Create a comment that's on the same line
|
|
234
|
+
previous_line = code_lines.pop()
|
|
235
|
+
new_comment = action.text.replace("\n", " ")
|
|
236
|
+
code_lines.append(f"{previous_line} # {new_comment}")
|
|
237
|
+
else:
|
|
238
|
+
comment_lines = action.text.split("\n")
|
|
239
|
+
for line in comment_lines:
|
|
240
|
+
code_lines.append(f"# {line}")
|
|
241
|
+
else:
|
|
242
|
+
raise ValueError(f"Unknown action type: {action}")
|
|
243
|
+
|
|
244
|
+
if not code_lines:
|
|
245
|
+
code_lines.append("pass")
|
|
246
|
+
|
|
247
|
+
return code_lines
|
|
248
|
+
|
|
249
|
+
def format_arg(self, value: Any) -> str:
|
|
250
|
+
"""
|
|
251
|
+
Format a Python value as a valid code string, handling proper escaping
|
|
252
|
+
and import tracking for complex types.
|
|
253
|
+
|
|
254
|
+
This method supports formatting of:
|
|
255
|
+
- Enums (with automatic import tracking)
|
|
256
|
+
- Basic types (bool, str, int, float)
|
|
257
|
+
- Collections (list, set, frozenset, tuple, dict)
|
|
258
|
+
- Pydantic models and dataclasses
|
|
259
|
+
- None values
|
|
260
|
+
|
|
261
|
+
:param value: The value to format as code
|
|
262
|
+
:return: A string representation of the value as valid Python code
|
|
263
|
+
:raises ValueError: If the value type is not supported
|
|
264
|
+
:raises TypeError: If a BaseModel/dataclass value is a class instead of an instance
|
|
265
|
+
|
|
266
|
+
```python {{sticky: True}}
|
|
267
|
+
generator = MigrationGenerator()
|
|
268
|
+
|
|
269
|
+
# Format different types of values
|
|
270
|
+
generator.format_arg(SomeEnum.VALUE) # -> "SomeEnum.VALUE"
|
|
271
|
+
generator.format_arg("hello") # -> '"hello"'
|
|
272
|
+
generator.format_arg([1, 2, 3]) # -> "[1, 2, 3]"
|
|
273
|
+
generator.format_arg({"a": 1}) # -> '{"a": 1}'
|
|
274
|
+
generator.format_arg(
|
|
275
|
+
UserModel(name="John")
|
|
276
|
+
) # -> 'UserModel(name="John")'
|
|
277
|
+
```
|
|
278
|
+
"""
|
|
279
|
+
if isinstance(value, Enum):
|
|
280
|
+
self.track_import(value.__class__)
|
|
281
|
+
class_name = value.__class__.__name__
|
|
282
|
+
return f"{class_name}.{value.name}"
|
|
283
|
+
elif isinstance(value, bool):
|
|
284
|
+
return "True" if value else "False"
|
|
285
|
+
elif isinstance(value, (str, int, float)):
|
|
286
|
+
# JSON dumps is used here for proper string escaping
|
|
287
|
+
return json_dumps(value)
|
|
288
|
+
elif isinstance(value, list):
|
|
289
|
+
return f"[{', '.join([self.format_arg(v) for v in value])}]"
|
|
290
|
+
elif isinstance(value, frozenset):
|
|
291
|
+
# Sorting values isn't necessary for client code, but useful for test stability over time
|
|
292
|
+
return f"frozenset({{{', '.join([self.format_arg(v) for v in sorted(value)])}}})"
|
|
293
|
+
elif isinstance(value, set):
|
|
294
|
+
return f"{{{', '.join([self.format_arg(v) for v in sorted(value)])}}}"
|
|
295
|
+
elif isinstance(value, tuple):
|
|
296
|
+
tuple_values = f"{', '.join([self.format_arg(v) for v in value])}"
|
|
297
|
+
if len(value) == 1:
|
|
298
|
+
# Trailing comma is necessary for single element tuples
|
|
299
|
+
return f"({tuple_values},)"
|
|
300
|
+
else:
|
|
301
|
+
return f"({tuple_values})"
|
|
302
|
+
elif isinstance(value, dict):
|
|
303
|
+
return (
|
|
304
|
+
"{"
|
|
305
|
+
+ ", ".join(
|
|
306
|
+
[
|
|
307
|
+
f"{self.format_arg(k)}: {self.format_arg(v)}"
|
|
308
|
+
for k, v in value.items()
|
|
309
|
+
]
|
|
310
|
+
)
|
|
311
|
+
+ "}"
|
|
312
|
+
)
|
|
313
|
+
elif isinstance(value, BaseModel) or is_dataclass(value):
|
|
314
|
+
if isinstance(value, BaseModel):
|
|
315
|
+
model_dict = value.model_dump()
|
|
316
|
+
elif is_dataclass(value) and not isinstance(value, type):
|
|
317
|
+
# Currently incorrect typehinting in pyright for isinstance(value, type)
|
|
318
|
+
# Still results in a type[DataclassInstance] possible type. This can remove
|
|
319
|
+
# the following 3 type ignores when fixed.
|
|
320
|
+
# https://github.com/microsoft/pyright/issues/8963
|
|
321
|
+
model_dict = asdict(value) # type: ignore
|
|
322
|
+
else:
|
|
323
|
+
raise TypeError(
|
|
324
|
+
"Value must be a BaseModel instance or a dataclass instance."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
self.track_import(value.__class__) # type: ignore
|
|
328
|
+
|
|
329
|
+
code = f"{value.__class__.__name__}(" # type: ignore
|
|
330
|
+
code += ", ".join(
|
|
331
|
+
[
|
|
332
|
+
f"{k}={self.format_arg(v)}"
|
|
333
|
+
for k, v in model_dict.items()
|
|
334
|
+
if v is not None
|
|
335
|
+
]
|
|
336
|
+
)
|
|
337
|
+
code += ")"
|
|
338
|
+
return code
|
|
339
|
+
elif value is None:
|
|
340
|
+
return "None"
|
|
341
|
+
else:
|
|
342
|
+
raise ValueError(f"Unknown argument type: {value} ({type(value)})")
|
|
343
|
+
|
|
344
|
+
def track_import(
|
|
345
|
+
self,
|
|
346
|
+
value: Type[Any] | Callable | ModuleType,
|
|
347
|
+
explicit: str | None = None,
|
|
348
|
+
):
|
|
349
|
+
"""
|
|
350
|
+
Track required imports for the generated migration file.
|
|
351
|
+
Manages the import statements needed for types and functions used in the migration.
|
|
352
|
+
|
|
353
|
+
:param value: The class, function, or module to import
|
|
354
|
+
:param explicit: Optional explicit import path override
|
|
355
|
+
:raises ValueError: If explicit import is required for a module but not provided
|
|
356
|
+
|
|
357
|
+
```python {{sticky: True}}
|
|
358
|
+
generator = MigrationGenerator()
|
|
359
|
+
|
|
360
|
+
# Track class import
|
|
361
|
+
generator.track_import(UserModel)
|
|
362
|
+
# -> Will add "from app.models import UserModel"
|
|
363
|
+
|
|
364
|
+
# Track with explicit path
|
|
365
|
+
generator.track_import(
|
|
366
|
+
some_module,
|
|
367
|
+
explicit="package.module.function"
|
|
368
|
+
)
|
|
369
|
+
# -> Will add "from package.module import function"
|
|
370
|
+
```
|
|
371
|
+
"""
|
|
372
|
+
if ismodule(value):
|
|
373
|
+
# We require an explicit import for modules
|
|
374
|
+
if not explicit:
|
|
375
|
+
raise ValueError("Explicit import required for modules")
|
|
376
|
+
|
|
377
|
+
if explicit:
|
|
378
|
+
module, class_name = explicit.rsplit(".", 1)
|
|
379
|
+
else:
|
|
380
|
+
module = value.__module__
|
|
381
|
+
class_name = value.__name__
|
|
382
|
+
|
|
383
|
+
self.import_tracker[module].add(class_name)
|
|
384
|
+
|
|
385
|
+
def indent_code(self, code: list[str], indent: int) -> str:
|
|
386
|
+
"""
|
|
387
|
+
Indent lines of code by a specified number of levels.
|
|
388
|
+
Each level is 4 spaces.
|
|
389
|
+
|
|
390
|
+
:param code: List of code lines to indent
|
|
391
|
+
:param indent: Number of indentation levels
|
|
392
|
+
:return: The indented code as a single string
|
|
393
|
+
|
|
394
|
+
```python {{sticky: True}}
|
|
395
|
+
generator = MigrationGenerator()
|
|
396
|
+
code = generator.indent_code(
|
|
397
|
+
["def example():", "return True"],
|
|
398
|
+
indent=1
|
|
399
|
+
)
|
|
400
|
+
# Results in:
|
|
401
|
+
# " def example():\n return True"
|
|
402
|
+
```
|
|
403
|
+
"""
|
|
404
|
+
return "\n".join([f"{' ' * 4 * indent}{line}" for line in code])
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
|
|
4
|
+
from iceaxe.migrations.migrator import Migrator
|
|
5
|
+
from iceaxe.session import DBConnection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MigrationRevisionBase:
|
|
9
|
+
"""
|
|
10
|
+
Base class for all revisions. This class is most often automatically
|
|
11
|
+
generated by the `migrate` CLI command, which automatically determines
|
|
12
|
+
the proper up and down revisions for your migration class.
|
|
13
|
+
|
|
14
|
+
Once added to your project, you can modify the up/down migration methods
|
|
15
|
+
however you see fit.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# up and down revision are both set, except for the initial revision
|
|
20
|
+
# where down_revision is None
|
|
21
|
+
up_revision: str
|
|
22
|
+
down_revision: str | None
|
|
23
|
+
|
|
24
|
+
use_transaction: bool = True
|
|
25
|
+
"""
|
|
26
|
+
Disables the transaction for the current migration. Only do this if you're
|
|
27
|
+
confident that the migration will succeed on the first try, or is otherwise
|
|
28
|
+
independent so it can be run multiple times.
|
|
29
|
+
|
|
30
|
+
This can speed up migrations, and in some cases might be even fully required for your
|
|
31
|
+
production database to avoid deadlocks when interacting with hot tables.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
async def _handle_up(self, db_connection: DBConnection):
|
|
36
|
+
"""
|
|
37
|
+
Internal method to handle the up migration.
|
|
38
|
+
"""
|
|
39
|
+
# Isolated migrator context just for this migration
|
|
40
|
+
async with self._optional_transaction(db_connection):
|
|
41
|
+
migrator = Migrator(db_connection)
|
|
42
|
+
await self.up(migrator)
|
|
43
|
+
await migrator.set_active_revision(self.up_revision)
|
|
44
|
+
|
|
45
|
+
async def _handle_down(self, db_connection: DBConnection):
|
|
46
|
+
"""
|
|
47
|
+
Internal method to handle the down migration.
|
|
48
|
+
"""
|
|
49
|
+
async with self._optional_transaction(db_connection):
|
|
50
|
+
migrator = Migrator(db_connection)
|
|
51
|
+
await self.down(migrator)
|
|
52
|
+
await migrator.set_active_revision(self.down_revision)
|
|
53
|
+
|
|
54
|
+
@asynccontextmanager
|
|
55
|
+
async def _optional_transaction(self, db_connection: DBConnection):
|
|
56
|
+
if self.use_transaction:
|
|
57
|
+
async with db_connection.transaction():
|
|
58
|
+
yield
|
|
59
|
+
else:
|
|
60
|
+
yield
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
async def up(self, migrator: Migrator):
|
|
64
|
+
"""
|
|
65
|
+
Perform the migration "up" action. This converts your old database schema to the new
|
|
66
|
+
schema that's found in your code definition. Add any other migration rules to your
|
|
67
|
+
data in this function as well.
|
|
68
|
+
|
|
69
|
+
It's good practice to make sure any up migrations don't immediately drop data. Instead,
|
|
70
|
+
consider moving to a temporary table.
|
|
71
|
+
|
|
72
|
+
Support both raw SQL execution and helper functions to manipulate the tables and
|
|
73
|
+
columns that you have defined. See the Migrator class for more information.
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
async def down(self, migrator: Migrator):
|
|
80
|
+
"""
|
|
81
|
+
Perform the migration "down" action. This converts the current database state to the
|
|
82
|
+
previous snapshot. It should be used in any automatic rollback pipelines if you
|
|
83
|
+
had a feature that was rolled out and then rolled back.
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
pass
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from typing import cast
|
|
2
|
+
|
|
3
|
+
from iceaxe.logging import LOGGER
|
|
4
|
+
from iceaxe.schemas.actions import DatabaseActions
|
|
5
|
+
from iceaxe.session import DBConnection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Migrator:
|
|
9
|
+
"""
|
|
10
|
+
Main interface for client migrations. Mountaineer provides a simple shim on top of
|
|
11
|
+
common database migration options within `migrator.actor`. This lets you add columns,
|
|
12
|
+
drop columns, migrate types, and the like. For more complex migrations, you can use
|
|
13
|
+
the `migrator.db_session` to run raw SQL queries within the current migration transaction.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
actor: DatabaseActions
|
|
18
|
+
"""
|
|
19
|
+
The main interface for client migrations. Add tables, columns, and more using this wrapper.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
db_connection: DBConnection
|
|
23
|
+
"""
|
|
24
|
+
The main database connection for the migration. Use this to run raw SQL queries. We auto-wrap
|
|
25
|
+
this connection in a transaction block for you, so successful migrations will be
|
|
26
|
+
automatically committed when completed and unsuccessful migrations will be rolled back.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, db_connection: DBConnection):
|
|
31
|
+
self.actor = DatabaseActions(dry_run=False, db_connection=db_connection)
|
|
32
|
+
self.db_connection = db_connection
|
|
33
|
+
|
|
34
|
+
async def init_db(self):
|
|
35
|
+
"""
|
|
36
|
+
Initialize our migration management table if it doesn't already exist
|
|
37
|
+
within the attached postgres database. This will be a no-op if the table
|
|
38
|
+
already exists.
|
|
39
|
+
|
|
40
|
+
Client callers should call this method once before running any migrations.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
# Create the table if it doesn't exist
|
|
44
|
+
await self.db_connection.conn.execute(
|
|
45
|
+
"""
|
|
46
|
+
CREATE TABLE IF NOT EXISTS migration_info (
|
|
47
|
+
active_revision VARCHAR(255)
|
|
48
|
+
)
|
|
49
|
+
"""
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Check if the table is empty and insert a default value if necessary
|
|
53
|
+
rows = await self.db_connection.conn.fetch(
|
|
54
|
+
"SELECT COUNT(*) AS migration_count FROM migration_info"
|
|
55
|
+
)
|
|
56
|
+
count = rows[0]["migration_count"] if rows else 0
|
|
57
|
+
if count == 0:
|
|
58
|
+
await self.db_connection.conn.execute(
|
|
59
|
+
"INSERT INTO migration_info (active_revision) VALUES (NULL)"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def set_active_revision(self, value: str | None):
|
|
63
|
+
"""
|
|
64
|
+
Sets the active revision in the migration_info table.
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
LOGGER.info(f"Setting active revision to {value}")
|
|
68
|
+
|
|
69
|
+
query = """
|
|
70
|
+
UPDATE migration_info SET active_revision = $1
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
await self.db_connection.conn.execute(query, value)
|
|
74
|
+
|
|
75
|
+
LOGGER.info("Active revision set")
|
|
76
|
+
|
|
77
|
+
async def get_active_revision(self) -> str | None:
|
|
78
|
+
"""
|
|
79
|
+
Gets the active revision from the migration_info table.
|
|
80
|
+
Requires that the migration_info table has been initialized.
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
query = """
|
|
84
|
+
SELECT active_revision FROM migration_info
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
result = await self.db_connection.conn.fetch(query)
|
|
88
|
+
return cast(str | None, result[0]["active_revision"] if result else None)
|
|
89
|
+
|
|
90
|
+
async def raw_sql(self, query: str, *args):
|
|
91
|
+
"""
|
|
92
|
+
Shortcut to execute a raw SQL query against the database. Raw SQL can be more useful
|
|
93
|
+
than using ORM objects within migrations, because you can interact with the old & new data
|
|
94
|
+
schemas via text (whereas the runtime ORM is only aware of the current schema).
|
|
95
|
+
|
|
96
|
+
```python {{sticky: True}}
|
|
97
|
+
await migrator.execute("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name VARCHAR(255))")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
"""
|
|
101
|
+
await self.db_connection.conn.execute(query, *args)
|