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.

Files changed (75) hide show
  1. iceaxe/__init__.py +20 -0
  2. iceaxe/__tests__/__init__.py +0 -0
  3. iceaxe/__tests__/benchmarks/__init__.py +0 -0
  4. iceaxe/__tests__/benchmarks/test_bulk_insert.py +45 -0
  5. iceaxe/__tests__/benchmarks/test_select.py +114 -0
  6. iceaxe/__tests__/conf_models.py +133 -0
  7. iceaxe/__tests__/conftest.py +204 -0
  8. iceaxe/__tests__/docker_helpers.py +208 -0
  9. iceaxe/__tests__/helpers.py +268 -0
  10. iceaxe/__tests__/migrations/__init__.py +0 -0
  11. iceaxe/__tests__/migrations/conftest.py +36 -0
  12. iceaxe/__tests__/migrations/test_action_sorter.py +237 -0
  13. iceaxe/__tests__/migrations/test_generator.py +140 -0
  14. iceaxe/__tests__/migrations/test_generics.py +91 -0
  15. iceaxe/__tests__/mountaineer/__init__.py +0 -0
  16. iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
  17. iceaxe/__tests__/mountaineer/dependencies/test_core.py +76 -0
  18. iceaxe/__tests__/schemas/__init__.py +0 -0
  19. iceaxe/__tests__/schemas/test_actions.py +1264 -0
  20. iceaxe/__tests__/schemas/test_cli.py +25 -0
  21. iceaxe/__tests__/schemas/test_db_memory_serializer.py +1525 -0
  22. iceaxe/__tests__/schemas/test_db_serializer.py +398 -0
  23. iceaxe/__tests__/schemas/test_db_stubs.py +190 -0
  24. iceaxe/__tests__/test_alias.py +83 -0
  25. iceaxe/__tests__/test_base.py +52 -0
  26. iceaxe/__tests__/test_comparison.py +383 -0
  27. iceaxe/__tests__/test_field.py +11 -0
  28. iceaxe/__tests__/test_helpers.py +9 -0
  29. iceaxe/__tests__/test_modifications.py +151 -0
  30. iceaxe/__tests__/test_queries.py +605 -0
  31. iceaxe/__tests__/test_queries_str.py +173 -0
  32. iceaxe/__tests__/test_session.py +1511 -0
  33. iceaxe/__tests__/test_text_search.py +287 -0
  34. iceaxe/alias_values.py +67 -0
  35. iceaxe/base.py +350 -0
  36. iceaxe/comparison.py +560 -0
  37. iceaxe/field.py +250 -0
  38. iceaxe/functions.py +906 -0
  39. iceaxe/generics.py +140 -0
  40. iceaxe/io.py +107 -0
  41. iceaxe/logging.py +91 -0
  42. iceaxe/migrations/__init__.py +5 -0
  43. iceaxe/migrations/action_sorter.py +98 -0
  44. iceaxe/migrations/cli.py +228 -0
  45. iceaxe/migrations/client_io.py +62 -0
  46. iceaxe/migrations/generator.py +404 -0
  47. iceaxe/migrations/migration.py +86 -0
  48. iceaxe/migrations/migrator.py +101 -0
  49. iceaxe/modifications.py +176 -0
  50. iceaxe/mountaineer/__init__.py +10 -0
  51. iceaxe/mountaineer/cli.py +74 -0
  52. iceaxe/mountaineer/config.py +46 -0
  53. iceaxe/mountaineer/dependencies/__init__.py +6 -0
  54. iceaxe/mountaineer/dependencies/core.py +67 -0
  55. iceaxe/postgres.py +133 -0
  56. iceaxe/py.typed +0 -0
  57. iceaxe/queries.py +1455 -0
  58. iceaxe/queries_str.py +294 -0
  59. iceaxe/schemas/__init__.py +0 -0
  60. iceaxe/schemas/actions.py +864 -0
  61. iceaxe/schemas/cli.py +30 -0
  62. iceaxe/schemas/db_memory_serializer.py +705 -0
  63. iceaxe/schemas/db_serializer.py +346 -0
  64. iceaxe/schemas/db_stubs.py +525 -0
  65. iceaxe/session.py +860 -0
  66. iceaxe/session_optimized.c +12035 -0
  67. iceaxe/session_optimized.cpython-313-darwin.so +0 -0
  68. iceaxe/session_optimized.pyx +212 -0
  69. iceaxe/sql_types.py +148 -0
  70. iceaxe/typing.py +73 -0
  71. iceaxe-0.7.1.dist-info/METADATA +261 -0
  72. iceaxe-0.7.1.dist-info/RECORD +75 -0
  73. iceaxe-0.7.1.dist-info/WHEEL +6 -0
  74. iceaxe-0.7.1.dist-info/licenses/LICENSE +21 -0
  75. 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)