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,455 @@
1
+ """Data management for CinchDB - handles CRUD operations on table data."""
2
+
3
+ import uuid
4
+ from pathlib import Path
5
+ from typing import List, Dict, Any, Optional, Type, TypeVar
6
+ from datetime import datetime
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from cinchdb.core.connection import DatabaseConnection
11
+ from cinchdb.core.path_utils import get_tenant_db_path
12
+ from cinchdb.core.maintenance import check_maintenance_mode
13
+ from cinchdb.managers.table import TableManager
14
+ from cinchdb.managers.query import QueryManager
15
+
16
+ T = TypeVar("T", bound=BaseModel)
17
+
18
+
19
+ class DataManager:
20
+ """Manages data operations within a database tenant."""
21
+
22
+ def __init__(
23
+ self, project_root: Path, database: str, branch: str, tenant: str = "main"
24
+ ):
25
+ """Initialize data manager.
26
+
27
+ Args:
28
+ project_root: Path to project root
29
+ database: Database name
30
+ branch: Branch name
31
+ tenant: Tenant name (default: main)
32
+ """
33
+ self.project_root = Path(project_root)
34
+ self.database = database
35
+ self.branch = branch
36
+ self.tenant = tenant
37
+ self.db_path = get_tenant_db_path(project_root, database, branch, tenant)
38
+ self.table_manager = TableManager(project_root, database, branch, tenant)
39
+ self.query_manager = QueryManager(project_root, database, branch, tenant)
40
+
41
+ def select(
42
+ self,
43
+ model_class: Type[T],
44
+ limit: Optional[int] = None,
45
+ offset: Optional[int] = None,
46
+ **filters,
47
+ ) -> List[T]:
48
+ """Select records from a table with optional filtering.
49
+
50
+ Args:
51
+ model_class: Pydantic model class representing the table
52
+ limit: Maximum number of records to return
53
+ offset: Number of records to skip
54
+ **filters: Column filters (exact match or special operators)
55
+
56
+ Returns:
57
+ List of model instances
58
+
59
+ Raises:
60
+ ValueError: If table doesn't exist or filters are invalid
61
+ """
62
+ table_name = self._get_table_name(model_class)
63
+
64
+ # Build WHERE clause from filters
65
+ where_clause, params = self._build_where_clause(filters)
66
+
67
+ # Build query
68
+ query = f"SELECT * FROM {table_name}"
69
+ if where_clause:
70
+ query += f" WHERE {where_clause}"
71
+ if limit:
72
+ query += f" LIMIT {limit}"
73
+ if offset:
74
+ query += f" OFFSET {offset}"
75
+
76
+ with DatabaseConnection(self.db_path) as conn:
77
+ cursor = conn.execute(query, params)
78
+ rows = cursor.fetchall()
79
+
80
+ # Convert rows to model instances
81
+ return [model_class(**dict(row)) for row in rows]
82
+
83
+ def find_by_id(self, model_class: Type[T], record_id: str) -> Optional[T]:
84
+ """Find a single record by ID.
85
+
86
+ Args:
87
+ model_class: Pydantic model class representing the table
88
+ record_id: The record ID to find
89
+
90
+ Returns:
91
+ Model instance or None if not found
92
+ """
93
+ results = self.select(model_class, limit=1, id=record_id)
94
+ return results[0] if results else None
95
+
96
+ def create(self, instance: T) -> T:
97
+ """Create a new record.
98
+
99
+ Args:
100
+ instance: Model instance to create
101
+
102
+ Returns:
103
+ Created model instance with populated ID and timestamps
104
+
105
+ Raises:
106
+ ValueError: If record with same ID already exists
107
+ MaintenanceError: If branch is in maintenance mode
108
+ """
109
+ # Check maintenance mode
110
+ check_maintenance_mode(self.project_root, self.database, self.branch)
111
+
112
+ table_name = self._get_table_name(type(instance))
113
+
114
+ # Prepare data for insertion
115
+ data = instance.model_dump()
116
+
117
+ # Generate ID if not provided
118
+ if not data.get("id"):
119
+ data["id"] = str(uuid.uuid4())
120
+
121
+ # Set timestamps
122
+ now = datetime.now()
123
+ data["created_at"] = now
124
+ data["updated_at"] = now
125
+
126
+ # Build INSERT query
127
+ columns = list(data.keys())
128
+ placeholders = [f":{col}" for col in columns]
129
+ query = f"""
130
+ INSERT INTO {table_name} ({", ".join(columns)})
131
+ VALUES ({", ".join(placeholders)})
132
+ """
133
+
134
+ with DatabaseConnection(self.db_path) as conn:
135
+ try:
136
+ conn.execute(query, data)
137
+ conn.commit()
138
+
139
+ # Return updated instance
140
+ return type(instance)(**data)
141
+ except Exception as e:
142
+ conn.rollback()
143
+ if "UNIQUE constraint failed" in str(e):
144
+ raise ValueError(f"Record with ID {data['id']} already exists")
145
+ raise
146
+
147
+ def save(self, instance: T) -> T:
148
+ """Save (upsert) a record - insert if new, update if exists.
149
+
150
+ Args:
151
+ instance: Model instance to save
152
+
153
+ Returns:
154
+ Saved model instance with updated timestamps
155
+
156
+ Raises:
157
+ MaintenanceError: If branch is in maintenance mode
158
+ """
159
+ # Check maintenance mode
160
+ check_maintenance_mode(self.project_root, self.database, self.branch)
161
+
162
+ data = instance.model_dump()
163
+
164
+ # Generate ID if not provided
165
+ if not data.get("id"):
166
+ data["id"] = str(uuid.uuid4())
167
+
168
+ # Check if record exists
169
+ existing = self.find_by_id(type(instance), data["id"])
170
+
171
+ if existing:
172
+ # Update existing record
173
+ return self.update(instance)
174
+ else:
175
+ # Create new record
176
+ return self.create(instance)
177
+
178
+ def update(self, instance: T) -> T:
179
+ """Update an existing record.
180
+
181
+ Args:
182
+ instance: Model instance to update
183
+
184
+ Returns:
185
+ Updated model instance
186
+
187
+ Raises:
188
+ ValueError: If record doesn't exist
189
+ MaintenanceError: If branch is in maintenance mode
190
+ """
191
+ # Check maintenance mode
192
+ check_maintenance_mode(self.project_root, self.database, self.branch)
193
+
194
+ table_name = self._get_table_name(type(instance))
195
+ data = instance.model_dump()
196
+
197
+ if not data.get("id"):
198
+ raise ValueError("Cannot update record without ID")
199
+
200
+ # Check if record exists
201
+ existing = self.find_by_id(type(instance), data["id"])
202
+ if not existing:
203
+ raise ValueError(f"Record with ID {data['id']} not found")
204
+
205
+ # Update timestamp
206
+ data["updated_at"] = datetime.now()
207
+
208
+ # Build UPDATE query (exclude id and created_at)
209
+ update_data = {k: v for k, v in data.items() if k not in ["id", "created_at"]}
210
+ set_clause = ", ".join([f"{col} = :{col}" for col in update_data.keys()])
211
+ query = f"UPDATE {table_name} SET {set_clause} WHERE id = :id"
212
+
213
+ params = {**update_data, "id": data["id"]}
214
+
215
+ with DatabaseConnection(self.db_path) as conn:
216
+ try:
217
+ conn.execute(query, params)
218
+ conn.commit()
219
+
220
+ # Return updated instance
221
+ return type(instance)(**data)
222
+ except Exception:
223
+ conn.rollback()
224
+ raise
225
+
226
+ def delete(self, model_class: Type[T], **filters) -> int:
227
+ """Delete records matching filters.
228
+
229
+ Args:
230
+ model_class: Pydantic model class representing the table
231
+ **filters: Column filters to identify records to delete
232
+
233
+ Returns:
234
+ Number of records deleted
235
+
236
+ Raises:
237
+ ValueError: If no filters provided or table doesn't exist
238
+ MaintenanceError: If branch is in maintenance mode
239
+ """
240
+ # Check maintenance mode
241
+ check_maintenance_mode(self.project_root, self.database, self.branch)
242
+
243
+ if not filters:
244
+ raise ValueError(
245
+ "Delete requires at least one filter to prevent accidental deletion of all records"
246
+ )
247
+
248
+ table_name = self._get_table_name(model_class)
249
+
250
+ # Build WHERE clause from filters
251
+ where_clause, params = self._build_where_clause(filters)
252
+
253
+ query = f"DELETE FROM {table_name} WHERE {where_clause}"
254
+
255
+ with DatabaseConnection(self.db_path) as conn:
256
+ try:
257
+ cursor = conn.execute(query, params)
258
+ deleted_count = cursor.rowcount
259
+ conn.commit()
260
+ return deleted_count
261
+ except Exception:
262
+ conn.rollback()
263
+ raise
264
+
265
+ def delete_by_id(self, model_class: Type[T], record_id: str) -> bool:
266
+ """Delete a single record by ID.
267
+
268
+ Args:
269
+ model_class: Pydantic model class representing the table
270
+ record_id: The record ID to delete
271
+
272
+ Returns:
273
+ True if record was deleted, False if not found
274
+
275
+ Raises:
276
+ MaintenanceError: If branch is in maintenance mode
277
+ """
278
+ deleted_count = self.delete(model_class, id=record_id)
279
+ return deleted_count > 0
280
+
281
+ def bulk_create(self, instances: List[T]) -> List[T]:
282
+ """Create multiple records in a single transaction.
283
+
284
+ Args:
285
+ instances: List of model instances to create
286
+
287
+ Returns:
288
+ List of created model instances with populated IDs and timestamps
289
+
290
+ Raises:
291
+ MaintenanceError: If branch is in maintenance mode
292
+ """
293
+ # Check maintenance mode
294
+ check_maintenance_mode(self.project_root, self.database, self.branch)
295
+
296
+ if not instances:
297
+ return []
298
+
299
+ table_name = self._get_table_name(type(instances[0]))
300
+ created_instances = []
301
+
302
+ with DatabaseConnection(self.db_path) as conn:
303
+ try:
304
+ for instance in instances:
305
+ data = instance.model_dump()
306
+
307
+ # Generate ID if not provided
308
+ if not data.get("id"):
309
+ data["id"] = str(uuid.uuid4())
310
+
311
+ # Set timestamps
312
+ now = datetime.now()
313
+ data["created_at"] = now
314
+ data["updated_at"] = now
315
+
316
+ # Build INSERT query
317
+ columns = list(data.keys())
318
+ placeholders = [f":{col}" for col in columns]
319
+ query = f"""
320
+ INSERT INTO {table_name} ({", ".join(columns)})
321
+ VALUES ({", ".join(placeholders)})
322
+ """
323
+
324
+ conn.execute(query, data)
325
+ created_instances.append(type(instance)(**data))
326
+
327
+ conn.commit()
328
+ return created_instances
329
+ except Exception:
330
+ conn.rollback()
331
+ raise
332
+
333
+ def count(self, model_class: Type[T], **filters) -> int:
334
+ """Count records with optional filtering.
335
+
336
+ Args:
337
+ model_class: Pydantic model class representing the table
338
+ **filters: Column filters
339
+
340
+ Returns:
341
+ Number of matching records
342
+ """
343
+ table_name = self._get_table_name(model_class)
344
+
345
+ # Build WHERE clause from filters
346
+ where_clause, params = self._build_where_clause(filters)
347
+
348
+ query = f"SELECT COUNT(*) as count FROM {table_name}"
349
+ if where_clause:
350
+ query += f" WHERE {where_clause}"
351
+
352
+ with DatabaseConnection(self.db_path) as conn:
353
+ cursor = conn.execute(query, params)
354
+ result = cursor.fetchone()
355
+ return result["count"] if result else 0
356
+
357
+ def _get_table_name(self, model_class: Type[BaseModel]) -> str:
358
+ """Extract table name from model class.
359
+
360
+ Args:
361
+ model_class: Pydantic model class
362
+
363
+ Returns:
364
+ Table name
365
+
366
+ Raises:
367
+ ValueError: If model doesn't have table name configuration
368
+ """
369
+ # Try to get table name from model config (Pydantic v2 style)
370
+ if hasattr(model_class, "model_config"):
371
+ model_config = model_class.model_config
372
+ if hasattr(model_config, "get"):
373
+ # ConfigDict is a dict-like object
374
+ json_schema_extra = model_config.get("json_schema_extra", {})
375
+ if (
376
+ isinstance(json_schema_extra, dict)
377
+ and "table_name" in json_schema_extra
378
+ ):
379
+ return json_schema_extra["table_name"]
380
+
381
+ # Try to get table name from Config class (Pydantic v1 style)
382
+ if hasattr(model_class, "Config"):
383
+ config = getattr(model_class, "Config")
384
+ if hasattr(config, "json_schema_extra"):
385
+ json_schema_extra = getattr(config, "json_schema_extra", {})
386
+ if (
387
+ isinstance(json_schema_extra, dict)
388
+ and "table_name" in json_schema_extra
389
+ ):
390
+ return json_schema_extra["table_name"]
391
+
392
+ # Fallback to class name in snake_case
393
+ class_name = model_class.__name__
394
+ # Convert PascalCase to snake_case
395
+ import re
396
+
397
+ snake_case = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", class_name)
398
+ snake_case = re.sub("([a-z0-9])([A-Z])", r"\1_\2", snake_case).lower()
399
+ return snake_case
400
+
401
+ def _build_where_clause(
402
+ self, filters: Dict[str, Any]
403
+ ) -> tuple[str, Dict[str, Any]]:
404
+ """Build WHERE clause and parameters from filters.
405
+
406
+ Args:
407
+ filters: Dictionary of column filters
408
+
409
+ Returns:
410
+ Tuple of (where_clause, parameters)
411
+ """
412
+ if not filters:
413
+ return "", {}
414
+
415
+ conditions = []
416
+ params = {}
417
+
418
+ for key, value in filters.items():
419
+ # Handle special operators (column__operator format)
420
+ if "__" in key:
421
+ column, operator = key.split("__", 1)
422
+ param_key = f"{column}_{operator}"
423
+
424
+ if operator == "gte":
425
+ conditions.append(f"{column} >= :{param_key}")
426
+ params[param_key] = value
427
+ elif operator == "lte":
428
+ conditions.append(f"{column} <= :{param_key}")
429
+ params[param_key] = value
430
+ elif operator == "gt":
431
+ conditions.append(f"{column} > :{param_key}")
432
+ params[param_key] = value
433
+ elif operator == "lt":
434
+ conditions.append(f"{column} < :{param_key}")
435
+ params[param_key] = value
436
+ elif operator == "like":
437
+ conditions.append(f"{column} LIKE :{param_key}")
438
+ params[param_key] = value
439
+ elif operator == "in":
440
+ if not isinstance(value, (list, tuple)):
441
+ raise ValueError(
442
+ f"'in' operator requires list or tuple, got {type(value)}"
443
+ )
444
+ placeholders = [f":{param_key}_{i}" for i in range(len(value))]
445
+ conditions.append(f"{column} IN ({', '.join(placeholders)})")
446
+ for i, v in enumerate(value):
447
+ params[f"{param_key}_{i}"] = v
448
+ else:
449
+ raise ValueError(f"Unsupported operator: {operator}")
450
+ else:
451
+ # Exact match
452
+ conditions.append(f"{key} = :{key}")
453
+ params[key] = value
454
+
455
+ return " AND ".join(conditions), params