iceaxe 0.8.3__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 +1265 -0
  20. iceaxe/__tests__/schemas/test_cli.py +25 -0
  21. iceaxe/__tests__/schemas/test_db_memory_serializer.py +1571 -0
  22. iceaxe/__tests__/schemas/test_db_serializer.py +435 -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 +764 -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 +351 -0
  36. iceaxe/comparison.py +560 -0
  37. iceaxe/field.py +263 -0
  38. iceaxe/functions.py +1432 -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 +1459 -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 +711 -0
  63. iceaxe/schemas/db_serializer.py +347 -0
  64. iceaxe/schemas/db_stubs.py +529 -0
  65. iceaxe/session.py +860 -0
  66. iceaxe/session_optimized.c +12207 -0
  67. iceaxe/session_optimized.cpython-313-darwin.so +0 -0
  68. iceaxe/session_optimized.pyx +212 -0
  69. iceaxe/sql_types.py +149 -0
  70. iceaxe/typing.py +73 -0
  71. iceaxe-0.8.3.dist-info/METADATA +262 -0
  72. iceaxe-0.8.3.dist-info/RECORD +75 -0
  73. iceaxe-0.8.3.dist-info/WHEEL +6 -0
  74. iceaxe-0.8.3.dist-info/licenses/LICENSE +21 -0
  75. iceaxe-0.8.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,529 @@
1
+ import re
2
+ from abc import abstractmethod
3
+ from dataclasses import dataclass
4
+ from typing import Generic, Self, TypeVar, Union, cast
5
+
6
+ from pydantic import BaseModel, Field, model_validator
7
+
8
+ from iceaxe.schemas.actions import (
9
+ CheckConstraint,
10
+ ColumnType,
11
+ ConstraintType,
12
+ DatabaseActions,
13
+ ForeignKeyConstraint,
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class ConstraintPointerInfo:
19
+ """Information parsed from a constraint pointer representation."""
20
+
21
+ table_name: str
22
+ column_names: list[str]
23
+ constraint_type: str
24
+
25
+
26
+ T = TypeVar("T", bound="DBObject")
27
+
28
+
29
+ class DBObject(BaseModel, Generic[T]):
30
+ """
31
+ A subclass for all models that are intended to store an in-memory representation
32
+ of a database object that we can perform diff support against.
33
+
34
+ Our Generic[T] here is a bit of a hack to allow us to properly typehint the expected
35
+ API contract of child implementations. `Self` in pyright results in fixing the API
36
+ contract to the base class DBObject whereas we want it to adjust to the child class.
37
+
38
+ """
39
+
40
+ model_config = {
41
+ "frozen": True,
42
+ }
43
+
44
+ @abstractmethod
45
+ def representation(self) -> str:
46
+ """
47
+ The representation should be unique in global namespace, used to de-duplicate
48
+ objects across multiple migration revisions.
49
+
50
+ """
51
+ pass
52
+
53
+ @abstractmethod
54
+ async def create(self, actor: DatabaseActions):
55
+ pass
56
+
57
+ @abstractmethod
58
+ async def migrate(self, previous: T, actor: DatabaseActions):
59
+ pass
60
+
61
+ @abstractmethod
62
+ async def destroy(self, actor: DatabaseActions):
63
+ pass
64
+
65
+ def merge(self, other: T) -> T:
66
+ """
67
+ If there is another object with the same .reference() as this object
68
+ this function is in charge of merging the two objects. By default
69
+ we will just use an equality check to ensure that the objects are the
70
+ same and return the current object.
71
+
72
+ If clients override this function, ensure that the result is the same regardless
73
+ of the order that the merge is called in. Callers make no guarantee about the
74
+ resolution order.
75
+
76
+ """
77
+ if self != other:
78
+ raise ValueError(
79
+ f"Conflicting definitions for {self.representation()}\n{self} != {other}"
80
+ )
81
+ return cast(T, self)
82
+
83
+
84
+ class DBObjectPointer(BaseModel):
85
+ """
86
+ A pointer to an object that was already created elsewhere. Used only for DAG comparisons. Make sure
87
+ the representation mirrors the root object string - otherwise comparison
88
+ won't work properly.
89
+
90
+ We typically use pointers in cases where we want to reference an object that should
91
+ already be created, and the change in the child value shouldn't auto-update the parent.
92
+ Since by default we use direct model-equality to determine whether we create a migration
93
+ stage, nesting a full DBObject within a parent object would otherwise cause the parent
94
+ to update.
95
+
96
+ """
97
+
98
+ model_config = {
99
+ "frozen": True,
100
+ }
101
+
102
+ @abstractmethod
103
+ def representation(self) -> str:
104
+ pass
105
+
106
+ def parse_constraint_pointer(self) -> ConstraintPointerInfo | None:
107
+ """
108
+ Parse a constraint pointer representation into its components.
109
+
110
+ Returns:
111
+ ConstraintPointerInfo | None: Parsed constraint information or None if not a constraint pointer
112
+
113
+ Examples:
114
+ "table.['column'].PRIMARY KEY" -> ConstraintPointerInfo("table", ["column"], "PRIMARY KEY")
115
+ "table.['col1', 'col2'].UNIQUE" -> ConstraintPointerInfo("table", ["col1", "col2"], "UNIQUE")
116
+ """
117
+ representation = self.representation()
118
+
119
+ # Pattern to match: table_name.[column_list].constraint_type
120
+ # where column_list can be ['col'] or ['col1', 'col2', ...]
121
+ # The table_name can contain dots (for schema.table), so we need to be more careful
122
+ # We look for the pattern .[...]. to identify where the column list starts
123
+ pattern = r"^(.+)\.(\[.*?\])\.(.+)$"
124
+ match = re.match(pattern, representation)
125
+
126
+ if not match:
127
+ return None
128
+
129
+ table_name, columns_part, constraint_type = match.groups()
130
+
131
+ # Validate that the column list contains properly quoted column names or is empty
132
+ # Remove brackets and check the content
133
+ columns_str = columns_part.strip("[]")
134
+ if not columns_str:
135
+ # Empty column list is valid
136
+ return ConstraintPointerInfo(table_name, [], constraint_type)
137
+
138
+ # Split by comma and validate each column name is properly quoted
139
+ columns = []
140
+ for col in columns_str.split(","):
141
+ col = col.strip()
142
+ # Check if the column is properly quoted (single or double quotes)
143
+ if (col.startswith("'") and col.endswith("'")) or (
144
+ col.startswith('"') and col.endswith('"')
145
+ ):
146
+ # Remove quotes and add to list
147
+ col_name = col[1:-1]
148
+ if col_name: # Don't add empty column names
149
+ columns.append(col_name)
150
+ else:
151
+ # Column is not properly quoted, this is not a valid constraint pointer
152
+ return None
153
+
154
+ return ConstraintPointerInfo(table_name, columns, constraint_type)
155
+
156
+ def get_table_name(self) -> str | None:
157
+ """
158
+ Extract the table name from the pointer representation.
159
+
160
+ Returns:
161
+ str | None: The table name if it can be parsed, None otherwise
162
+ """
163
+ # Try constraint pointer format first
164
+ parsed = self.parse_constraint_pointer()
165
+ if parsed is not None:
166
+ return parsed.table_name
167
+
168
+ # Try simple table.column format
169
+ representation = self.representation()
170
+ if not representation:
171
+ return None
172
+
173
+ parts = representation.split(".")
174
+ if len(parts) >= 2:
175
+ # For schema.table.column format, take all parts except the last one
176
+ return ".".join(parts[:-1])
177
+ elif len(parts) == 1:
178
+ # Just a table name
179
+ return parts[0]
180
+ else:
181
+ return None
182
+
183
+ def get_column_names(self) -> list[str]:
184
+ """
185
+ Extract column names from the pointer representation.
186
+
187
+ Returns:
188
+ list[str]: List of column names if they can be parsed, empty list otherwise
189
+ """
190
+ # Try constraint pointer format first
191
+ parsed = self.parse_constraint_pointer()
192
+ if parsed is not None:
193
+ return parsed.column_names
194
+
195
+ # Try simple table.column format
196
+ representation = self.representation()
197
+ if not representation:
198
+ return []
199
+
200
+ parts = representation.split(".")
201
+ if len(parts) >= 2:
202
+ # For schema.table.column format, take the last part as the column name
203
+ return [parts[-1]]
204
+ else:
205
+ # Just a table name, no columns
206
+ return []
207
+
208
+
209
+ class DBTable(DBObject["DBTable"]):
210
+ table_name: str
211
+
212
+ def representation(self):
213
+ return self.table_name
214
+
215
+ async def create(self, actor: DatabaseActions):
216
+ actor.add_comment(f"\nNEW TABLE: {self.table_name}\n")
217
+ await actor.add_table(self.table_name)
218
+
219
+ async def migrate(self, previous: Self, actor: DatabaseActions):
220
+ raise NotImplementedError
221
+
222
+ async def destroy(self, actor: DatabaseActions):
223
+ await actor.drop_table(self.table_name)
224
+
225
+
226
+ class DBColumnBase(BaseModel):
227
+ table_name: str
228
+ column_name: str
229
+
230
+ def representation(self):
231
+ return f"{self.table_name}.{self.column_name}"
232
+
233
+
234
+ class DBColumnPointer(DBColumnBase, DBObjectPointer):
235
+ pass
236
+
237
+
238
+ class DBColumn(DBColumnBase, DBObject["DBColumn"]):
239
+ # Use a type pointer here to avoid full equality checks
240
+ # of the values; if the pointer is the same, we can avoid
241
+ # updating the column type during a migration.
242
+ column_type: Union["DBTypePointer", ColumnType]
243
+ column_is_list: bool
244
+
245
+ nullable: bool
246
+
247
+ autoincrement: bool = False
248
+
249
+ async def create(self, actor: DatabaseActions):
250
+ # The only time SERIAL types are allowed is during creation for autoincrementing
251
+ # integer columns
252
+ explicit_data_type: ColumnType | None = None
253
+ if isinstance(self.column_type, ColumnType):
254
+ if self.column_type == ColumnType.INTEGER and self.autoincrement:
255
+ explicit_data_type = ColumnType.SERIAL
256
+ elif self.column_type == ColumnType.BIGINT and self.autoincrement:
257
+ explicit_data_type = ColumnType.BIGSERIAL
258
+ elif self.column_type == ColumnType.SMALLINT and self.autoincrement:
259
+ explicit_data_type = ColumnType.SMALLSERIAL
260
+ else:
261
+ explicit_data_type = self.column_type
262
+
263
+ await actor.add_column(
264
+ self.table_name,
265
+ self.column_name,
266
+ explicit_data_type=explicit_data_type,
267
+ explicit_data_is_list=self.column_is_list,
268
+ custom_data_type=(
269
+ self.column_type.representation()
270
+ if isinstance(self.column_type, DBTypePointer)
271
+ else None
272
+ ),
273
+ )
274
+
275
+ if not self.nullable:
276
+ await actor.add_not_null(self.table_name, self.column_name)
277
+
278
+ async def destroy(self, actor: DatabaseActions):
279
+ # Destorying the column means we'll also drop constraints associated with it
280
+ # like not-null.
281
+ await actor.drop_column(self.table_name, self.column_name)
282
+
283
+ async def migrate(self, previous: Self, actor: DatabaseActions):
284
+ if (
285
+ self.column_type != previous.column_type
286
+ or self.column_is_list != previous.column_is_list
287
+ ):
288
+ await actor.modify_column_type(
289
+ self.table_name,
290
+ self.column_name,
291
+ explicit_data_type=(
292
+ self.column_type
293
+ if isinstance(self.column_type, ColumnType)
294
+ else None
295
+ ),
296
+ explicit_data_is_list=self.column_is_list,
297
+ custom_data_type=(
298
+ self.column_type.name
299
+ if isinstance(self.column_type, DBTypePointer)
300
+ else None
301
+ ),
302
+ autocast=True,
303
+ )
304
+ actor.add_comment(
305
+ "TODO: Perform a migration of values across types", previous_line=True
306
+ )
307
+
308
+ if not self.nullable and previous.nullable:
309
+ await actor.add_not_null(self.table_name, self.column_name)
310
+ if self.nullable and not previous.nullable:
311
+ await actor.drop_not_null(self.table_name, self.column_name)
312
+
313
+
314
+ class DBConstraint(DBObject["DBConstraint"]):
315
+ table_name: str
316
+ constraint_name: str = Field(exclude=True)
317
+ columns: frozenset[str]
318
+
319
+ constraint_type: ConstraintType
320
+
321
+ foreign_key_constraint: ForeignKeyConstraint | None = None
322
+ check_constraint: CheckConstraint | None = None
323
+
324
+ @model_validator(mode="after")
325
+ def validate_constraint_type(self):
326
+ if (
327
+ self.constraint_type == ConstraintType.FOREIGN_KEY
328
+ and self.foreign_key_constraint is None
329
+ ):
330
+ raise ValueError("Foreign key constraints require a ForeignKeyConstraint")
331
+ if (
332
+ self.constraint_type != ConstraintType.FOREIGN_KEY
333
+ and self.foreign_key_constraint is not None
334
+ ):
335
+ raise ValueError(
336
+ "Only foreign key constraints require a ForeignKeyConstraint"
337
+ )
338
+ return self
339
+
340
+ def representation(self) -> str:
341
+ # Different construction methods sort the constraint parameters in different ways
342
+ # We rely on sorting these parameters to ensure that the representation matches
343
+ # across these different construction methods
344
+ return f"{self.table_name}.{sorted(self.columns)}.{self.constraint_type}"
345
+
346
+ @classmethod
347
+ def new_constraint_name(
348
+ cls,
349
+ table_name: str,
350
+ columns: list[str],
351
+ constraint_type: ConstraintType,
352
+ ):
353
+ elements = [table_name]
354
+ if constraint_type == ConstraintType.PRIMARY_KEY:
355
+ elements.append("pkey")
356
+ elif constraint_type == ConstraintType.FOREIGN_KEY:
357
+ elements += sorted(columns)
358
+ elements.append("fkey")
359
+ elif constraint_type == ConstraintType.UNIQUE:
360
+ elements += sorted(columns)
361
+ elements.append("unique")
362
+ elif constraint_type == ConstraintType.INDEX:
363
+ elements += sorted(columns)
364
+ elements.append("idx")
365
+ else:
366
+ elements += sorted(columns)
367
+ elements.append("key")
368
+ return "_".join(elements)
369
+
370
+ async def create(self, actor: DatabaseActions):
371
+ if self.constraint_type == ConstraintType.FOREIGN_KEY:
372
+ assert self.foreign_key_constraint is not None
373
+ await actor.add_constraint(
374
+ self.table_name,
375
+ constraint=self.constraint_type,
376
+ constraint_name=self.constraint_name,
377
+ constraint_args=self.foreign_key_constraint,
378
+ columns=list(self.columns),
379
+ )
380
+ elif self.constraint_type == ConstraintType.CHECK:
381
+ assert self.check_constraint is not None
382
+ await actor.add_constraint(
383
+ self.table_name,
384
+ constraint=self.constraint_type,
385
+ constraint_name=self.constraint_name,
386
+ constraint_args=self.check_constraint,
387
+ columns=list(self.columns),
388
+ )
389
+ elif self.constraint_type == ConstraintType.INDEX:
390
+ await actor.add_index(
391
+ self.table_name,
392
+ columns=list(self.columns),
393
+ index_name=self.constraint_name,
394
+ )
395
+ else:
396
+ await actor.add_constraint(
397
+ self.table_name,
398
+ constraint=self.constraint_type,
399
+ constraint_name=self.constraint_name,
400
+ columns=list(self.columns),
401
+ )
402
+
403
+ async def destroy(self, actor: DatabaseActions):
404
+ if self.constraint_type == ConstraintType.INDEX:
405
+ await actor.drop_index(
406
+ self.table_name,
407
+ index_name=self.constraint_name,
408
+ )
409
+ else:
410
+ await actor.drop_constraint(
411
+ self.table_name,
412
+ constraint_name=self.constraint_name,
413
+ )
414
+
415
+ async def migrate(self, previous: Self, actor: DatabaseActions):
416
+ if self.constraint_type != previous.constraint_type:
417
+ raise NotImplementedError
418
+
419
+ # Since we allow some flexibility in column ordering, and that affects
420
+ # the actual constarint name, it's possible that this function is being called
421
+ # with a previous example that is actually the same - but fails the equality check.
422
+ # We re-do a proper comparison here to ensure that we don't do unnecessary work.
423
+ has_changed = False
424
+
425
+ self_dict = self.model_dump()
426
+ previous_dict = previous.model_dump()
427
+
428
+ for key in self_dict.keys():
429
+ previous_value = self_dict[key]
430
+ current_value = previous_dict[key]
431
+ if previous_value != current_value:
432
+ has_changed = True
433
+ break
434
+
435
+ if has_changed:
436
+ await self.destroy(actor)
437
+ await self.create(actor)
438
+
439
+
440
+ class DBTypeBase(BaseModel):
441
+ name: str
442
+
443
+ def representation(self):
444
+ # Type definitions are global by nature
445
+ return self.name
446
+
447
+
448
+ class DBTypePointer(DBTypeBase, DBObjectPointer):
449
+ pass
450
+
451
+
452
+ class DBType(DBTypeBase, DBObject["DBType"]):
453
+ values: frozenset[str]
454
+
455
+ # Captures the columns that use this type value, (table_name, column_name)
456
+ # so we can migrate them properly to new types. Type dropping in Postgres
457
+ # isn't supported.
458
+ reference_columns: frozenset[tuple[str, str]]
459
+
460
+ async def create(self, actor: DatabaseActions):
461
+ await actor.add_type(self.name, sorted(list(self.values)))
462
+
463
+ async def destroy(self, actor: DatabaseActions):
464
+ await actor.drop_type(self.name)
465
+
466
+ async def migrate(self, previous: Self, actor: DatabaseActions):
467
+ previous_values = {value for value in previous.values}
468
+ next_values = {value for value in self.values}
469
+
470
+ # We need to update the enum with the new values
471
+ new_values = set(next_values) - set(previous_values)
472
+ deleted_values = set(previous_values) - set(next_values)
473
+
474
+ if new_values:
475
+ await actor.add_type_values(
476
+ self.name,
477
+ sorted(new_values),
478
+ )
479
+
480
+ if deleted_values:
481
+ await actor.drop_type_values(
482
+ self.name,
483
+ sorted(deleted_values),
484
+ list(self.reference_columns),
485
+ )
486
+
487
+ def merge(self, other: "DBType") -> "DBType":
488
+ # We should only be merged with other types that are basically the same
489
+ # but might have different reference columns since they might be produced by
490
+ # different parts of the pipeline.
491
+ if self.name != other.name or self.values != other.values:
492
+ raise ValueError(
493
+ "Cannot merge types with different core values: {self.name}({self.values}) != {other.name}({other.values})"
494
+ )
495
+
496
+ return DBType(
497
+ name=self.name,
498
+ values=self.values,
499
+ reference_columns=self.reference_columns | other.reference_columns,
500
+ )
501
+
502
+
503
+ class DBConstraintPointer(DBObjectPointer):
504
+ """
505
+ A pointer to a constraint that will be created. Used for dependency tracking
506
+ without needing to know the full constraint definition.
507
+ """
508
+
509
+ table_name: str
510
+ columns: frozenset[str]
511
+ constraint_type: ConstraintType
512
+
513
+ def representation(self) -> str:
514
+ # Match the representation of DBConstraint
515
+ return f"{self.table_name}.{sorted(self.columns)}.{self.constraint_type}"
516
+
517
+
518
+ class DBPointerOr(DBObjectPointer):
519
+ """
520
+ A pointer that represents an OR relationship between multiple pointers.
521
+ When resolving dependencies, any of the provided pointers being present
522
+ will satisfy the dependency.
523
+ """
524
+
525
+ pointers: tuple[DBObjectPointer, ...]
526
+
527
+ def representation(self) -> str:
528
+ # Sort the representations to ensure consistent ordering
529
+ return "OR(" + ",".join(sorted(p.representation() for p in self.pointers)) + ")"