altcodepro-polydb-python 2.5.9__py3-none-any.whl → 2.5.11__py3-none-any.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.
Files changed (64) hide show
  1. {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/METADATA +1 -1
  2. altcodepro_polydb_python-2.5.11.dist-info/RECORD +74 -0
  3. polydb/PolyDB.py +1 -1
  4. polydb/__init__.py +25 -14
  5. polydb/adapters/AzureBlobStorageAdapter.py +2 -1
  6. polydb/adapters/AzureQueueAdapter.py +3 -5
  7. polydb/adapters/AzureTableStorageAdapter.py +27 -9
  8. polydb/adapters/BlockchainBlobAdapter.py +0 -1
  9. polydb/adapters/BlockchainFileAdapter.py +1 -1
  10. polydb/adapters/BlockchainKVAdapter.py +20 -5
  11. polydb/adapters/BlockchainQueueAdapter.py +0 -1
  12. polydb/adapters/DynamoDBAdapter.py +2 -6
  13. polydb/adapters/EFSAdapter.py +1 -1
  14. polydb/adapters/GCPFilestoreAdapter.py +1 -1
  15. polydb/adapters/GCPPubSubAdapter.py +2 -2
  16. polydb/adapters/GCPStorageAdapter.py +1 -1
  17. polydb/adapters/KafkaQueueAdapter.py +10 -16
  18. polydb/adapters/MongoDBAdapter.py +3 -5
  19. polydb/adapters/PostgreSQLAdapter.py +38 -13
  20. polydb/adapters/RabbitMQAdapter.py +2 -2
  21. polydb/adapters/S3Adapter.py +1 -3
  22. polydb/adapters/S3CompatibleAdapter.py +1 -1
  23. polydb/adapters/SQSAdapter.py +1 -2
  24. polydb/adapters/VercelBlobAdapter.py +1 -1
  25. polydb/adapters/VercelKVAdapter.py +6 -5
  26. polydb/adapters/VercelQueueAdapter.py +18 -4
  27. polydb/advanced_query.py +5 -4
  28. polydb/aio.py +198 -0
  29. polydb/audit/AuditStorage.py +14 -19
  30. polydb/audit/__init__.py +2 -2
  31. polydb/audit/context.py +6 -5
  32. polydb/audit/manager.py +3 -3
  33. polydb/audit/models.py +3 -2
  34. polydb/base/NoSQLKVAdapter.py +22 -14
  35. polydb/base/ObjectStorageAdapter.py +3 -2
  36. polydb/base/QueueAdapter.py +2 -3
  37. polydb/base/SecretsAdapter.py +2 -1
  38. polydb/base/SharedFilesAdapter.py +2 -3
  39. polydb/batch.py +38 -49
  40. polydb/cache.py +5 -2
  41. polydb/cloudDatabaseFactory.py +59 -22
  42. polydb/databaseFactory.py +7 -13
  43. polydb/errors.py +2 -0
  44. polydb/json_safe.py +1 -2
  45. polydb/models.py +1 -1
  46. polydb/monitoring.py +27 -15
  47. polydb/multitenancy.py +50 -59
  48. polydb/observability/logging.py +0 -1
  49. polydb/overflow_gc.py +179 -0
  50. polydb/query.py +3 -1
  51. polydb/retry.py +1 -1
  52. polydb/schema.py +45 -60
  53. polydb/security.py +7 -9
  54. polydb/services/__init__.py +2 -2
  55. polydb/services/compliance_service.py +10 -4
  56. polydb/services/security_service.py +9 -12
  57. polydb/types.py +28 -62
  58. polydb/utils.py +7 -8
  59. polydb/validation.py +50 -52
  60. altcodepro_polydb_python-2.5.9.dist-info/RECORD +0 -73
  61. polydb/decorators.py +0 -1040
  62. {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/WHEEL +0 -0
  63. {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/licenses/LICENSE +0 -0
  64. {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/top_level.txt +0 -0
polydb/multitenancy.py CHANGED
@@ -2,11 +2,12 @@
2
2
  """
3
3
  Multi-tenancy enforcement and isolation
4
4
  """
5
+
5
6
  import re
6
- from typing import Dict, Any, List, Optional, Callable
7
7
  from contextvars import ContextVar
8
8
  from dataclasses import dataclass, field
9
9
  from enum import Enum
10
+ from typing import Any, Dict, List, Optional
10
11
 
11
12
  from .errors import ValidationError
12
13
 
@@ -42,6 +43,7 @@ def _validate_sql_identifier(value: str, *, kind: str) -> str:
42
43
 
43
44
  class IsolationLevel(Enum):
44
45
  """Tenant isolation levels"""
46
+
45
47
  SHARED_SCHEMA = "shared" # Shared tables with tenant_id
46
48
  SEPARATE_SCHEMA = "schema" # Separate schema per tenant
47
49
  SEPARATE_DATABASE = "database" # Separate DB per tenant
@@ -50,6 +52,7 @@ class IsolationLevel(Enum):
50
52
  @dataclass
51
53
  class TenantConfig:
52
54
  """Tenant configuration"""
55
+
53
56
  tenant_id: str
54
57
  isolation_level: IsolationLevel
55
58
  schema_name: Optional[str] = None
@@ -61,18 +64,18 @@ class TenantConfig:
61
64
 
62
65
  class TenantRegistry:
63
66
  """Registry of tenant configurations"""
64
-
67
+
65
68
  def __init__(self):
66
69
  self._tenants: Dict[str, TenantConfig] = {}
67
-
70
+
68
71
  def register(self, config: TenantConfig):
69
72
  """Register tenant"""
70
73
  self._tenants[config.tenant_id] = config
71
-
74
+
72
75
  def get(self, tenant_id: str) -> Optional[TenantConfig]:
73
76
  """Get tenant config"""
74
77
  return self._tenants.get(tenant_id)
75
-
78
+
76
79
  def list_all(self) -> List[TenantConfig]:
77
80
  """List all tenants"""
78
81
  return list(self._tenants.values())
@@ -80,24 +83,23 @@ class TenantRegistry:
80
83
 
81
84
  class TenantContext:
82
85
  """Tenant context management"""
83
-
84
- current_tenant: ContextVar[Optional[TenantConfig]] = \
85
- ContextVar("current_tenant", default=None)
86
-
86
+
87
+ current_tenant: ContextVar[Optional[TenantConfig]] = ContextVar("current_tenant", default=None)
88
+
87
89
  @classmethod
88
90
  def set_tenant(cls, tenant_id: str, registry: TenantRegistry):
89
91
  """Set current tenant"""
90
92
  config = registry.get(tenant_id)
91
93
  if not config:
92
94
  raise ValueError(f"Tenant not found: {tenant_id}")
93
-
95
+
94
96
  cls.current_tenant.set(config)
95
-
97
+
96
98
  @classmethod
97
99
  def get_tenant(cls) -> Optional[TenantConfig]:
98
100
  """Get current tenant"""
99
101
  return cls.current_tenant.get()
100
-
102
+
101
103
  @classmethod
102
104
  def clear(cls):
103
105
  """Clear tenant context"""
@@ -106,53 +108,45 @@ class TenantContext:
106
108
 
107
109
  class TenantIsolationEnforcer:
108
110
  """Enforces tenant isolation at query level"""
109
-
111
+
110
112
  def __init__(self, registry: TenantRegistry):
111
113
  self.registry = registry
112
-
113
- def enforce_read(
114
- self,
115
- model: str,
116
- query: Dict[str, Any]
117
- ) -> Dict[str, Any]:
114
+
115
+ def enforce_read(self, model: str, query: Dict[str, Any]) -> Dict[str, Any]:
118
116
  """Enforce tenant isolation on read"""
119
117
  tenant = TenantContext.get_tenant()
120
-
118
+
121
119
  if not tenant:
122
120
  raise ValueError("No tenant context set")
123
-
121
+
124
122
  if tenant.isolation_level == IsolationLevel.SHARED_SCHEMA:
125
123
  # Add tenant_id filter
126
124
  query = query.copy()
127
- query['tenant_id'] = tenant.tenant_id
128
-
125
+ query["tenant_id"] = tenant.tenant_id
126
+
129
127
  return query
130
-
131
- def enforce_write(
132
- self,
133
- model: str,
134
- data: Dict[str, Any]
135
- ) -> Dict[str, Any]:
128
+
129
+ def enforce_write(self, model: str, data: Dict[str, Any]) -> Dict[str, Any]:
136
130
  """Enforce tenant isolation on write"""
137
131
  tenant = TenantContext.get_tenant()
138
-
132
+
139
133
  if not tenant:
140
134
  raise ValueError("No tenant context set")
141
-
135
+
142
136
  if tenant.isolation_level == IsolationLevel.SHARED_SCHEMA:
143
137
  # Add tenant_id
144
138
  data = data.copy()
145
- data['tenant_id'] = tenant.tenant_id
146
-
139
+ data["tenant_id"] = tenant.tenant_id
140
+
147
141
  return data
148
-
142
+
149
143
  def get_table_name(self, base_table: str) -> str:
150
144
  """Get tenant-specific table name"""
151
145
  tenant = TenantContext.get_tenant()
152
-
146
+
153
147
  if not tenant:
154
148
  raise ValueError("No tenant context set")
155
-
149
+
156
150
  if tenant.isolation_level == IsolationLevel.SEPARATE_SCHEMA:
157
151
  return f"{tenant.schema_name}.{base_table}"
158
152
  elif tenant.isolation_level == IsolationLevel.SEPARATE_DATABASE:
@@ -163,28 +157,29 @@ class TenantIsolationEnforcer:
163
157
 
164
158
  class TenantQuotaManager:
165
159
  """Manages tenant resource quotas"""
166
-
160
+
167
161
  def __init__(self, registry: TenantRegistry):
168
162
  self.registry = registry
169
163
  self._usage: Dict[str, Dict[str, float]] = {}
170
-
164
+
171
165
  def check_storage_quota(self, tenant_id: str, size_gb: float) -> bool:
172
166
  """Check if operation would exceed storage quota"""
173
167
  config = self.registry.get(tenant_id)
174
168
  if not config or not config.storage_quota_gb:
175
169
  return True
176
-
177
- current_usage = self._usage.get(tenant_id, {}).get('storage_gb', 0.0)
170
+
171
+ current_usage = self._usage.get(tenant_id, {}).get("storage_gb", 0.0)
178
172
  return (current_usage + size_gb) <= config.storage_quota_gb
179
-
173
+
180
174
  def record_storage_usage(self, tenant_id: str, size_gb: float):
181
175
  """Record storage usage"""
182
176
  if tenant_id not in self._usage:
183
177
  self._usage[tenant_id] = {}
184
-
185
- self._usage[tenant_id]['storage_gb'] = \
186
- self._usage[tenant_id].get('storage_gb', 0.0) + size_gb
187
-
178
+
179
+ self._usage[tenant_id]["storage_gb"] = (
180
+ self._usage[tenant_id].get("storage_gb", 0.0) + size_gb
181
+ )
182
+
188
183
  def get_usage(self, tenant_id: str) -> Dict[str, float]:
189
184
  """Get tenant resource usage"""
190
185
  return self._usage.get(tenant_id, {})
@@ -192,46 +187,42 @@ class TenantQuotaManager:
192
187
 
193
188
  class TenantMigrationManager:
194
189
  """Manages tenant migrations and onboarding"""
195
-
190
+
196
191
  def __init__(self, factory, registry: TenantRegistry):
197
192
  self.factory = factory
198
193
  self.registry = registry
199
-
194
+
200
195
  def provision_tenant(self, config: TenantConfig):
201
196
  """Provision new tenant"""
202
197
  # Register tenant
203
198
  self.registry.register(config)
204
-
199
+
205
200
  if config.isolation_level == IsolationLevel.SEPARATE_SCHEMA:
206
201
  # Create schema
207
202
  schema = _validate_sql_identifier(config.schema_name, kind="schema_name")
208
203
  schema_sql = f"CREATE SCHEMA IF NOT EXISTS {schema};"
209
204
  self.factory._sql.execute(schema_sql)
210
-
205
+
211
206
  elif config.isolation_level == IsolationLevel.SEPARATE_DATABASE:
212
207
  # Create database (requires superuser)
213
- database = _validate_sql_identifier(
214
- config.database_name, kind="database_name"
215
- )
208
+ database = _validate_sql_identifier(config.database_name, kind="database_name")
216
209
  db_sql = f"CREATE DATABASE {database};"
217
210
  self.factory._sql.execute(db_sql)
218
-
211
+
219
212
  def deprovision_tenant(self, tenant_id: str):
220
213
  """Deprovision tenant"""
221
214
  config = self.registry.get(tenant_id)
222
215
  if not config:
223
216
  return
224
-
217
+
225
218
  if config.isolation_level == IsolationLevel.SEPARATE_SCHEMA:
226
219
  # Drop schema
227
220
  schema = _validate_sql_identifier(config.schema_name, kind="schema_name")
228
221
  schema_sql = f"DROP SCHEMA IF EXISTS {schema} CASCADE;"
229
222
  self.factory._sql.execute(schema_sql)
230
-
223
+
231
224
  elif config.isolation_level == IsolationLevel.SEPARATE_DATABASE:
232
225
  # Drop database
233
- database = _validate_sql_identifier(
234
- config.database_name, kind="database_name"
235
- )
226
+ database = _validate_sql_identifier(config.database_name, kind="database_name")
236
227
  db_sql = f"DROP DATABASE IF EXISTS {database};"
237
- self.factory._sql.execute(db_sql)
228
+ self.factory._sql.execute(db_sql)
@@ -15,7 +15,6 @@ import contextvars
15
15
  import json
16
16
  import logging
17
17
  import os
18
- import time
19
18
  from typing import Any, Optional
20
19
 
21
20
  # ---------------------------------------------------------------------------
polydb/overflow_gc.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ src/polydb/overflow_gc.py
3
+
4
+ Garbage collection for orphaned NoSQL KV overflow blobs.
5
+
6
+ Every `NoSQLKVAdapter` spills an oversized record's JSON to the paired
7
+ object store under a content-addressed key (``overflow/<md5>.json``) and
8
+ keeps only a small reference row (``_overflow``/``_blob_key``/``_size``/
9
+ ``_checksum``) in the KV store itself (see
10
+ ``NoSQLKVAdapter._check_overflow``). An update to an already-overflowed
11
+ record writes a *new* blob (a new md5 for the new content) and leaves the
12
+ old one behind with nothing referencing it any more -- ``delete()`` only
13
+ ever removes the reference row, never the blob it pointed at. Nothing in
14
+ this codebase has ever swept those orphans, so overflow storage grows
15
+ without bound for the life of a deployment (see CLAUDE.md's "Overflow
16
+ blobs are never garbage-collected").
17
+
18
+ This module closes that gap with a conservative two-pass mark-and-sweep:
19
+ a blob under the given prefix that isn't referenced by any live row is
20
+ only a *candidate* the first time it's seen -- it's recorded, with the
21
+ wall-clock time it was first seen, in a small JSON state blob written
22
+ back into the same object store (``<prefix>_gc_state.json``). A candidate
23
+ is only actually deleted once ``grace_seconds`` have elapsed since that
24
+ first sighting *and* it is still unreferenced on this run. This gives a
25
+ real grace window without needing last-modified timestamps from the
26
+ object store, which the ``ObjectStorageAdapter`` protocol does not
27
+ expose on any backend.
28
+
29
+ A blob that becomes referenced again between two sweeps (a genuine race
30
+ with a concurrent write, not merely a slow one -- content-addressed keys
31
+ mean a live row can only ever reference the blob matching its own
32
+ current content) drops out of the candidate list rather than being
33
+ deleted, so a sweep can never delete a blob a live row still points at.
34
+
35
+ PolyDB doesn't own a model registry (see databaseFactory.py's own "dumb
36
+ storage layer" design stance) -- the caller supplies the list of models
37
+ to scan, e.g.:
38
+
39
+ from polydb.overflow_gc import sweep_overflow_blobs
40
+ from polydb.cloudDatabaseFactory import CloudDatabaseFactory
41
+
42
+ factory = CloudDatabaseFactory()
43
+ report = sweep_overflow_blobs(
44
+ factory.get_object_storage(),
45
+ factory.get_nosql_kv(),
46
+ models=[Order, Widget],
47
+ )
48
+
49
+ Run on a schedule (daily is plenty) -- a blob only gets deleted on the
50
+ *second* sweep that finds it orphaned, at least `grace_seconds` after the
51
+ first.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import json
57
+ import time
58
+ from dataclasses import dataclass, field
59
+ from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple
60
+
61
+ from .json_safe import json_safe
62
+
63
+ if TYPE_CHECKING:
64
+ from .base.NoSQLKVAdapter import NoSQLKVAdapter
65
+ from .base.ObjectStorageAdapter import ObjectStorageAdapter
66
+
67
+
68
+ DEFAULT_PREFIX = "overflow/"
69
+ DEFAULT_GRACE_SECONDS = 24 * 60 * 60 # 24h
70
+
71
+
72
+ @dataclass
73
+ class GCReport:
74
+ """Result of one sweep_overflow_blobs() call."""
75
+
76
+ scanned_blobs: int = 0
77
+ referenced_blobs: int = 0
78
+ new_candidates: List[str] = field(default_factory=list)
79
+ still_pending: List[str] = field(default_factory=list)
80
+ deleted: List[str] = field(default_factory=list)
81
+ errors: List[Tuple[str, str]] = field(default_factory=list)
82
+
83
+
84
+ def _state_key(prefix: str) -> str:
85
+ return f"{prefix.rstrip('/')}/_gc_state.json"
86
+
87
+
88
+ def _load_state(object_storage: "ObjectStorageAdapter", prefix: str) -> dict:
89
+ try:
90
+ raw = object_storage.get(_state_key(prefix))
91
+ except Exception:
92
+ return {}
93
+ if not raw:
94
+ return {}
95
+ try:
96
+ return json.loads(raw.decode() if isinstance(raw, bytes) else raw)
97
+ except Exception:
98
+ return {}
99
+
100
+
101
+ def _save_state(object_storage: "ObjectStorageAdapter", prefix: str, state: dict) -> None:
102
+ object_storage.put(_state_key(prefix), json.dumps(state, default=json_safe).encode())
103
+
104
+
105
+ def _referenced_blob_keys(adapter: "NoSQLKVAdapter", models: Iterable[type]) -> set:
106
+ """Every _blob_key a live row currently points at, across the given models."""
107
+ referenced: set = set()
108
+ for model in models:
109
+ for row in adapter._query_raw(model, {}, None):
110
+ blob_key = row.get("_blob_key")
111
+ if blob_key:
112
+ referenced.add(blob_key)
113
+ return referenced
114
+
115
+
116
+ def sweep_overflow_blobs(
117
+ object_storage: "ObjectStorageAdapter",
118
+ adapter: "NoSQLKVAdapter",
119
+ models: Iterable[type],
120
+ *,
121
+ prefix: str = DEFAULT_PREFIX,
122
+ grace_seconds: int = DEFAULT_GRACE_SECONDS,
123
+ dry_run: bool = False,
124
+ now: Optional[float] = None,
125
+ ) -> GCReport:
126
+ """
127
+ Run one GC pass over `prefix` in `object_storage`, treating every
128
+ `_blob_key` referenced by a live row of `models` (read through
129
+ `adapter`) as live. Anything else is orphaned; an orphan is only
130
+ deleted once it has been orphaned for at least `grace_seconds` across
131
+ two separate sweeps. `dry_run=True` reports what *would* be deleted
132
+ without deleting anything or advancing the grace clock.
133
+ """
134
+ now = time.time() if now is None else now
135
+ report = GCReport()
136
+
137
+ state_key_name = _state_key(prefix)
138
+ all_keys = [k for k in object_storage.list(prefix) if k != state_key_name]
139
+ report.scanned_blobs = len(all_keys)
140
+
141
+ referenced = _referenced_blob_keys(adapter, models)
142
+ report.referenced_blobs = len(referenced)
143
+
144
+ state = _load_state(object_storage, prefix)
145
+ next_state: dict = {}
146
+
147
+ for key in all_keys:
148
+ if key in referenced:
149
+ continue # live -- never a candidate, never written into next_state
150
+
151
+ first_seen = state.get(key)
152
+ if first_seen is None:
153
+ # First sweep to see this one orphaned: start its clock, don't delete yet.
154
+ next_state[key] = now
155
+ report.new_candidates.append(key)
156
+ continue
157
+
158
+ if now - first_seen < grace_seconds:
159
+ next_state[key] = first_seen
160
+ report.still_pending.append(key)
161
+ continue
162
+
163
+ # Orphaned on this sweep AND the previous one, past the grace window.
164
+ if dry_run:
165
+ report.deleted.append(key) # what WOULD be deleted
166
+ next_state[key] = first_seen
167
+ continue
168
+
169
+ try:
170
+ object_storage.delete(key)
171
+ report.deleted.append(key)
172
+ except Exception as e:
173
+ report.errors.append((key, str(e)))
174
+ next_state[key] = first_seen # retry on the next sweep
175
+
176
+ if not dry_run:
177
+ _save_state(object_storage, prefix, next_state)
178
+
179
+ return report
polydb/query.py CHANGED
@@ -1,9 +1,11 @@
1
1
  # src/polydb/query.py
2
2
 
3
3
  from __future__ import annotations
4
+
4
5
  from dataclasses import dataclass, field
5
- from typing import Any, Dict, List, Mapping, Optional, Union
6
6
  from enum import Enum
7
+ from typing import Any, Dict, List, Mapping, Optional, Union
8
+
7
9
  from .errors import ValidationError
8
10
  from .utils import validate_column_name
9
11
 
polydb/retry.py CHANGED
@@ -3,9 +3,9 @@
3
3
  Retry logic with exponential backoff and metrics hooks
4
4
  """
5
5
 
6
+ import logging
6
7
  import os
7
8
  import time
8
- import logging
9
9
  from functools import wraps
10
10
  from typing import Callable, Tuple, Type
11
11
 
polydb/schema.py CHANGED
@@ -2,12 +2,11 @@
2
2
  """
3
3
  Schema management and migrations
4
4
  """
5
- from typing import Dict, List, Optional, Any
5
+
6
6
  from dataclasses import dataclass
7
7
  from decimal import Decimal
8
8
  from enum import Enum
9
- import json
10
- from datetime import datetime
9
+ from typing import Any, Dict, List, Optional
11
10
 
12
11
  from .errors import ValidationError
13
12
  from .utils import validate_column_name, validate_table_name
@@ -66,22 +65,22 @@ class Index:
66
65
 
67
66
  class SchemaBuilder:
68
67
  """Build SQL schema definitions"""
69
-
68
+
70
69
  def __init__(self):
71
70
  self.columns: List[Column] = []
72
71
  self.indexes: List[Index] = []
73
72
  self.primary_keys: List[str] = []
74
-
75
- def add_column(self, column: Column) -> 'SchemaBuilder':
73
+
74
+ def add_column(self, column: Column) -> "SchemaBuilder":
76
75
  self.columns.append(column)
77
76
  if column.primary_key:
78
77
  self.primary_keys.append(column.name)
79
78
  return self
80
-
81
- def add_index(self, index: Index) -> 'SchemaBuilder':
79
+
80
+ def add_index(self, index: Index) -> "SchemaBuilder":
82
81
  self.indexes.append(index)
83
82
  return self
84
-
83
+
85
84
  def to_create_table(self, table_name: str) -> str:
86
85
  """Generate CREATE TABLE statement"""
87
86
  validate_table_name(table_name)
@@ -89,42 +88,42 @@ class SchemaBuilder:
89
88
 
90
89
  for col in self.columns:
91
90
  parts = [validate_column_name(col.name)]
92
-
91
+
93
92
  # Type
94
93
  if col.type == ColumnType.VARCHAR and col.max_length:
95
94
  parts.append(f"VARCHAR({col.max_length})")
96
95
  else:
97
96
  parts.append(col.type.value)
98
-
97
+
99
98
  # Nullable
100
99
  if not col.nullable:
101
100
  parts.append("NOT NULL")
102
-
101
+
103
102
  # Default
104
103
  if col.default is not None:
105
104
  parts.append(_render_default(col.default))
106
-
105
+
107
106
  # Unique
108
107
  if col.unique:
109
108
  parts.append("UNIQUE")
110
-
109
+
111
110
  col_defs.append(" ".join(parts))
112
-
111
+
113
112
  # Primary key
114
113
  if self.primary_keys:
115
114
  pk_cols = [validate_column_name(c) for c in self.primary_keys]
116
115
  col_defs.append(f"PRIMARY KEY ({', '.join(pk_cols)})")
117
-
116
+
118
117
  sql = f"CREATE TABLE IF NOT EXISTS {table_name} (\n"
119
118
  sql += ",\n".join(f" {col}" for col in col_defs)
120
119
  sql += "\n);"
121
-
120
+
122
121
  return sql
123
-
122
+
124
123
  def to_create_indexes(self, table_name: str) -> List[str]:
125
124
  """Generate CREATE INDEX statements"""
126
125
  statements = []
127
-
126
+
128
127
  validate_table_name(table_name)
129
128
 
130
129
  for idx in self.indexes:
@@ -135,17 +134,17 @@ class SchemaBuilder:
135
134
  f"ON {table_name}({cols});"
136
135
  )
137
136
  statements.append(sql)
138
-
137
+
139
138
  return statements
140
139
 
141
140
 
142
141
  class MigrationManager:
143
142
  """Database migration management"""
144
-
143
+
145
144
  def __init__(self, sql_adapter):
146
145
  self.sql = sql_adapter
147
146
  self._ensure_migrations_table()
148
-
147
+
149
148
  def _ensure_migrations_table(self):
150
149
  """Create migrations tracking table"""
151
150
  schema = """
@@ -159,74 +158,60 @@ class MigrationManager:
159
158
  );
160
159
  """
161
160
  self.sql.execute(schema)
162
-
161
+
163
162
  def apply_migration(
164
- self,
165
- version: str,
166
- name: str,
167
- up_sql: str,
168
- down_sql: Optional[str] = None
163
+ self, version: str, name: str, up_sql: str, down_sql: Optional[str] = None
169
164
  ) -> bool:
170
165
  """Apply a migration"""
171
166
  import hashlib
172
-
167
+
173
168
  # Check if already applied
174
169
  existing = self.sql.execute(
175
- "SELECT version FROM polydb_migrations WHERE version = %s",
176
- [version],
177
- fetch_one=True
170
+ "SELECT version FROM polydb_migrations WHERE version = %s", [version], fetch_one=True
178
171
  )
179
-
172
+
180
173
  if existing:
181
174
  return False
182
-
175
+
183
176
  # Calculate checksum
184
177
  checksum = hashlib.sha256(up_sql.encode()).hexdigest()
185
-
178
+
186
179
  try:
187
180
  # Execute migration
188
181
  self.sql.execute(up_sql)
189
-
182
+
190
183
  # Record migration
191
- self.sql.insert('polydb_migrations', {
192
- 'version': version,
193
- 'name': name,
194
- 'rollback_sql': down_sql,
195
- 'checksum': checksum
196
- })
197
-
184
+ self.sql.insert(
185
+ "polydb_migrations",
186
+ {"version": version, "name": name, "rollback_sql": down_sql, "checksum": checksum},
187
+ )
188
+
198
189
  return True
199
190
  except Exception as e:
200
191
  raise Exception(f"Migration {version} failed: {str(e)}")
201
-
192
+
202
193
  def rollback_migration(self, version: str) -> bool:
203
194
  """Rollback a migration"""
204
195
  migration = self.sql.execute(
205
196
  "SELECT rollback_sql FROM polydb_migrations WHERE version = %s",
206
197
  [version],
207
- fetch_one=True
198
+ fetch_one=True,
208
199
  )
209
-
210
- if not migration or not migration.get('rollback_sql'):
200
+
201
+ if not migration or not migration.get("rollback_sql"):
211
202
  raise Exception(f"No rollback available for {version}")
212
-
203
+
213
204
  try:
214
205
  # Execute rollback
215
- self.sql.execute(migration['rollback_sql'])
216
-
206
+ self.sql.execute(migration["rollback_sql"])
207
+
217
208
  # Remove from migrations
218
- self.sql.execute(
219
- "DELETE FROM polydb_migrations WHERE version = %s",
220
- [version]
221
- )
222
-
209
+ self.sql.execute("DELETE FROM polydb_migrations WHERE version = %s", [version])
210
+
223
211
  return True
224
212
  except Exception as e:
225
213
  raise Exception(f"Rollback {version} failed: {str(e)}")
226
-
214
+
227
215
  def get_applied_migrations(self) -> List[Dict[str, Any]]:
228
216
  """Get list of applied migrations"""
229
- return self.sql.execute(
230
- "SELECT * FROM polydb_migrations ORDER BY applied_at",
231
- fetch=True
232
- )
217
+ return self.sql.execute("SELECT * FROM polydb_migrations ORDER BY applied_at", fetch=True)