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.
- cinchdb/__init__.py +7 -0
- cinchdb/__main__.py +6 -0
- cinchdb/api/__init__.py +5 -0
- cinchdb/api/app.py +76 -0
- cinchdb/api/auth.py +290 -0
- cinchdb/api/main.py +137 -0
- cinchdb/api/routers/__init__.py +25 -0
- cinchdb/api/routers/auth.py +135 -0
- cinchdb/api/routers/branches.py +368 -0
- cinchdb/api/routers/codegen.py +164 -0
- cinchdb/api/routers/columns.py +290 -0
- cinchdb/api/routers/data.py +479 -0
- cinchdb/api/routers/databases.py +177 -0
- cinchdb/api/routers/projects.py +133 -0
- cinchdb/api/routers/query.py +156 -0
- cinchdb/api/routers/tables.py +349 -0
- cinchdb/api/routers/tenants.py +216 -0
- cinchdb/api/routers/views.py +219 -0
- cinchdb/cli/__init__.py +0 -0
- cinchdb/cli/commands/__init__.py +1 -0
- cinchdb/cli/commands/branch.py +479 -0
- cinchdb/cli/commands/codegen.py +176 -0
- cinchdb/cli/commands/column.py +308 -0
- cinchdb/cli/commands/database.py +212 -0
- cinchdb/cli/commands/query.py +136 -0
- cinchdb/cli/commands/remote.py +144 -0
- cinchdb/cli/commands/table.py +289 -0
- cinchdb/cli/commands/tenant.py +173 -0
- cinchdb/cli/commands/view.py +189 -0
- cinchdb/cli/handlers/__init__.py +5 -0
- cinchdb/cli/handlers/codegen_handler.py +189 -0
- cinchdb/cli/main.py +137 -0
- cinchdb/cli/utils.py +182 -0
- cinchdb/config.py +177 -0
- cinchdb/core/__init__.py +5 -0
- cinchdb/core/connection.py +175 -0
- cinchdb/core/database.py +537 -0
- cinchdb/core/maintenance.py +73 -0
- cinchdb/core/path_utils.py +153 -0
- cinchdb/managers/__init__.py +26 -0
- cinchdb/managers/branch.py +167 -0
- cinchdb/managers/change_applier.py +414 -0
- cinchdb/managers/change_comparator.py +194 -0
- cinchdb/managers/change_tracker.py +182 -0
- cinchdb/managers/codegen.py +523 -0
- cinchdb/managers/column.py +579 -0
- cinchdb/managers/data.py +455 -0
- cinchdb/managers/merge_manager.py +429 -0
- cinchdb/managers/query.py +214 -0
- cinchdb/managers/table.py +383 -0
- cinchdb/managers/tenant.py +258 -0
- cinchdb/managers/view.py +252 -0
- cinchdb/models/__init__.py +27 -0
- cinchdb/models/base.py +44 -0
- cinchdb/models/branch.py +26 -0
- cinchdb/models/change.py +47 -0
- cinchdb/models/database.py +20 -0
- cinchdb/models/project.py +20 -0
- cinchdb/models/table.py +86 -0
- cinchdb/models/tenant.py +19 -0
- cinchdb/models/view.py +15 -0
- cinchdb/utils/__init__.py +15 -0
- cinchdb/utils/sql_validator.py +137 -0
- cinchdb-0.1.0.dist-info/METADATA +195 -0
- cinchdb-0.1.0.dist-info/RECORD +68 -0
- cinchdb-0.1.0.dist-info/WHEEL +4 -0
- cinchdb-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|