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,383 @@
1
+ """Table management for CinchDB."""
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from cinchdb.models import Table, Column, Change, ChangeType
7
+ from cinchdb.core.connection import DatabaseConnection
8
+ from cinchdb.core.path_utils import get_tenant_db_path
9
+ from cinchdb.core.maintenance import check_maintenance_mode
10
+ from cinchdb.managers.change_tracker import ChangeTracker
11
+ from cinchdb.managers.tenant import TenantManager
12
+
13
+
14
+ class TableManager:
15
+ """Manages tables within a database."""
16
+
17
+ # Protected column names that users cannot use
18
+ PROTECTED_COLUMNS = {"id", "created_at", "updated_at"}
19
+
20
+ def __init__(
21
+ self, project_root: Path, database: str, branch: str, tenant: str = "main"
22
+ ):
23
+ """Initialize table manager.
24
+
25
+ Args:
26
+ project_root: Path to project root
27
+ database: Database name
28
+ branch: Branch name
29
+ tenant: Tenant name (default: main)
30
+ """
31
+ self.project_root = Path(project_root)
32
+ self.database = database
33
+ self.branch = branch
34
+ self.tenant = tenant
35
+ self.db_path = get_tenant_db_path(project_root, database, branch, tenant)
36
+ self.change_tracker = ChangeTracker(project_root, database, branch)
37
+ self.tenant_manager = TenantManager(project_root, database, branch)
38
+
39
+ def list_tables(self) -> List[Table]:
40
+ """List all tables in the tenant.
41
+
42
+ Returns:
43
+ List of Table objects
44
+ """
45
+ tables = []
46
+
47
+ with DatabaseConnection(self.db_path) as conn:
48
+ # Get all user tables (exclude sqlite internal tables)
49
+ cursor = conn.execute(
50
+ """
51
+ SELECT name FROM sqlite_master
52
+ WHERE type='table'
53
+ AND name NOT LIKE 'sqlite_%'
54
+ ORDER BY name
55
+ """
56
+ )
57
+
58
+ for row in cursor.fetchall():
59
+ table = self.get_table(row["name"])
60
+ tables.append(table)
61
+
62
+ return tables
63
+
64
+ def create_table(self, table_name: str, columns: List[Column]) -> Table:
65
+ """Create a new table with optional foreign key constraints.
66
+
67
+ Args:
68
+ table_name: Name of the table
69
+ columns: List of Column objects defining the schema
70
+
71
+ Returns:
72
+ Created Table object
73
+
74
+ Raises:
75
+ ValueError: If table already exists, uses protected column names,
76
+ or has invalid foreign key references
77
+ MaintenanceError: If branch is in maintenance mode
78
+ """
79
+ # Check maintenance mode
80
+ check_maintenance_mode(self.project_root, self.database, self.branch)
81
+
82
+ # Validate table doesn't exist
83
+ if self._table_exists(table_name):
84
+ raise ValueError(f"Table '{table_name}' already exists")
85
+
86
+ # Check for protected column names
87
+ for column in columns:
88
+ if column.name in self.PROTECTED_COLUMNS:
89
+ raise ValueError(
90
+ f"Column name '{column.name}' is protected and cannot be used"
91
+ )
92
+
93
+ # Validate foreign key references
94
+ foreign_key_constraints = []
95
+ for column in columns:
96
+ if column.foreign_key:
97
+ fk = column.foreign_key
98
+
99
+ # Validate referenced table exists
100
+ if not self._table_exists(fk.table):
101
+ raise ValueError(
102
+ f"Foreign key reference to non-existent table: '{fk.table}'"
103
+ )
104
+
105
+ # Validate referenced column exists
106
+ ref_table = self.get_table(fk.table)
107
+ ref_col_names = [col.name for col in ref_table.columns]
108
+ if fk.column not in ref_col_names:
109
+ raise ValueError(
110
+ f"Foreign key reference to non-existent column: '{fk.table}.{fk.column}'"
111
+ )
112
+
113
+ # Build foreign key constraint
114
+ fk_constraint = f"FOREIGN KEY ({column.name}) REFERENCES {fk.table}({fk.column})"
115
+ if fk.on_delete != "RESTRICT":
116
+ fk_constraint += f" ON DELETE {fk.on_delete}"
117
+ if fk.on_update != "RESTRICT":
118
+ fk_constraint += f" ON UPDATE {fk.on_update}"
119
+ foreign_key_constraints.append(fk_constraint)
120
+
121
+ # Build automatic columns
122
+ auto_columns = [
123
+ Column(name="id", type="TEXT", primary_key=True, nullable=False),
124
+ Column(name="created_at", type="TEXT", nullable=False),
125
+ Column(name="updated_at", type="TEXT", nullable=True),
126
+ ]
127
+
128
+ # Combine all columns
129
+ all_columns = auto_columns + columns
130
+
131
+ # Build CREATE TABLE SQL
132
+ sql_parts = []
133
+ for col in all_columns:
134
+ col_def = f"{col.name} {col.type}"
135
+
136
+ if col.primary_key:
137
+ col_def += " PRIMARY KEY"
138
+ if not col.nullable:
139
+ col_def += " NOT NULL"
140
+ if col.default is not None:
141
+ col_def += f" DEFAULT {col.default}"
142
+ if col.unique and not col.primary_key:
143
+ col_def += " UNIQUE"
144
+
145
+ sql_parts.append(col_def)
146
+
147
+ # Add foreign key constraints
148
+ sql_parts.extend(foreign_key_constraints)
149
+
150
+ create_sql = f"CREATE TABLE {table_name} ({', '.join(sql_parts)})"
151
+
152
+ # Track the change first (as unapplied)
153
+ change = Change(
154
+ type=ChangeType.CREATE_TABLE,
155
+ entity_type="table",
156
+ entity_name=table_name,
157
+ branch=self.branch,
158
+ details={"columns": [col.model_dump() for col in all_columns]},
159
+ sql=create_sql,
160
+ )
161
+ self.change_tracker.add_change(change)
162
+
163
+ # Apply to all tenants in the branch
164
+ from cinchdb.managers.change_applier import ChangeApplier
165
+
166
+ applier = ChangeApplier(self.project_root, self.database, self.branch)
167
+ applier.apply_change(change.id)
168
+
169
+ # Return the created table
170
+ return Table(
171
+ name=table_name,
172
+ database=self.database,
173
+ branch=self.branch,
174
+ columns=all_columns,
175
+ )
176
+
177
+ def get_table(self, table_name: str) -> Table:
178
+ """Get table information.
179
+
180
+ Args:
181
+ table_name: Name of the table
182
+
183
+ Returns:
184
+ Table object with schema information
185
+
186
+ Raises:
187
+ ValueError: If table doesn't exist
188
+ """
189
+ if not self._table_exists(table_name):
190
+ raise ValueError(f"Table '{table_name}' does not exist")
191
+
192
+ columns = []
193
+ foreign_keys = {}
194
+
195
+ with DatabaseConnection(self.db_path) as conn:
196
+ # Get foreign key information first
197
+ fk_cursor = conn.execute(f"PRAGMA foreign_key_list({table_name})")
198
+ for fk_row in fk_cursor.fetchall():
199
+ from_col = fk_row["from"]
200
+ to_table = fk_row["table"]
201
+ to_col = fk_row["to"]
202
+ on_update = fk_row["on_update"]
203
+ on_delete = fk_row["on_delete"]
204
+
205
+ # Create ForeignKeyRef
206
+ from cinchdb.models import ForeignKeyRef
207
+ foreign_keys[from_col] = ForeignKeyRef(
208
+ table=to_table,
209
+ column=to_col,
210
+ on_update=on_update,
211
+ on_delete=on_delete
212
+ )
213
+
214
+ # Get column information
215
+ cursor = conn.execute(f"PRAGMA table_info({table_name})")
216
+
217
+ for row in cursor.fetchall():
218
+ # Map SQLite types
219
+ sqlite_type = row["type"].upper()
220
+ if "INT" in sqlite_type:
221
+ col_type = "INTEGER"
222
+ elif (
223
+ "REAL" in sqlite_type
224
+ or "FLOAT" in sqlite_type
225
+ or "DOUBLE" in sqlite_type
226
+ ):
227
+ col_type = "REAL"
228
+ elif "BLOB" in sqlite_type:
229
+ col_type = "BLOB"
230
+ elif "NUMERIC" in sqlite_type:
231
+ col_type = "NUMERIC"
232
+ else:
233
+ col_type = "TEXT"
234
+
235
+ # Check if this column has a foreign key
236
+ foreign_key = foreign_keys.get(row["name"])
237
+
238
+ column = Column(
239
+ name=row["name"],
240
+ type=col_type,
241
+ nullable=(row["notnull"] == 0),
242
+ default=row["dflt_value"],
243
+ primary_key=(row["pk"] == 1),
244
+ foreign_key=foreign_key
245
+ )
246
+ columns.append(column)
247
+
248
+ return Table(
249
+ name=table_name, database=self.database, branch=self.branch, columns=columns
250
+ )
251
+
252
+ def delete_table(self, table_name: str) -> None:
253
+ """Delete a table.
254
+
255
+ Args:
256
+ table_name: Name of the table to delete
257
+
258
+ Raises:
259
+ ValueError: If table doesn't exist
260
+ MaintenanceError: If branch is in maintenance mode
261
+ """
262
+ # Check maintenance mode
263
+ check_maintenance_mode(self.project_root, self.database, self.branch)
264
+
265
+ if not self._table_exists(table_name):
266
+ raise ValueError(f"Table '{table_name}' does not exist")
267
+
268
+ # Build DROP TABLE SQL
269
+ drop_sql = f"DROP TABLE {table_name}"
270
+
271
+ # Track the change
272
+ change = Change(
273
+ type=ChangeType.DROP_TABLE,
274
+ entity_type="table",
275
+ entity_name=table_name,
276
+ branch=self.branch,
277
+ sql=drop_sql,
278
+ )
279
+ self.change_tracker.add_change(change)
280
+
281
+ # Apply to all tenants in the branch
282
+ from cinchdb.managers.change_applier import ChangeApplier
283
+
284
+ applier = ChangeApplier(self.project_root, self.database, self.branch)
285
+ applier.apply_change(change.id)
286
+
287
+ def copy_table(
288
+ self, source_table: str, target_table: str, copy_data: bool = True
289
+ ) -> Table:
290
+ """Copy a table to a new table.
291
+
292
+ Args:
293
+ source_table: Name of the source table
294
+ target_table: Name of the target table
295
+ copy_data: Whether to copy data (default: True)
296
+
297
+ Returns:
298
+ Created Table object
299
+
300
+ Raises:
301
+ ValueError: If source doesn't exist or target already exists
302
+ MaintenanceError: If branch is in maintenance mode
303
+ """
304
+ # Check maintenance mode
305
+ check_maintenance_mode(self.project_root, self.database, self.branch)
306
+
307
+ if not self._table_exists(source_table):
308
+ raise ValueError(f"Source table '{source_table}' does not exist")
309
+
310
+ if self._table_exists(target_table):
311
+ raise ValueError(f"Target table '{target_table}' already exists")
312
+
313
+ # Get source table structure
314
+ source = self.get_table(source_table)
315
+
316
+ # Create SQL for new table
317
+ sql_parts = []
318
+ for col in source.columns:
319
+ col_def = f"{col.name} {col.type}"
320
+
321
+ if col.primary_key:
322
+ col_def += " PRIMARY KEY"
323
+ if not col.nullable:
324
+ col_def += " NOT NULL"
325
+ if col.default is not None:
326
+ col_def += f" DEFAULT {col.default}"
327
+
328
+ sql_parts.append(col_def)
329
+
330
+ create_sql = f"CREATE TABLE {target_table} ({', '.join(sql_parts)})"
331
+
332
+ # Build copy data SQL if requested
333
+ copy_sql = None
334
+ if copy_data:
335
+ col_names = [col.name for col in source.columns]
336
+ col_list = ", ".join(col_names)
337
+ copy_sql = f"INSERT INTO {target_table} ({col_list}) SELECT {col_list} FROM {source_table}"
338
+
339
+ # Track the change
340
+ change = Change(
341
+ type=ChangeType.CREATE_TABLE,
342
+ entity_type="table",
343
+ entity_name=target_table,
344
+ branch=self.branch,
345
+ details={
346
+ "columns": [col.model_dump() for col in source.columns],
347
+ "copied_from": source_table,
348
+ "with_data": copy_data,
349
+ "copy_sql": copy_sql,
350
+ },
351
+ sql=create_sql,
352
+ )
353
+ self.change_tracker.add_change(change)
354
+
355
+ # Apply to all tenants in the branch
356
+ from cinchdb.managers.change_applier import ChangeApplier
357
+
358
+ applier = ChangeApplier(self.project_root, self.database, self.branch)
359
+ applier.apply_change(change.id)
360
+
361
+ # Return the new table
362
+ return Table(
363
+ name=target_table,
364
+ database=self.database,
365
+ branch=self.branch,
366
+ columns=source.columns,
367
+ )
368
+
369
+ def _table_exists(self, table_name: str) -> bool:
370
+ """Check if a table exists.
371
+
372
+ Args:
373
+ table_name: Name of the table
374
+
375
+ Returns:
376
+ True if table exists
377
+ """
378
+ with DatabaseConnection(self.db_path) as conn:
379
+ cursor = conn.execute(
380
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
381
+ (table_name,),
382
+ )
383
+ return cursor.fetchone() is not None
@@ -0,0 +1,258 @@
1
+ """Tenant management for CinchDB."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from cinchdb.models import Tenant
8
+ from cinchdb.core.path_utils import (
9
+ get_branch_path,
10
+ get_tenant_db_path,
11
+ list_tenants,
12
+ )
13
+ from cinchdb.core.connection import DatabaseConnection
14
+ from cinchdb.core.maintenance import check_maintenance_mode
15
+
16
+
17
+ class TenantManager:
18
+ """Manages tenants within a branch."""
19
+
20
+ def __init__(self, project_root: Path, database: str, branch: str):
21
+ """Initialize tenant manager.
22
+
23
+ Args:
24
+ project_root: Path to project root
25
+ database: Database name
26
+ branch: Branch name
27
+ """
28
+ self.project_root = Path(project_root)
29
+ self.database = database
30
+ self.branch = branch
31
+ self.branch_path = get_branch_path(self.project_root, database, branch)
32
+
33
+ def list_tenants(self) -> List[Tenant]:
34
+ """List all tenants in the branch.
35
+
36
+ Returns:
37
+ List of Tenant objects
38
+ """
39
+ tenant_names = list_tenants(self.project_root, self.database, self.branch)
40
+ tenants = []
41
+
42
+ for name in tenant_names:
43
+ tenant = Tenant(
44
+ name=name,
45
+ database=self.database,
46
+ branch=self.branch,
47
+ is_main=(name == "main"),
48
+ )
49
+ tenants.append(tenant)
50
+
51
+ return tenants
52
+
53
+ def create_tenant(
54
+ self, tenant_name: str, description: Optional[str] = None
55
+ ) -> Tenant:
56
+ """Create a new tenant by copying schema from main tenant.
57
+
58
+ Args:
59
+ tenant_name: Name for the new tenant
60
+ description: Optional description
61
+
62
+ Returns:
63
+ Created Tenant object
64
+
65
+ Raises:
66
+ ValueError: If tenant already exists
67
+ MaintenanceError: If branch is in maintenance mode
68
+ """
69
+ # Check maintenance mode
70
+ check_maintenance_mode(self.project_root, self.database, self.branch)
71
+
72
+ # Validate tenant doesn't exist
73
+ if tenant_name in list_tenants(self.project_root, self.database, self.branch):
74
+ raise ValueError(f"Tenant '{tenant_name}' already exists")
75
+
76
+ # Get paths
77
+ main_db_path = get_tenant_db_path(
78
+ self.project_root, self.database, self.branch, "main"
79
+ )
80
+ new_db_path = get_tenant_db_path(
81
+ self.project_root, self.database, self.branch, tenant_name
82
+ )
83
+
84
+ # Copy main tenant database to new tenant
85
+ shutil.copy2(main_db_path, new_db_path)
86
+
87
+ # Clear any data from the copied database (keep schema only)
88
+ with DatabaseConnection(new_db_path) as conn:
89
+ # Get all tables
90
+ result = conn.execute("""
91
+ SELECT name FROM sqlite_master
92
+ WHERE type='table'
93
+ AND name NOT LIKE 'sqlite_%'
94
+ """)
95
+ tables = [row["name"] for row in result.fetchall()]
96
+
97
+ # Clear data from each table
98
+ for table in tables:
99
+ conn.execute(f"DELETE FROM {table}")
100
+
101
+ conn.commit()
102
+
103
+ return Tenant(
104
+ name=tenant_name,
105
+ database=self.database,
106
+ branch=self.branch,
107
+ description=description,
108
+ is_main=False,
109
+ )
110
+
111
+ def delete_tenant(self, tenant_name: str) -> None:
112
+ """Delete a tenant.
113
+
114
+ Args:
115
+ tenant_name: Name of tenant to delete
116
+
117
+ Raises:
118
+ ValueError: If tenant doesn't exist or is main tenant
119
+ MaintenanceError: If branch is in maintenance mode
120
+ """
121
+ # Check maintenance mode
122
+ check_maintenance_mode(self.project_root, self.database, self.branch)
123
+
124
+ # Can't delete main tenant
125
+ if tenant_name == "main":
126
+ raise ValueError("Cannot delete the main tenant")
127
+
128
+ # Validate tenant exists
129
+ if tenant_name not in list_tenants(
130
+ self.project_root, self.database, self.branch
131
+ ):
132
+ raise ValueError(f"Tenant '{tenant_name}' does not exist")
133
+
134
+ # Delete tenant database file and related files
135
+ db_path = get_tenant_db_path(
136
+ self.project_root, self.database, self.branch, tenant_name
137
+ )
138
+ db_path.unlink()
139
+
140
+ # Also remove WAL and SHM files if they exist
141
+ wal_path = db_path.with_suffix(".db-wal")
142
+ shm_path = db_path.with_suffix(".db-shm")
143
+
144
+ if wal_path.exists():
145
+ wal_path.unlink()
146
+ if shm_path.exists():
147
+ shm_path.unlink()
148
+
149
+ def copy_tenant(self, source_tenant: str, target_tenant: str) -> Tenant:
150
+ """Copy a tenant to a new tenant.
151
+
152
+ Args:
153
+ source_tenant: Name of tenant to copy from
154
+ target_tenant: Name for the new tenant
155
+
156
+ Returns:
157
+ Created Tenant object
158
+
159
+ Raises:
160
+ ValueError: If source doesn't exist or target already exists
161
+ MaintenanceError: If branch is in maintenance mode
162
+ """
163
+ # Check maintenance mode
164
+ check_maintenance_mode(self.project_root, self.database, self.branch)
165
+
166
+ # Validate source exists
167
+ if source_tenant not in list_tenants(
168
+ self.project_root, self.database, self.branch
169
+ ):
170
+ raise ValueError(f"Source tenant '{source_tenant}' does not exist")
171
+
172
+ # Validate target doesn't exist
173
+ if target_tenant in list_tenants(self.project_root, self.database, self.branch):
174
+ raise ValueError(f"Tenant '{target_tenant}' already exists")
175
+
176
+ # Get paths
177
+ source_path = get_tenant_db_path(
178
+ self.project_root, self.database, self.branch, source_tenant
179
+ )
180
+ target_path = get_tenant_db_path(
181
+ self.project_root, self.database, self.branch, target_tenant
182
+ )
183
+
184
+ # Copy database file
185
+ shutil.copy2(source_path, target_path)
186
+
187
+ return Tenant(
188
+ name=target_tenant,
189
+ database=self.database,
190
+ branch=self.branch,
191
+ is_main=False,
192
+ )
193
+
194
+ def rename_tenant(self, old_name: str, new_name: str) -> None:
195
+ """Rename a tenant.
196
+
197
+ Args:
198
+ old_name: Current tenant name
199
+ new_name: New tenant name
200
+
201
+ Raises:
202
+ ValueError: If old doesn't exist, new already exists, or trying to rename main
203
+ """
204
+ # Can't rename main tenant
205
+ if old_name == "main":
206
+ raise ValueError("Cannot rename the main tenant")
207
+
208
+ # Validate old exists
209
+ if old_name not in list_tenants(self.project_root, self.database, self.branch):
210
+ raise ValueError(f"Tenant '{old_name}' does not exist")
211
+
212
+ # Validate new doesn't exist
213
+ if new_name in list_tenants(self.project_root, self.database, self.branch):
214
+ raise ValueError(f"Tenant '{new_name}' already exists")
215
+
216
+ # Get paths
217
+ old_path = get_tenant_db_path(
218
+ self.project_root, self.database, self.branch, old_name
219
+ )
220
+ new_path = get_tenant_db_path(
221
+ self.project_root, self.database, self.branch, new_name
222
+ )
223
+
224
+ # Rename database file
225
+ old_path.rename(new_path)
226
+
227
+ # Also rename WAL and SHM files if they exist
228
+ old_wal = old_path.with_suffix(".db-wal")
229
+ old_shm = old_path.with_suffix(".db-shm")
230
+ new_wal = new_path.with_suffix(".db-wal")
231
+ new_shm = new_path.with_suffix(".db-shm")
232
+
233
+ if old_wal.exists():
234
+ old_wal.rename(new_wal)
235
+ if old_shm.exists():
236
+ old_shm.rename(new_shm)
237
+
238
+ def get_tenant_connection(self, tenant_name: str) -> DatabaseConnection:
239
+ """Get a database connection for a tenant.
240
+
241
+ Args:
242
+ tenant_name: Tenant name
243
+
244
+ Returns:
245
+ DatabaseConnection object
246
+
247
+ Raises:
248
+ ValueError: If tenant doesn't exist
249
+ """
250
+ if tenant_name not in list_tenants(
251
+ self.project_root, self.database, self.branch
252
+ ):
253
+ raise ValueError(f"Tenant '{tenant_name}' does not exist")
254
+
255
+ db_path = get_tenant_db_path(
256
+ self.project_root, self.database, self.branch, tenant_name
257
+ )
258
+ return DatabaseConnection(db_path)