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,523 @@
1
+ """Code generation manager for creating models from database schemas."""
2
+
3
+ from pathlib import Path
4
+ from typing import List, Dict, Any, Literal
5
+ from ..core.connection import DatabaseConnection
6
+ from ..core.path_utils import get_tenant_db_path
7
+ from ..models import Table, Column, View
8
+ from ..managers.table import TableManager
9
+ from ..managers.view import ViewModel
10
+
11
+
12
+ LanguageType = Literal["python", "typescript"]
13
+
14
+
15
+ class CodegenManager:
16
+ """Manages code generation for database schemas."""
17
+
18
+ SUPPORTED_LANGUAGES = ["python", "typescript"]
19
+
20
+ def __init__(
21
+ self, project_root: Path, database: str, branch: str, tenant: str = "main"
22
+ ):
23
+ """Initialize codegen manager.
24
+
25
+ Args:
26
+ project_root: Path to project root
27
+ database: Database name
28
+ branch: Branch name
29
+ tenant: Tenant name (defaults to 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
+
37
+ # Initialize managers for data access
38
+ self.table_manager = TableManager(project_root, database, branch, tenant)
39
+ self.view_manager = ViewModel(project_root, database, branch, tenant)
40
+
41
+ def get_supported_languages(self) -> List[str]:
42
+ """Get list of supported code generation languages.
43
+
44
+ Returns:
45
+ List of supported language names
46
+ """
47
+ return self.SUPPORTED_LANGUAGES.copy()
48
+
49
+ def generate_models(
50
+ self,
51
+ language: LanguageType,
52
+ output_dir: Path,
53
+ include_tables: bool = True,
54
+ include_views: bool = True,
55
+ ) -> Dict[str, Any]:
56
+ """Generate model files for the specified language.
57
+
58
+ Args:
59
+ language: Target language for generation
60
+ output_dir: Directory to write generated files
61
+ include_tables: Whether to generate table models
62
+ include_views: Whether to generate view models
63
+
64
+ Returns:
65
+ Dictionary with generation results
66
+
67
+ Raises:
68
+ ValueError: If language not supported or output directory invalid
69
+ """
70
+ if language not in self.SUPPORTED_LANGUAGES:
71
+ raise ValueError(
72
+ f"Language '{language}' not supported. Available: {self.SUPPORTED_LANGUAGES}"
73
+ )
74
+
75
+ output_path = Path(output_dir)
76
+ if not output_path.exists():
77
+ output_path.mkdir(parents=True, exist_ok=True)
78
+
79
+ results = {
80
+ "language": language,
81
+ "output_dir": str(output_path),
82
+ "files_generated": [],
83
+ "tables_processed": [],
84
+ "views_processed": [],
85
+ }
86
+
87
+ if language == "python":
88
+ return self._generate_python_models(
89
+ output_path, include_tables, include_views, results
90
+ )
91
+ elif language == "typescript":
92
+ return self._generate_typescript_models(
93
+ output_path, include_tables, include_views, results
94
+ )
95
+
96
+ return results
97
+
98
+ def _generate_python_models(
99
+ self,
100
+ output_dir: Path,
101
+ include_tables: bool,
102
+ include_views: bool,
103
+ results: Dict[str, Any],
104
+ ) -> Dict[str, Any]:
105
+ """Generate Python Pydantic models."""
106
+
107
+ # Generate __init__.py
108
+ init_content = ['"""Generated CinchDB models."""\n']
109
+ model_imports = []
110
+
111
+ if include_tables:
112
+ # Get all tables
113
+ tables = self.table_manager.list_tables()
114
+
115
+ for table in tables:
116
+ # Generate model for each table
117
+ model_content = self._generate_python_table_model(table)
118
+ model_filename = f"{self._to_snake_case(table.name)}.py"
119
+ model_path = output_dir / model_filename
120
+
121
+ with open(model_path, "w") as f:
122
+ f.write(model_content)
123
+
124
+ results["files_generated"].append(model_filename)
125
+ results["tables_processed"].append(table.name)
126
+
127
+ # Add to imports
128
+ class_name = self._to_pascal_case(table.name)
129
+ model_imports.append(
130
+ f"from .{self._to_snake_case(table.name)} import {class_name}"
131
+ )
132
+
133
+ if include_views:
134
+ # Get all views
135
+ views = self.view_manager.list_views()
136
+
137
+ for view in views:
138
+ # Generate model for each view (read-only)
139
+ model_content = self._generate_python_view_model(view)
140
+ model_filename = f"{self._to_snake_case(view.name)}_view.py"
141
+ model_path = output_dir / model_filename
142
+
143
+ with open(model_path, "w") as f:
144
+ f.write(model_content)
145
+
146
+ results["files_generated"].append(model_filename)
147
+ results["views_processed"].append(view.name)
148
+
149
+ # Add to imports
150
+ class_name = f"{self._to_pascal_case(view.name)}View"
151
+ model_imports.append(
152
+ f"from .{self._to_snake_case(view.name)}_view import {class_name}"
153
+ )
154
+
155
+ # Generate CinchModels container class
156
+ cinch_models_content = self._generate_cinch_models_class()
157
+ cinch_models_path = output_dir / "cinch_models.py"
158
+ with open(cinch_models_path, "w") as f:
159
+ f.write(cinch_models_content)
160
+ results["files_generated"].append("cinch_models.py")
161
+
162
+ # Write __init__.py with all imports and factory function
163
+ if model_imports:
164
+ # Import CinchModels
165
+ init_content.append("from .cinch_models import CinchModels")
166
+ init_content.extend(model_imports)
167
+ init_content.append("") # Empty line
168
+
169
+ # Create model registry
170
+ init_content.append("# Model registry for dynamic loading")
171
+ init_content.append("_MODEL_REGISTRY = {")
172
+ if include_tables:
173
+ for table_name in results["tables_processed"]:
174
+ class_name = self._to_pascal_case(table_name)
175
+ init_content.append(f" '{class_name}': {class_name},")
176
+ if include_views:
177
+ for view_name in results["views_processed"]:
178
+ class_name = f"{self._to_pascal_case(view_name)}View"
179
+ init_content.append(f" '{class_name}': {class_name},")
180
+ init_content.append("}")
181
+ init_content.append("")
182
+
183
+ # Add factory function
184
+ init_content.extend(
185
+ [
186
+ "def create_models(connection) -> CinchModels:",
187
+ ' """Create unified models interface.',
188
+ "",
189
+ " Args:",
190
+ " connection: CinchDB instance (local or remote)",
191
+ "",
192
+ " Returns:",
193
+ " CinchModels container with all generated models",
194
+ ' """',
195
+ " models = CinchModels(connection)",
196
+ " models._model_registry = _MODEL_REGISTRY",
197
+ " return models",
198
+ "",
199
+ ]
200
+ )
201
+
202
+ # Add __all__ export
203
+ all_exports = ["'CinchModels'", "'create_models'"]
204
+ if include_tables:
205
+ for table_name in results["tables_processed"]:
206
+ all_exports.append(f'"{self._to_pascal_case(table_name)}"')
207
+ if include_views:
208
+ for view_name in results["views_processed"]:
209
+ all_exports.append(f'"{self._to_pascal_case(view_name)}View"')
210
+
211
+ init_content.append(f"__all__ = [{', '.join(all_exports)}]")
212
+
213
+ init_path = output_dir / "__init__.py"
214
+ with open(init_path, "w") as f:
215
+ f.write("\n".join(init_content))
216
+
217
+ results["files_generated"].append("__init__.py")
218
+
219
+ return results
220
+
221
+ def _generate_python_table_model(self, table: Table) -> str:
222
+ """Generate Python Pydantic model for a table."""
223
+ class_name = self._to_pascal_case(table.name)
224
+
225
+ content = [
226
+ f'"""Generated model for {table.name} table."""',
227
+ "",
228
+ "from typing import Optional, List, ClassVar, Union",
229
+ "from datetime import datetime",
230
+ "from pathlib import Path",
231
+ "from pydantic import BaseModel, Field, ConfigDict",
232
+ "",
233
+ "from cinchdb.managers.data import DataManager",
234
+ "",
235
+ "",
236
+ f"class {class_name}(BaseModel):",
237
+ f' """Model for {table.name} table with CRUD operations."""',
238
+ " model_config = ConfigDict(",
239
+ " from_attributes=True,",
240
+ f' json_schema_extra={{"table_name": "{table.name}"}}',
241
+ " )",
242
+ "",
243
+ " # Class variables for database connection info",
244
+ " _data_manager: ClassVar[Optional[DataManager]] = None",
245
+ "",
246
+ ]
247
+
248
+ # Generate fields for each column
249
+ for column in table.columns:
250
+ field_content = self._generate_python_field(column)
251
+ content.append(f" {field_content}")
252
+
253
+ content.extend(
254
+ [
255
+ "",
256
+ " @classmethod",
257
+ " def _get_data_manager(cls) -> DataManager:",
258
+ ' """Get data manager instance (set by CinchModels container)."""',
259
+ " if cls._data_manager is None:",
260
+ ' raise RuntimeError("Model not initialized. Access models through CinchModels container.")',
261
+ " return cls._data_manager",
262
+ "",
263
+ " @classmethod",
264
+ f" def select(cls, limit: Optional[int] = None, offset: Optional[int] = None, **filters) -> List['{class_name}']:",
265
+ ' """Select records with optional filtering."""',
266
+ " return cls._get_data_manager().select(cls, limit=limit, offset=offset, **filters)",
267
+ "",
268
+ " @classmethod",
269
+ f" def find_by_id(cls, record_id: str) -> Optional['{class_name}']:",
270
+ ' """Find a single record by ID."""',
271
+ " return cls._get_data_manager().find_by_id(cls, record_id)",
272
+ "",
273
+ " @classmethod",
274
+ f" def create(cls, **data) -> '{class_name}':",
275
+ ' """Create a new record."""',
276
+ " instance = cls(**data)",
277
+ " return cls._get_data_manager().create(instance)",
278
+ "",
279
+ " @classmethod",
280
+ f" def bulk_create(cls, records: List[dict]) -> List['{class_name}']:",
281
+ ' """Create multiple records in a single transaction."""',
282
+ " instances = [cls(**record) for record in records]",
283
+ " return cls._get_data_manager().bulk_create(instances)",
284
+ "",
285
+ " @classmethod",
286
+ " def count(cls, **filters) -> int:",
287
+ ' """Count records with optional filtering."""',
288
+ " return cls._get_data_manager().count(cls, **filters)",
289
+ "",
290
+ " @classmethod",
291
+ " def delete_records(cls, **filters) -> int:",
292
+ ' """Delete records matching filters."""',
293
+ " return cls._get_data_manager().delete(cls, **filters)",
294
+ "",
295
+ f" def save(self) -> '{class_name}':",
296
+ ' """Save (upsert) this record."""',
297
+ " return self._get_data_manager().save(self)",
298
+ "",
299
+ f" def update(self) -> '{class_name}':",
300
+ ' """Update this existing record."""',
301
+ " return self._get_data_manager().update(self)",
302
+ "",
303
+ " def delete(self) -> bool:",
304
+ ' """Delete this record."""',
305
+ " if not self.id:",
306
+ ' raise ValueError("Cannot delete record without ID")',
307
+ " return self._get_data_manager().delete_by_id(type(self), self.id)",
308
+ ]
309
+ )
310
+
311
+ return "\n".join(content)
312
+
313
+ def _generate_python_view_model(self, view: View) -> str:
314
+ """Generate Python Pydantic model for a view (read-only)."""
315
+ class_name = f"{self._to_pascal_case(view.name)}View"
316
+
317
+ # Get view schema by inspecting the view
318
+ columns = self._get_view_columns(view.name)
319
+
320
+ content = [
321
+ f'"""Generated model for {view.name} view."""',
322
+ "",
323
+ "from typing import Optional, Any",
324
+ "from pydantic import BaseModel, Field, ConfigDict",
325
+ "",
326
+ "",
327
+ f"class {class_name}(BaseModel):",
328
+ f' """Read-only model for {view.name} view."""',
329
+ " model_config = ConfigDict(",
330
+ " from_attributes=True,",
331
+ f' json_schema_extra={{"view_name": "{view.name}", "readonly": True}}',
332
+ " )",
333
+ "",
334
+ ]
335
+
336
+ # Generate fields for each column (all Optional since we can't know schema exactly)
337
+ for column_name, column_type in columns.items():
338
+ python_type = self._sqlite_to_python_type(column_type)
339
+ content.append(
340
+ f" {column_name}: Optional[{python_type}] = Field(default=None)"
341
+ )
342
+
343
+ return "\n".join(content)
344
+
345
+ def _generate_python_field(self, column: Column) -> str:
346
+ """Generate Python field definition for a column."""
347
+ python_type = self._sqlite_to_python_type(column.type, column.name)
348
+
349
+ # Handle nullable columns - all fields should be Optional except required business fields
350
+ # ID is always optional (auto-generated), timestamps are optional for model creation
351
+ if column.nullable or column.name in ["id", "created_at", "updated_at"]:
352
+ python_type = f"Optional[{python_type}]"
353
+
354
+ # Create field definition
355
+ field_parts = []
356
+
357
+ # Add description
358
+ field_parts.append(f'description="{column.name} field"')
359
+
360
+ # Add default values
361
+ if column.name == "id":
362
+ field_parts.append("default=None") # Auto-generated by DataManager
363
+ elif column.name == "created_at":
364
+ field_parts.append("default=None") # Set by DataManager on create
365
+ elif column.name == "updated_at":
366
+ field_parts.append("default=None") # Set by DataManager on create/update
367
+ elif column.nullable:
368
+ field_parts.append("default=None")
369
+
370
+ field_def = f"Field({', '.join(field_parts)})" if field_parts else "Field()"
371
+
372
+ return f"{column.name}: {python_type} = {field_def}"
373
+
374
+ def _generate_typescript_models(
375
+ self,
376
+ output_dir: Path,
377
+ include_tables: bool,
378
+ include_views: bool,
379
+ results: Dict[str, Any],
380
+ ) -> Dict[str, Any]:
381
+ """Generate TypeScript interface models."""
382
+ # TODO: Implement TypeScript generation
383
+ # For now, return placeholder
384
+ results["files_generated"].append("typescript_generation_todo.md")
385
+
386
+ placeholder_path = output_dir / "typescript_generation_todo.md"
387
+ with open(placeholder_path, "w") as f:
388
+ content = "# TypeScript Generation\n\nTypeScript model generation will be implemented in a future update.\n"
389
+ if include_tables:
390
+ content += "\nTables will be generated as TypeScript interfaces.\n"
391
+ if include_views:
392
+ content += (
393
+ "\nViews will be generated as read-only TypeScript interfaces.\n"
394
+ )
395
+ f.write(content)
396
+
397
+ return results
398
+
399
+ def _get_view_columns(self, view_name: str) -> Dict[str, str]:
400
+ """Get column information for a view by querying PRAGMA."""
401
+ columns = {}
402
+
403
+ with DatabaseConnection(self.db_path) as conn:
404
+ try:
405
+ # Try to get view column info
406
+ cursor = conn.execute(f"PRAGMA table_info('{view_name}')")
407
+ for row in cursor.fetchall():
408
+ column_name = row["name"]
409
+ column_type = (
410
+ row["type"] or "TEXT"
411
+ ) # Default to TEXT if type is empty
412
+ columns[column_name] = column_type.upper()
413
+ except Exception:
414
+ # If we can't get schema, use generic approach
415
+ columns = {"data": "TEXT"} # Fallback
416
+
417
+ return columns
418
+
419
+ def _sqlite_to_python_type(self, sqlite_type: str, column_name: str = "") -> str:
420
+ """Convert SQLite type to Python type string."""
421
+ sqlite_type = sqlite_type.upper()
422
+
423
+ # Special case for timestamp fields
424
+ if column_name in ["created_at", "updated_at"]:
425
+ return "datetime"
426
+
427
+ if "INT" in sqlite_type:
428
+ return "int"
429
+ elif sqlite_type in ["REAL", "FLOAT", "DOUBLE"]:
430
+ return "float"
431
+ elif sqlite_type == "BLOB":
432
+ return "bytes"
433
+ elif "NUMERIC" in sqlite_type:
434
+ return "float" # Could be Decimal, but float is simpler
435
+ else:
436
+ # TEXT, VARCHAR, etc.
437
+ return "str"
438
+
439
+ def _to_snake_case(self, name: str) -> str:
440
+ """Convert name to snake_case."""
441
+ import re
442
+
443
+ # Insert underscore before uppercase letters (except first)
444
+ s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
445
+ # Insert underscore before uppercase letters preceded by lowercase
446
+ return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
447
+
448
+ def _to_pascal_case(self, name: str) -> str:
449
+ """Convert name to PascalCase."""
450
+ # If already PascalCase, return as-is
451
+ if name and name[0].isupper() and "_" not in name and "-" not in name:
452
+ return name
453
+
454
+ # Split on underscores and capitalize each part
455
+ parts = name.replace("-", "_").split("_")
456
+ return "".join(word.capitalize() for word in parts if word)
457
+
458
+ def _generate_cinch_models_class(self) -> str:
459
+ """Generate the CinchModels container class."""
460
+ content = [
461
+ '"""CinchModels container class for unified model access."""',
462
+ "",
463
+ "from typing import Dict, Any, Optional",
464
+ "from cinchdb.core.database import CinchDB",
465
+ "from cinchdb.managers.data import DataManager",
466
+ "",
467
+ "",
468
+ "class CinchModels:",
469
+ ' """Unified interface for generated models."""',
470
+ "",
471
+ " def __init__(self, connection: CinchDB):",
472
+ ' """Initialize with a CinchDB connection.',
473
+ "",
474
+ " Args:",
475
+ " connection: CinchDB instance (local or remote)",
476
+ ' """',
477
+ " if not isinstance(connection, CinchDB):",
478
+ ' raise TypeError("CinchModels requires a CinchDB connection instance")',
479
+ "",
480
+ " self._connection = connection",
481
+ " self._models = {} # Lazy loaded model cache",
482
+ " self._model_registry = {} # Map of model names to classes",
483
+ " self._tenant_override = None # Optional tenant override",
484
+ "",
485
+ " def __getattr__(self, name: str):",
486
+ ' """Lazy load and return model class with connection set."""',
487
+ " if name not in self._models:",
488
+ " if name not in self._model_registry:",
489
+ " raise AttributeError(f\"Model '{name}' not found\")",
490
+ "",
491
+ " model_class = self._model_registry[name]",
492
+ "",
493
+ " # Determine tenant to use (override or connection default)",
494
+ " tenant = self._tenant_override or self._connection.tenant",
495
+ "",
496
+ " # Initialize model with connection context",
497
+ " if self._connection.is_local:",
498
+ " # Create DataManager for local connections",
499
+ " data_manager = DataManager(",
500
+ " self._connection.project_dir,",
501
+ " self._connection.database,",
502
+ " self._connection.branch,",
503
+ " tenant",
504
+ " )",
505
+ " model_class._data_manager = data_manager",
506
+ " else:",
507
+ " # Remote connections not yet supported in codegen",
508
+ ' raise NotImplementedError("Remote connections not yet supported in generated models")',
509
+ "",
510
+ " self._models[name] = model_class",
511
+ "",
512
+ " return self._models[name]",
513
+ "",
514
+ " def with_tenant(self, tenant: str) -> 'CinchModels':",
515
+ ' """Create models interface for a specific tenant with connection context override."""',
516
+ " # Create a new CinchModels with same connection but different tenant context",
517
+ " new_models = CinchModels(self._connection)",
518
+ " new_models._tenant_override = tenant",
519
+ " new_models._model_registry = self._model_registry",
520
+ " return new_models",
521
+ ]
522
+
523
+ return "\n".join(content)