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