cinchdb 0.1.0__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 (68) hide show
  1. cinchdb/__init__.py +7 -0
  2. cinchdb/__main__.py +6 -0
  3. cinchdb/api/__init__.py +5 -0
  4. cinchdb/api/app.py +76 -0
  5. cinchdb/api/auth.py +290 -0
  6. cinchdb/api/main.py +137 -0
  7. cinchdb/api/routers/__init__.py +25 -0
  8. cinchdb/api/routers/auth.py +135 -0
  9. cinchdb/api/routers/branches.py +368 -0
  10. cinchdb/api/routers/codegen.py +164 -0
  11. cinchdb/api/routers/columns.py +290 -0
  12. cinchdb/api/routers/data.py +479 -0
  13. cinchdb/api/routers/databases.py +177 -0
  14. cinchdb/api/routers/projects.py +133 -0
  15. cinchdb/api/routers/query.py +156 -0
  16. cinchdb/api/routers/tables.py +349 -0
  17. cinchdb/api/routers/tenants.py +216 -0
  18. cinchdb/api/routers/views.py +219 -0
  19. cinchdb/cli/__init__.py +0 -0
  20. cinchdb/cli/commands/__init__.py +1 -0
  21. cinchdb/cli/commands/branch.py +479 -0
  22. cinchdb/cli/commands/codegen.py +176 -0
  23. cinchdb/cli/commands/column.py +308 -0
  24. cinchdb/cli/commands/database.py +212 -0
  25. cinchdb/cli/commands/query.py +136 -0
  26. cinchdb/cli/commands/remote.py +144 -0
  27. cinchdb/cli/commands/table.py +289 -0
  28. cinchdb/cli/commands/tenant.py +173 -0
  29. cinchdb/cli/commands/view.py +189 -0
  30. cinchdb/cli/handlers/__init__.py +5 -0
  31. cinchdb/cli/handlers/codegen_handler.py +189 -0
  32. cinchdb/cli/main.py +137 -0
  33. cinchdb/cli/utils.py +182 -0
  34. cinchdb/config.py +177 -0
  35. cinchdb/core/__init__.py +5 -0
  36. cinchdb/core/connection.py +175 -0
  37. cinchdb/core/database.py +537 -0
  38. cinchdb/core/maintenance.py +73 -0
  39. cinchdb/core/path_utils.py +153 -0
  40. cinchdb/managers/__init__.py +26 -0
  41. cinchdb/managers/branch.py +167 -0
  42. cinchdb/managers/change_applier.py +414 -0
  43. cinchdb/managers/change_comparator.py +194 -0
  44. cinchdb/managers/change_tracker.py +182 -0
  45. cinchdb/managers/codegen.py +523 -0
  46. cinchdb/managers/column.py +579 -0
  47. cinchdb/managers/data.py +455 -0
  48. cinchdb/managers/merge_manager.py +429 -0
  49. cinchdb/managers/query.py +214 -0
  50. cinchdb/managers/table.py +383 -0
  51. cinchdb/managers/tenant.py +258 -0
  52. cinchdb/managers/view.py +252 -0
  53. cinchdb/models/__init__.py +27 -0
  54. cinchdb/models/base.py +44 -0
  55. cinchdb/models/branch.py +26 -0
  56. cinchdb/models/change.py +47 -0
  57. cinchdb/models/database.py +20 -0
  58. cinchdb/models/project.py +20 -0
  59. cinchdb/models/table.py +86 -0
  60. cinchdb/models/tenant.py +19 -0
  61. cinchdb/models/view.py +15 -0
  62. cinchdb/utils/__init__.py +15 -0
  63. cinchdb/utils/sql_validator.py +137 -0
  64. cinchdb-0.1.0.dist-info/METADATA +195 -0
  65. cinchdb-0.1.0.dist-info/RECORD +68 -0
  66. cinchdb-0.1.0.dist-info/WHEEL +4 -0
  67. cinchdb-0.1.0.dist-info/entry_points.txt +3 -0
  68. cinchdb-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,414 @@
1
+ """Change application logic for CinchDB."""
2
+
3
+ from pathlib import Path
4
+ import logging
5
+ import shutil
6
+ from typing import List
7
+ from datetime import datetime
8
+
9
+ from cinchdb.models import Change, ChangeType, Tenant
10
+ from cinchdb.managers.change_tracker import ChangeTracker
11
+ from cinchdb.managers.tenant import TenantManager
12
+ from cinchdb.core.connection import DatabaseConnection
13
+ from cinchdb.core.path_utils import get_tenant_db_path, get_branch_path
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ChangeError(Exception):
19
+ """Exception raised when change application fails."""
20
+
21
+ pass
22
+
23
+
24
+ class ChangeApplier:
25
+ """Applies tracked changes to tenants."""
26
+
27
+ def __init__(self, project_root: Path, database: str, branch: str):
28
+ """Initialize change applier.
29
+
30
+ Args:
31
+ project_root: Path to project root
32
+ database: Database name
33
+ branch: Branch name
34
+ """
35
+ self.project_root = Path(project_root)
36
+ self.database = database
37
+ self.branch = branch
38
+ self.change_tracker = ChangeTracker(project_root, database, branch)
39
+ self.tenant_manager = TenantManager(project_root, database, branch)
40
+
41
+ # Import here to avoid circular imports
42
+ from cinchdb.managers.branch import BranchManager
43
+
44
+ self.branch_manager = BranchManager(project_root, database)
45
+
46
+ def _get_change_by_id(self, change_id: str) -> Change:
47
+ """Get a change by its ID.
48
+
49
+ Args:
50
+ change_id: ID of the change
51
+
52
+ Returns:
53
+ Change object
54
+
55
+ Raises:
56
+ ValueError: If change not found
57
+ """
58
+ changes = self.change_tracker.get_changes()
59
+ for change in changes:
60
+ if change.id == change_id:
61
+ return change
62
+ raise ValueError(f"Change with ID '{change_id}' not found")
63
+
64
+ def apply_change(self, change_id: str) -> None:
65
+ """Apply a single change to all tenants atomically with snapshot-based rollback.
66
+
67
+ Args:
68
+ change_id: ID of the change to apply
69
+
70
+ Raises:
71
+ ValueError: If change not found
72
+ ChangeError: If change application fails
73
+ """
74
+ # Get the change
75
+ try:
76
+ change = self._get_change_by_id(change_id)
77
+ except ValueError:
78
+ raise
79
+
80
+ if change.applied:
81
+ logger.info(f"Change {change_id} already applied")
82
+ return
83
+
84
+ backup_dir = self._get_backup_dir(change.id)
85
+ tenants = self.tenant_manager.list_tenants()
86
+
87
+ if not tenants:
88
+ # No tenants, just mark as applied
89
+ self.change_tracker.mark_change_applied(change_id)
90
+ return
91
+
92
+ logger.info(f"Applying change {change_id} to {len(tenants)} tenants...")
93
+
94
+ try:
95
+ # Phase 1: Create snapshots
96
+ logger.info("Creating database snapshots...")
97
+ self._create_snapshots(tenants, backup_dir)
98
+
99
+ # Phase 2: Enter maintenance mode to block writes
100
+ logger.info("Entering maintenance mode for schema update...")
101
+ self._enter_maintenance_mode()
102
+
103
+ try:
104
+ # Track which tenants we've applied to
105
+ applied_tenants = []
106
+
107
+ for tenant in tenants:
108
+ try:
109
+ self._apply_change_to_tenant(change, tenant.name)
110
+ applied_tenants.append(tenant.name)
111
+ except Exception as e:
112
+ logger.error(
113
+ f"Failed to apply change to tenant '{tenant.name}': {e}"
114
+ )
115
+ raise ChangeError(
116
+ f"Change application failed on tenant '{tenant.name}': {e}"
117
+ )
118
+
119
+ # Phase 3: Mark as applied
120
+ self.change_tracker.mark_change_applied(change_id)
121
+
122
+ # Exit maintenance mode before cleanup
123
+ self._exit_maintenance_mode()
124
+ logger.info("Exited maintenance mode")
125
+
126
+ # Cleanup snapshots
127
+ self._cleanup_snapshots(backup_dir)
128
+
129
+ logger.info(
130
+ f"Schema update complete. Applied change {change_id} to {len(tenants)} tenants"
131
+ )
132
+
133
+ except Exception:
134
+ # Always exit maintenance mode on error
135
+ self._exit_maintenance_mode()
136
+ raise
137
+
138
+ except Exception as e:
139
+ # Rollback all tenants
140
+ logger.error(f"Change {change_id} failed: {e}")
141
+ logger.info("Rolling back all tenants to snapshot...")
142
+
143
+ # Restore all tenants from snapshots
144
+ self._restore_all_snapshots(tenants, backup_dir)
145
+
146
+ # Clean up backup directory
147
+ self._cleanup_snapshots(backup_dir)
148
+
149
+ logger.info("Rollback complete. All tenants restored to pre-change state")
150
+
151
+ # Re-raise as ChangeError
152
+ if not isinstance(e, ChangeError):
153
+ raise ChangeError(f"Failed to apply change {change_id}: {e}")
154
+ raise
155
+
156
+ def apply_all_unapplied(self) -> int:
157
+ """Apply all unapplied changes to all tenants.
158
+
159
+ Returns:
160
+ Number of changes applied
161
+ """
162
+ unapplied = self.change_tracker.get_unapplied_changes()
163
+ applied_count = 0
164
+
165
+ for change in unapplied:
166
+ try:
167
+ self.apply_change(change.id)
168
+ applied_count += 1
169
+ except Exception as e:
170
+ logger.error(f"Failed to apply change {change.id}: {e}")
171
+ # Stop on first error to maintain consistency
172
+ raise
173
+
174
+ return applied_count
175
+
176
+ def apply_changes_since(self, change_id: str) -> int:
177
+ """Apply all changes after a specific change.
178
+
179
+ Args:
180
+ change_id: ID of change to start after
181
+
182
+ Returns:
183
+ Number of changes applied
184
+ """
185
+ changes = self.change_tracker.get_changes_since(change_id)
186
+ applied_count = 0
187
+
188
+ for change in changes:
189
+ if not change.applied:
190
+ self.apply_change(change.id)
191
+ applied_count += 1
192
+
193
+ return applied_count
194
+
195
+ def _apply_change_to_tenant(self, change: Change, tenant_name: str) -> None:
196
+ """Apply a change to a specific tenant.
197
+
198
+ Args:
199
+ change: Change to apply
200
+ tenant_name: Name of tenant
201
+
202
+ Raises:
203
+ Exception: If SQL execution fails
204
+ """
205
+ db_path = get_tenant_db_path(
206
+ self.project_root, self.database, self.branch, tenant_name
207
+ )
208
+
209
+ with DatabaseConnection(db_path) as conn:
210
+ try:
211
+ # Check if this is a complex operation with multiple statements
212
+ if change.details and "statements" in change.details:
213
+ # Execute multiple statements in sequence within a transaction
214
+ conn.execute("BEGIN")
215
+ for step_name, sql in change.details["statements"]:
216
+ conn.execute(sql)
217
+ conn.execute("COMMIT")
218
+ elif change.type == ChangeType.UPDATE_VIEW:
219
+ # For view updates, first drop the existing view if it exists
220
+ view_name = change.entity_name
221
+ conn.execute(f"DROP VIEW IF EXISTS {view_name}")
222
+ conn.execute(change.sql)
223
+ conn.commit()
224
+ elif (
225
+ change.type == ChangeType.CREATE_TABLE
226
+ and change.details
227
+ and change.details.get("copy_sql")
228
+ ):
229
+ # For table copy operations, create table and copy data
230
+ conn.execute(change.sql) # CREATE TABLE
231
+ conn.execute(change.details["copy_sql"]) # INSERT data
232
+ conn.commit()
233
+ else:
234
+ # Regular single statement execution
235
+ conn.execute(change.sql)
236
+ conn.commit()
237
+ logger.debug(f"Applied {change.type} to tenant '{tenant_name}'")
238
+ except Exception as e:
239
+ conn.rollback()
240
+ logger.error(f"Failed to apply change to tenant '{tenant_name}': {e}")
241
+ raise
242
+
243
+ def validate_change(self, change: Change) -> bool:
244
+ """Validate that a change can be applied.
245
+
246
+ Args:
247
+ change: Change to validate
248
+
249
+ Returns:
250
+ True if change is valid
251
+ """
252
+ # Basic validation
253
+ if not change.sql:
254
+ return False
255
+
256
+ # Validate based on change type
257
+ if change.type == ChangeType.ADD_COLUMN:
258
+ # Ensure table name is provided in details
259
+ if not change.details or "table" not in change.details:
260
+ return False
261
+
262
+ # Could add more validation here (e.g., check table exists for ADD_COLUMN)
263
+ return True
264
+
265
+ def _get_backup_dir(self, change_id: str) -> Path:
266
+ """Get path for change backup directory.
267
+
268
+ Args:
269
+ change_id: ID of the change
270
+
271
+ Returns:
272
+ Path to backup directory
273
+ """
274
+ branch_path = get_branch_path(self.project_root, self.database, self.branch)
275
+ return branch_path / ".change_backups" / change_id
276
+
277
+ def _create_tenant_snapshot(self, tenant_name: str, backup_dir: Path) -> None:
278
+ """Create snapshot of a tenant database.
279
+
280
+ Args:
281
+ tenant_name: Name of tenant
282
+ backup_dir: Directory to store backup
283
+ """
284
+ db_path = get_tenant_db_path(
285
+ self.project_root, self.database, self.branch, tenant_name
286
+ )
287
+ backup_path = backup_dir / f"{tenant_name}.db"
288
+
289
+ # Copy main database file
290
+ if db_path.exists():
291
+ shutil.copy2(db_path, backup_path)
292
+
293
+ # Copy WAL file if exists
294
+ wal_path = Path(str(db_path) + "-wal")
295
+ if wal_path.exists():
296
+ shutil.copy2(wal_path, backup_dir / f"{tenant_name}.db-wal")
297
+
298
+ # Copy SHM file if exists
299
+ shm_path = Path(str(db_path) + "-shm")
300
+ if shm_path.exists():
301
+ shutil.copy2(shm_path, backup_dir / f"{tenant_name}.db-shm")
302
+
303
+ def _restore_tenant_snapshot(self, tenant_name: str, backup_dir: Path) -> None:
304
+ """Restore tenant database from snapshot.
305
+
306
+ Args:
307
+ tenant_name: Name of tenant
308
+ backup_dir: Directory containing backup
309
+ """
310
+ db_path = get_tenant_db_path(
311
+ self.project_root, self.database, self.branch, tenant_name
312
+ )
313
+ backup_path = backup_dir / f"{tenant_name}.db"
314
+
315
+ # Restore main database file
316
+ if backup_path.exists():
317
+ shutil.copy2(backup_path, db_path)
318
+
319
+ # Restore WAL file
320
+ wal_backup = backup_dir / f"{tenant_name}.db-wal"
321
+ wal_path = Path(str(db_path) + "-wal")
322
+ if wal_backup.exists():
323
+ shutil.copy2(wal_backup, wal_path)
324
+ elif wal_path.exists():
325
+ # Remove WAL if it wasn't in backup
326
+ wal_path.unlink()
327
+
328
+ # Restore SHM file
329
+ shm_backup = backup_dir / f"{tenant_name}.db-shm"
330
+ shm_path = Path(str(db_path) + "-shm")
331
+ if shm_backup.exists():
332
+ shutil.copy2(shm_backup, shm_path)
333
+ elif shm_path.exists():
334
+ # Remove SHM if it wasn't in backup
335
+ shm_path.unlink()
336
+
337
+ def _create_snapshots(self, tenants: List[Tenant], backup_dir: Path) -> None:
338
+ """Create snapshots of all tenant databases.
339
+
340
+ Args:
341
+ tenants: List of tenants
342
+ backup_dir: Directory to store backups
343
+ """
344
+ backup_dir.mkdir(parents=True, exist_ok=True)
345
+
346
+ for tenant in tenants:
347
+ self._create_tenant_snapshot(tenant.name, backup_dir)
348
+
349
+ def _restore_all_snapshots(self, tenants: List[Tenant], backup_dir: Path) -> None:
350
+ """Restore all tenants from snapshots.
351
+
352
+ Args:
353
+ tenants: List of tenants
354
+ backup_dir: Directory containing backups
355
+ """
356
+ for tenant in tenants:
357
+ try:
358
+ self._restore_tenant_snapshot(tenant.name, backup_dir)
359
+ except Exception as e:
360
+ # Log but continue restoring other tenants
361
+ logger.error(f"Failed to restore tenant {tenant.name}: {e}")
362
+
363
+ def _cleanup_snapshots(self, backup_dir: Path) -> None:
364
+ """Remove backup directory and all snapshots.
365
+
366
+ Args:
367
+ backup_dir: Directory to remove
368
+ """
369
+ if backup_dir.exists():
370
+ shutil.rmtree(backup_dir, ignore_errors=True)
371
+
372
+ def _enter_maintenance_mode(self) -> None:
373
+ """Enter maintenance mode to block writes during schema changes."""
374
+ # Create a maintenance mode file that all connections can check
375
+ branch_path = get_branch_path(self.project_root, self.database, self.branch)
376
+ maintenance_file = branch_path / ".maintenance_mode"
377
+
378
+ with open(maintenance_file, "w") as f:
379
+ import json
380
+
381
+ json.dump(
382
+ {
383
+ "active": True,
384
+ "reason": "Schema update in progress",
385
+ "started_at": datetime.now().isoformat(),
386
+ },
387
+ f,
388
+ )
389
+
390
+ # Give time for any in-flight writes to complete
391
+ # Can be disabled for tests via environment variable
392
+ import time
393
+ import os
394
+
395
+ if os.getenv("CINCHDB_SKIP_MAINTENANCE_DELAY") != "1":
396
+ time.sleep(0.25) # A quarter second should be enough
397
+
398
+ def _exit_maintenance_mode(self) -> None:
399
+ """Exit maintenance mode to allow writes again."""
400
+ branch_path = get_branch_path(self.project_root, self.database, self.branch)
401
+ maintenance_file = branch_path / ".maintenance_mode"
402
+
403
+ if maintenance_file.exists():
404
+ maintenance_file.unlink()
405
+
406
+ def is_in_maintenance_mode(self) -> bool:
407
+ """Check if branch is in maintenance mode.
408
+
409
+ Returns:
410
+ True if in maintenance mode, False otherwise
411
+ """
412
+ branch_path = get_branch_path(self.project_root, self.database, self.branch)
413
+ maintenance_file = branch_path / ".maintenance_mode"
414
+ return maintenance_file.exists()
@@ -0,0 +1,194 @@
1
+ """Change comparison and divergence detection for CinchDB branches."""
2
+
3
+ from pathlib import Path
4
+ from typing import List, Tuple, Optional
5
+ from cinchdb.models import Change
6
+ from cinchdb.managers.change_tracker import ChangeTracker
7
+
8
+
9
+ class ChangeComparator:
10
+ """Compares changes between branches to detect divergence and conflicts."""
11
+
12
+ def __init__(self, project_root: Path, database_name: str):
13
+ """Initialize the change comparator.
14
+
15
+ Args:
16
+ project_root: Path to the project root
17
+ database_name: Name of the database
18
+ """
19
+ self.project_root = Path(project_root)
20
+ self.database_name = database_name
21
+
22
+ def get_branch_changes(self, branch_name: str) -> List[Change]:
23
+ """Get all changes for a branch.
24
+
25
+ Args:
26
+ branch_name: Name of the branch
27
+
28
+ Returns:
29
+ List of changes in the branch
30
+ """
31
+ tracker = ChangeTracker(self.project_root, self.database_name, branch_name)
32
+ return tracker.get_changes()
33
+
34
+ def find_common_ancestor(
35
+ self, source_branch: str, target_branch: str
36
+ ) -> Optional[str]:
37
+ """Find the common ancestor change between two branches.
38
+
39
+ Args:
40
+ source_branch: Name of the source branch
41
+ target_branch: Name of the target branch
42
+
43
+ Returns:
44
+ ID of the common ancestor change, or None if no common ancestor
45
+ """
46
+ source_changes = self.get_branch_changes(source_branch)
47
+ target_changes = self.get_branch_changes(target_branch)
48
+
49
+ # Convert to sets of change IDs for efficient lookup
50
+ source_ids = {change.id for change in source_changes}
51
+ target_ids = {change.id for change in target_changes}
52
+
53
+ # Find common changes
54
+ common_ids = source_ids & target_ids
55
+
56
+ # Find the latest common change (highest timestamp)
57
+ common_changes = [c for c in source_changes if c.id in common_ids]
58
+ if not common_changes:
59
+ return None
60
+
61
+ return max(common_changes, key=lambda c: c.created_at).id
62
+
63
+ def get_divergent_changes(
64
+ self, source_branch: str, target_branch: str
65
+ ) -> Tuple[List[Change], List[Change]]:
66
+ """Get changes that diverge between two branches.
67
+
68
+ Args:
69
+ source_branch: Name of the source branch
70
+ target_branch: Name of the target branch
71
+
72
+ Returns:
73
+ Tuple of (source_only_changes, target_only_changes)
74
+ """
75
+ source_changes = self.get_branch_changes(source_branch)
76
+ target_changes = self.get_branch_changes(target_branch)
77
+
78
+ # Convert to sets of change IDs
79
+ source_ids = {change.id for change in source_changes}
80
+ target_ids = {change.id for change in target_changes}
81
+
82
+ # Find changes unique to each branch
83
+ source_only_ids = source_ids - target_ids
84
+ target_only_ids = target_ids - source_ids
85
+
86
+ # Get the actual change objects
87
+ source_only = [c for c in source_changes if c.id in source_only_ids]
88
+ target_only = [c for c in target_changes if c.id in target_only_ids]
89
+
90
+ # Sort by timestamp for consistent ordering
91
+ source_only.sort(key=lambda c: c.created_at)
92
+ target_only.sort(key=lambda c: c.created_at)
93
+
94
+ return source_only, target_only
95
+
96
+ def can_fast_forward_merge(self, source_branch: str, target_branch: str) -> bool:
97
+ """Check if source can be fast-forward merged into target.
98
+
99
+ A fast-forward merge is possible when target branch has no changes
100
+ that source doesn't have (target is ancestor of source).
101
+
102
+ Args:
103
+ source_branch: Name of the source branch
104
+ target_branch: Name of the target branch
105
+
106
+ Returns:
107
+ True if fast-forward merge is possible
108
+ """
109
+ source_only, target_only = self.get_divergent_changes(
110
+ source_branch, target_branch
111
+ )
112
+
113
+ # Fast-forward is possible if target has no unique changes
114
+ return len(target_only) == 0 and len(source_only) > 0
115
+
116
+ def detect_conflicts(self, source_branch: str, target_branch: str) -> List[str]:
117
+ """Detect potential conflicts between two branches.
118
+
119
+ Args:
120
+ source_branch: Name of the source branch
121
+ target_branch: Name of the target branch
122
+
123
+ Returns:
124
+ List of conflict descriptions
125
+ """
126
+ source_only, target_only = self.get_divergent_changes(
127
+ source_branch, target_branch
128
+ )
129
+
130
+ conflicts = []
131
+
132
+ # Check for same table/column operations
133
+ source_entities = set()
134
+ target_entities = set()
135
+
136
+ for change in source_only:
137
+ if change.entity_type == "table":
138
+ source_entities.add(f"table:{change.entity_name}")
139
+ elif change.entity_type == "column":
140
+ # Extract table name from SQL for column operations
141
+ table_name = self._extract_table_from_column_sql(change.sql)
142
+ if table_name:
143
+ source_entities.add(f"column:{table_name}.{change.entity_name}")
144
+
145
+ for change in target_only:
146
+ if change.entity_type == "table":
147
+ target_entities.add(f"table:{change.entity_name}")
148
+ elif change.entity_type == "column":
149
+ table_name = self._extract_table_from_column_sql(change.sql)
150
+ if table_name:
151
+ target_entities.add(f"column:{table_name}.{change.entity_name}")
152
+
153
+ # Find overlapping entities
154
+ overlapping = source_entities & target_entities
155
+ if overlapping:
156
+ conflicts.extend(
157
+ [f"Both branches modified {entity}" for entity in overlapping]
158
+ )
159
+
160
+ return conflicts
161
+
162
+ def _extract_table_from_column_sql(self, sql: str) -> Optional[str]:
163
+ """Extract table name from column SQL statement.
164
+
165
+ Args:
166
+ sql: SQL statement for column operation
167
+
168
+ Returns:
169
+ Table name if found, None otherwise
170
+ """
171
+ # Simple extraction for common patterns
172
+ # ALTER TABLE table_name ADD COLUMN ...
173
+ # ALTER TABLE table_name DROP COLUMN ...
174
+ # ALTER TABLE table_name RENAME COLUMN ...
175
+
176
+ sql_upper = sql.upper().strip()
177
+ if sql_upper.startswith("ALTER TABLE"):
178
+ parts = sql.split()
179
+ if len(parts) >= 3:
180
+ return parts[2] # table_name is the third part
181
+
182
+ return None
183
+
184
+ def get_merge_order(self, changes: List[Change]) -> List[Change]:
185
+ """Get changes in the correct order for merging.
186
+
187
+ Args:
188
+ changes: List of changes to order
189
+
190
+ Returns:
191
+ Changes ordered by timestamp for safe merging
192
+ """
193
+ # Sort by timestamp to maintain chronological order
194
+ return sorted(changes, key=lambda c: c.created_at)