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,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)
|