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,308 @@
1
+ """Column management commands for CinchDB CLI."""
2
+
3
+ import typer
4
+ from typing import Optional
5
+ from rich.console import Console
6
+ from rich.table import Table as RichTable
7
+
8
+ from cinchdb.managers.column import ColumnManager
9
+ from cinchdb.managers.change_applier import ChangeApplier
10
+ from cinchdb.models import Column
11
+ from cinchdb.cli.utils import get_config_with_data, validate_required_arg
12
+
13
+ app = typer.Typer(help="Column management commands", invoke_without_command=True)
14
+ console = Console()
15
+
16
+
17
+ @app.callback()
18
+ def callback(ctx: typer.Context):
19
+ """Show help when no subcommand is provided."""
20
+ if ctx.invoked_subcommand is None:
21
+ console.print(ctx.get_help())
22
+ raise typer.Exit(0)
23
+
24
+
25
+ @app.command(name="list")
26
+ def list_columns(
27
+ ctx: typer.Context, table: Optional[str] = typer.Argument(None, help="Table name")
28
+ ):
29
+ """List all columns in a table."""
30
+ table = validate_required_arg(table, "table", ctx)
31
+ config, config_data = get_config_with_data()
32
+ db_name = config_data.active_database
33
+ branch_name = config_data.active_branch
34
+
35
+ try:
36
+ column_mgr = ColumnManager(config.project_dir, db_name, branch_name, "main")
37
+ columns = column_mgr.list_columns(table)
38
+
39
+ # Create a table
40
+ col_table = RichTable(title=f"Columns in '{table}'")
41
+ col_table.add_column("Name", style="cyan")
42
+ col_table.add_column("Type", style="green")
43
+ col_table.add_column("Nullable", style="yellow")
44
+ col_table.add_column("Primary Key", style="red")
45
+ col_table.add_column("Default", style="blue")
46
+
47
+ for col in columns:
48
+ nullable = "Yes" if col.nullable else "No"
49
+ pk = "Yes" if col.primary_key else "No"
50
+ default = col.default or "-"
51
+ col_table.add_row(col.name, col.type, nullable, pk, default)
52
+
53
+ console.print(col_table)
54
+
55
+ except ValueError as e:
56
+ console.print(f"[red]❌ {e}[/red]")
57
+ raise typer.Exit(1)
58
+
59
+
60
+ @app.command()
61
+ def add(
62
+ ctx: typer.Context,
63
+ table: Optional[str] = typer.Argument(None, help="Table name"),
64
+ name: Optional[str] = typer.Argument(None, help="Column name"),
65
+ type: Optional[str] = typer.Argument(
66
+ None, help="Column type (TEXT, INTEGER, REAL, BLOB, NUMERIC)"
67
+ ),
68
+ nullable: bool = typer.Option(
69
+ True, "--nullable/--not-null", help="Allow NULL values"
70
+ ),
71
+ default: Optional[str] = typer.Option(
72
+ None, "--default", "-d", help="Default value"
73
+ ),
74
+ apply: bool = typer.Option(
75
+ True, "--apply/--no-apply", help="Apply changes to all tenants"
76
+ ),
77
+ ):
78
+ """Add a new column to a table."""
79
+ table = validate_required_arg(table, "table", ctx)
80
+ name = validate_required_arg(name, "name", ctx)
81
+ type = validate_required_arg(type, "type", ctx)
82
+ config, config_data = get_config_with_data()
83
+ db_name = config_data.active_database
84
+ branch_name = config_data.active_branch
85
+
86
+ # Validate type
87
+ type = type.upper()
88
+ if type not in ["TEXT", "INTEGER", "REAL", "BLOB", "NUMERIC"]:
89
+ console.print(f"[red]❌ Invalid type: '{type}'[/red]")
90
+ console.print(
91
+ "[yellow]Valid types: TEXT, INTEGER, REAL, BLOB, NUMERIC[/yellow]"
92
+ )
93
+ raise typer.Exit(1)
94
+
95
+ try:
96
+ column_mgr = ColumnManager(config.project_dir, db_name, branch_name, "main")
97
+ column = Column(name=name, type=type, nullable=nullable, default=default)
98
+ column_mgr.add_column(table, column)
99
+
100
+ console.print(f"[green]✅ Added column '{name}' to table '{table}'[/green]")
101
+
102
+ if apply:
103
+ # Apply to all tenants
104
+ applier = ChangeApplier(config.project_dir, db_name, branch_name)
105
+ applied = applier.apply_all_unapplied()
106
+ if applied > 0:
107
+ console.print("[green]✅ Applied changes to all tenants[/green]")
108
+
109
+ except ValueError as e:
110
+ console.print(f"[red]❌ {e}[/red]")
111
+ raise typer.Exit(1)
112
+
113
+
114
+ @app.command()
115
+ def drop(
116
+ ctx: typer.Context,
117
+ table: Optional[str] = typer.Argument(None, help="Table name"),
118
+ name: Optional[str] = typer.Argument(None, help="Column name to drop"),
119
+ force: bool = typer.Option(
120
+ False, "--force", "-f", help="Force deletion without confirmation"
121
+ ),
122
+ apply: bool = typer.Option(
123
+ True, "--apply/--no-apply", help="Apply changes to all tenants"
124
+ ),
125
+ ):
126
+ """Drop a column from a table."""
127
+ table = validate_required_arg(table, "table", ctx)
128
+ name = validate_required_arg(name, "name", ctx)
129
+ config, config_data = get_config_with_data()
130
+ db_name = config_data.active_database
131
+ branch_name = config_data.active_branch
132
+
133
+ # Confirmation
134
+ if not force:
135
+ confirm = typer.confirm(
136
+ f"Are you sure you want to drop column '{name}' from table '{table}'?"
137
+ )
138
+ if not confirm:
139
+ console.print("[yellow]Cancelled[/yellow]")
140
+ raise typer.Exit(0)
141
+
142
+ try:
143
+ column_mgr = ColumnManager(config.project_dir, db_name, branch_name, "main")
144
+ column_mgr.drop_column(table, name)
145
+
146
+ console.print(f"[green]✅ Dropped column '{name}' from table '{table}'[/green]")
147
+
148
+ if apply:
149
+ # Apply to all tenants
150
+ applier = ChangeApplier(config.project_dir, db_name, branch_name)
151
+ applied = applier.apply_all_unapplied()
152
+ if applied > 0:
153
+ console.print("[green]✅ Applied changes to all tenants[/green]")
154
+
155
+ except ValueError as e:
156
+ console.print(f"[red]❌ {e}[/red]")
157
+ raise typer.Exit(1)
158
+
159
+
160
+ @app.command()
161
+ def rename(
162
+ ctx: typer.Context,
163
+ table: Optional[str] = typer.Argument(None, help="Table name"),
164
+ old_name: Optional[str] = typer.Argument(None, help="Current column name"),
165
+ new_name: Optional[str] = typer.Argument(None, help="New column name"),
166
+ apply: bool = typer.Option(
167
+ True, "--apply/--no-apply", help="Apply changes to all tenants"
168
+ ),
169
+ ):
170
+ """Rename a column in a table."""
171
+ table = validate_required_arg(table, "table", ctx)
172
+ old_name = validate_required_arg(old_name, "old_name", ctx)
173
+ new_name = validate_required_arg(new_name, "new_name", ctx)
174
+ config, config_data = get_config_with_data()
175
+ db_name = config_data.active_database
176
+ branch_name = config_data.active_branch
177
+
178
+ try:
179
+ column_mgr = ColumnManager(config.project_dir, db_name, branch_name, "main")
180
+ column_mgr.rename_column(table, old_name, new_name)
181
+
182
+ console.print(
183
+ f"[green]✅ Renamed column '{old_name}' to '{new_name}' in table '{table}'[/green]"
184
+ )
185
+
186
+ if apply:
187
+ # Apply to all tenants
188
+ applier = ChangeApplier(config.project_dir, db_name, branch_name)
189
+ applied = applier.apply_all_unapplied()
190
+ if applied > 0:
191
+ console.print("[green]✅ Applied changes to all tenants[/green]")
192
+
193
+ except ValueError as e:
194
+ console.print(f"[red]❌ {e}[/red]")
195
+ raise typer.Exit(1)
196
+
197
+
198
+ @app.command()
199
+ def info(
200
+ ctx: typer.Context,
201
+ table: Optional[str] = typer.Argument(None, help="Table name"),
202
+ name: Optional[str] = typer.Argument(None, help="Column name"),
203
+ ):
204
+ """Show detailed information about a column."""
205
+ table = validate_required_arg(table, "table", ctx)
206
+ name = validate_required_arg(name, "name", ctx)
207
+ config, config_data = get_config_with_data()
208
+ db_name = config_data.active_database
209
+ branch_name = config_data.active_branch
210
+
211
+ try:
212
+ column_mgr = ColumnManager(config.project_dir, db_name, branch_name, "main")
213
+ column = column_mgr.get_column_info(table, name)
214
+
215
+ # Display info
216
+ console.print(f"\n[bold]Column: {column.name}[/bold]")
217
+ console.print(f"Table: {table}")
218
+ console.print(f"Type: {column.type}")
219
+ console.print(f"Nullable: {'Yes' if column.nullable else 'No'}")
220
+ console.print(f"Primary Key: {'Yes' if column.primary_key else 'No'}")
221
+ console.print(f"Unique: {'Yes' if column.unique else 'No'}")
222
+ console.print(f"Default: {column.default or 'None'}")
223
+
224
+ except ValueError as e:
225
+ console.print(f"[red]❌ {e}[/red]")
226
+ raise typer.Exit(1)
227
+
228
+
229
+ @app.command(name="alter-nullable")
230
+ def alter_nullable(
231
+ ctx: typer.Context,
232
+ table: Optional[str] = typer.Argument(None, help="Table name"),
233
+ column: Optional[str] = typer.Argument(None, help="Column name"),
234
+ nullable: bool = typer.Option(
235
+ None, "--nullable/--not-nullable", help="Make column nullable or NOT NULL"
236
+ ),
237
+ fill_value: Optional[str] = typer.Option(
238
+ None, "--fill-value", "-f", help="Value to use for NULL values when making NOT NULL"
239
+ ),
240
+ apply: bool = typer.Option(
241
+ True, "--apply/--no-apply", help="Apply changes to all tenants"
242
+ ),
243
+ ):
244
+ """Change the nullable constraint on a column."""
245
+ table = validate_required_arg(table, "table", ctx)
246
+ column = validate_required_arg(column, "column", ctx)
247
+
248
+ # Validate nullable flag was provided
249
+ if nullable is None:
250
+ console.print(ctx.get_help())
251
+ console.print("\n[red]❌ Error: Must specify either --nullable or --not-nullable[/red]")
252
+ raise typer.Exit(1)
253
+
254
+ config, config_data = get_config_with_data()
255
+ db_name = config_data.active_database
256
+ branch_name = config_data.active_branch
257
+
258
+ try:
259
+ column_mgr = ColumnManager(config.project_dir, db_name, branch_name, "main")
260
+
261
+ # Check if column has NULLs when making NOT NULL
262
+ if not nullable and fill_value is None:
263
+ # Get column info to check current state
264
+ col_info = column_mgr.get_column_info(table, column)
265
+ if col_info.nullable:
266
+ # Check for NULL values
267
+ from cinchdb.core.connection import DatabaseConnection
268
+ from cinchdb.core.path_utils import get_tenant_db_path
269
+
270
+ db_path = get_tenant_db_path(config.project_dir, db_name, branch_name, "main")
271
+ with DatabaseConnection(db_path) as conn:
272
+ cursor = conn.execute(
273
+ f"SELECT COUNT(*) FROM {table} WHERE {column} IS NULL"
274
+ )
275
+ null_count = cursor.fetchone()[0]
276
+
277
+ if null_count > 0:
278
+ console.print(f"[yellow]Column '{column}' has {null_count} NULL values.[/yellow]")
279
+ fill_value = typer.prompt("Provide a fill value")
280
+
281
+ # Convert fill_value to appropriate type
282
+ if fill_value is not None:
283
+ # Try to interpret the value
284
+ if fill_value.lower() == "null":
285
+ fill_value = None
286
+ elif fill_value.isdigit():
287
+ fill_value = int(fill_value)
288
+ elif fill_value.replace(".", "", 1).isdigit():
289
+ fill_value = float(fill_value)
290
+ # Otherwise keep as string
291
+
292
+ column_mgr.alter_column_nullable(table, column, nullable, fill_value)
293
+
294
+ if nullable:
295
+ console.print(f"[green]✅ Made column '{column}' nullable in table '{table}'[/green]")
296
+ else:
297
+ console.print(f"[green]✅ Made column '{column}' NOT NULL in table '{table}'[/green]")
298
+
299
+ if apply:
300
+ # Apply to all tenants
301
+ applier = ChangeApplier(config.project_dir, db_name, branch_name)
302
+ applied = applier.apply_all_unapplied()
303
+ if applied > 0:
304
+ console.print("[green]✅ Applied changes to all tenants[/green]")
305
+
306
+ except ValueError as e:
307
+ console.print(f"[red]❌ {e}[/red]")
308
+ raise typer.Exit(1)
@@ -0,0 +1,212 @@
1
+ """Database management commands for CinchDB CLI."""
2
+
3
+ import typer
4
+ from typing import Optional
5
+ from rich.console import Console
6
+ from rich.table import Table as RichTable
7
+
8
+ from cinchdb.core.path_utils import list_databases
9
+ from cinchdb.cli.utils import (
10
+ get_config_with_data,
11
+ set_active_database,
12
+ set_active_branch,
13
+ validate_required_arg,
14
+ )
15
+
16
+ app = typer.Typer(help="Database management commands", invoke_without_command=True)
17
+ console = Console()
18
+
19
+
20
+ @app.callback(invoke_without_command=True)
21
+ def main(ctx: typer.Context):
22
+ """Database management commands."""
23
+ if ctx.invoked_subcommand is None:
24
+ print(ctx.get_help())
25
+ raise typer.Exit(0)
26
+
27
+
28
+ @app.command(name="list")
29
+ def list_dbs():
30
+ """List all databases in the project."""
31
+ config, config_data = get_config_with_data()
32
+ databases = list_databases(config.project_dir)
33
+
34
+ if not databases:
35
+ console.print("[yellow]No databases found[/yellow]")
36
+ return
37
+
38
+ # Create a table
39
+ table = RichTable(title="Databases")
40
+ table.add_column("Name", style="cyan")
41
+ table.add_column("Active", style="green")
42
+ table.add_column("Protected", style="yellow")
43
+
44
+ current_db = config_data.active_database
45
+
46
+ for db_name in databases:
47
+ is_active = "✓" if db_name == current_db else ""
48
+ is_protected = "✓" if db_name == "main" else ""
49
+ table.add_row(db_name, is_active, is_protected)
50
+
51
+ console.print(table)
52
+
53
+
54
+ @app.command()
55
+ def create(
56
+ ctx: typer.Context,
57
+ name: Optional[str] = typer.Argument(None, help="Name of the database to create"),
58
+ description: Optional[str] = typer.Option(
59
+ None, "--description", "-d", help="Database description"
60
+ ),
61
+ switch: bool = typer.Option(
62
+ False, "--switch", "-s", help="Switch to the new database after creation"
63
+ ),
64
+ ):
65
+ """Create a new database."""
66
+ name = validate_required_arg(name, "name", ctx)
67
+ config, config_data = get_config_with_data()
68
+
69
+ # Create database directory structure
70
+ db_path = config.project_dir / ".cinchdb" / "databases" / name
71
+ if db_path.exists():
72
+ console.print(f"[red]❌ Database '{name}' already exists[/red]")
73
+ raise typer.Exit(1)
74
+
75
+ # Create the database structure
76
+ db_path.mkdir(parents=True)
77
+ branches_dir = db_path / "branches"
78
+ branches_dir.mkdir()
79
+
80
+ # Create main branch
81
+ main_branch = branches_dir / "main"
82
+ main_branch.mkdir()
83
+
84
+ # Create main tenant
85
+ tenants_dir = main_branch / "tenants"
86
+ tenants_dir.mkdir()
87
+ main_tenant = tenants_dir / "main.db"
88
+ main_tenant.touch()
89
+
90
+ # Create branch metadata
91
+ import json
92
+ from datetime import datetime, timezone
93
+
94
+ metadata = {
95
+ "name": "main",
96
+ "parent": None,
97
+ "created_at": datetime.now(timezone.utc).isoformat(),
98
+ }
99
+ with open(main_branch / "metadata.json", "w") as f:
100
+ json.dump(metadata, f, indent=2)
101
+
102
+ # Create empty changes file
103
+ with open(main_branch / "changes.json", "w") as f:
104
+ json.dump([], f)
105
+
106
+ console.print(f"[green]✅ Created database '{name}'[/green]")
107
+
108
+ if switch:
109
+ set_active_database(config, name)
110
+ console.print(f"[green]✅ Switched to database '{name}'[/green]")
111
+
112
+
113
+ @app.command()
114
+ def delete(
115
+ ctx: typer.Context,
116
+ name: Optional[str] = typer.Argument(None, help="Name of the database to delete"),
117
+ force: bool = typer.Option(
118
+ False, "--force", "-f", help="Force deletion without confirmation"
119
+ ),
120
+ ):
121
+ """Delete a database."""
122
+ name = validate_required_arg(name, "name", ctx)
123
+ if name == "main":
124
+ console.print("[red]❌ Cannot delete the main database[/red]")
125
+ raise typer.Exit(1)
126
+
127
+ config, config_data = get_config_with_data()
128
+ db_path = config.project_dir / ".cinchdb" / "databases" / name
129
+
130
+ if not db_path.exists():
131
+ console.print(f"[red]❌ Database '{name}' does not exist[/red]")
132
+ raise typer.Exit(1)
133
+
134
+ # Confirmation
135
+ if not force:
136
+ confirm = typer.confirm(f"Are you sure you want to delete database '{name}'?")
137
+ if not confirm:
138
+ console.print("[yellow]Cancelled[/yellow]")
139
+ raise typer.Exit(0)
140
+
141
+ # Delete the database
142
+ import shutil
143
+
144
+ shutil.rmtree(db_path)
145
+
146
+ # If this was the active database, switch to main
147
+ if config_data.active_database == name:
148
+ set_active_database(config, "main")
149
+ console.print("[yellow]Switched to main database[/yellow]")
150
+
151
+ console.print(f"[green]✅ Deleted database '{name}'[/green]")
152
+
153
+
154
+ @app.command()
155
+ def info(
156
+ name: Optional[str] = typer.Argument(None, help="Database name (default: current)"),
157
+ ):
158
+ """Show information about a database."""
159
+ config, config_data = get_config_with_data()
160
+ db_name = name or config_data.active_database
161
+
162
+ db_path = config.project_dir / ".cinchdb" / "databases" / db_name
163
+ if not db_path.exists():
164
+ console.print(f"[red]❌ Database '{db_name}' does not exist[/red]")
165
+ raise typer.Exit(1)
166
+
167
+ # Count branches
168
+ branches_path = db_path / "branches"
169
+ branch_count = len(list(branches_path.iterdir())) if branches_path.exists() else 0
170
+
171
+ # Get active branch
172
+ active_branch = config_data.active_branch
173
+
174
+ # Display info
175
+ console.print(f"\n[bold]Database: {db_name}[/bold]")
176
+ console.print(f"Location: {db_path}")
177
+ console.print(f"Branches: {branch_count}")
178
+ console.print(f"Active Branch: {active_branch}")
179
+ console.print(f"Protected: {'Yes' if db_name == 'main' else 'No'}")
180
+
181
+ # List branches
182
+ if branch_count > 0:
183
+ console.print("\n[bold]Branches:[/bold]")
184
+ for branch_dir in sorted(branches_path.iterdir()):
185
+ if branch_dir.is_dir():
186
+ branch_name = branch_dir.name
187
+ is_active = " (active)" if branch_name == active_branch else ""
188
+ console.print(f" - {branch_name}{is_active}")
189
+
190
+
191
+ @app.command()
192
+ def switch(
193
+ ctx: typer.Context,
194
+ name: Optional[str] = typer.Argument(
195
+ None, help="Name of the database to switch to"
196
+ ),
197
+ ):
198
+ """Switch to a different database."""
199
+ name = validate_required_arg(name, "name", ctx)
200
+ config, config_data = get_config_with_data()
201
+
202
+ # Check if database exists
203
+ db_path = config.project_dir / ".cinchdb" / "databases" / name
204
+ if not db_path.exists():
205
+ console.print(f"[red]❌ Database '{name}' does not exist[/red]")
206
+ raise typer.Exit(1)
207
+
208
+ # Switch
209
+ set_active_database(config, name)
210
+ set_active_branch(config, "main") # Always switch to main branch
211
+
212
+ console.print(f"[green]✅ Switched to database '{name}'[/green]")
@@ -0,0 +1,136 @@
1
+ """Query execution command for CinchDB CLI."""
2
+
3
+ import typer
4
+ from typing import Optional
5
+ from rich.console import Console
6
+ from rich.table import Table as RichTable
7
+
8
+ from cinchdb.cli.utils import get_config_with_data, get_cinchdb_instance
9
+ from cinchdb.managers.query import QueryManager
10
+
11
+ app = typer.Typer(help="Execute SQL queries", invoke_without_command=True)
12
+ console = Console()
13
+
14
+
15
+ def execute_query(sql: str, tenant: str, format: str, limit: Optional[int], force_local: bool = False, remote_alias: Optional[str] = None):
16
+ """Execute a SQL query."""
17
+ # Add LIMIT if specified
18
+ query_sql = sql
19
+ if limit and "LIMIT" not in sql.upper():
20
+ query_sql = f"{sql} LIMIT {limit}"
21
+
22
+ # Get CinchDB instance (handles local/remote automatically)
23
+ db = get_cinchdb_instance(tenant=tenant, force_local=force_local, remote_alias=remote_alias)
24
+
25
+ try:
26
+ # Check if this is a SELECT query
27
+ is_select = query_sql.strip().upper().startswith("SELECT")
28
+
29
+ if is_select:
30
+ # Execute SELECT query
31
+ rows = db.query(query_sql)
32
+
33
+ if not rows:
34
+ console.print("[yellow]No results[/yellow]")
35
+ return
36
+
37
+ # Get column names from first row
38
+ columns = list(rows[0].keys()) if rows else []
39
+
40
+ if format == "json":
41
+ # JSON output - rows are already dicts
42
+ console.print_json(data=rows)
43
+
44
+ elif format == "csv":
45
+ # CSV output
46
+ import csv
47
+ import sys
48
+
49
+ writer = csv.writer(sys.stdout)
50
+ writer.writerow(columns)
51
+ for row in rows:
52
+ writer.writerow([row[col] for col in columns])
53
+
54
+ else:
55
+ # Table output (default)
56
+ table = RichTable(title=f"Query Results ({len(rows)} rows)")
57
+
58
+ # Add columns
59
+ for col in columns:
60
+ table.add_column(col, style="cyan")
61
+
62
+ # Add rows
63
+ for row in rows:
64
+ # Convert all values to strings
65
+ str_row = [
66
+ str(row[col]) if row[col] is not None else "NULL"
67
+ for col in columns
68
+ ]
69
+ table.add_row(*str_row)
70
+
71
+ console.print(table)
72
+ else:
73
+ # For INSERT/UPDATE/DELETE, we need to handle local vs remote differently
74
+ if db.is_local:
75
+ # For local connections, use the query manager directly
76
+ from cinchdb.managers.query import QueryManager
77
+ query_mgr = QueryManager(db.project_dir, db.database, db.branch, tenant)
78
+ affected_rows = query_mgr.execute_non_query(query_sql)
79
+ console.print(
80
+ f"[green]✅ Query executed successfully[/green]\n"
81
+ f"[cyan]Rows affected: {affected_rows}[/cyan]"
82
+ )
83
+ else:
84
+ # For remote connections, the API should handle all SQL types
85
+ # This might need API support - for now, try using query
86
+ try:
87
+ result = db.query(query_sql)
88
+ console.print(
89
+ f"[green]✅ Query executed successfully[/green]"
90
+ )
91
+ except Exception as e:
92
+ # If remote doesn't support non-SELECT via query, show helpful message
93
+ console.print(
94
+ f"[red]❌ Remote execution of non-SELECT queries may require API support[/red]\n"
95
+ f"[yellow]Error: {e}[/yellow]"
96
+ )
97
+ raise
98
+
99
+ except Exception as e:
100
+ console.print(f"[red]❌ Query error: {e}[/red]")
101
+ raise typer.Exit(1)
102
+
103
+
104
+ @app.callback()
105
+ def main(
106
+ ctx: typer.Context,
107
+ sql: Optional[str] = typer.Argument(None, help="SQL query to execute"),
108
+ tenant: Optional[str] = typer.Option("main", "--tenant", "-t", help="Tenant name"),
109
+ format: Optional[str] = typer.Option(
110
+ "table", "--format", "-f", help="Output format (table, json, csv)"
111
+ ),
112
+ limit: Optional[int] = typer.Option(
113
+ None, "--limit", "-l", help="Limit number of rows"
114
+ ),
115
+ local: bool = typer.Option(
116
+ False, "--local", "-L", help="Force local connection"
117
+ ),
118
+ remote: Optional[str] = typer.Option(
119
+ None, "--remote", "-r", help="Use specific remote alias"
120
+ ),
121
+ ):
122
+ """Execute a SQL query.
123
+
124
+ Examples:
125
+ cinch query "SELECT * FROM users"
126
+ cinch query "SELECT * FROM users WHERE active = 1" --format json
127
+ cinch query "SELECT COUNT(*) FROM posts" --tenant tenant1
128
+ cinch query "SELECT * FROM users" --remote production
129
+ cinch query "SELECT * FROM users" --local
130
+ """
131
+ # If no subcommand is invoked and we have SQL, execute it
132
+ if ctx.invoked_subcommand is None:
133
+ if not sql:
134
+ console.print(ctx.get_help())
135
+ raise typer.Exit(0)
136
+ execute_query(sql, tenant, format, limit, force_local=local, remote_alias=remote)