anysite-cli 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.
Potentially problematic release.
This version of anysite-cli might be problematic. Click here for more details.
- anysite/__init__.py +4 -0
- anysite/__main__.py +6 -0
- anysite/api/__init__.py +21 -0
- anysite/api/client.py +271 -0
- anysite/api/errors.py +137 -0
- anysite/api/schemas.py +333 -0
- anysite/batch/__init__.py +1 -0
- anysite/batch/executor.py +176 -0
- anysite/batch/input.py +160 -0
- anysite/batch/rate_limiter.py +98 -0
- anysite/cli/__init__.py +1 -0
- anysite/cli/config.py +176 -0
- anysite/cli/executor.py +388 -0
- anysite/cli/options.py +249 -0
- anysite/config/__init__.py +11 -0
- anysite/config/paths.py +46 -0
- anysite/config/settings.py +187 -0
- anysite/dataset/__init__.py +37 -0
- anysite/dataset/analyzer.py +268 -0
- anysite/dataset/cli.py +644 -0
- anysite/dataset/collector.py +686 -0
- anysite/dataset/db_loader.py +248 -0
- anysite/dataset/errors.py +30 -0
- anysite/dataset/exporters.py +121 -0
- anysite/dataset/history.py +153 -0
- anysite/dataset/models.py +245 -0
- anysite/dataset/notifications.py +87 -0
- anysite/dataset/scheduler.py +107 -0
- anysite/dataset/storage.py +171 -0
- anysite/dataset/transformer.py +213 -0
- anysite/db/__init__.py +38 -0
- anysite/db/adapters/__init__.py +1 -0
- anysite/db/adapters/base.py +158 -0
- anysite/db/adapters/postgres.py +201 -0
- anysite/db/adapters/sqlite.py +183 -0
- anysite/db/cli.py +687 -0
- anysite/db/config.py +92 -0
- anysite/db/manager.py +166 -0
- anysite/db/operations/__init__.py +1 -0
- anysite/db/operations/insert.py +199 -0
- anysite/db/operations/query.py +43 -0
- anysite/db/schema/__init__.py +1 -0
- anysite/db/schema/inference.py +213 -0
- anysite/db/schema/types.py +71 -0
- anysite/db/utils/__init__.py +1 -0
- anysite/db/utils/sanitize.py +99 -0
- anysite/main.py +498 -0
- anysite/models/__init__.py +1 -0
- anysite/output/__init__.py +11 -0
- anysite/output/console.py +45 -0
- anysite/output/formatters.py +301 -0
- anysite/output/templates.py +76 -0
- anysite/py.typed +0 -0
- anysite/streaming/__init__.py +1 -0
- anysite/streaming/progress.py +121 -0
- anysite/streaming/writer.py +130 -0
- anysite/utils/__init__.py +1 -0
- anysite/utils/fields.py +242 -0
- anysite/utils/retry.py +109 -0
- anysite_cli-0.1.0.dist-info/METADATA +437 -0
- anysite_cli-0.1.0.dist-info/RECORD +64 -0
- anysite_cli-0.1.0.dist-info/WHEEL +4 -0
- anysite_cli-0.1.0.dist-info/entry_points.txt +2 -0
- anysite_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
anysite/db/cli.py
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
"""CLI commands for the database subsystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from anysite.db.config import ConnectionConfig, DatabaseType, OnConflict
|
|
14
|
+
from anysite.db.manager import ConnectionManager
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(help="Store API data in SQL databases")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_manager() -> ConnectionManager:
|
|
20
|
+
return ConnectionManager()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_config_or_exit(name: str) -> ConnectionConfig:
|
|
24
|
+
"""Get a connection config by name or exit with error."""
|
|
25
|
+
config = _get_manager().get(name)
|
|
26
|
+
if config is None:
|
|
27
|
+
typer.echo(f"Error: connection '{name}' not found", err=True)
|
|
28
|
+
typer.echo("Run 'anysite db list' to see available connections.", err=True)
|
|
29
|
+
raise typer.Exit(1)
|
|
30
|
+
return config
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Connection management ──────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("add")
|
|
37
|
+
def add(
|
|
38
|
+
name: Annotated[
|
|
39
|
+
str,
|
|
40
|
+
typer.Argument(help="Connection name"),
|
|
41
|
+
],
|
|
42
|
+
type: Annotated[
|
|
43
|
+
DatabaseType,
|
|
44
|
+
typer.Option("--type", "-t", help="Database type"),
|
|
45
|
+
] = DatabaseType.SQLITE,
|
|
46
|
+
host: Annotated[
|
|
47
|
+
str | None,
|
|
48
|
+
typer.Option("--host", "-h", help="Database host"),
|
|
49
|
+
] = None,
|
|
50
|
+
port: Annotated[
|
|
51
|
+
int | None,
|
|
52
|
+
typer.Option("--port", "-p", help="Database port"),
|
|
53
|
+
] = None,
|
|
54
|
+
database: Annotated[
|
|
55
|
+
str | None,
|
|
56
|
+
typer.Option("--database", "-d", help="Database name"),
|
|
57
|
+
] = None,
|
|
58
|
+
user: Annotated[
|
|
59
|
+
str | None,
|
|
60
|
+
typer.Option("--user", "-u", help="Database user"),
|
|
61
|
+
] = None,
|
|
62
|
+
password_env: Annotated[
|
|
63
|
+
str | None,
|
|
64
|
+
typer.Option("--password-env", help="Env var containing password"),
|
|
65
|
+
] = None,
|
|
66
|
+
url_env: Annotated[
|
|
67
|
+
str | None,
|
|
68
|
+
typer.Option("--url-env", help="Env var containing connection URL"),
|
|
69
|
+
] = None,
|
|
70
|
+
path: Annotated[
|
|
71
|
+
str | None,
|
|
72
|
+
typer.Option("--path", help="Database file path (SQLite/DuckDB)"),
|
|
73
|
+
] = None,
|
|
74
|
+
ssl: Annotated[
|
|
75
|
+
bool,
|
|
76
|
+
typer.Option("--ssl", help="Enable SSL"),
|
|
77
|
+
] = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Add a named database connection.
|
|
80
|
+
|
|
81
|
+
\b
|
|
82
|
+
Examples:
|
|
83
|
+
anysite db add local --type sqlite --path ./data.db
|
|
84
|
+
anysite db add prod --type postgres --host db.example.com --database analytics --user app --password-env DB_PASS
|
|
85
|
+
anysite db add remote --type postgres --url-env DATABASE_URL
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
config = ConnectionConfig(
|
|
89
|
+
name=name,
|
|
90
|
+
type=type,
|
|
91
|
+
host=host,
|
|
92
|
+
port=port,
|
|
93
|
+
database=database,
|
|
94
|
+
user=user,
|
|
95
|
+
password_env=password_env,
|
|
96
|
+
url_env=url_env,
|
|
97
|
+
path=path,
|
|
98
|
+
ssl=ssl,
|
|
99
|
+
)
|
|
100
|
+
except ValueError as e:
|
|
101
|
+
typer.echo(f"Error: {e}", err=True)
|
|
102
|
+
raise typer.Exit(1) from None
|
|
103
|
+
|
|
104
|
+
manager = _get_manager()
|
|
105
|
+
manager.add(config)
|
|
106
|
+
|
|
107
|
+
console = Console()
|
|
108
|
+
console.print(f"[green]Added[/green] connection '{name}' ({type.value})")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.command("list")
|
|
112
|
+
def list_connections() -> None:
|
|
113
|
+
"""List all saved database connections."""
|
|
114
|
+
manager = _get_manager()
|
|
115
|
+
connections = manager.list()
|
|
116
|
+
|
|
117
|
+
if not connections:
|
|
118
|
+
typer.echo("No connections configured.")
|
|
119
|
+
typer.echo("Add one with: anysite db add <name> --type sqlite --path ./data.db")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
console = Console()
|
|
123
|
+
table = Table(title="Database Connections")
|
|
124
|
+
table.add_column("Name", style="bold")
|
|
125
|
+
table.add_column("Type")
|
|
126
|
+
table.add_column("Location")
|
|
127
|
+
|
|
128
|
+
for conn in connections:
|
|
129
|
+
if conn.type in (DatabaseType.SQLITE, DatabaseType.DUCKDB):
|
|
130
|
+
location = conn.path or ""
|
|
131
|
+
elif conn.url_env:
|
|
132
|
+
location = f"${conn.url_env}"
|
|
133
|
+
else:
|
|
134
|
+
parts = []
|
|
135
|
+
if conn.user:
|
|
136
|
+
parts.append(f"{conn.user}@")
|
|
137
|
+
if conn.host:
|
|
138
|
+
parts.append(conn.host)
|
|
139
|
+
if conn.port:
|
|
140
|
+
parts.append(f":{conn.port}")
|
|
141
|
+
if conn.database:
|
|
142
|
+
parts.append(f"/{conn.database}")
|
|
143
|
+
location = "".join(parts)
|
|
144
|
+
|
|
145
|
+
table.add_row(conn.name, conn.type.value, location)
|
|
146
|
+
|
|
147
|
+
console.print(table)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("test")
|
|
151
|
+
def test(
|
|
152
|
+
name: Annotated[
|
|
153
|
+
str,
|
|
154
|
+
typer.Argument(help="Connection name to test"),
|
|
155
|
+
],
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Test a database connection."""
|
|
158
|
+
_get_config_or_exit(name)
|
|
159
|
+
|
|
160
|
+
console = Console()
|
|
161
|
+
console.print(f"Testing connection '{name}'...")
|
|
162
|
+
|
|
163
|
+
manager = _get_manager()
|
|
164
|
+
try:
|
|
165
|
+
info = manager.test(name)
|
|
166
|
+
console.print(f"[green]Connected[/green] to {info.get('type', 'unknown')}")
|
|
167
|
+
for key, value in info.items():
|
|
168
|
+
console.print(f" {key}: {value}")
|
|
169
|
+
except Exception as e:
|
|
170
|
+
console.print(f"[red]Failed[/red]: {e}")
|
|
171
|
+
raise typer.Exit(1) from None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@app.command("remove")
|
|
175
|
+
def remove(
|
|
176
|
+
name: Annotated[
|
|
177
|
+
str,
|
|
178
|
+
typer.Argument(help="Connection name to remove"),
|
|
179
|
+
],
|
|
180
|
+
force: Annotated[
|
|
181
|
+
bool,
|
|
182
|
+
typer.Option("--force", "-f", help="Skip confirmation"),
|
|
183
|
+
] = False,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""Remove a saved database connection."""
|
|
186
|
+
_get_config_or_exit(name)
|
|
187
|
+
|
|
188
|
+
if not force:
|
|
189
|
+
confirm = typer.confirm(f"Remove connection '{name}'?")
|
|
190
|
+
if not confirm:
|
|
191
|
+
raise typer.Abort()
|
|
192
|
+
|
|
193
|
+
manager = _get_manager()
|
|
194
|
+
manager.remove(name)
|
|
195
|
+
|
|
196
|
+
console = Console()
|
|
197
|
+
console.print(f"[green]Removed[/green] connection '{name}'")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("info")
|
|
201
|
+
def info(
|
|
202
|
+
name: Annotated[
|
|
203
|
+
str,
|
|
204
|
+
typer.Argument(help="Connection name"),
|
|
205
|
+
],
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Show details about a database connection."""
|
|
208
|
+
config = _get_config_or_exit(name)
|
|
209
|
+
|
|
210
|
+
console = Console()
|
|
211
|
+
console.print(f"[bold]Connection: {config.name}[/bold]")
|
|
212
|
+
console.print(f" Type: {config.type.value}")
|
|
213
|
+
|
|
214
|
+
if config.path:
|
|
215
|
+
console.print(f" Path: {config.path}")
|
|
216
|
+
if config.host:
|
|
217
|
+
console.print(f" Host: {config.host}")
|
|
218
|
+
if config.port:
|
|
219
|
+
console.print(f" Port: {config.port}")
|
|
220
|
+
if config.database:
|
|
221
|
+
console.print(f" Database: {config.database}")
|
|
222
|
+
if config.user:
|
|
223
|
+
console.print(f" User: {config.user}")
|
|
224
|
+
if config.password_env:
|
|
225
|
+
console.print(f" Password: ${config.password_env}")
|
|
226
|
+
if config.url_env:
|
|
227
|
+
console.print(f" URL: ${config.url_env}")
|
|
228
|
+
if config.ssl:
|
|
229
|
+
console.print(" SSL: enabled")
|
|
230
|
+
if config.options:
|
|
231
|
+
console.print(f" Options: {config.options}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── Schema commands ────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command("schema")
|
|
238
|
+
def schema(
|
|
239
|
+
name: Annotated[
|
|
240
|
+
str,
|
|
241
|
+
typer.Argument(help="Connection name"),
|
|
242
|
+
],
|
|
243
|
+
table: Annotated[
|
|
244
|
+
str | None,
|
|
245
|
+
typer.Option("--table", "-t", help="Table to inspect"),
|
|
246
|
+
] = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Inspect database schema — list tables or show table columns.
|
|
249
|
+
|
|
250
|
+
\b
|
|
251
|
+
Examples:
|
|
252
|
+
anysite db schema mydb # list all tables
|
|
253
|
+
anysite db schema mydb --table users # show columns of 'users'
|
|
254
|
+
"""
|
|
255
|
+
config = _get_config_or_exit(name)
|
|
256
|
+
manager = _get_manager()
|
|
257
|
+
adapter = manager.get_adapter(config)
|
|
258
|
+
|
|
259
|
+
console = Console()
|
|
260
|
+
|
|
261
|
+
with adapter:
|
|
262
|
+
if table:
|
|
263
|
+
if not adapter.table_exists(table):
|
|
264
|
+
typer.echo(f"Error: table '{table}' does not exist", err=True)
|
|
265
|
+
raise typer.Exit(1)
|
|
266
|
+
|
|
267
|
+
columns = adapter.get_table_schema(table)
|
|
268
|
+
tbl = Table(title=f"Table: {table}")
|
|
269
|
+
tbl.add_column("Column", style="bold")
|
|
270
|
+
tbl.add_column("Type")
|
|
271
|
+
tbl.add_column("Nullable")
|
|
272
|
+
tbl.add_column("Primary Key")
|
|
273
|
+
|
|
274
|
+
for col in columns:
|
|
275
|
+
tbl.add_row(col["name"], col["type"], col["nullable"], col["primary_key"])
|
|
276
|
+
console.print(tbl)
|
|
277
|
+
else:
|
|
278
|
+
# List tables - adapter-agnostic via querying system tables
|
|
279
|
+
tables = _list_tables(adapter, config.type)
|
|
280
|
+
if not tables:
|
|
281
|
+
console.print("[dim]No tables found[/dim]")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
tbl = Table(title="Tables")
|
|
285
|
+
tbl.add_column("Table Name", style="bold")
|
|
286
|
+
for t in tables:
|
|
287
|
+
tbl.add_row(t)
|
|
288
|
+
console.print(tbl)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _list_tables(adapter: Any, db_type: DatabaseType) -> list[str]:
|
|
292
|
+
"""List all tables in the database."""
|
|
293
|
+
if db_type == DatabaseType.SQLITE:
|
|
294
|
+
rows = adapter.fetch_all(
|
|
295
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
296
|
+
)
|
|
297
|
+
return [r["name"] for r in rows]
|
|
298
|
+
elif db_type == DatabaseType.POSTGRES:
|
|
299
|
+
rows = adapter.fetch_all(
|
|
300
|
+
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
|
|
301
|
+
)
|
|
302
|
+
return [r["tablename"] for r in rows]
|
|
303
|
+
return []
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.command("create-table")
|
|
307
|
+
def create_table(
|
|
308
|
+
name: Annotated[
|
|
309
|
+
str,
|
|
310
|
+
typer.Argument(help="Connection name"),
|
|
311
|
+
],
|
|
312
|
+
table: Annotated[
|
|
313
|
+
str,
|
|
314
|
+
typer.Option("--table", "-t", help="Table name to create"),
|
|
315
|
+
],
|
|
316
|
+
stdin: Annotated[
|
|
317
|
+
bool,
|
|
318
|
+
typer.Option("--stdin", help="Infer schema from JSONL on stdin"),
|
|
319
|
+
] = False,
|
|
320
|
+
pk: Annotated[
|
|
321
|
+
str | None,
|
|
322
|
+
typer.Option("--pk", help="Primary key column"),
|
|
323
|
+
] = None,
|
|
324
|
+
dry_run: Annotated[
|
|
325
|
+
bool,
|
|
326
|
+
typer.Option("--dry-run", help="Show CREATE TABLE SQL without executing"),
|
|
327
|
+
] = False,
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Create a table with schema inferred from JSON data.
|
|
330
|
+
|
|
331
|
+
\b
|
|
332
|
+
Examples:
|
|
333
|
+
echo '{"name":"test","age":30}' | anysite db create-table mydb --table users --stdin
|
|
334
|
+
echo '{"id":1,"name":"test"}' | anysite db create-table mydb --table users --stdin --pk id --dry-run
|
|
335
|
+
"""
|
|
336
|
+
import json
|
|
337
|
+
|
|
338
|
+
from anysite.db.schema.inference import infer_table_schema
|
|
339
|
+
from anysite.db.utils.sanitize import sanitize_identifier, sanitize_table_name
|
|
340
|
+
|
|
341
|
+
if not stdin:
|
|
342
|
+
typer.echo("Error: --stdin is required (provide JSON data via stdin)", err=True)
|
|
343
|
+
raise typer.Exit(1)
|
|
344
|
+
|
|
345
|
+
content = sys.stdin.read().strip()
|
|
346
|
+
if not content:
|
|
347
|
+
typer.echo("Error: no data on stdin", err=True)
|
|
348
|
+
raise typer.Exit(1)
|
|
349
|
+
|
|
350
|
+
# Parse input
|
|
351
|
+
rows: list[dict[str, Any]] = []
|
|
352
|
+
try:
|
|
353
|
+
data = json.loads(content)
|
|
354
|
+
if isinstance(data, list):
|
|
355
|
+
rows = [r for r in data if isinstance(r, dict)]
|
|
356
|
+
elif isinstance(data, dict):
|
|
357
|
+
rows = [data]
|
|
358
|
+
except json.JSONDecodeError:
|
|
359
|
+
for line in content.split("\n"):
|
|
360
|
+
line = line.strip()
|
|
361
|
+
if line:
|
|
362
|
+
try:
|
|
363
|
+
obj = json.loads(line)
|
|
364
|
+
if isinstance(obj, dict):
|
|
365
|
+
rows.append(obj)
|
|
366
|
+
except json.JSONDecodeError:
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
if not rows:
|
|
370
|
+
typer.echo("Error: no valid JSON objects found in input", err=True)
|
|
371
|
+
raise typer.Exit(1)
|
|
372
|
+
|
|
373
|
+
config = _get_config_or_exit(name)
|
|
374
|
+
manager = _get_manager()
|
|
375
|
+
dialect = config.type.value
|
|
376
|
+
|
|
377
|
+
schema = infer_table_schema(table, rows)
|
|
378
|
+
sql_types = schema.to_sql_types(dialect)
|
|
379
|
+
|
|
380
|
+
safe_table = sanitize_table_name(table)
|
|
381
|
+
col_defs = []
|
|
382
|
+
for col_name, col_type in sql_types.items():
|
|
383
|
+
safe_col = sanitize_identifier(col_name)
|
|
384
|
+
pk_suffix = " PRIMARY KEY" if col_name == pk else ""
|
|
385
|
+
col_defs.append(f" {safe_col} {col_type}{pk_suffix}")
|
|
386
|
+
create_sql = f"CREATE TABLE IF NOT EXISTS {safe_table} (\n" + ",\n".join(col_defs) + "\n)"
|
|
387
|
+
|
|
388
|
+
console = Console()
|
|
389
|
+
|
|
390
|
+
if dry_run:
|
|
391
|
+
console.print(f"[dim]-- Inferred from {len(rows)} row(s)[/dim]")
|
|
392
|
+
console.print(create_sql)
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
adapter = manager.get_adapter(config)
|
|
396
|
+
with adapter:
|
|
397
|
+
if adapter.table_exists(table):
|
|
398
|
+
typer.echo(f"Error: table '{table}' already exists", err=True)
|
|
399
|
+
raise typer.Exit(1)
|
|
400
|
+
adapter.create_table(table, sql_types, primary_key=pk)
|
|
401
|
+
console.print(f"[green]Created[/green] table '{table}' with {len(sql_types)} columns")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ── Data commands ──────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@app.command("insert")
|
|
408
|
+
def insert(
|
|
409
|
+
name: Annotated[
|
|
410
|
+
str,
|
|
411
|
+
typer.Argument(help="Connection name"),
|
|
412
|
+
],
|
|
413
|
+
table: Annotated[
|
|
414
|
+
str,
|
|
415
|
+
typer.Option("--table", "-t", help="Target table"),
|
|
416
|
+
],
|
|
417
|
+
stdin: Annotated[
|
|
418
|
+
bool,
|
|
419
|
+
typer.Option("--stdin", help="Read JSON data from stdin"),
|
|
420
|
+
] = False,
|
|
421
|
+
file: Annotated[
|
|
422
|
+
Path | None,
|
|
423
|
+
typer.Option("--file", "-f", help="Read JSON data from file"),
|
|
424
|
+
] = None,
|
|
425
|
+
auto_create: Annotated[
|
|
426
|
+
bool,
|
|
427
|
+
typer.Option("--auto-create", help="Create table if it doesn't exist"),
|
|
428
|
+
] = False,
|
|
429
|
+
pk: Annotated[
|
|
430
|
+
str | None,
|
|
431
|
+
typer.Option("--pk", help="Primary key column (for auto-create)"),
|
|
432
|
+
] = None,
|
|
433
|
+
on_conflict: Annotated[
|
|
434
|
+
OnConflict,
|
|
435
|
+
typer.Option("--on-conflict", help="Conflict handling: error, ignore, replace, update"),
|
|
436
|
+
] = OnConflict.ERROR,
|
|
437
|
+
conflict_columns: Annotated[
|
|
438
|
+
str | None,
|
|
439
|
+
typer.Option("--conflict-columns", help="Comma-separated conflict columns (for upsert)"),
|
|
440
|
+
] = None,
|
|
441
|
+
batch_size: Annotated[
|
|
442
|
+
int,
|
|
443
|
+
typer.Option("--batch-size", help="Rows per batch insert"),
|
|
444
|
+
] = 100,
|
|
445
|
+
quiet: Annotated[
|
|
446
|
+
bool,
|
|
447
|
+
typer.Option("--quiet", "-q", help="Suppress non-data output"),
|
|
448
|
+
] = False,
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Insert JSON data into a database table.
|
|
451
|
+
|
|
452
|
+
Reads JSONL (one JSON object per line) or a JSON array from stdin or file.
|
|
453
|
+
|
|
454
|
+
\b
|
|
455
|
+
Examples:
|
|
456
|
+
echo '{"name":"test","value":42}' | anysite db insert mydb --table demo --stdin --auto-create
|
|
457
|
+
anysite api /api/linkedin/user user=satyanadella | anysite db insert mydb --table users --stdin
|
|
458
|
+
anysite db insert mydb --table users --file data.jsonl
|
|
459
|
+
"""
|
|
460
|
+
from anysite.db.operations.insert import insert_from_file, insert_from_stdin
|
|
461
|
+
|
|
462
|
+
if not stdin and not file:
|
|
463
|
+
typer.echo("Error: provide --stdin or --file", err=True)
|
|
464
|
+
raise typer.Exit(1)
|
|
465
|
+
|
|
466
|
+
if file and not file.exists():
|
|
467
|
+
typer.echo(f"Error: file not found: {file}", err=True)
|
|
468
|
+
raise typer.Exit(1)
|
|
469
|
+
|
|
470
|
+
conflict_cols = [c.strip() for c in conflict_columns.split(",")] if conflict_columns else None
|
|
471
|
+
|
|
472
|
+
config = _get_config_or_exit(name)
|
|
473
|
+
manager = _get_manager()
|
|
474
|
+
adapter = manager.get_adapter(config)
|
|
475
|
+
|
|
476
|
+
console = Console()
|
|
477
|
+
|
|
478
|
+
with adapter:
|
|
479
|
+
if stdin:
|
|
480
|
+
count = insert_from_stdin(
|
|
481
|
+
adapter,
|
|
482
|
+
table,
|
|
483
|
+
on_conflict=on_conflict,
|
|
484
|
+
conflict_columns=conflict_cols,
|
|
485
|
+
auto_create=auto_create,
|
|
486
|
+
primary_key=pk,
|
|
487
|
+
batch_size=batch_size,
|
|
488
|
+
quiet=quiet,
|
|
489
|
+
)
|
|
490
|
+
else:
|
|
491
|
+
assert file is not None
|
|
492
|
+
count = insert_from_file(
|
|
493
|
+
adapter,
|
|
494
|
+
table,
|
|
495
|
+
file,
|
|
496
|
+
on_conflict=on_conflict,
|
|
497
|
+
conflict_columns=conflict_cols,
|
|
498
|
+
auto_create=auto_create,
|
|
499
|
+
primary_key=pk,
|
|
500
|
+
batch_size=batch_size,
|
|
501
|
+
quiet=quiet,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if not quiet:
|
|
505
|
+
console.print(f"[green]Inserted[/green] {count} row(s) into '{table}'")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@app.command("upsert")
|
|
509
|
+
def upsert(
|
|
510
|
+
name: Annotated[
|
|
511
|
+
str,
|
|
512
|
+
typer.Argument(help="Connection name"),
|
|
513
|
+
],
|
|
514
|
+
table: Annotated[
|
|
515
|
+
str,
|
|
516
|
+
typer.Option("--table", "-t", help="Target table"),
|
|
517
|
+
],
|
|
518
|
+
conflict_columns: Annotated[
|
|
519
|
+
str,
|
|
520
|
+
typer.Option("--conflict-columns", help="Comma-separated conflict columns"),
|
|
521
|
+
],
|
|
522
|
+
stdin: Annotated[
|
|
523
|
+
bool,
|
|
524
|
+
typer.Option("--stdin", help="Read JSON data from stdin"),
|
|
525
|
+
] = False,
|
|
526
|
+
file: Annotated[
|
|
527
|
+
Path | None,
|
|
528
|
+
typer.Option("--file", "-f", help="Read JSON data from file"),
|
|
529
|
+
] = None,
|
|
530
|
+
auto_create: Annotated[
|
|
531
|
+
bool,
|
|
532
|
+
typer.Option("--auto-create", help="Create table if it doesn't exist"),
|
|
533
|
+
] = False,
|
|
534
|
+
pk: Annotated[
|
|
535
|
+
str | None,
|
|
536
|
+
typer.Option("--pk", help="Primary key column (for auto-create)"),
|
|
537
|
+
] = None,
|
|
538
|
+
batch_size: Annotated[
|
|
539
|
+
int,
|
|
540
|
+
typer.Option("--batch-size", help="Rows per batch insert"),
|
|
541
|
+
] = 100,
|
|
542
|
+
quiet: Annotated[
|
|
543
|
+
bool,
|
|
544
|
+
typer.Option("--quiet", "-q", help="Suppress non-data output"),
|
|
545
|
+
] = False,
|
|
546
|
+
) -> None:
|
|
547
|
+
"""Upsert JSON data — insert or update on conflict.
|
|
548
|
+
|
|
549
|
+
Shorthand for `insert --on-conflict update --conflict-columns ...`.
|
|
550
|
+
|
|
551
|
+
\b
|
|
552
|
+
Examples:
|
|
553
|
+
anysite api /api/linkedin/user user=satyanadella \\
|
|
554
|
+
| anysite db upsert mydb --table users --conflict-columns linkedin_url --stdin
|
|
555
|
+
"""
|
|
556
|
+
from anysite.db.operations.insert import insert_from_file, insert_from_stdin
|
|
557
|
+
|
|
558
|
+
if not stdin and not file:
|
|
559
|
+
typer.echo("Error: provide --stdin or --file", err=True)
|
|
560
|
+
raise typer.Exit(1)
|
|
561
|
+
|
|
562
|
+
if file and not file.exists():
|
|
563
|
+
typer.echo(f"Error: file not found: {file}", err=True)
|
|
564
|
+
raise typer.Exit(1)
|
|
565
|
+
|
|
566
|
+
conflict_cols = [c.strip() for c in conflict_columns.split(",")]
|
|
567
|
+
|
|
568
|
+
config = _get_config_or_exit(name)
|
|
569
|
+
manager = _get_manager()
|
|
570
|
+
adapter = manager.get_adapter(config)
|
|
571
|
+
|
|
572
|
+
console = Console()
|
|
573
|
+
|
|
574
|
+
with adapter:
|
|
575
|
+
if stdin:
|
|
576
|
+
count = insert_from_stdin(
|
|
577
|
+
adapter,
|
|
578
|
+
table,
|
|
579
|
+
on_conflict=OnConflict.UPDATE,
|
|
580
|
+
conflict_columns=conflict_cols,
|
|
581
|
+
auto_create=auto_create,
|
|
582
|
+
primary_key=pk,
|
|
583
|
+
batch_size=batch_size,
|
|
584
|
+
quiet=quiet,
|
|
585
|
+
)
|
|
586
|
+
else:
|
|
587
|
+
assert file is not None
|
|
588
|
+
count = insert_from_file(
|
|
589
|
+
adapter,
|
|
590
|
+
table,
|
|
591
|
+
file,
|
|
592
|
+
on_conflict=OnConflict.UPDATE,
|
|
593
|
+
conflict_columns=conflict_cols,
|
|
594
|
+
auto_create=auto_create,
|
|
595
|
+
primary_key=pk,
|
|
596
|
+
batch_size=batch_size,
|
|
597
|
+
quiet=quiet,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
if not quiet:
|
|
601
|
+
console.print(f"[green]Upserted[/green] {count} row(s) into '{table}'")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# ── Query command ──────────────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@app.command("query")
|
|
608
|
+
def query(
|
|
609
|
+
name: Annotated[
|
|
610
|
+
str,
|
|
611
|
+
typer.Argument(help="Connection name"),
|
|
612
|
+
],
|
|
613
|
+
sql: Annotated[
|
|
614
|
+
str | None,
|
|
615
|
+
typer.Option("--sql", help="SQL query to execute"),
|
|
616
|
+
] = None,
|
|
617
|
+
file: Annotated[
|
|
618
|
+
Path | None,
|
|
619
|
+
typer.Option("--file", "-f", help="Read SQL from file"),
|
|
620
|
+
] = None,
|
|
621
|
+
format: Annotated[
|
|
622
|
+
str,
|
|
623
|
+
typer.Option("--format", help="Output format (json/jsonl/csv/table)"),
|
|
624
|
+
] = "table",
|
|
625
|
+
output: Annotated[
|
|
626
|
+
Path | None,
|
|
627
|
+
typer.Option("--output", "-o", help="Save output to file"),
|
|
628
|
+
] = None,
|
|
629
|
+
fields: Annotated[
|
|
630
|
+
str | None,
|
|
631
|
+
typer.Option("--fields", help="Comma-separated fields to include"),
|
|
632
|
+
] = None,
|
|
633
|
+
) -> None:
|
|
634
|
+
"""Run a SQL query against a database.
|
|
635
|
+
|
|
636
|
+
\b
|
|
637
|
+
Examples:
|
|
638
|
+
anysite db query mydb --sql "SELECT * FROM users LIMIT 10"
|
|
639
|
+
anysite db query mydb --sql "SELECT * FROM users" --format csv --output users.csv
|
|
640
|
+
anysite db query mydb --file queries/report.sql --format json
|
|
641
|
+
"""
|
|
642
|
+
from anysite.db.operations.query import execute_query, execute_query_from_file
|
|
643
|
+
|
|
644
|
+
if not sql and not file:
|
|
645
|
+
typer.echo("Error: provide --sql or --file", err=True)
|
|
646
|
+
raise typer.Exit(1)
|
|
647
|
+
|
|
648
|
+
if file and not file.exists():
|
|
649
|
+
typer.echo(f"Error: SQL file not found: {file}", err=True)
|
|
650
|
+
raise typer.Exit(1)
|
|
651
|
+
|
|
652
|
+
config = _get_config_or_exit(name)
|
|
653
|
+
manager = _get_manager()
|
|
654
|
+
adapter = manager.get_adapter(config)
|
|
655
|
+
|
|
656
|
+
with adapter:
|
|
657
|
+
try:
|
|
658
|
+
if file:
|
|
659
|
+
results = execute_query_from_file(adapter, file)
|
|
660
|
+
else:
|
|
661
|
+
assert sql is not None
|
|
662
|
+
results = execute_query(adapter, sql)
|
|
663
|
+
except Exception as e:
|
|
664
|
+
typer.echo(f"Query error: {e}", err=True)
|
|
665
|
+
raise typer.Exit(1) from None
|
|
666
|
+
|
|
667
|
+
_output_results(results, format, output, fields)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _output_results(
|
|
671
|
+
data: list[dict[str, Any]],
|
|
672
|
+
format: str = "table",
|
|
673
|
+
output: Path | None = None,
|
|
674
|
+
fields: str | None = None,
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Output query results using the existing formatter pipeline."""
|
|
677
|
+
from anysite.cli.options import parse_fields
|
|
678
|
+
from anysite.output.formatters import OutputFormat, format_output
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
fmt = OutputFormat(format.lower())
|
|
682
|
+
except ValueError:
|
|
683
|
+
typer.echo(f"Error: invalid format '{format}', use json/jsonl/csv/table", err=True)
|
|
684
|
+
raise typer.Exit(1) from None
|
|
685
|
+
|
|
686
|
+
include_fields = parse_fields(fields)
|
|
687
|
+
format_output(data, fmt, include_fields, output, quiet=False)
|