gaard-api 0.2.0__tar.gz → 0.2.2__tar.gz

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 (63) hide show
  1. {gaard_api-0.2.0/src/gaard_api.egg-info → gaard_api-0.2.2}/PKG-INFO +19 -6
  2. {gaard_api-0.2.0 → gaard_api-0.2.2}/pyproject.toml +16 -6
  3. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin/database.py +41 -22
  4. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin/models.py +1 -0
  5. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin/services.py +274 -10
  6. gaard_api-0.2.2/src/gaard_api/admin-web/assets/getgaard.svg +87 -0
  7. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin-web/assets/main.js +849 -98
  8. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin-web/assets/styles.css +308 -12
  9. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/api/v1/admin.py +342 -53
  10. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/api/v1/analysis.py +15 -12
  11. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/api/v1/query.py +273 -107
  12. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/cli_commands.py +5 -2
  13. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/core/settings.py +34 -0
  14. gaard_api-0.2.2/src/gaard_api/extensions.py +125 -0
  15. gaard_api-0.2.2/src/gaard_api/license.py +899 -0
  16. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/main.py +27 -5
  17. gaard_api-0.2.2/src/gaard_api/package_updates.py +1007 -0
  18. gaard_api-0.2.2/src/gaard_api/query_hooks.py +322 -0
  19. {gaard_api-0.2.0 → gaard_api-0.2.2/src/gaard_api.egg-info}/PKG-INFO +19 -6
  20. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api.egg-info/SOURCES.txt +6 -1
  21. gaard_api-0.2.2/src/gaard_api.egg-info/requires.txt +34 -0
  22. {gaard_api-0.2.0 → gaard_api-0.2.2}/tests/test_admin_api.py +575 -1
  23. {gaard_api-0.2.0 → gaard_api-0.2.2}/tests/test_api_registry.py +74 -1
  24. {gaard_api-0.2.0 → gaard_api-0.2.2}/tests/test_error_handlers.py +25 -0
  25. gaard_api-0.2.2/tests/test_license_api.py +425 -0
  26. gaard_api-0.2.2/tests/test_license_service.py +324 -0
  27. gaard_api-0.2.0/src/gaard_api/admin-web/src/main.ts +0 -2755
  28. gaard_api-0.2.0/src/gaard_api/extensions.py +0 -50
  29. gaard_api-0.2.0/src/gaard_api.egg-info/requires.txt +0 -16
  30. {gaard_api-0.2.0 → gaard_api-0.2.2}/MANIFEST.in +0 -0
  31. {gaard_api-0.2.0 → gaard_api-0.2.2}/README.md +0 -0
  32. {gaard_api-0.2.0 → gaard_api-0.2.2}/setup.cfg +0 -0
  33. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/__init__.py +0 -0
  34. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin/__init__.py +0 -0
  35. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin/defaults.py +0 -0
  36. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin/prompt_runtime.py +0 -0
  37. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin/security.py +0 -0
  38. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin-web/index.html +0 -0
  39. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/admin-web/package.json +0 -0
  40. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/api/__init__.py +0 -0
  41. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/api/v1/__init__.py +0 -0
  42. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/api/v1/prompts.py +0 -0
  43. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/api/v1/schema.py +0 -0
  44. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/api_registry.py +0 -0
  45. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/cli.py +0 -0
  46. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/core/__init__.py +0 -0
  47. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/core/error_handlers.py +0 -0
  48. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/core/schema_cache.py +0 -0
  49. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/example_data/__init__.py +0 -0
  50. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  51. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  52. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  53. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/example_database.py +0 -0
  54. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/extension_services.py +0 -0
  55. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api/server_cli.py +0 -0
  56. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  57. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api.egg-info/entry_points.txt +0 -0
  58. {gaard_api-0.2.0 → gaard_api-0.2.2}/src/gaard_api.egg-info/top_level.txt +0 -0
  59. {gaard_api-0.2.0 → gaard_api-0.2.2}/tests/test_analysis_api.py +0 -0
  60. {gaard_api-0.2.0 → gaard_api-0.2.2}/tests/test_prompt_runtime.py +0 -0
  61. {gaard_api-0.2.0 → gaard_api-0.2.2}/tests/test_query_error_messages.py +0 -0
  62. {gaard_api-0.2.0 → gaard_api-0.2.2}/tests/test_server_cli.py +0 -0
  63. {gaard_api-0.2.0 → gaard_api-0.2.2}/tests/test_settings.py +0 -0
@@ -1,18 +1,31 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-core==0.2.0
8
- Requires-Dist: gaard-connectors==0.2.0
9
- Requires-Dist: gaard-llm==0.2.0
10
- Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.0
7
+ Requires-Dist: gaard-core==0.2.2
8
+ Requires-Dist: gaard-connectors==0.2.2
9
+ Requires-Dist: gaard-llm==0.2.2
10
+ Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.2
11
11
  Requires-Dist: fastapi>=0.111.0
12
12
  Requires-Dist: uvicorn[standard]>=0.30.0
13
13
  Requires-Dist: pydantic>=2.7.0
14
14
  Requires-Dist: sqlalchemy>=2.0.0
15
- Requires-Dist: httpx>=0.27.0
15
+ Requires-Dist: httpx2>=2.5.0
16
+ Provides-Extra: oracle
17
+ Requires-Dist: oracledb>=2.0.0; extra == "oracle"
18
+ Provides-Extra: mssql
19
+ Requires-Dist: pyodbc>=5.0.0; extra == "mssql"
20
+ Provides-Extra: ibm-db2
21
+ Requires-Dist: ibm-db-sa>=0.4.4; extra == "ibm-db2"
22
+ Provides-Extra: teradata
23
+ Requires-Dist: teradatasqlalchemy>=20.0.0.9; extra == "teradata"
24
+ Provides-Extra: all-datasources
25
+ Requires-Dist: oracledb>=2.0.0; extra == "all-datasources"
26
+ Requires-Dist: pyodbc>=5.0.0; extra == "all-datasources"
27
+ Requires-Dist: ibm-db-sa>=0.4.4; extra == "all-datasources"
28
+ Requires-Dist: teradatasqlalchemy>=20.0.0.9; extra == "all-datasources"
16
29
  Provides-Extra: dev
17
30
  Requires-Dist: pytest>=8.0.0; extra == "dev"
18
31
  Requires-Dist: ruff>=0.5.0; extra == "dev"
@@ -4,24 +4,34 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "GAARD backend web services providing admin interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
 
12
12
  dependencies = [
13
- "gaard-core==0.2.0",
14
- "gaard-connectors==0.2.0",
15
- "gaard-llm==0.2.0",
16
- "gaard-plugin-api>=0.2.0,<0.3.0",
13
+ "gaard-core==0.2.2",
14
+ "gaard-connectors==0.2.2",
15
+ "gaard-llm==0.2.2",
16
+ "gaard-plugin-api>=0.2.2,<0.3.0",
17
17
  "fastapi>=0.111.0",
18
18
  "uvicorn[standard]>=0.30.0",
19
19
  "pydantic>=2.7.0",
20
20
  "sqlalchemy>=2.0.0",
21
- "httpx>=0.27.0"
21
+ "httpx2>=2.5.0"
22
22
  ]
23
23
 
24
24
  [project.optional-dependencies]
25
+ oracle = ["oracledb>=2.0.0"]
26
+ mssql = ["pyodbc>=5.0.0"]
27
+ ibm_db2 = ["ibm-db-sa>=0.4.4"]
28
+ teradata = ["teradatasqlalchemy>=20.0.0.9"]
29
+ all-datasources = [
30
+ "oracledb>=2.0.0",
31
+ "pyodbc>=5.0.0",
32
+ "ibm-db-sa>=0.4.4",
33
+ "teradatasqlalchemy>=20.0.0.9",
34
+ ]
25
35
  dev = [
26
36
  "pytest>=8.0.0",
27
37
  "ruff>=0.5.0",
@@ -1,9 +1,10 @@
1
1
  from collections.abc import Iterator
2
2
  import json
3
+ import threading
3
4
 
4
5
  from sqlalchemy import create_engine, delete, select
5
6
  from sqlalchemy.engine import Engine
6
- from sqlalchemy import inspect, text
7
+ from sqlalchemy import inspect, text, Table
7
8
  from sqlalchemy.orm import Session, sessionmaker
8
9
 
9
10
  from gaard_api.admin.defaults import DEFAULT_GOVERNANCE_POLICY_CONFIG, DEFAULT_PROMPTS
@@ -20,10 +21,13 @@ from gaard_api.admin.models import (
20
21
  from gaard_api.admin.security import hash_password
21
22
  from gaard_api.core.settings import settings
22
23
 
24
+ from typing import cast
25
+
23
26
 
24
27
  _engine: Engine | None = None
25
28
  _session_factory: sessionmaker[Session] | None = None
26
29
  _engine_url: str | None = None
30
+ _init_lock = threading.RLock()
27
31
  LEGACY_PROMPT_KEYS = {"investigation_readiness"}
28
32
 
29
33
 
@@ -66,23 +70,24 @@ def create_session() -> Session:
66
70
 
67
71
 
68
72
  def init_metadata_store() -> None:
69
- engine = get_engine()
70
- Base.metadata.create_all(engine)
71
- ensure_data_query_audit_schema(engine)
72
- ensure_overview_widget_schema(engine)
73
-
74
- if _session_factory is None:
75
- raise RuntimeError("Admin metadata session factory is not initialized.")
76
-
77
- with _session_factory() as session:
78
- seed_admin_user(session)
79
- seed_settings(session)
80
- apply_runtime_settings(session)
81
- seed_prompts(session)
82
- seed_datasource_connectors(session)
83
- seed_overview_widgets(session)
84
- backfill_data_query_audit_types(session)
85
- session.commit()
73
+ with _init_lock:
74
+ engine = get_engine()
75
+ Base.metadata.create_all(engine)
76
+ ensure_data_query_audit_schema(engine)
77
+ ensure_overview_widget_schema(engine)
78
+
79
+ if _session_factory is None:
80
+ raise RuntimeError("Admin metadata session factory is not initialized.")
81
+
82
+ with _session_factory() as session:
83
+ seed_admin_user(session)
84
+ seed_settings(session)
85
+ apply_runtime_settings(session)
86
+ seed_prompts(session)
87
+ seed_datasource_connectors(session)
88
+ seed_overview_widgets(session)
89
+ backfill_data_query_audit_types(session)
90
+ session.commit()
86
91
 
87
92
 
88
93
  def seed_admin_user(session: Session) -> None:
@@ -129,7 +134,6 @@ def seed_settings(session: Session) -> None:
129
134
  ),
130
135
  "data_query_audit_retention_days": str(settings.gaard_audit_retention_days),
131
136
  "schema_cache_ttl_seconds": str(settings.gaard_schema_cache_ttl_seconds),
132
- "license_edition": "community",
133
137
  }
134
138
 
135
139
  for key, value in defaults.items():
@@ -139,6 +143,9 @@ def seed_settings(session: Session) -> None:
139
143
  elif setting.updated_by == "system" and setting.value != value:
140
144
  setting.value = value
141
145
 
146
+ if session.get(AdminSetting, "license_edition") is None:
147
+ session.add(AdminSetting(key="license_edition", value="community"))
148
+
142
149
 
143
150
  def apply_runtime_settings(session: Session) -> None:
144
151
  schema_cache_ttl = session.get(AdminSetting, "schema_cache_ttl_seconds")
@@ -374,8 +381,8 @@ def seed_overview_widgets(session: Session) -> None:
374
381
  existing.active = False
375
382
 
376
383
  if existing is not None and existing.updated_by == "system":
377
- existing.position = int(item["position"])
378
- existing.grid_width = int(item["grid_width"])
384
+ existing.position = int(cast(int,item["position"]))
385
+ existing.grid_width = int(cast(int,item["grid_width"]))
379
386
  existing.result_mode = str(item["result_mode"])
380
387
 
381
388
 
@@ -406,8 +413,20 @@ def ensure_data_query_audit_schema(engine: Engine) -> None:
406
413
  )
407
414
  )
408
415
 
416
+ if "llm_sql_language" not in columns:
417
+ with engine.begin() as connection:
418
+ connection.execute(
419
+ text(
420
+ "ALTER TABLE data_query_audit_logs "
421
+ "ADD COLUMN llm_sql_language VARCHAR(50) DEFAULT ''"
422
+ )
423
+ )
424
+
409
425
  with engine.begin() as connection:
410
- for index in DataQueryAuditLog.__table__.indexes:
426
+
427
+ audit_log_table = cast(Table, DataQueryAuditLog.__table__)
428
+
429
+ for index in audit_log_table.indexes:
411
430
  index.create(bind=connection, checkfirst=True)
412
431
 
413
432
 
@@ -89,6 +89,7 @@ class DataQueryAuditLog(Base):
89
89
  question: Mapped[str] = mapped_column(Text)
90
90
  answer: Mapped[str] = mapped_column(Text)
91
91
  sql: Mapped[str] = mapped_column(Text)
92
+ llm_sql_language: Mapped[str] = mapped_column(String(50), default="")
92
93
  output_classification: Mapped[OutputClassification] = mapped_column(
93
94
  SAEnum(
94
95
  OutputClassification,
@@ -2,8 +2,10 @@ import json
2
2
  import re
3
3
  from dataclasses import dataclass
4
4
  from datetime import UTC, datetime, timedelta
5
- from typing import Any
5
+ from pathlib import Path
6
+ from typing import Any, cast
6
7
 
8
+ from sqlalchemy.engine import URL
7
9
  from sqlalchemy import delete, desc, select
8
10
  from sqlalchemy.exc import SQLAlchemyError
9
11
  from sqlalchemy.orm import Session
@@ -53,6 +55,209 @@ def validate_datasource_configuration(
53
55
  get_connector_registry().validate(database_type, database_url, sql_dialect)
54
56
 
55
57
 
58
+ @dataclass(frozen=True)
59
+ class NormalizedDatasourceConfiguration:
60
+ database_type: str
61
+ database_url: str
62
+ sql_dialect: str
63
+
64
+
65
+ def normalize_datasource_configuration(
66
+ database_type: str,
67
+ connection_config: dict[str, Any] | None = None,
68
+ database_path: str | None = None,
69
+ database_url: str | None = None,
70
+ sql_dialect: str | None = None,
71
+ ) -> NormalizedDatasourceConfiguration:
72
+ registry = get_connector_registry()
73
+ normalized_url = (database_url or "").strip()
74
+ normalized_path = (database_path or "").strip()
75
+
76
+ if not normalized_url and connection_config:
77
+ normalized_url = build_sqlalchemy_url_from_connection_config(
78
+ database_type,
79
+ connection_config,
80
+ )
81
+
82
+ if not normalized_url and normalized_path:
83
+ normalized_url = build_sqlalchemy_url_from_path(database_type, normalized_path)
84
+
85
+ if not normalized_url:
86
+ raise ValueError("Datasource database path is required.")
87
+
88
+ if database_type:
89
+ definition = registry.get(database_type)
90
+ else:
91
+ definition = registry.detect_from_database_url(normalized_url)
92
+
93
+ resolved_dialect = (sql_dialect or "").strip() or definition.default_sql_dialect
94
+ registry.validate(definition.type_key, normalized_url, resolved_dialect)
95
+
96
+ return NormalizedDatasourceConfiguration(
97
+ database_type=definition.type_key,
98
+ database_url=normalized_url,
99
+ sql_dialect=resolved_dialect,
100
+ )
101
+
102
+
103
+ def build_sqlalchemy_url_from_path(database_type: str, database_path: str) -> str:
104
+ if "://" in database_path:
105
+ return database_path
106
+
107
+ if database_type == "sqlite":
108
+ return str(URL.create("sqlite", database=str(Path(database_path).expanduser())))
109
+
110
+ raise ValueError(
111
+ "Only SQLite datasources can be created from a filesystem path. "
112
+ "Use a SQLAlchemy URL for this datasource type."
113
+ )
114
+
115
+
116
+ def build_sqlalchemy_url_from_connection_config(
117
+ database_type: str,
118
+ connection_config: dict[str, Any],
119
+ ) -> str:
120
+ if database_type == "sqlite":
121
+ database_path = str(connection_config.get("database_path") or "").strip()
122
+ if not database_path:
123
+ raise ValueError("SQLite datasource requires a database path.")
124
+ return build_sqlalchemy_url_from_path(database_type, database_path)
125
+
126
+ if database_type == "postgresql":
127
+ return build_server_database_url(
128
+ drivername="postgresql+psycopg",
129
+ connection_config=connection_config,
130
+ default_port=5432,
131
+ query_keys=("sslmode",),
132
+ )
133
+
134
+ if database_type == "mysql":
135
+ return build_server_database_url(
136
+ drivername="mysql+pymysql",
137
+ connection_config=connection_config,
138
+ default_port=3306,
139
+ query_keys=("charset",),
140
+ )
141
+
142
+ if database_type == "oracle":
143
+ return build_server_database_url(
144
+ drivername="oracle+oracledb",
145
+ connection_config=connection_config,
146
+ default_port=1521,
147
+ query_keys=("service_name",),
148
+ database_required=False,
149
+ required_query_keys=("service_name",),
150
+ required_query_key_labels={"service_name": "service name"},
151
+ )
152
+
153
+ if database_type == "mssql":
154
+ return build_server_database_url(
155
+ drivername="mssql+pyodbc",
156
+ connection_config=connection_config,
157
+ default_port=1433,
158
+ query_keys=("driver", "Encrypt", "TrustServerCertificate"),
159
+ )
160
+
161
+ if database_type == "ibm_db2":
162
+ return build_server_database_url(
163
+ drivername="db2+ibm_db",
164
+ connection_config=connection_config,
165
+ default_port=50000,
166
+ query_keys=(),
167
+ )
168
+
169
+ if database_type == "teradata":
170
+ return build_teradata_database_url(connection_config)
171
+
172
+ raise ValueError(f"Datasource type {database_type!r} does not support generated URLs.")
173
+
174
+
175
+ def build_server_database_url(
176
+ drivername: str,
177
+ connection_config: dict[str, Any],
178
+ default_port: int,
179
+ query_keys: tuple[str, ...],
180
+ database_required: bool = True,
181
+ required_query_keys: tuple[str, ...] = (),
182
+ required_query_key_labels: dict[str, str] | None = None,
183
+ ) -> str:
184
+ host = str(connection_config.get("host") or "").strip()
185
+ database = str(connection_config.get("database") or "").strip()
186
+ username = str(connection_config.get("username") or "").strip()
187
+ password = str(connection_config.get("password") or "")
188
+ port_value = connection_config.get("port") or default_port
189
+
190
+ if not host:
191
+ raise ValueError("Datasource host is required.")
192
+ if database_required and not database:
193
+ raise ValueError("Datasource database name is required.")
194
+ if not username:
195
+ raise ValueError("Datasource username is required.")
196
+
197
+ try:
198
+ port = int(port_value)
199
+ except (TypeError, ValueError) as exc:
200
+ raise ValueError("Datasource port must be a number.") from exc
201
+
202
+ labels = required_query_key_labels or {}
203
+ for key in required_query_keys:
204
+ if not str(connection_config.get(key) or "").strip():
205
+ label = labels.get(key, key.replace("_", " "))
206
+ raise ValueError(f"Datasource {label} is required.")
207
+
208
+ query = {
209
+ key: str(connection_config.get(key)).strip()
210
+ for key in query_keys
211
+ if connection_config.get(key) not in (None, "")
212
+ }
213
+
214
+ return (
215
+ URL.create(
216
+ drivername=drivername,
217
+ username=username,
218
+ password=password or None,
219
+ host=host,
220
+ port=port,
221
+ database=database or None,
222
+ query=query,
223
+ )
224
+ .render_as_string(hide_password=False)
225
+ )
226
+
227
+
228
+ def build_teradata_database_url(connection_config: dict[str, Any]) -> str:
229
+ host = str(connection_config.get("host") or "").strip()
230
+ username = str(connection_config.get("username") or "").strip()
231
+ password = str(connection_config.get("password") or "")
232
+ dbs_port_value = connection_config.get("dbs_port") or 1025
233
+
234
+ if not host:
235
+ raise ValueError("Datasource host is required.")
236
+ if not username:
237
+ raise ValueError("Datasource username is required.")
238
+
239
+ try:
240
+ dbs_port = int(dbs_port_value)
241
+ except (TypeError, ValueError) as exc:
242
+ raise ValueError("Datasource port must be a number.") from exc
243
+
244
+ query = {"dbs_port": str(dbs_port)}
245
+ for key in ("database", "tmode"):
246
+ if connection_config.get(key) not in (None, ""):
247
+ query[key] = str(connection_config.get(key)).strip()
248
+
249
+ return (
250
+ URL.create(
251
+ drivername="teradatasql",
252
+ username=username,
253
+ password=password or None,
254
+ host=host,
255
+ query=query,
256
+ )
257
+ .render_as_string(hide_password=False)
258
+ )
259
+
260
+
56
261
  def mask_database_url(database_url: str) -> str:
57
262
  if "://" not in database_url or "@" not in database_url:
58
263
  return database_url
@@ -280,6 +485,7 @@ def _record_data_query_audit(
280
485
  audit_metadata = dict(metadata)
281
486
  audit_metadata.pop("audit_type", None)
282
487
  audit_metadata.pop("output_classification", None)
488
+ llm_sql_language = str(audit_metadata.get("llm_sql_language") or "")
283
489
  log = DataQueryAuditLog(
284
490
  type=coerce_data_query_audit_type(audit_type),
285
491
  output_classification=coerce_output_classification(output_classification),
@@ -288,6 +494,7 @@ def _record_data_query_audit(
288
494
  question=request.question,
289
495
  answer=answer,
290
496
  sql=sql,
497
+ llm_sql_language=llm_sql_language,
291
498
  metadata_json=json_dumps(audit_metadata),
292
499
  )
293
500
  session.add(log)
@@ -352,7 +559,7 @@ def set_setting(session: Session, key: str, value: str, actor: str) -> AdminSett
352
559
 
353
560
 
354
561
  def default_governance_policy_config() -> dict[str, Any]:
355
- return json.loads(json_dumps(DEFAULT_GOVERNANCE_POLICY_CONFIG))
562
+ return cast(dict[str, Any], json.loads(json_dumps(DEFAULT_GOVERNANCE_POLICY_CONFIG)))
356
563
 
357
564
 
358
565
  def normalize_bool_setting(value: Any, field_name: str) -> bool:
@@ -1029,10 +1236,20 @@ def list_business_logic_suggestions(
1029
1236
  session: Session,
1030
1237
  connector_id: int,
1031
1238
  ) -> list[BusinessLogicSuggestion]:
1239
+ return list_business_logic_suggestions_for_connectors(session, [connector_id])
1240
+
1241
+
1242
+ def list_business_logic_suggestions_for_connectors(
1243
+ session: Session,
1244
+ connector_ids: list[int],
1245
+ ) -> list[BusinessLogicSuggestion]:
1246
+ if not connector_ids:
1247
+ return []
1248
+
1032
1249
  return list(
1033
1250
  session.scalars(
1034
1251
  select(BusinessLogicSuggestion)
1035
- .where(BusinessLogicSuggestion.connector_id == connector_id)
1252
+ .where(BusinessLogicSuggestion.connector_id.in_(connector_ids))
1036
1253
  .order_by(
1037
1254
  BusinessLogicSuggestion.enabled.desc(),
1038
1255
  desc(BusinessLogicSuggestion.updated_at),
@@ -1840,6 +2057,16 @@ def get_active_datasource_connector(session: Session) -> DatasourceConnector | N
1840
2057
  return session.scalar(select(DatasourceConnector).where(DatasourceConnector.active.is_(True)))
1841
2058
 
1842
2059
 
2060
+ def get_active_datasource_connectors(session: Session) -> list[DatasourceConnector]:
2061
+ return list(
2062
+ session.scalars(
2063
+ select(DatasourceConnector)
2064
+ .where(DatasourceConnector.active.is_(True))
2065
+ .order_by(DatasourceConnector.name, DatasourceConnector.id)
2066
+ )
2067
+ )
2068
+
2069
+
1843
2070
  def get_active_datasource_connector_safe() -> DatasourceConnector | None:
1844
2071
  try:
1845
2072
  session = create_session()
@@ -1859,13 +2086,9 @@ def set_active_datasource_connector(
1859
2086
  connector: DatasourceConnector,
1860
2087
  actor: str,
1861
2088
  ) -> None:
1862
- for item in list_datasource_connectors(session):
1863
- if is_system_datasource_connector(item):
1864
- item.active = False
1865
- continue
2089
+ from gaard_api.extensions import get_query_hook_registry
1866
2090
 
1867
- item.active = item.id == connector.id
1868
- item.updated_by = actor if item.id == connector.id else item.updated_by
2091
+ get_query_hook_registry().set_active_datasource_connector(session, connector, actor)
1869
2092
 
1870
2093
 
1871
2094
  def test_datasource_connection(connector: DatasourceConnector) -> None:
@@ -2085,7 +2308,6 @@ def get_datasource_schema_context_safe() -> (
2085
2308
  session = create_session()
2086
2309
  except SQLAlchemyError:
2087
2310
  return None
2088
-
2089
2311
  try:
2090
2312
  connector = get_active_datasource_connector(session)
2091
2313
 
@@ -2104,3 +2326,45 @@ def get_datasource_schema_context_safe() -> (
2104
2326
  return None
2105
2327
  finally:
2106
2328
  session.close()
2329
+
2330
+
2331
+ def get_datasource_schema_contexts_safe(
2332
+ datasource_ids: list[str] | None = None,
2333
+ ) -> list[tuple[DatasourceConnector, DatasourceSchemaCache]]:
2334
+ try:
2335
+ session = create_session()
2336
+ except SQLAlchemyError:
2337
+ return []
2338
+
2339
+ try:
2340
+ if datasource_ids:
2341
+ connectors = [
2342
+ connector
2343
+ for datasource_id in datasource_ids
2344
+ if (connector := get_datasource_connector_by_key(session, datasource_id))
2345
+ is not None
2346
+ and not is_system_datasource_connector(connector)
2347
+ ]
2348
+ else:
2349
+ connectors = [
2350
+ connector
2351
+ for connector in get_active_datasource_connectors(session)
2352
+ if not is_system_datasource_connector(connector)
2353
+ ]
2354
+
2355
+ contexts: list[tuple[DatasourceConnector, DatasourceSchemaCache]] = []
2356
+ for connector in connectors:
2357
+ cache = get_datasource_schema_cache(session, connector.id)
2358
+
2359
+ if cache is None:
2360
+ cache = introspect_datasource_connector(session, connector, "system")
2361
+ session.commit()
2362
+
2363
+ contexts.append((connector, cache))
2364
+
2365
+ return contexts
2366
+ except SQLAlchemyError:
2367
+ session.rollback()
2368
+ return []
2369
+ finally:
2370
+ session.close()
@@ -0,0 +1,87 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <!-- Created with Inkscape (http://www.inkscape.org/) -->
3
+
4
+ <svg
5
+ version="1.1"
6
+ id="svg1"
7
+ width="1672"
8
+ height="1672"
9
+ viewBox="0 0 1672 1672"
10
+ sodipodi:docname="getgaard.svg"
11
+ inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
12
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
13
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
14
+ xmlns="http://www.w3.org/2000/svg"
15
+ xmlns:svg="http://www.w3.org/2000/svg">
16
+ <defs
17
+ id="defs1" />
18
+ <sodipodi:namedview
19
+ id="namedview1"
20
+ pagecolor="#ffffff"
21
+ bordercolor="#000000"
22
+ borderopacity="0.25"
23
+ inkscape:showpageshadow="2"
24
+ inkscape:pageopacity="0.0"
25
+ inkscape:pagecheckerboard="0"
26
+ inkscape:deskcolor="#d1d1d1"
27
+ inkscape:zoom="0.17088526"
28
+ inkscape:cx="2182.7512"
29
+ inkscape:cy="111.18572"
30
+ inkscape:window-width="1560"
31
+ inkscape:window-height="771"
32
+ inkscape:window-x="0"
33
+ inkscape:window-y="25"
34
+ inkscape:window-maximized="0"
35
+ inkscape:current-layer="g1" />
36
+ <g
37
+ inkscape:groupmode="layer"
38
+ inkscape:label="Image"
39
+ id="g1">
40
+ <g
41
+ id="g6"
42
+ transform="matrix(1.0610508,0,0,1.0610508,-2611.1446,1006.5308)">
43
+ <rect
44
+ style="fill:#111e2f;fill-opacity:1;stroke-width:1.09542"
45
+ id="rect2"
46
+ width="1437.7893"
47
+ height="1437.7893"
48
+ x="2529.908"
49
+ y="-879.6134"
50
+ ry="320.62204" />
51
+ <path
52
+ id="rect1"
53
+ style="fill:#ffffff;stroke-width:1.09542"
54
+ d="m 3234.3408,-626.0861 c -13.6106,0.3309 -25.9368,1.0895 -34.8867,2.3418 -84.1236,11.7709 -151.8696,39.6365 -216.4922,89.0508 -88.4727,67.6513 -151.6819,174.0139 -171.4746,288.5391 -8.8934,51.4587 -8.6698,114.0636 0.5859,163.333996 8.6422,46.004504 21.2085,84.4823042 40.9121,125.273404 23.7832,49.237 50.781,87.8784 88.418,126.5547 91.8576,94.394 218.5332,143.6113 347.0449,134.8359 127.7123,-8.7209 240.4621,-72.1949 316.1836,-177.998 45.9159,-64.1567 74.5299,-138.5546 84.8711,-220.666004 2.9258,-23.231196 4.2525,-75.228896 1.9883,-77.957096 -1.5107,-1.8203 -25.8188,-2.0429 -222.8203,-2.0429 -217.2534,0 -221.1459,0.044 -222.502,2.5781 -0.885,1.6537 -1.3808,21.59 -1.3808,55.5625 0,47.4299 0.2317,53.2391 2.207,55.4218 2.1138,2.3358 8.7633,2.4375 158,2.4376 129.1634,0 155.793,0.305 155.793,1.7832 0,3.2418 -7.4812,26.672 -12.6289,39.5507 -17.8663,44.6984 -41.4198,80.6843 -74.6856,114.1075 -26.441,26.566 -51.6323,44.8176 -82.6855,59.9082 -124.9554,60.7235 -275.5308,28.7986 -373.086,-79.1016 -92.4325,-102.2344 -119.9458,-248.8362 -70.1562,-373.8106 17.3657,-43.5887 43.6592,-83.5871 76.4023,-116.2246 38.482,-38.3578 81.0392,-64.5511 132.1739,-81.3515 48.2572,-15.855 102.913,-19.5453 159.332,-10.7598 6.9666,1.0848 13.6858,2.2858 14.9316,2.6699 1.272,0.392 3.4643,-0.5013 5,-2.0371 2.6697,-2.6697 2.7364,-4.0526 2.7364,-58.0371 v -55.3027 l -3,-1.9356 c -7.5481,-4.8687 -59.9495,-7.7173 -100.7813,-6.7246 z"
55
+ sodipodi:nodetypes="ssssssssssssssssssssssssssscsss" />
56
+ <g
57
+ id="g5"
58
+ transform="translate(2457.7895,-1000.6715)"
59
+ style="fill:#0cc6a6;fill-opacity:1">
60
+ <rect
61
+ style="opacity:1;fill:#0cc6a6;fill-opacity:1;stroke-width:0.938067"
62
+ id="rect3"
63
+ width="72.260895"
64
+ height="134.18539"
65
+ x="936.10278"
66
+ y="636.39392"
67
+ ry="15.974472" />
68
+ <rect
69
+ style="opacity:1;fill:#0cc6a6;fill-opacity:1;stroke-width:1.20121"
70
+ id="rect4"
71
+ width="72.260895"
72
+ height="220.02458"
73
+ x="1046.6084"
74
+ y="550.55475"
75
+ ry="19.286819" />
76
+ <rect
77
+ style="opacity:1;fill:#0cc6a6;fill-opacity:1;stroke-width:1.50056"
78
+ id="rect5"
79
+ width="72.260895"
80
+ height="343.35675"
81
+ x="1159.0873"
82
+ y="427.22256"
83
+ ry="10.289478" />
84
+ </g>
85
+ </g>
86
+ </g>
87
+ </svg>