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,579 @@
|
|
1
|
+
"""Column management for CinchDB."""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import List, Any, Optional
|
5
|
+
|
6
|
+
from cinchdb.models import 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.table import TableManager
|
12
|
+
|
13
|
+
|
14
|
+
class ColumnManager:
|
15
|
+
"""Manages columns within tables."""
|
16
|
+
|
17
|
+
# Protected column names that cannot be modified
|
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 column 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.table_manager = TableManager(project_root, database, branch, tenant)
|
38
|
+
|
39
|
+
def list_columns(self, table_name: str) -> List[Column]:
|
40
|
+
"""List all columns in a table.
|
41
|
+
|
42
|
+
Args:
|
43
|
+
table_name: Name of the table
|
44
|
+
|
45
|
+
Returns:
|
46
|
+
List of Column objects
|
47
|
+
|
48
|
+
Raises:
|
49
|
+
ValueError: If table doesn't exist
|
50
|
+
"""
|
51
|
+
table = self.table_manager.get_table(table_name)
|
52
|
+
return table.columns
|
53
|
+
|
54
|
+
def add_column(self, table_name: str, column: Column) -> None:
|
55
|
+
"""Add a column to a table.
|
56
|
+
|
57
|
+
Args:
|
58
|
+
table_name: Name of the table
|
59
|
+
column: Column to add
|
60
|
+
|
61
|
+
Raises:
|
62
|
+
ValueError: If table doesn't exist, column exists, or uses protected name
|
63
|
+
MaintenanceError: If branch is in maintenance mode
|
64
|
+
"""
|
65
|
+
# Check maintenance mode
|
66
|
+
check_maintenance_mode(self.project_root, self.database, self.branch)
|
67
|
+
|
68
|
+
# Validate table exists
|
69
|
+
if not self.table_manager._table_exists(table_name):
|
70
|
+
raise ValueError(f"Table '{table_name}' does not exist")
|
71
|
+
|
72
|
+
# Check for protected column names
|
73
|
+
if column.name in self.PROTECTED_COLUMNS:
|
74
|
+
raise ValueError(
|
75
|
+
f"Column name '{column.name}' is protected and cannot be used"
|
76
|
+
)
|
77
|
+
|
78
|
+
# Check if column already exists
|
79
|
+
existing_columns = self.list_columns(table_name)
|
80
|
+
if any(col.name == column.name for col in existing_columns):
|
81
|
+
raise ValueError(
|
82
|
+
f"Column '{column.name}' already exists in table '{table_name}'"
|
83
|
+
)
|
84
|
+
|
85
|
+
# Build ALTER TABLE SQL
|
86
|
+
col_def = f"{column.name} {column.type}"
|
87
|
+
if not column.nullable:
|
88
|
+
col_def += " NOT NULL"
|
89
|
+
if column.default is not None:
|
90
|
+
col_def += f" DEFAULT {column.default}"
|
91
|
+
|
92
|
+
alter_sql = f"ALTER TABLE {table_name} ADD COLUMN {col_def}"
|
93
|
+
|
94
|
+
# Track the change first (as unapplied)
|
95
|
+
change = Change(
|
96
|
+
type=ChangeType.ADD_COLUMN,
|
97
|
+
entity_type="column",
|
98
|
+
entity_name=column.name,
|
99
|
+
branch=self.branch,
|
100
|
+
details={"table": table_name, "column_def": column.model_dump()},
|
101
|
+
sql=alter_sql,
|
102
|
+
)
|
103
|
+
self.change_tracker.add_change(change)
|
104
|
+
|
105
|
+
# Apply to all tenants in the branch
|
106
|
+
from cinchdb.managers.change_applier import ChangeApplier
|
107
|
+
|
108
|
+
applier = ChangeApplier(self.project_root, self.database, self.branch)
|
109
|
+
applier.apply_change(change.id)
|
110
|
+
|
111
|
+
def drop_column(self, table_name: str, column_name: str) -> None:
|
112
|
+
"""Drop a column from a table.
|
113
|
+
|
114
|
+
SQLite doesn't support DROP COLUMN directly, so we need to:
|
115
|
+
1. Create a new table without the column
|
116
|
+
2. Copy data from old table
|
117
|
+
3. Drop old table
|
118
|
+
4. Rename new table
|
119
|
+
|
120
|
+
Args:
|
121
|
+
table_name: Name of the table
|
122
|
+
column_name: Name of column to drop
|
123
|
+
|
124
|
+
Raises:
|
125
|
+
ValueError: If table/column doesn't exist or column is protected
|
126
|
+
MaintenanceError: If branch is in maintenance mode
|
127
|
+
"""
|
128
|
+
# Check maintenance mode
|
129
|
+
check_maintenance_mode(self.project_root, self.database, self.branch)
|
130
|
+
|
131
|
+
# Validate table exists
|
132
|
+
if not self.table_manager._table_exists(table_name):
|
133
|
+
raise ValueError(f"Table '{table_name}' does not exist")
|
134
|
+
|
135
|
+
# Check if column is protected
|
136
|
+
if column_name in self.PROTECTED_COLUMNS:
|
137
|
+
raise ValueError(f"Cannot drop protected column '{column_name}'")
|
138
|
+
|
139
|
+
# Get existing columns
|
140
|
+
existing_columns = self.list_columns(table_name)
|
141
|
+
if not any(col.name == column_name for col in existing_columns):
|
142
|
+
raise ValueError(
|
143
|
+
f"Column '{column_name}' does not exist in table '{table_name}'"
|
144
|
+
)
|
145
|
+
|
146
|
+
# Filter out the column to drop
|
147
|
+
new_columns = [col for col in existing_columns if col.name != column_name]
|
148
|
+
|
149
|
+
# Build SQL statements
|
150
|
+
temp_table = f"{table_name}_temp"
|
151
|
+
|
152
|
+
# Create new table without the column
|
153
|
+
col_defs = []
|
154
|
+
for col in new_columns:
|
155
|
+
col_def = f"{col.name} {col.type}"
|
156
|
+
if col.primary_key:
|
157
|
+
col_def += " PRIMARY KEY"
|
158
|
+
if not col.nullable:
|
159
|
+
col_def += " NOT NULL"
|
160
|
+
if col.default is not None:
|
161
|
+
col_def += f" DEFAULT {col.default}"
|
162
|
+
col_defs.append(col_def)
|
163
|
+
|
164
|
+
create_sql = f"CREATE TABLE {temp_table} ({', '.join(col_defs)})"
|
165
|
+
|
166
|
+
# Column names for copying
|
167
|
+
col_names = [col.name for col in new_columns]
|
168
|
+
col_list = ", ".join(col_names)
|
169
|
+
copy_sql = (
|
170
|
+
f"INSERT INTO {temp_table} ({col_list}) SELECT {col_list} FROM {table_name}"
|
171
|
+
)
|
172
|
+
drop_sql = f"DROP TABLE {table_name}"
|
173
|
+
rename_sql = f"ALTER TABLE {temp_table} RENAME TO {table_name}"
|
174
|
+
|
175
|
+
# Track the change with individual SQL statements
|
176
|
+
change = Change(
|
177
|
+
type=ChangeType.DROP_COLUMN,
|
178
|
+
entity_type="column",
|
179
|
+
entity_name=column_name,
|
180
|
+
branch=self.branch,
|
181
|
+
details={
|
182
|
+
"table": table_name,
|
183
|
+
"temp_table": temp_table,
|
184
|
+
"statements": [
|
185
|
+
("CREATE", create_sql),
|
186
|
+
("COPY", copy_sql),
|
187
|
+
("DROP", drop_sql),
|
188
|
+
("RENAME", rename_sql),
|
189
|
+
],
|
190
|
+
"remaining_columns": [col.model_dump() for col in new_columns],
|
191
|
+
},
|
192
|
+
sql=f"-- DROP COLUMN {column_name} FROM {table_name}",
|
193
|
+
)
|
194
|
+
self.change_tracker.add_change(change)
|
195
|
+
|
196
|
+
# Apply to all tenants in the branch
|
197
|
+
from cinchdb.managers.change_applier import ChangeApplier
|
198
|
+
|
199
|
+
applier = ChangeApplier(self.project_root, self.database, self.branch)
|
200
|
+
applier.apply_change(change.id)
|
201
|
+
|
202
|
+
def rename_column(self, table_name: str, old_name: str, new_name: str) -> None:
|
203
|
+
"""Rename a column in a table.
|
204
|
+
|
205
|
+
Args:
|
206
|
+
table_name: Name of the table
|
207
|
+
old_name: Current column name
|
208
|
+
new_name: New column name
|
209
|
+
|
210
|
+
Raises:
|
211
|
+
ValueError: If table/column doesn't exist or names are protected
|
212
|
+
MaintenanceError: If branch is in maintenance mode
|
213
|
+
"""
|
214
|
+
# Check maintenance mode
|
215
|
+
check_maintenance_mode(self.project_root, self.database, self.branch)
|
216
|
+
|
217
|
+
# Validate table exists
|
218
|
+
if not self.table_manager._table_exists(table_name):
|
219
|
+
raise ValueError(f"Table '{table_name}' does not exist")
|
220
|
+
|
221
|
+
# Check if old column is protected
|
222
|
+
if old_name in self.PROTECTED_COLUMNS:
|
223
|
+
raise ValueError(f"Cannot rename protected column '{old_name}'")
|
224
|
+
|
225
|
+
# Check if new name is protected
|
226
|
+
if new_name in self.PROTECTED_COLUMNS:
|
227
|
+
raise ValueError(f"Cannot use protected name '{new_name}'")
|
228
|
+
|
229
|
+
# Get existing columns
|
230
|
+
existing_columns = self.list_columns(table_name)
|
231
|
+
|
232
|
+
# Check old column exists
|
233
|
+
if not any(col.name == old_name for col in existing_columns):
|
234
|
+
raise ValueError(
|
235
|
+
f"Column '{old_name}' does not exist in table '{table_name}'"
|
236
|
+
)
|
237
|
+
|
238
|
+
# Check new name doesn't exist
|
239
|
+
if any(col.name == new_name for col in existing_columns):
|
240
|
+
raise ValueError(
|
241
|
+
f"Column '{new_name}' already exists in table '{table_name}'"
|
242
|
+
)
|
243
|
+
|
244
|
+
# Try using ALTER TABLE RENAME COLUMN (SQLite 3.25.0+)
|
245
|
+
rename_sql = f"ALTER TABLE {table_name} RENAME COLUMN {old_name} TO {new_name}"
|
246
|
+
|
247
|
+
# Check if we need fallback SQL for older SQLite versions
|
248
|
+
fallback_sqls = self._get_rename_column_fallback_sqls(
|
249
|
+
table_name, old_name, new_name, existing_columns
|
250
|
+
)
|
251
|
+
|
252
|
+
# Track the change
|
253
|
+
change = Change(
|
254
|
+
type=ChangeType.RENAME_COLUMN,
|
255
|
+
entity_type="column",
|
256
|
+
entity_name=new_name,
|
257
|
+
branch=self.branch,
|
258
|
+
details={
|
259
|
+
"table": table_name,
|
260
|
+
"old_name": old_name,
|
261
|
+
"fallback_sqls": fallback_sqls,
|
262
|
+
},
|
263
|
+
sql=rename_sql,
|
264
|
+
)
|
265
|
+
self.change_tracker.add_change(change)
|
266
|
+
|
267
|
+
# Apply to all tenants in the branch
|
268
|
+
from cinchdb.managers.change_applier import ChangeApplier
|
269
|
+
|
270
|
+
applier = ChangeApplier(self.project_root, self.database, self.branch)
|
271
|
+
applier.apply_change(change.id)
|
272
|
+
|
273
|
+
def get_column_info(self, table_name: str, column_name: str) -> Column:
|
274
|
+
"""Get information about a specific column.
|
275
|
+
|
276
|
+
Args:
|
277
|
+
table_name: Name of the table
|
278
|
+
column_name: Name of the column
|
279
|
+
|
280
|
+
Returns:
|
281
|
+
Column object
|
282
|
+
|
283
|
+
Raises:
|
284
|
+
ValueError: If table/column doesn't exist
|
285
|
+
"""
|
286
|
+
columns = self.list_columns(table_name)
|
287
|
+
|
288
|
+
for col in columns:
|
289
|
+
if col.name == column_name:
|
290
|
+
return col
|
291
|
+
|
292
|
+
raise ValueError(
|
293
|
+
f"Column '{column_name}' does not exist in table '{table_name}'"
|
294
|
+
)
|
295
|
+
|
296
|
+
def _get_rename_column_fallback_sqls(
|
297
|
+
self,
|
298
|
+
table_name: str,
|
299
|
+
old_name: str,
|
300
|
+
new_name: str,
|
301
|
+
existing_columns: List[Column],
|
302
|
+
) -> dict:
|
303
|
+
"""Generate fallback SQL for rename column operation (for older SQLite versions).
|
304
|
+
|
305
|
+
Args:
|
306
|
+
table_name: Name of the table
|
307
|
+
old_name: Current column name
|
308
|
+
new_name: New column name
|
309
|
+
existing_columns: List of existing columns
|
310
|
+
|
311
|
+
Returns:
|
312
|
+
Dictionary with all SQL statements needed for fallback
|
313
|
+
"""
|
314
|
+
temp_table = f"{table_name}_temp"
|
315
|
+
|
316
|
+
# Build new column list with renamed column
|
317
|
+
new_columns = []
|
318
|
+
old_col_names = []
|
319
|
+
new_col_names = []
|
320
|
+
|
321
|
+
for col in existing_columns:
|
322
|
+
old_col_names.append(col.name)
|
323
|
+
if col.name == old_name:
|
324
|
+
new_col = Column(
|
325
|
+
name=new_name,
|
326
|
+
type=col.type,
|
327
|
+
nullable=col.nullable,
|
328
|
+
default=col.default,
|
329
|
+
primary_key=col.primary_key,
|
330
|
+
unique=col.unique,
|
331
|
+
)
|
332
|
+
new_columns.append(new_col)
|
333
|
+
new_col_names.append(new_name)
|
334
|
+
else:
|
335
|
+
new_columns.append(col)
|
336
|
+
new_col_names.append(col.name)
|
337
|
+
|
338
|
+
# Create new table
|
339
|
+
col_defs = []
|
340
|
+
for col in new_columns:
|
341
|
+
col_def = f"{col.name} {col.type}"
|
342
|
+
if col.primary_key:
|
343
|
+
col_def += " PRIMARY KEY"
|
344
|
+
if not col.nullable:
|
345
|
+
col_def += " NOT NULL"
|
346
|
+
if col.default is not None:
|
347
|
+
col_def += f" DEFAULT {col.default}"
|
348
|
+
col_defs.append(col_def)
|
349
|
+
|
350
|
+
create_sql = f"CREATE TABLE {temp_table} ({', '.join(col_defs)})"
|
351
|
+
|
352
|
+
# Copy data with column mapping
|
353
|
+
old_list = ", ".join(old_col_names)
|
354
|
+
new_list = ", ".join(new_col_names)
|
355
|
+
copy_sql = (
|
356
|
+
f"INSERT INTO {temp_table} ({new_list}) SELECT {old_list} FROM {table_name}"
|
357
|
+
)
|
358
|
+
drop_sql = f"DROP TABLE {table_name}"
|
359
|
+
rename_sql = f"ALTER TABLE {temp_table} RENAME TO {table_name}"
|
360
|
+
|
361
|
+
return {
|
362
|
+
"temp_table": temp_table,
|
363
|
+
"create_sql": create_sql,
|
364
|
+
"copy_sql": copy_sql,
|
365
|
+
"drop_sql": drop_sql,
|
366
|
+
"rename_sql": rename_sql,
|
367
|
+
"new_columns": [col.model_dump() for col in new_columns],
|
368
|
+
}
|
369
|
+
|
370
|
+
def _rename_column_via_recreate(
|
371
|
+
self,
|
372
|
+
table_name: str,
|
373
|
+
old_name: str,
|
374
|
+
new_name: str,
|
375
|
+
existing_columns: List[Column],
|
376
|
+
) -> None:
|
377
|
+
"""Rename column by recreating the table (for older SQLite versions).
|
378
|
+
|
379
|
+
Args:
|
380
|
+
table_name: Name of the table
|
381
|
+
old_name: Current column name
|
382
|
+
new_name: New column name
|
383
|
+
existing_columns: List of existing columns
|
384
|
+
"""
|
385
|
+
temp_table = f"{table_name}_temp"
|
386
|
+
|
387
|
+
# Build new column list with renamed column
|
388
|
+
new_columns = []
|
389
|
+
old_col_names = []
|
390
|
+
new_col_names = []
|
391
|
+
|
392
|
+
for col in existing_columns:
|
393
|
+
old_col_names.append(col.name)
|
394
|
+
if col.name == old_name:
|
395
|
+
new_col = Column(
|
396
|
+
name=new_name,
|
397
|
+
type=col.type,
|
398
|
+
nullable=col.nullable,
|
399
|
+
default=col.default,
|
400
|
+
primary_key=col.primary_key,
|
401
|
+
unique=col.unique,
|
402
|
+
)
|
403
|
+
new_columns.append(new_col)
|
404
|
+
new_col_names.append(new_name)
|
405
|
+
else:
|
406
|
+
new_columns.append(col)
|
407
|
+
new_col_names.append(col.name)
|
408
|
+
|
409
|
+
# Create new table
|
410
|
+
col_defs = []
|
411
|
+
for col in new_columns:
|
412
|
+
col_def = f"{col.name} {col.type}"
|
413
|
+
if col.primary_key:
|
414
|
+
col_def += " PRIMARY KEY"
|
415
|
+
if not col.nullable:
|
416
|
+
col_def += " NOT NULL"
|
417
|
+
if col.default is not None:
|
418
|
+
col_def += f" DEFAULT {col.default}"
|
419
|
+
col_defs.append(col_def)
|
420
|
+
|
421
|
+
create_sql = f"CREATE TABLE {temp_table} ({', '.join(col_defs)})"
|
422
|
+
|
423
|
+
with DatabaseConnection(self.db_path) as conn:
|
424
|
+
# Create new table
|
425
|
+
conn.execute(create_sql)
|
426
|
+
|
427
|
+
# Copy data with column mapping
|
428
|
+
old_list = ", ".join(old_col_names)
|
429
|
+
new_list = ", ".join(new_col_names)
|
430
|
+
copy_sql = f"INSERT INTO {temp_table} ({new_list}) SELECT {old_list} FROM {table_name}"
|
431
|
+
conn.execute(copy_sql)
|
432
|
+
|
433
|
+
# Drop old table
|
434
|
+
conn.execute(f"DROP TABLE {table_name}")
|
435
|
+
|
436
|
+
# Rename new table
|
437
|
+
conn.execute(f"ALTER TABLE {temp_table} RENAME TO {table_name}")
|
438
|
+
|
439
|
+
conn.commit()
|
440
|
+
|
441
|
+
def alter_column_nullable(
|
442
|
+
self, table_name: str, column_name: str, nullable: bool, fill_value: Optional[Any] = None
|
443
|
+
) -> None:
|
444
|
+
"""Change the nullable constraint on a column.
|
445
|
+
|
446
|
+
Args:
|
447
|
+
table_name: Name of the table
|
448
|
+
column_name: Name of the column to modify
|
449
|
+
nullable: Whether the column should allow NULL values
|
450
|
+
fill_value: Value to use for existing NULL values when making column NOT NULL
|
451
|
+
|
452
|
+
Raises:
|
453
|
+
ValueError: If table/column doesn't exist or column is protected
|
454
|
+
ValueError: If making NOT NULL and column has NULL values without fill_value
|
455
|
+
MaintenanceError: If branch is in maintenance mode
|
456
|
+
"""
|
457
|
+
# Check maintenance mode
|
458
|
+
check_maintenance_mode(self.project_root, self.database, self.branch)
|
459
|
+
|
460
|
+
# Validate table exists
|
461
|
+
if not self.table_manager._table_exists(table_name):
|
462
|
+
raise ValueError(f"Table '{table_name}' does not exist")
|
463
|
+
|
464
|
+
# Check if column is protected
|
465
|
+
if column_name in self.PROTECTED_COLUMNS:
|
466
|
+
raise ValueError(f"Cannot modify protected column '{column_name}'")
|
467
|
+
|
468
|
+
# Get existing columns
|
469
|
+
existing_columns = self.list_columns(table_name)
|
470
|
+
column_found = False
|
471
|
+
old_column = None
|
472
|
+
|
473
|
+
for col in existing_columns:
|
474
|
+
if col.name == column_name:
|
475
|
+
column_found = True
|
476
|
+
old_column = col
|
477
|
+
break
|
478
|
+
|
479
|
+
if not column_found:
|
480
|
+
raise ValueError(
|
481
|
+
f"Column '{column_name}' does not exist in table '{table_name}'"
|
482
|
+
)
|
483
|
+
|
484
|
+
# Check if already has the desired nullable state
|
485
|
+
if old_column.nullable == nullable:
|
486
|
+
raise ValueError(
|
487
|
+
f"Column '{column_name}' is already {'nullable' if nullable else 'NOT NULL'}"
|
488
|
+
)
|
489
|
+
|
490
|
+
# If making NOT NULL, check for NULL values
|
491
|
+
if not nullable:
|
492
|
+
with DatabaseConnection(self.db_path) as conn:
|
493
|
+
cursor = conn.execute(
|
494
|
+
f"SELECT COUNT(*) FROM {table_name} WHERE {column_name} IS NULL"
|
495
|
+
)
|
496
|
+
null_count = cursor.fetchone()[0]
|
497
|
+
|
498
|
+
if null_count > 0 and fill_value is None:
|
499
|
+
raise ValueError(
|
500
|
+
f"Column '{column_name}' has {null_count} NULL values. "
|
501
|
+
"Provide a fill_value to replace them."
|
502
|
+
)
|
503
|
+
|
504
|
+
# Build SQL statements for table recreation
|
505
|
+
temp_table = f"{table_name}_temp"
|
506
|
+
|
507
|
+
# Create new table with modified column
|
508
|
+
col_defs = []
|
509
|
+
for col in existing_columns:
|
510
|
+
col_def = f"{col.name} {col.type}"
|
511
|
+
if col.primary_key:
|
512
|
+
col_def += " PRIMARY KEY"
|
513
|
+
# Apply nullable change to target column
|
514
|
+
if col.name == column_name:
|
515
|
+
if not nullable:
|
516
|
+
col_def += " NOT NULL"
|
517
|
+
else:
|
518
|
+
# Keep original nullable state for other columns
|
519
|
+
if not col.nullable:
|
520
|
+
col_def += " NOT NULL"
|
521
|
+
if col.default is not None:
|
522
|
+
col_def += f" DEFAULT {col.default}"
|
523
|
+
col_defs.append(col_def)
|
524
|
+
|
525
|
+
create_sql = f"CREATE TABLE {temp_table} ({', '.join(col_defs)})"
|
526
|
+
|
527
|
+
# Column names for copying
|
528
|
+
col_names = [col.name for col in existing_columns]
|
529
|
+
col_list = ", ".join(col_names)
|
530
|
+
|
531
|
+
# Build copy SQL with COALESCE if needed
|
532
|
+
if not nullable and fill_value is not None:
|
533
|
+
# Build select list with COALESCE for the target column
|
534
|
+
select_cols = []
|
535
|
+
for col_name in col_names:
|
536
|
+
if col_name == column_name:
|
537
|
+
# Properly quote string values
|
538
|
+
if isinstance(fill_value, str):
|
539
|
+
select_cols.append(f"COALESCE({col_name}, '{fill_value}')")
|
540
|
+
else:
|
541
|
+
select_cols.append(f"COALESCE({col_name}, {fill_value})")
|
542
|
+
else:
|
543
|
+
select_cols.append(col_name)
|
544
|
+
select_list = ", ".join(select_cols)
|
545
|
+
copy_sql = f"INSERT INTO {temp_table} ({col_list}) SELECT {select_list} FROM {table_name}"
|
546
|
+
else:
|
547
|
+
copy_sql = f"INSERT INTO {temp_table} ({col_list}) SELECT {col_list} FROM {table_name}"
|
548
|
+
|
549
|
+
drop_sql = f"DROP TABLE {table_name}"
|
550
|
+
rename_sql = f"ALTER TABLE {temp_table} RENAME TO {table_name}"
|
551
|
+
|
552
|
+
# Track the change with individual SQL statements
|
553
|
+
change = Change(
|
554
|
+
type=ChangeType.ALTER_COLUMN_NULLABLE,
|
555
|
+
entity_type="column",
|
556
|
+
entity_name=column_name,
|
557
|
+
branch=self.branch,
|
558
|
+
details={
|
559
|
+
"table": table_name,
|
560
|
+
"nullable": nullable,
|
561
|
+
"fill_value": fill_value,
|
562
|
+
"old_nullable": old_column.nullable,
|
563
|
+
"temp_table": temp_table,
|
564
|
+
"statements": [
|
565
|
+
("CREATE", create_sql),
|
566
|
+
("COPY", copy_sql),
|
567
|
+
("DROP", drop_sql),
|
568
|
+
("RENAME", rename_sql),
|
569
|
+
],
|
570
|
+
},
|
571
|
+
sql=f"-- ALTER COLUMN {column_name} {'NULL' if nullable else 'NOT NULL'}",
|
572
|
+
)
|
573
|
+
self.change_tracker.add_change(change)
|
574
|
+
|
575
|
+
# Apply to all tenants in the branch
|
576
|
+
from cinchdb.managers.change_applier import ChangeApplier
|
577
|
+
|
578
|
+
applier = ChangeApplier(self.project_root, self.database, self.branch)
|
579
|
+
applier.apply_change(change.id)
|