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
cinchdb/managers/data.py
ADDED
@@ -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
|