cinchdb 0.1.3__py3-none-any.whl → 0.1.5__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/cli/commands/branch.py +22 -13
- cinchdb/cli/commands/column.py +27 -14
- cinchdb/cli/commands/database.py +2 -2
- cinchdb/cli/commands/query.py +19 -12
- cinchdb/cli/commands/remote.py +32 -28
- cinchdb/cli/commands/table.py +20 -16
- cinchdb/cli/commands/tenant.py +4 -4
- cinchdb/cli/main.py +21 -11
- cinchdb/cli/utils.py +8 -6
- cinchdb/config.py +18 -45
- cinchdb/core/__init__.py +2 -1
- cinchdb/core/database.py +25 -7
- cinchdb/core/initializer.py +214 -0
- cinchdb/managers/branch.py +1 -3
- cinchdb/managers/column.py +13 -9
- cinchdb/managers/data.py +38 -17
- cinchdb/managers/query.py +12 -6
- cinchdb/managers/table.py +12 -9
- cinchdb/managers/tenant.py +3 -3
- cinchdb/models/branch.py +1 -1
- cinchdb/models/database.py +1 -1
- cinchdb/models/table.py +5 -8
- cinchdb/models/tenant.py +1 -1
- cinchdb/utils/__init__.py +5 -5
- cinchdb/utils/name_validator.py +61 -30
- cinchdb/utils/sql_validator.py +91 -41
- {cinchdb-0.1.3.dist-info → cinchdb-0.1.5.dist-info}/METADATA +2 -2
- cinchdb-0.1.5.dist-info/RECORD +54 -0
- cinchdb-0.1.3.dist-info/RECORD +0 -53
- {cinchdb-0.1.3.dist-info → cinchdb-0.1.5.dist-info}/WHEEL +0 -0
- {cinchdb-0.1.3.dist-info → cinchdb-0.1.5.dist-info}/entry_points.txt +0 -0
- {cinchdb-0.1.3.dist-info → cinchdb-0.1.5.dist-info}/licenses/LICENSE +0 -0
cinchdb/config.py
CHANGED
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, ConfigDict
|
|
9
9
|
|
10
10
|
class RemoteConfig(BaseModel):
|
11
11
|
"""Configuration for a remote CinchDB instance."""
|
12
|
-
|
12
|
+
|
13
13
|
url: str = Field(description="Base URL of the remote CinchDB API")
|
14
14
|
key: str = Field(description="API key for authentication")
|
15
15
|
|
@@ -50,7 +50,7 @@ class Config:
|
|
50
50
|
env_dir = os.environ.get("CINCHDB_PROJECT_DIR")
|
51
51
|
if env_dir:
|
52
52
|
project_dir = Path(env_dir)
|
53
|
-
|
53
|
+
|
54
54
|
self.project_dir = Path(project_dir) if project_dir else Path.cwd()
|
55
55
|
self.config_dir = self.project_dir / ".cinchdb"
|
56
56
|
self.config_path = self.config_dir / "config.toml"
|
@@ -86,24 +86,24 @@ class Config:
|
|
86
86
|
# Override database and branch
|
87
87
|
if env_db := os.environ.get("CINCHDB_DATABASE"):
|
88
88
|
data["active_database"] = env_db
|
89
|
-
|
89
|
+
|
90
90
|
if env_branch := os.environ.get("CINCHDB_BRANCH"):
|
91
91
|
data["active_branch"] = env_branch
|
92
|
-
|
92
|
+
|
93
93
|
# Override or create remote configuration
|
94
94
|
env_url = os.environ.get("CINCHDB_REMOTE_URL")
|
95
95
|
env_key = os.environ.get("CINCHDB_API_KEY")
|
96
|
-
|
96
|
+
|
97
97
|
if env_url and env_key:
|
98
98
|
# Create or update "env" remote
|
99
99
|
if "remotes" not in data:
|
100
100
|
data["remotes"] = {}
|
101
|
-
|
101
|
+
|
102
102
|
data["remotes"]["env"] = {
|
103
103
|
"url": env_url.rstrip("/"), # Remove trailing slash
|
104
|
-
"key": env_key
|
104
|
+
"key": env_key,
|
105
105
|
}
|
106
|
-
|
106
|
+
|
107
107
|
# Make it active if no other remote is set
|
108
108
|
if not data.get("active_remote"):
|
109
109
|
data["active_remote"] = "env"
|
@@ -130,48 +130,21 @@ class Config:
|
|
130
130
|
for alias, remote in config_dict["remotes"].items():
|
131
131
|
if isinstance(remote, dict):
|
132
132
|
config_dict["remotes"][alias] = remote
|
133
|
-
|
133
|
+
|
134
134
|
with open(self.config_path, "w") as f:
|
135
135
|
toml.dump(config_dict, f)
|
136
136
|
|
137
137
|
def init_project(self) -> ProjectConfig:
|
138
|
-
"""Initialize a new CinchDB project with default configuration.
|
139
|
-
if self.exists:
|
140
|
-
raise FileExistsError(f"Project already exists at {self.config_dir}")
|
138
|
+
"""Initialize a new CinchDB project with default configuration.
|
141
139
|
|
142
|
-
|
143
|
-
|
144
|
-
|
140
|
+
This method now delegates to the ProjectInitializer for the actual
|
141
|
+
initialization logic.
|
142
|
+
"""
|
143
|
+
from cinchdb.core.initializer import ProjectInitializer
|
145
144
|
|
146
|
-
|
147
|
-
|
145
|
+
initializer = ProjectInitializer(self.project_dir)
|
146
|
+
config = initializer.init_project()
|
148
147
|
|
148
|
+
# Load the config into this instance
|
149
|
+
self._config = config
|
149
150
|
return config
|
150
|
-
|
151
|
-
def _create_default_structure(self) -> None:
|
152
|
-
"""Create default project structure."""
|
153
|
-
# Create main database with main branch
|
154
|
-
db_path = self.config_dir / "databases" / "main" / "branches" / "main"
|
155
|
-
db_path.mkdir(parents=True, exist_ok=True)
|
156
|
-
|
157
|
-
# Create metadata files
|
158
|
-
from datetime import datetime, timezone
|
159
|
-
|
160
|
-
metadata = {"created_at": datetime.now(timezone.utc).isoformat()}
|
161
|
-
|
162
|
-
import json
|
163
|
-
|
164
|
-
with open(db_path / "metadata.json", "w") as f:
|
165
|
-
json.dump(metadata, f, indent=2)
|
166
|
-
|
167
|
-
# Create empty changes file
|
168
|
-
with open(db_path / "changes.json", "w") as f:
|
169
|
-
json.dump([], f, indent=2)
|
170
|
-
|
171
|
-
# Create main tenant directory and database
|
172
|
-
tenant_dir = db_path / "tenants"
|
173
|
-
tenant_dir.mkdir(exist_ok=True)
|
174
|
-
|
175
|
-
# Create main tenant database file
|
176
|
-
main_db = tenant_dir / "main.db"
|
177
|
-
main_db.touch()
|
cinchdb/core/__init__.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
"""Core CinchDB functionality."""
|
2
2
|
|
3
3
|
from cinchdb.core.database import CinchDB, connect, connect_api
|
4
|
+
from cinchdb.core.initializer import init_project, init_database
|
4
5
|
|
5
|
-
__all__ = ["CinchDB", "connect", "connect_api"]
|
6
|
+
__all__ = ["CinchDB", "connect", "connect_api", "init_project", "init_database"]
|
cinchdb/core/database.py
CHANGED
@@ -3,10 +3,9 @@
|
|
3
3
|
from pathlib import Path
|
4
4
|
from typing import List, Dict, Any, Optional, TYPE_CHECKING
|
5
5
|
|
6
|
-
from cinchdb.config import Config
|
7
6
|
from cinchdb.models import Column, Change
|
8
7
|
from cinchdb.core.path_utils import get_project_root
|
9
|
-
from cinchdb.utils import validate_query_safe
|
8
|
+
from cinchdb.utils import validate_query_safe
|
10
9
|
|
11
10
|
if TYPE_CHECKING:
|
12
11
|
from cinchdb.managers.table import TableManager
|
@@ -307,7 +306,10 @@ class CinchDB:
|
|
307
306
|
# Convenience methods for common operations
|
308
307
|
|
309
308
|
def query(
|
310
|
-
self,
|
309
|
+
self,
|
310
|
+
sql: str,
|
311
|
+
params: Optional[List[Any]] = None,
|
312
|
+
skip_validation: bool = False,
|
311
313
|
) -> List[Dict[str, Any]]:
|
312
314
|
"""Execute a SQL query.
|
313
315
|
|
@@ -318,14 +320,14 @@ class CinchDB:
|
|
318
320
|
|
319
321
|
Returns:
|
320
322
|
List of result rows as dictionaries
|
321
|
-
|
323
|
+
|
322
324
|
Raises:
|
323
325
|
SQLValidationError: If the query contains restricted operations
|
324
326
|
"""
|
325
327
|
# Validate query unless explicitly skipped
|
326
328
|
if not skip_validation:
|
327
329
|
validate_query_safe(sql)
|
328
|
-
|
330
|
+
|
329
331
|
if self.is_local:
|
330
332
|
if self._query_manager is None:
|
331
333
|
from cinchdb.managers.query import QueryManager
|
@@ -369,10 +371,24 @@ class CinchDB:
|
|
369
371
|
data: Record data as dictionary
|
370
372
|
|
371
373
|
Returns:
|
372
|
-
Inserted record with generated fields
|
374
|
+
Inserted record with generated fields (id, created_at, updated_at)
|
375
|
+
|
376
|
+
Examples:
|
377
|
+
# Simple insert
|
378
|
+
db.insert("users", {"name": "John", "email": "john@example.com"})
|
379
|
+
|
380
|
+
# Insert with custom ID
|
381
|
+
db.insert("products", {"id": "prod-123", "name": "Widget", "price": 9.99})
|
373
382
|
"""
|
374
383
|
if self.is_local:
|
375
|
-
|
384
|
+
# Initialize data manager if needed
|
385
|
+
if self._data_manager is None:
|
386
|
+
from cinchdb.managers.data import DataManager
|
387
|
+
self._data_manager = DataManager(
|
388
|
+
self.project_dir, self.database, self.branch, self.tenant
|
389
|
+
)
|
390
|
+
# Use the new create_from_dict method
|
391
|
+
return self._data_manager.create_from_dict(table, data)
|
376
392
|
else:
|
377
393
|
# Remote insert - use new data CRUD endpoint
|
378
394
|
result = self._make_request(
|
@@ -427,6 +443,7 @@ class CinchDB:
|
|
427
443
|
"""
|
428
444
|
if self.is_local:
|
429
445
|
from cinchdb.managers.change_tracker import ChangeTracker
|
446
|
+
|
430
447
|
tracker = ChangeTracker(self.project_dir, self.database, self.branch)
|
431
448
|
return tracker.get_changes()
|
432
449
|
else:
|
@@ -435,6 +452,7 @@ class CinchDB:
|
|
435
452
|
# Convert API response to Change objects
|
436
453
|
from cinchdb.models import Change
|
437
454
|
from datetime import datetime
|
455
|
+
|
438
456
|
changes = []
|
439
457
|
for data in result.get("changes", []):
|
440
458
|
# Convert string dates back to datetime if present
|
@@ -0,0 +1,214 @@
|
|
1
|
+
"""Project initialization for CinchDB."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
from datetime import datetime, timezone
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Optional
|
7
|
+
|
8
|
+
from cinchdb.core.connection import DatabaseConnection
|
9
|
+
from cinchdb.config import ProjectConfig
|
10
|
+
|
11
|
+
|
12
|
+
class ProjectInitializer:
|
13
|
+
"""Handles initialization of CinchDB projects."""
|
14
|
+
|
15
|
+
def __init__(self, project_dir: Optional[Path] = None):
|
16
|
+
"""Initialize the project initializer.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
project_dir: Path to project directory. If None, uses current directory.
|
20
|
+
"""
|
21
|
+
self.project_dir = Path(project_dir) if project_dir else Path.cwd()
|
22
|
+
self.config_dir = self.project_dir / ".cinchdb"
|
23
|
+
self.config_path = self.config_dir / "config.toml"
|
24
|
+
|
25
|
+
def init_project(
|
26
|
+
self, database_name: str = "main", branch_name: str = "main"
|
27
|
+
) -> ProjectConfig:
|
28
|
+
"""Initialize a new CinchDB project with default configuration.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
database_name: Name for the initial database (default: "main")
|
32
|
+
branch_name: Name for the initial branch (default: "main")
|
33
|
+
|
34
|
+
Returns:
|
35
|
+
The created ProjectConfig
|
36
|
+
|
37
|
+
Raises:
|
38
|
+
FileExistsError: If project already exists at the location
|
39
|
+
"""
|
40
|
+
if self.config_path.exists():
|
41
|
+
raise FileExistsError(f"Project already exists at {self.config_dir}")
|
42
|
+
|
43
|
+
# Create config directory
|
44
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
45
|
+
|
46
|
+
# Create default config
|
47
|
+
config = ProjectConfig(active_database=database_name, active_branch=branch_name)
|
48
|
+
|
49
|
+
# Save config
|
50
|
+
self._save_config(config)
|
51
|
+
|
52
|
+
# Create default database structure
|
53
|
+
self._create_database_structure(database_name, branch_name)
|
54
|
+
|
55
|
+
return config
|
56
|
+
|
57
|
+
def init_database(
|
58
|
+
self,
|
59
|
+
database_name: str,
|
60
|
+
branch_name: str = "main",
|
61
|
+
description: Optional[str] = None,
|
62
|
+
) -> None:
|
63
|
+
"""Initialize a new database within an existing project.
|
64
|
+
|
65
|
+
Args:
|
66
|
+
database_name: Name for the database
|
67
|
+
branch_name: Initial branch name (default: "main")
|
68
|
+
description: Optional description for the database
|
69
|
+
|
70
|
+
Raises:
|
71
|
+
FileNotFoundError: If project doesn't exist
|
72
|
+
FileExistsError: If database already exists
|
73
|
+
"""
|
74
|
+
if not self.config_path.exists():
|
75
|
+
raise FileNotFoundError(f"No CinchDB project found at {self.config_dir}")
|
76
|
+
|
77
|
+
db_path = self.config_dir / "databases" / database_name
|
78
|
+
if db_path.exists():
|
79
|
+
raise FileExistsError(f"Database '{database_name}' already exists")
|
80
|
+
|
81
|
+
# Create database structure
|
82
|
+
self._create_database_structure(database_name, branch_name, description)
|
83
|
+
|
84
|
+
def _create_database_structure(
|
85
|
+
self,
|
86
|
+
database_name: str,
|
87
|
+
branch_name: str = "main",
|
88
|
+
description: Optional[str] = None,
|
89
|
+
) -> None:
|
90
|
+
"""Create the directory structure for a database.
|
91
|
+
|
92
|
+
Args:
|
93
|
+
database_name: Name of the database
|
94
|
+
branch_name: Name of the initial branch
|
95
|
+
description: Optional description
|
96
|
+
"""
|
97
|
+
# Create database branch path
|
98
|
+
branch_path = (
|
99
|
+
self.config_dir / "databases" / database_name / "branches" / branch_name
|
100
|
+
)
|
101
|
+
branch_path.mkdir(parents=True, exist_ok=True)
|
102
|
+
|
103
|
+
# Create metadata file
|
104
|
+
metadata = {
|
105
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
106
|
+
"name": branch_name,
|
107
|
+
"parent": None,
|
108
|
+
"description": description,
|
109
|
+
}
|
110
|
+
|
111
|
+
with open(branch_path / "metadata.json", "w") as f:
|
112
|
+
json.dump(metadata, f, indent=2)
|
113
|
+
|
114
|
+
# Create empty changes file
|
115
|
+
with open(branch_path / "changes.json", "w") as f:
|
116
|
+
json.dump([], f, indent=2)
|
117
|
+
|
118
|
+
# Create tenants directory
|
119
|
+
tenant_dir = branch_path / "tenants"
|
120
|
+
tenant_dir.mkdir(exist_ok=True)
|
121
|
+
|
122
|
+
# Create and initialize main tenant database
|
123
|
+
self._init_tenant_database(tenant_dir / "main.db")
|
124
|
+
|
125
|
+
def _init_tenant_database(self, db_path: Path) -> None:
|
126
|
+
"""Initialize a tenant database with proper PRAGMAs.
|
127
|
+
|
128
|
+
Args:
|
129
|
+
db_path: Path to the database file
|
130
|
+
"""
|
131
|
+
# Create the database file
|
132
|
+
db_path.touch()
|
133
|
+
|
134
|
+
# Initialize with proper PRAGMAs
|
135
|
+
with DatabaseConnection(db_path):
|
136
|
+
# The connection automatically sets up PRAGMAs in _connect():
|
137
|
+
# - journal_mode = WAL
|
138
|
+
# - synchronous = NORMAL
|
139
|
+
# - wal_autocheckpoint = 0
|
140
|
+
# - foreign_keys = ON
|
141
|
+
pass
|
142
|
+
|
143
|
+
def _save_config(self, config: ProjectConfig) -> None:
|
144
|
+
"""Save configuration to disk.
|
145
|
+
|
146
|
+
Args:
|
147
|
+
config: Configuration to save
|
148
|
+
"""
|
149
|
+
import toml
|
150
|
+
|
151
|
+
# Ensure config directory exists
|
152
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
153
|
+
|
154
|
+
# Convert to dict for TOML serialization
|
155
|
+
config_dict = config.model_dump()
|
156
|
+
|
157
|
+
# Convert RemoteConfig objects to dicts if present
|
158
|
+
if "remotes" in config_dict:
|
159
|
+
for alias, remote in config_dict["remotes"].items():
|
160
|
+
if isinstance(remote, dict):
|
161
|
+
config_dict["remotes"][alias] = remote
|
162
|
+
|
163
|
+
with open(self.config_path, "w") as f:
|
164
|
+
toml.dump(config_dict, f)
|
165
|
+
|
166
|
+
|
167
|
+
def init_project(
|
168
|
+
project_dir: Optional[Path] = None,
|
169
|
+
database_name: str = "main",
|
170
|
+
branch_name: str = "main",
|
171
|
+
) -> ProjectConfig:
|
172
|
+
"""Initialize a new CinchDB project.
|
173
|
+
|
174
|
+
This is a convenience function that creates a ProjectInitializer
|
175
|
+
and initializes a project.
|
176
|
+
|
177
|
+
Args:
|
178
|
+
project_dir: Directory to initialize in (default: current directory)
|
179
|
+
database_name: Name for initial database (default: "main")
|
180
|
+
branch_name: Name for initial branch (default: "main")
|
181
|
+
|
182
|
+
Returns:
|
183
|
+
The created ProjectConfig
|
184
|
+
|
185
|
+
Raises:
|
186
|
+
FileExistsError: If project already exists
|
187
|
+
"""
|
188
|
+
initializer = ProjectInitializer(project_dir)
|
189
|
+
return initializer.init_project(database_name, branch_name)
|
190
|
+
|
191
|
+
|
192
|
+
def init_database(
|
193
|
+
project_dir: Optional[Path] = None,
|
194
|
+
database_name: str = "main",
|
195
|
+
branch_name: str = "main",
|
196
|
+
description: Optional[str] = None,
|
197
|
+
) -> None:
|
198
|
+
"""Initialize a new database within an existing project.
|
199
|
+
|
200
|
+
This is a convenience function that creates a ProjectInitializer
|
201
|
+
and initializes a database.
|
202
|
+
|
203
|
+
Args:
|
204
|
+
project_dir: Project directory (default: current directory)
|
205
|
+
database_name: Name for the database
|
206
|
+
branch_name: Initial branch name (default: "main")
|
207
|
+
description: Optional description
|
208
|
+
|
209
|
+
Raises:
|
210
|
+
FileNotFoundError: If project doesn't exist
|
211
|
+
FileExistsError: If database already exists
|
212
|
+
"""
|
213
|
+
initializer = ProjectInitializer(project_dir)
|
214
|
+
initializer.init_database(database_name, branch_name, description)
|
cinchdb/managers/branch.py
CHANGED
@@ -6,7 +6,6 @@ from pathlib import Path
|
|
6
6
|
from typing import List, Dict, Any
|
7
7
|
from datetime import datetime, timezone
|
8
8
|
|
9
|
-
from cinchdb.config import Config
|
10
9
|
from cinchdb.models import Branch
|
11
10
|
from cinchdb.core.path_utils import (
|
12
11
|
get_database_path,
|
@@ -68,7 +67,7 @@ class BranchManager:
|
|
68
67
|
"""
|
69
68
|
# Validate new branch name
|
70
69
|
validate_name(new_branch_name, "branch")
|
71
|
-
|
70
|
+
|
72
71
|
# Validate source branch exists
|
73
72
|
if source_branch not in list_branches(self.project_root, self.database):
|
74
73
|
raise ValueError(f"Source branch '{source_branch}' does not exist")
|
@@ -122,7 +121,6 @@ class BranchManager:
|
|
122
121
|
branch_path = get_branch_path(self.project_root, self.database, branch_name)
|
123
122
|
shutil.rmtree(branch_path)
|
124
123
|
|
125
|
-
|
126
124
|
def get_branch_metadata(self, branch_name: str) -> Dict[str, Any]:
|
127
125
|
"""Get metadata for a branch.
|
128
126
|
|
cinchdb/managers/column.py
CHANGED
@@ -439,16 +439,20 @@ class ColumnManager:
|
|
439
439
|
conn.commit()
|
440
440
|
|
441
441
|
def alter_column_nullable(
|
442
|
-
self,
|
442
|
+
self,
|
443
|
+
table_name: str,
|
444
|
+
column_name: str,
|
445
|
+
nullable: bool,
|
446
|
+
fill_value: Optional[Any] = None,
|
443
447
|
) -> None:
|
444
448
|
"""Change the nullable constraint on a column.
|
445
|
-
|
449
|
+
|
446
450
|
Args:
|
447
451
|
table_name: Name of the table
|
448
452
|
column_name: Name of the column to modify
|
449
453
|
nullable: Whether the column should allow NULL values
|
450
454
|
fill_value: Value to use for existing NULL values when making column NOT NULL
|
451
|
-
|
455
|
+
|
452
456
|
Raises:
|
453
457
|
ValueError: If table/column doesn't exist or column is protected
|
454
458
|
ValueError: If making NOT NULL and column has NULL values without fill_value
|
@@ -469,13 +473,13 @@ class ColumnManager:
|
|
469
473
|
existing_columns = self.list_columns(table_name)
|
470
474
|
column_found = False
|
471
475
|
old_column = None
|
472
|
-
|
476
|
+
|
473
477
|
for col in existing_columns:
|
474
478
|
if col.name == column_name:
|
475
479
|
column_found = True
|
476
480
|
old_column = col
|
477
481
|
break
|
478
|
-
|
482
|
+
|
479
483
|
if not column_found:
|
480
484
|
raise ValueError(
|
481
485
|
f"Column '{column_name}' does not exist in table '{table_name}'"
|
@@ -494,7 +498,7 @@ class ColumnManager:
|
|
494
498
|
f"SELECT COUNT(*) FROM {table_name} WHERE {column_name} IS NULL"
|
495
499
|
)
|
496
500
|
null_count = cursor.fetchone()[0]
|
497
|
-
|
501
|
+
|
498
502
|
if null_count > 0 and fill_value is None:
|
499
503
|
raise ValueError(
|
500
504
|
f"Column '{column_name}' has {null_count} NULL values. "
|
@@ -503,7 +507,7 @@ class ColumnManager:
|
|
503
507
|
|
504
508
|
# Build SQL statements for table recreation
|
505
509
|
temp_table = f"{table_name}_temp"
|
506
|
-
|
510
|
+
|
507
511
|
# Create new table with modified column
|
508
512
|
col_defs = []
|
509
513
|
for col in existing_columns:
|
@@ -527,7 +531,7 @@ class ColumnManager:
|
|
527
531
|
# Column names for copying
|
528
532
|
col_names = [col.name for col in existing_columns]
|
529
533
|
col_list = ", ".join(col_names)
|
530
|
-
|
534
|
+
|
531
535
|
# Build copy SQL with COALESCE if needed
|
532
536
|
if not nullable and fill_value is not None:
|
533
537
|
# Build select list with COALESCE for the target column
|
@@ -545,7 +549,7 @@ class ColumnManager:
|
|
545
549
|
copy_sql = f"INSERT INTO {temp_table} ({col_list}) SELECT {select_list} FROM {table_name}"
|
546
550
|
else:
|
547
551
|
copy_sql = f"INSERT INTO {temp_table} ({col_list}) SELECT {col_list} FROM {table_name}"
|
548
|
-
|
552
|
+
|
549
553
|
drop_sql = f"DROP TABLE {table_name}"
|
550
554
|
rename_sql = f"ALTER TABLE {temp_table} RENAME TO {table_name}"
|
551
555
|
|
cinchdb/managers/data.py
CHANGED
@@ -93,14 +93,15 @@ class DataManager:
|
|
93
93
|
results = self.select(model_class, limit=1, id=record_id)
|
94
94
|
return results[0] if results else None
|
95
95
|
|
96
|
-
def
|
97
|
-
"""Create a new record.
|
96
|
+
def create_from_dict(self, table_name: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
97
|
+
"""Create a new record from a dictionary.
|
98
98
|
|
99
99
|
Args:
|
100
|
-
|
100
|
+
table_name: Name of the table to insert into
|
101
|
+
data: Dictionary containing the record data
|
101
102
|
|
102
103
|
Returns:
|
103
|
-
|
104
|
+
Dictionary with created record including generated ID and timestamps
|
104
105
|
|
105
106
|
Raises:
|
106
107
|
ValueError: If record with same ID already exists
|
@@ -109,22 +110,20 @@ class DataManager:
|
|
109
110
|
# Check maintenance mode
|
110
111
|
check_maintenance_mode(self.project_root, self.database, self.branch)
|
111
112
|
|
112
|
-
|
113
|
-
|
114
|
-
# Prepare data for insertion
|
115
|
-
data = instance.model_dump()
|
113
|
+
# Make a copy to avoid modifying the original
|
114
|
+
record_data = data.copy()
|
116
115
|
|
117
116
|
# Generate ID if not provided
|
118
|
-
if not
|
119
|
-
|
117
|
+
if not record_data.get("id"):
|
118
|
+
record_data["id"] = str(uuid.uuid4())
|
120
119
|
|
121
120
|
# Set timestamps
|
122
121
|
now = datetime.now()
|
123
|
-
|
124
|
-
|
122
|
+
record_data["created_at"] = now
|
123
|
+
record_data["updated_at"] = now
|
125
124
|
|
126
125
|
# Build INSERT query
|
127
|
-
columns = list(
|
126
|
+
columns = list(record_data.keys())
|
128
127
|
placeholders = [f":{col}" for col in columns]
|
129
128
|
query = f"""
|
130
129
|
INSERT INTO {table_name} ({", ".join(columns)})
|
@@ -133,17 +132,39 @@ class DataManager:
|
|
133
132
|
|
134
133
|
with DatabaseConnection(self.db_path) as conn:
|
135
134
|
try:
|
136
|
-
conn.execute(query,
|
135
|
+
conn.execute(query, record_data)
|
137
136
|
conn.commit()
|
138
137
|
|
139
|
-
# Return
|
140
|
-
return
|
138
|
+
# Return the created record data
|
139
|
+
return record_data
|
141
140
|
except Exception as e:
|
142
141
|
conn.rollback()
|
143
142
|
if "UNIQUE constraint failed" in str(e):
|
144
|
-
raise ValueError(f"Record with ID {
|
143
|
+
raise ValueError(f"Record with ID {record_data['id']} already exists")
|
145
144
|
raise
|
146
145
|
|
146
|
+
def create(self, instance: T) -> T:
|
147
|
+
"""Create a new record from a model instance.
|
148
|
+
|
149
|
+
Args:
|
150
|
+
instance: Model instance to create
|
151
|
+
|
152
|
+
Returns:
|
153
|
+
Created model instance with populated ID and timestamps
|
154
|
+
|
155
|
+
Raises:
|
156
|
+
ValueError: If record with same ID already exists
|
157
|
+
MaintenanceError: If branch is in maintenance mode
|
158
|
+
"""
|
159
|
+
table_name = self._get_table_name(type(instance))
|
160
|
+
data = instance.model_dump()
|
161
|
+
|
162
|
+
# Use the new create_from_dict method
|
163
|
+
created_data = self.create_from_dict(table_name, data)
|
164
|
+
|
165
|
+
# Return updated instance
|
166
|
+
return type(instance)(**created_data)
|
167
|
+
|
147
168
|
def save(self, instance: T) -> T:
|
148
169
|
"""Save (upsert) a record - insert if new, update if exists.
|
149
170
|
|
cinchdb/managers/query.py
CHANGED
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ValidationError
|
|
7
7
|
|
8
8
|
from cinchdb.core.connection import DatabaseConnection
|
9
9
|
from cinchdb.core.path_utils import get_tenant_db_path
|
10
|
-
from cinchdb.utils import validate_query_safe
|
10
|
+
from cinchdb.utils import validate_query_safe
|
11
11
|
|
12
12
|
T = TypeVar("T", bound=BaseModel)
|
13
13
|
|
@@ -33,7 +33,10 @@ class QueryManager:
|
|
33
33
|
self.db_path = get_tenant_db_path(project_root, database, branch, tenant)
|
34
34
|
|
35
35
|
def execute(
|
36
|
-
self,
|
36
|
+
self,
|
37
|
+
sql: str,
|
38
|
+
params: Optional[Union[tuple, dict]] = None,
|
39
|
+
skip_validation: bool = False,
|
37
40
|
) -> List[Dict[str, Any]]:
|
38
41
|
"""Execute a SQL query and return results as dictionaries.
|
39
42
|
|
@@ -52,7 +55,7 @@ class QueryManager:
|
|
52
55
|
# Validate query unless explicitly skipped
|
53
56
|
if not skip_validation:
|
54
57
|
validate_query_safe(sql)
|
55
|
-
|
58
|
+
|
56
59
|
# Note: The original code had SELECT-only validation, but we're now more permissive
|
57
60
|
if not sql.strip().upper().startswith("SELECT"):
|
58
61
|
raise ValueError(
|
@@ -161,7 +164,10 @@ class QueryManager:
|
|
161
164
|
return results[0] if results else None
|
162
165
|
|
163
166
|
def execute_non_query(
|
164
|
-
self,
|
167
|
+
self,
|
168
|
+
sql: str,
|
169
|
+
params: Optional[Union[tuple, dict]] = None,
|
170
|
+
skip_validation: bool = False,
|
165
171
|
) -> int:
|
166
172
|
"""Execute a non-SELECT SQL query (INSERT, UPDATE, DELETE, etc.).
|
167
173
|
|
@@ -172,7 +178,7 @@ class QueryManager:
|
|
172
178
|
|
173
179
|
Returns:
|
174
180
|
Number of rows affected
|
175
|
-
|
181
|
+
|
176
182
|
Raises:
|
177
183
|
SQLValidationError: If query contains restricted operations
|
178
184
|
Exception: If query execution fails
|
@@ -180,7 +186,7 @@ class QueryManager:
|
|
180
186
|
# Validate query unless explicitly skipped
|
181
187
|
if not skip_validation:
|
182
188
|
validate_query_safe(sql)
|
183
|
-
|
189
|
+
|
184
190
|
with DatabaseConnection(self.db_path) as conn:
|
185
191
|
cursor = conn.execute(sql, params)
|
186
192
|
affected_rows = cursor.rowcount
|