morphy-cli 0.5.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.
morphy_cli/control.py ADDED
@@ -0,0 +1,323 @@
1
+ """Client for Supabase control-plane RPCs and PostgREST tables."""
2
+
3
+ from typing import Any, Optional
4
+ import uuid
5
+ import httpx
6
+
7
+ from .auth import AuthManager, AuthError
8
+
9
+
10
+ class ControlPlaneError(Exception):
11
+ """Error during control plane operations."""
12
+ pass
13
+
14
+
15
+ class ControlClient:
16
+ """Interacts with Supabase PostgREST endpoints and RPCs."""
17
+
18
+ def __init__(self, auth_manager: AuthManager):
19
+ self.auth = auth_manager
20
+
21
+ def _request(
22
+ self,
23
+ method: str,
24
+ path: str,
25
+ params: Optional[dict[str, Any]] = None,
26
+ json_body: Optional[Any] = None,
27
+ ) -> Any:
28
+ token = self.auth.get_valid_token()
29
+ headers = {
30
+ "apikey": self.auth.supabase_key,
31
+ "Authorization": f"Bearer {token}",
32
+ "Content-Type": "application/json",
33
+ "Prefer": "return=representation",
34
+ }
35
+ url = f"{self.auth.supabase_url}{path}"
36
+
37
+ try:
38
+ with httpx.Client(timeout=15.0) as client:
39
+ resp = client.request(method, url, headers=headers, params=params, json=json_body)
40
+ except httpx.HTTPError as exc:
41
+ raise ControlPlaneError(f"Network error accessing control plane: {exc}")
42
+
43
+ if resp.is_error:
44
+ try:
45
+ err = resp.json()
46
+ detail = err.get("message") or err.get("detail") or resp.text
47
+ except Exception:
48
+ detail = resp.text
49
+ raise ControlPlaneError(f"Control plane error ({resp.status_code}): {detail}")
50
+
51
+ if resp.status_code == 204 or not resp.content:
52
+ return None
53
+ return resp.json()
54
+
55
+ # --- Profile ---
56
+
57
+ def get_profile(self) -> Optional[dict[str, Any]]:
58
+ status = self.auth.status()
59
+ user_id = status.get("user_id")
60
+ if not user_id:
61
+ res = self._request("GET", "/rest/v1/profiles?select=*")
62
+ return res[0] if res else None
63
+
64
+ res = self._request("GET", f"/rest/v1/profiles?id=eq.{user_id}&select=*")
65
+ return res[0] if res else None
66
+
67
+ def claim_profile(self, username: str, display_name: str) -> None:
68
+ self._request(
69
+ "POST",
70
+ "/rest/v1/rpc/claim_profile",
71
+ json_body={"handle": username.strip().lower(), "full_name": display_name.strip()},
72
+ )
73
+
74
+ def update_profile(self, display_name: str, avatar_path: Optional[str] = None) -> None:
75
+ try:
76
+ self._request(
77
+ "POST",
78
+ "/rest/v1/rpc/update_profile",
79
+ json_body={"full_name": display_name.strip(), "avatar": avatar_path},
80
+ )
81
+ return
82
+ except Exception:
83
+ pass
84
+
85
+ profile = self.get_profile()
86
+ user_id = profile.get("id") if profile else None
87
+ if not user_id:
88
+ status = self.auth.status()
89
+ user_id = status.get("user_id")
90
+ if not user_id:
91
+ raise ControlPlaneError("Must be authenticated with a claimed profile to edit.")
92
+ self._request(
93
+ "PATCH",
94
+ f"/rest/v1/profiles?id=eq.{user_id}",
95
+ json_body={"display_name": display_name.strip()},
96
+ )
97
+
98
+ # --- Organizations ---
99
+
100
+ def list_organizations(self) -> list[dict[str, Any]]:
101
+ return self._request("GET", "/rest/v1/organizations?select=*&order=name.asc") or []
102
+
103
+ def create_organization(self, name: str) -> str:
104
+ res = self._request(
105
+ "POST",
106
+ "/rest/v1/rpc/create_workspace",
107
+ json_body={"title": name.strip()},
108
+ )
109
+ return str(res)
110
+
111
+ # --- Target Databases ---
112
+
113
+ def list_databases(self, org_id: Optional[str] = None) -> list[dict[str, Any]]:
114
+ query = "/rest/v1/target_databases?select=*&order=name.asc"
115
+ if org_id:
116
+ query += f"&org_id=eq.{org_id}"
117
+ return self._request("GET", query) or []
118
+
119
+ def register_database(self, org_id: str, name: str, dialect: str) -> str:
120
+ res = self._request(
121
+ "POST",
122
+ "/rest/v1/rpc/register_database",
123
+ json_body={"o": org_id, "title": name.strip(), "engine": dialect.strip().lower()},
124
+ )
125
+ return str(res)
126
+
127
+ # --- Members & Roles ---
128
+
129
+ def list_roles(self, org_id: str) -> list[dict[str, Any]]:
130
+ return self._request("GET", f"/rest/v1/roles?org_id=eq.{org_id}&select=*") or []
131
+
132
+ def list_members(self, org_id: str) -> list[dict[str, Any]]:
133
+ return self._request(
134
+ "POST",
135
+ "/rest/v1/rpc/org_people",
136
+ json_body={"o": org_id},
137
+ ) or []
138
+
139
+ def set_member_role(self, org_id: str, user_id: str, role_id: str) -> None:
140
+ self._request(
141
+ "POST",
142
+ "/rest/v1/rpc/set_member_role",
143
+ json_body={"o": org_id, "person": user_id, "r": role_id},
144
+ )
145
+
146
+ # --- Invitations ---
147
+
148
+ def list_invitations(self) -> list[dict[str, Any]]:
149
+ return self._request("GET", "/rest/v1/invitations?select=*&order=created_at.desc") or []
150
+
151
+ def invite_person(self, org_id: str, user_id: str, role_id: str) -> None:
152
+ self._request(
153
+ "POST",
154
+ "/rest/v1/rpc/invite_person",
155
+ json_body={"o": org_id, "person": user_id, "r": role_id},
156
+ )
157
+
158
+ def accept_invitation(self, invitation_id: str) -> None:
159
+ self._request(
160
+ "POST",
161
+ "/rest/v1/rpc/accept_invitation",
162
+ json_body={"inv": invitation_id},
163
+ )
164
+
165
+ def withdraw_invitation(self, invitation_id: str) -> None:
166
+ try:
167
+ self._request(
168
+ "POST",
169
+ "/rest/v1/rpc/revoke_invitation",
170
+ json_body={"inv": invitation_id},
171
+ )
172
+ return
173
+ except Exception:
174
+ pass
175
+
176
+ self._request(
177
+ "DELETE",
178
+ f"/rest/v1/invitations?id=eq.{invitation_id}",
179
+ )
180
+
181
+ def find_people(self, prefix: str) -> list[dict[str, Any]]:
182
+ return self._request(
183
+ "POST",
184
+ "/rest/v1/rpc/find_people",
185
+ json_body={"prefix": prefix.strip().lower()},
186
+ ) or []
187
+
188
+ # --- Dashboards & Panels (004) ---
189
+
190
+ def list_dashboards(
191
+ self, org_id: Optional[str] = None, database_id: Optional[str] = None
192
+ ) -> list[dict[str, Any]]:
193
+ query = "/rest/v1/dashboards?select=*,dashboard_panels(*)&order=updated_at.desc"
194
+ if org_id:
195
+ query += f"&org_id=eq.{org_id}"
196
+ if database_id:
197
+ query += f"&database_id=eq.{database_id}"
198
+ return self._request("GET", query) or []
199
+
200
+ def get_dashboard(self, dashboard_id: str) -> Optional[dict[str, Any]]:
201
+ res = self._request(
202
+ "GET",
203
+ f"/rest/v1/dashboards?id=eq.{dashboard_id}&select=*,dashboard_panels(*)",
204
+ )
205
+ return res[0] if res else None
206
+
207
+ def create_dashboard(
208
+ self, org_id: str, database_id: str, title: str, shared: bool = False
209
+ ) -> str:
210
+ res = self._request(
211
+ "POST",
212
+ "/rest/v1/dashboards",
213
+ json_body={
214
+ "org_id": org_id,
215
+ "database_id": database_id,
216
+ "title": title.strip(),
217
+ "shared": shared,
218
+ },
219
+ )
220
+ if isinstance(res, list) and res:
221
+ return str(res[0].get("id"))
222
+ return str(res)
223
+
224
+ def add_dashboard_panel(
225
+ self, dashboard_id: str, database_id: str, query_id: str, position: int = 0
226
+ ) -> None:
227
+ self._request(
228
+ "POST",
229
+ "/rest/v1/dashboard_panels",
230
+ json_body={
231
+ "dashboard_id": dashboard_id,
232
+ "database_id": database_id,
233
+ "query_id": query_id,
234
+ "position": position,
235
+ },
236
+ )
237
+
238
+ def delete_dashboard(self, dashboard_id: str) -> None:
239
+ self._request("DELETE", f"/rest/v1/dashboards?id=eq.{dashboard_id}")
240
+
241
+ # --- Cloud Saved Queries (003) ---
242
+
243
+ def list_saved_queries(
244
+ self, org_id: Optional[str] = None, database_id: Optional[str] = None
245
+ ) -> list[dict[str, Any]]:
246
+ query = "/rest/v1/saved_queries?select=*&order=updated_at.desc"
247
+ if org_id:
248
+ query += f"&org_id=eq.{org_id}"
249
+ if database_id:
250
+ query += f"&database_id=eq.{database_id}"
251
+ return self._request("GET", query) or []
252
+
253
+ def create_saved_query(
254
+ self, org_id: str, database_id: str, title: str, sql_text: str, shared: bool = False
255
+ ) -> str:
256
+ res = self._request(
257
+ "POST",
258
+ "/rest/v1/saved_queries",
259
+ json_body={
260
+ "org_id": org_id,
261
+ "database_id": database_id,
262
+ "title": title.strip(),
263
+ "sql_text": sql_text.strip(),
264
+ "shared": shared,
265
+ },
266
+ )
267
+ if isinstance(res, list) and res:
268
+ return str(res[0].get("id"))
269
+ return str(res)
270
+
271
+ def delete_saved_query(self, query_id: str) -> None:
272
+ self._request("DELETE", f"/rest/v1/saved_queries?id=eq.{query_id}")
273
+
274
+ # --- Permissions & Access Policy ---
275
+
276
+ def save_permission(
277
+ self,
278
+ org_id: str,
279
+ database_id: str,
280
+ role_id: Optional[str] = None,
281
+ user_id: Optional[str] = None,
282
+ level: str = "QUERY_ONLY",
283
+ cols: Optional[dict[str, list[str]]] = None,
284
+ rules: Optional[list[dict[str, Any]]] = None,
285
+ ) -> None:
286
+ self._request(
287
+ "POST",
288
+ "/rest/v1/rpc/save_permission",
289
+ json_body={
290
+ "o": org_id,
291
+ "d": database_id,
292
+ "r": role_id,
293
+ "person": user_id,
294
+ "level": level,
295
+ "cols": cols or {},
296
+ "rules": rules or [],
297
+ },
298
+ )
299
+
300
+ def revoke_permission(
301
+ self,
302
+ org_id: str,
303
+ database_id: str,
304
+ role_id: Optional[str] = None,
305
+ user_id: Optional[str] = None,
306
+ ) -> None:
307
+ self.save_permission(
308
+ org_id=org_id,
309
+ database_id=database_id,
310
+ role_id=role_id,
311
+ user_id=user_id,
312
+ level="NONE",
313
+ cols={},
314
+ rules=[],
315
+ )
316
+
317
+ def effective_permission(self, database_id: str) -> dict[str, Any]:
318
+ return self._request(
319
+ "POST",
320
+ "/rest/v1/rpc/effective_permission",
321
+ json_body={"d": database_id},
322
+ ) or {"mode": "NONE", "table_columns": {}, "row_rules": []}
323
+
@@ -0,0 +1,143 @@
1
+ """Result formatting for the MORPHY terminal."""
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ from typing import Any, Sequence
7
+
8
+
9
+ def format_table(columns: list[str], rows: list[list], row_count: int, elapsed_ms: float) -> str:
10
+ """Format query results as a readable aligned table."""
11
+ if not columns:
12
+ return f"(empty result · {elapsed_ms} ms)"
13
+
14
+ # Convert values to display strings, respecting NULLs.
15
+ str_rows = []
16
+ for row in rows:
17
+ str_rows.append(["\u2205" if v is None else str(v) for v in row])
18
+
19
+ # Compute column widths.
20
+ widths = [len(c) for c in columns]
21
+ for row in str_rows:
22
+ for i, cell in enumerate(row):
23
+ widths[i] = max(widths[i], len(cell))
24
+
25
+ # Cap each column at 48 characters for readability.
26
+ widths = [min(w, 48) for w in widths]
27
+
28
+ def truncate(text: str, width: int) -> str:
29
+ return text if len(text) <= width else text[: width - 1] + "\u2026"
30
+
31
+ lines = []
32
+ header = " \u2502 ".join(truncate(c, w).ljust(w) for c, w in zip(columns, widths))
33
+ lines.append(header)
34
+ lines.append("\u2500" * len(header))
35
+ for row in str_rows:
36
+ line = " \u2502 ".join(truncate(cell, w).ljust(w) for cell, w in zip(row, widths))
37
+ lines.append(line)
38
+
39
+ capped = " (capped)" if row_count >= 1000 else ""
40
+ lines.append(f"\n{row_count} row{'s' if row_count != 1 else ''}{capped} · {elapsed_ms} ms")
41
+ return "\n".join(lines)
42
+
43
+
44
+ def format_csv(columns: list[str], rows: list[list]) -> str:
45
+ """Export query results as a standard RFC-4180 CSV string."""
46
+ output = io.StringIO()
47
+ writer = csv.writer(output, lineterminator="\n")
48
+ writer.writerow(columns)
49
+ for row in rows:
50
+ writer.writerow(["" if cell is None else cell for cell in row])
51
+ return output.getvalue()
52
+
53
+
54
+ def format_records_table(headers: list[str], items: list[dict[str, Any]], keys: list[str]) -> str:
55
+ """Format an arbitrary list of objects as an aligned CLI table."""
56
+ if not items:
57
+ return "(none)"
58
+ rows = [[str(item.get(k, "")) for k in keys] for item in items]
59
+ return format_table(headers, rows, len(items), 0.0).split("\n\n")[0]
60
+
61
+
62
+ def format_schema(tables: dict) -> str:
63
+ """Format schema metadata as an indented tree."""
64
+ if not tables:
65
+ return "(no permitted tables)"
66
+ lines = []
67
+ for table, columns in tables.items():
68
+ lines.append(f" {table}")
69
+ if isinstance(columns, dict):
70
+ for col, dtype in columns.items():
71
+ lines.append(f" {col}: {dtype}")
72
+ elif isinstance(columns, list):
73
+ for col in columns:
74
+ lines.append(f" {col}")
75
+ return "\n".join(lines)
76
+
77
+
78
+ def format_examples(examples: list[str]) -> str:
79
+ """Format example question titles."""
80
+ if not examples:
81
+ return "(no examples available)"
82
+ return "\n".join(f" • {title}" for title in examples)
83
+
84
+
85
+ def format_generation(gen: dict) -> str:
86
+ """Format a generation response showing SQL and metadata."""
87
+ lines = []
88
+ lines.append(gen["sql"])
89
+ meta = []
90
+ if gen.get("provider"):
91
+ meta.append(gen["provider"])
92
+ if gen.get("attempts"):
93
+ meta.append(f"{gen['attempts']} attempt{'s' if gen['attempts'] != 1 else ''}")
94
+ if gen.get("cache"):
95
+ meta.append(f"cache {gen['cache']}")
96
+ if meta:
97
+ lines.append(f"({' · '.join(meta)})")
98
+ return "\n".join(lines)
99
+
100
+
101
+ def format_json(data: Any) -> str:
102
+ """Pretty-print arbitrary data as JSON."""
103
+ return json.dumps(data, indent=2, default=str)
104
+
105
+
106
+ def format_banner(
107
+ *,
108
+ version: str = "0.5.0",
109
+ engine_url: str = "http://127.0.0.1:8000",
110
+ database: Any = None,
111
+ user_email: Any = None,
112
+ org_name: Any = None,
113
+ ) -> str:
114
+ """Render the stylish Claude-Code-inspired ASCII brand banner."""
115
+ ORANGE = "\033[38;5;208m"
116
+ DIM = "\033[90m"
117
+ BOLD = "\033[1m"
118
+ GREEN = "\033[92m"
119
+ RESET = "\033[0m"
120
+ WHITE = "\033[97m"
121
+ CYAN = "\033[96m"
122
+
123
+ logo = f"""
124
+ {ORANGE}{BOLD} ███╗ ███╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗██╗ ██╗
125
+ ████╗ ████║██╔═══██╗██╔══██╗██╔══██╗██║ ██║╚██╗ ██╔╝
126
+ ██╔████╔██║██║ ██║██████╔╝██████╔╝███████║ ╚████╔╝
127
+ ██║╚██╔╝██║██║ ██║██╔══██╗██╔═══╝ ██╔══██║ ╚██╔╝
128
+ ██║ ╚═╝ ██║╚██████╔╝██║ ██║██║ ██║ ██║ ██║
129
+ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ {RESET}
130
+ {DIM}Governed Natural-Language Database Terminal{RESET} {CYAN}v{version}{RESET}"""
131
+
132
+ lines = [logo]
133
+ if database:
134
+ db_label = f"{org_name}/{database}" if org_name else str(database)
135
+ lines.append(f" {GREEN}●{RESET} Database: {WHITE}{BOLD}{db_label}{RESET} {DIM}(live query mode){RESET}")
136
+ if user_email:
137
+ lines.append(f" {GREEN}●{RESET} User: {WHITE}{BOLD}{user_email}{RESET}")
138
+ else:
139
+ lines.append(f" {GREEN}●{RESET} Workspace: {WHITE}{BOLD}SAMPLE{RESET} {DIM}(synthetic commerce · APAC policy){RESET}")
140
+
141
+ lines.append(f" {GREEN}●{RESET} Engine: {WHITE}{BOLD}{engine_url}{RESET}")
142
+ lines.append(f"\n {DIM}Type {CYAN}/help{DIM} for commands · {CYAN}Tab{DIM} to autocomplete · {CYAN}Ctrl+C{DIM} to clear · {CYAN}Ctrl+D{DIM} to exit{RESET}\n")
143
+ return "\n".join(lines)
morphy_cli/main.py ADDED
@@ -0,0 +1,113 @@
1
+ """MORPHY governed query terminal.
2
+
3
+ Supports both interactive (REPL with prompt-toolkit) and noninteractive
4
+ (--sql) modes. Install with ``python -m pip install -e ./cli``.
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+ from uuid import UUID
10
+
11
+ from .auth import AuthManager
12
+ from .client import EngineError, MorphyClient
13
+ from .formatting import format_csv, format_json, format_table
14
+
15
+ import httpx
16
+
17
+
18
+ def main():
19
+ parser = argparse.ArgumentParser(
20
+ prog="morphy",
21
+ description="MORPHY — governed natural-language database terminal",
22
+ epilog=(
23
+ "Examples:\n"
24
+ " morphy Open the sample REPL\n"
25
+ " morphy --database <UUID> Query an organization database\n"
26
+ " morphy --sql 'SELECT 1' Run one query and exit\n"
27
+ " morphy --sql 'SELECT 1' --json Output results in JSON format\n"
28
+ " morphy --sql 'SELECT 1' --csv Output results in CSV format\n"
29
+ ),
30
+ formatter_class=argparse.RawDescriptionHelpFormatter,
31
+ )
32
+ parser.add_argument(
33
+ "--url",
34
+ default="http://127.0.0.1:8000",
35
+ help="Engine base URL (default: http://127.0.0.1:8000)",
36
+ )
37
+ parser.add_argument(
38
+ "--sql",
39
+ metavar="QUERY",
40
+ help="Run one read-only SQL query and exit",
41
+ )
42
+ parser.add_argument(
43
+ "--database",
44
+ type=UUID,
45
+ metavar="UUID",
46
+ help="Query an organization database using saved session or MORPHY_ACCESS_TOKEN",
47
+ )
48
+ parser.add_argument(
49
+ "--json",
50
+ action="store_true",
51
+ help="Output noninteractive query results as JSON",
52
+ )
53
+ parser.add_argument(
54
+ "--csv",
55
+ action="store_true",
56
+ help="Output noninteractive query results as CSV",
57
+ )
58
+ args = parser.parse_args()
59
+
60
+ auth = AuthManager()
61
+ auth.restore_session()
62
+
63
+ try:
64
+ client = MorphyClient(
65
+ args.url,
66
+ args.database,
67
+ token=lambda: auth.get_valid_token(),
68
+ )
69
+ except SystemExit as exc:
70
+ parser.error(str(exc))
71
+
72
+ # Noninteractive mode: execute single SQL statement and exit
73
+ if args.sql:
74
+ try:
75
+ result = client.query(args.sql)
76
+ if args.json:
77
+ print(format_json(result))
78
+ elif args.csv:
79
+ print(format_csv(result["columns"], result["rows"]), end="")
80
+ else:
81
+ print(
82
+ format_table(
83
+ result["columns"],
84
+ result["rows"],
85
+ result["row_count"],
86
+ result["elapsed_ms"],
87
+ )
88
+ )
89
+ except EngineError as error:
90
+ print(f"Error: {error.detail}", file=sys.stderr)
91
+ if error.status_code >= 500:
92
+ sys.exit(2)
93
+ sys.exit(1)
94
+ except httpx.HTTPError as error:
95
+ print(f"Network error: {error}", file=sys.stderr)
96
+ sys.exit(2)
97
+ except KeyboardInterrupt:
98
+ print("\nCancelled.", file=sys.stderr)
99
+ sys.exit(130)
100
+ finally:
101
+ client.close()
102
+ return
103
+
104
+ # Interactive REPL mode
105
+ try:
106
+ from .shell import run_repl
107
+
108
+ run_repl(client, auth_manager=auth)
109
+ except KeyboardInterrupt:
110
+ print()
111
+ sys.exit(130)
112
+ finally:
113
+ client.close()
morphy_cli/queries.py ADDED
@@ -0,0 +1,75 @@
1
+ """Persistent saved queries manager for MORPHY CLI."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import time
6
+ from typing import Any, Optional
7
+
8
+ CONFIG_DIR = Path.home() / ".morphy"
9
+ QUERIES_FILE = CONFIG_DIR / "queries.json"
10
+
11
+
12
+ class QueryStore:
13
+ """Manages saved queries persisted in ~/.morphy/queries.json."""
14
+
15
+ def __init__(self, filepath: Optional[Path] = None):
16
+ self.filepath = filepath or QUERIES_FILE
17
+
18
+ def _load(self) -> dict[str, dict[str, Any]]:
19
+ if not self.filepath.is_file():
20
+ return {}
21
+ try:
22
+ data = json.loads(self.filepath.read_text(encoding="utf-8"))
23
+ return data if isinstance(data, dict) else {}
24
+ except Exception:
25
+ return {}
26
+
27
+ def _save(self, data: dict[str, dict[str, Any]]) -> None:
28
+ self.filepath.parent.mkdir(parents=True, exist_ok=True)
29
+ self.filepath.write_text(json.dumps(data, indent=2), encoding="utf-8")
30
+
31
+ def save(
32
+ self,
33
+ name: str,
34
+ sql: str,
35
+ database_id: Optional[str] = None,
36
+ description: str = "",
37
+ ) -> dict[str, Any]:
38
+ """Save or update a query by name."""
39
+ clean_name = name.strip()
40
+ data = self._load()
41
+ record = {
42
+ "name": clean_name,
43
+ "sql": sql.strip(),
44
+ "database_id": database_id or "sample",
45
+ "description": description.strip(),
46
+ "created_at": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()),
47
+ }
48
+ data[clean_name] = record
49
+ self._save(data)
50
+ return record
51
+
52
+ def list(self, database_id: Optional[str] = None) -> list[dict[str, Any]]:
53
+ """List all saved queries, optionally filtered by database."""
54
+ data = self._load()
55
+ records = list(data.values())
56
+ if database_id:
57
+ records = [r for r in records if r.get("database_id") in (database_id, "sample")]
58
+ records.sort(key=lambda r: r.get("name", ""))
59
+ return records
60
+
61
+ def get(self, name: str) -> Optional[dict[str, Any]]:
62
+ """Retrieve a saved query by name."""
63
+ data = self._load()
64
+ return data.get(name.strip())
65
+
66
+ def delete(self, name: str) -> bool:
67
+ """Delete a saved query by name. Returns True if found and deleted."""
68
+ clean_name = name.strip()
69
+ data = self._load()
70
+ if clean_name in data:
71
+ del data[clean_name]
72
+ self._save(data)
73
+ return True
74
+ return False
75
+