gaard-connectors 0.1.0__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.
- gaard_connectors-0.1.0/PKG-INFO +31 -0
- gaard_connectors-0.1.0/README.md +10 -0
- gaard_connectors-0.1.0/pyproject.toml +32 -0
- gaard_connectors-0.1.0/setup.cfg +4 -0
- gaard_connectors-0.1.0/src/gaard_connectors/__init__.py +0 -0
- gaard_connectors-0.1.0/src/gaard_connectors/mssql/__init__.py +0 -0
- gaard_connectors-0.1.0/src/gaard_connectors/mysql/__init__.py +0 -0
- gaard_connectors-0.1.0/src/gaard_connectors/oracle/__init__.py +0 -0
- gaard_connectors-0.1.0/src/gaard_connectors/postgres/__init__.py +0 -0
- gaard_connectors-0.1.0/src/gaard_connectors/sqlalchemy/__init__.py +0 -0
- gaard_connectors-0.1.0/src/gaard_connectors/sqlalchemy/executor.py +51 -0
- gaard_connectors-0.1.0/src/gaard_connectors/sqlalchemy/introspector.py +62 -0
- gaard_connectors-0.1.0/src/gaard_connectors/sqlite/__init__.py +0 -0
- gaard_connectors-0.1.0/src/gaard_connectors.egg-info/PKG-INFO +31 -0
- gaard_connectors-0.1.0/src/gaard_connectors.egg-info/SOURCES.txt +18 -0
- gaard_connectors-0.1.0/src/gaard_connectors.egg-info/dependency_links.txt +1 -0
- gaard_connectors-0.1.0/src/gaard_connectors.egg-info/requires.txt +19 -0
- gaard_connectors-0.1.0/src/gaard_connectors.egg-info/top_level.txt +1 -0
- gaard_connectors-0.1.0/tests/test_sqlalchemy_executor_errors.py +85 -0
- gaard_connectors-0.1.0/tests/test_sqlalchemy_introspector.py +66 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gaard-connectors
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Database connectors for GAARD based on SQLAlchemy
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: gaard-core==0.1.0
|
|
8
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
9
|
+
Provides-Extra: postgres
|
|
10
|
+
Requires-Dist: psycopg[binary]>=3.2.0; extra == "postgres"
|
|
11
|
+
Provides-Extra: mysql
|
|
12
|
+
Requires-Dist: pymysql>=1.1.0; extra == "mysql"
|
|
13
|
+
Provides-Extra: mssql
|
|
14
|
+
Requires-Dist: pyodbc>=5.1.0; extra == "mssql"
|
|
15
|
+
Provides-Extra: oracle
|
|
16
|
+
Requires-Dist: oracledb>=2.0.0; extra == "oracle"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.10.0; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# GAARD - Governed AI Access to Relational Data
|
|
23
|
+
|
|
24
|
+
GAARD is a self-hosted AI SQL Gateway for governed natural-language access to relational data.
|
|
25
|
+
|
|
26
|
+
GAARD allows applications and users to ask questions about relational databases using natural language while keeping SQL generation, validation, execution, prompts, connectors, and auditability under control.
|
|
27
|
+
|
|
28
|
+
For more informacion see https://github.com/pkroliszewski/gaard
|
|
29
|
+
|
|
30
|
+
# This package
|
|
31
|
+
Package gaard-connectors extends gaard functionality by adding support for various database connection schemes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# GAARD - Governed AI Access to Relational Data
|
|
2
|
+
|
|
3
|
+
GAARD is a self-hosted AI SQL Gateway for governed natural-language access to relational data.
|
|
4
|
+
|
|
5
|
+
GAARD allows applications and users to ask questions about relational databases using natural language while keeping SQL generation, validation, execution, prompts, connectors, and auditability under control.
|
|
6
|
+
|
|
7
|
+
For more informacion see https://github.com/pkroliszewski/gaard
|
|
8
|
+
|
|
9
|
+
# This package
|
|
10
|
+
Package gaard-connectors extends gaard functionality by adding support for various database connection schemes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gaard-connectors"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Database connectors for GAARD based on SQLAlchemy"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"gaard-core==0.1.0",
|
|
13
|
+
"sqlalchemy>=2.0.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
postgres = ["psycopg[binary]>=3.2.0"]
|
|
18
|
+
mysql = ["pymysql>=1.1.0"]
|
|
19
|
+
mssql = ["pyodbc>=5.1.0"]
|
|
20
|
+
oracle = ["oracledb>=2.0.0"]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0.0",
|
|
23
|
+
"ruff>=0.5.0",
|
|
24
|
+
"mypy>=1.10.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 100
|
|
29
|
+
target-version = "py311"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import create_engine, text
|
|
6
|
+
from sqlalchemy.engine import Engine, RowMapping
|
|
7
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
8
|
+
|
|
9
|
+
from gaard_core.errors import QueryExecutionError
|
|
10
|
+
from gaard_core.json_utils import to_jsonable
|
|
11
|
+
from gaard_core.query_pipeline.models import QueryResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SQLAlchemyQueryExecutor:
|
|
15
|
+
def __init__(self, database_url: str, max_rows: int = 100) -> None:
|
|
16
|
+
self.database_url = database_url
|
|
17
|
+
self.max_rows = max_rows
|
|
18
|
+
self.engine: Engine = create_engine(database_url)
|
|
19
|
+
|
|
20
|
+
def execute(self, sql: str) -> QueryResult:
|
|
21
|
+
limited_sql = self._apply_limit(sql)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
with self.engine.connect() as connection:
|
|
25
|
+
result = connection.execute(text(limited_sql))
|
|
26
|
+
rows = result.mappings().fetchall()
|
|
27
|
+
except SQLAlchemyError as exc:
|
|
28
|
+
raise QueryExecutionError(
|
|
29
|
+
f"Query execution failed. SQL: {limited_sql}. Error: {exc}",
|
|
30
|
+
sql=limited_sql,
|
|
31
|
+
error_detail=str(exc),
|
|
32
|
+
) from exc
|
|
33
|
+
|
|
34
|
+
normalized_rows = [self._normalize_row(row) for row in rows]
|
|
35
|
+
columns = list(normalized_rows[0].keys()) if normalized_rows else []
|
|
36
|
+
|
|
37
|
+
return QueryResult(
|
|
38
|
+
columns=columns,
|
|
39
|
+
rows=normalized_rows,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _apply_limit(self, sql: str) -> str:
|
|
43
|
+
normalized = sql.strip().rstrip(";")
|
|
44
|
+
|
|
45
|
+
if re.search(r"\blimit\s+\d+\b", normalized, flags=re.IGNORECASE):
|
|
46
|
+
return normalized
|
|
47
|
+
|
|
48
|
+
return f"{normalized} LIMIT {self.max_rows}"
|
|
49
|
+
|
|
50
|
+
def _normalize_row(self, row: Mapping[str, Any] | RowMapping) -> dict[str, Any]:
|
|
51
|
+
return cast(dict[str, Any], to_jsonable(dict(row)))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import create_engine, inspect
|
|
4
|
+
from sqlalchemy.engine import Engine
|
|
5
|
+
from sqlalchemy.engine.reflection import Inspector
|
|
6
|
+
|
|
7
|
+
from gaard_core.schema.models import ColumnInfo, DatabaseSchema, ForeignKeyInfo, TableInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SQLAlchemySchemaIntrospector:
|
|
11
|
+
def __init__(self, database_url: str) -> None:
|
|
12
|
+
self.database_url = database_url
|
|
13
|
+
self.engine: Engine = create_engine(database_url)
|
|
14
|
+
|
|
15
|
+
def introspect(self) -> DatabaseSchema:
|
|
16
|
+
inspector = inspect(self.engine)
|
|
17
|
+
|
|
18
|
+
tables: list[TableInfo] = []
|
|
19
|
+
|
|
20
|
+
for table_name in inspector.get_table_names():
|
|
21
|
+
tables.append(self._introspect_table(inspector, table_name, "table"))
|
|
22
|
+
|
|
23
|
+
for view_name in inspector.get_view_names():
|
|
24
|
+
tables.append(self._introspect_table(inspector, view_name, "view"))
|
|
25
|
+
|
|
26
|
+
return DatabaseSchema(tables=tables)
|
|
27
|
+
|
|
28
|
+
def _introspect_table(
|
|
29
|
+
self,
|
|
30
|
+
inspector: Inspector,
|
|
31
|
+
name: str,
|
|
32
|
+
object_type: Literal["table", "view"],
|
|
33
|
+
) -> TableInfo:
|
|
34
|
+
columns = [
|
|
35
|
+
ColumnInfo(
|
|
36
|
+
name=column["name"],
|
|
37
|
+
type=str(column["type"]),
|
|
38
|
+
nullable=bool(column.get("nullable", True)),
|
|
39
|
+
primary_key=bool(column.get("primary_key", False)),
|
|
40
|
+
)
|
|
41
|
+
for column in inspector.get_columns(name)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
foreign_keys = []
|
|
45
|
+
|
|
46
|
+
if object_type == "table":
|
|
47
|
+
foreign_keys = [
|
|
48
|
+
ForeignKeyInfo(
|
|
49
|
+
constrained_columns=list(foreign_key.get("constrained_columns") or []),
|
|
50
|
+
referred_table=str(foreign_key.get("referred_table")),
|
|
51
|
+
referred_columns=list(foreign_key.get("referred_columns") or []),
|
|
52
|
+
)
|
|
53
|
+
for foreign_key in inspector.get_foreign_keys(name)
|
|
54
|
+
if foreign_key.get("referred_table")
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
return TableInfo(
|
|
58
|
+
name=name,
|
|
59
|
+
object_type=object_type,
|
|
60
|
+
columns=columns,
|
|
61
|
+
foreign_keys=foreign_keys,
|
|
62
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gaard-connectors
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Database connectors for GAARD based on SQLAlchemy
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: gaard-core==0.1.0
|
|
8
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
9
|
+
Provides-Extra: postgres
|
|
10
|
+
Requires-Dist: psycopg[binary]>=3.2.0; extra == "postgres"
|
|
11
|
+
Provides-Extra: mysql
|
|
12
|
+
Requires-Dist: pymysql>=1.1.0; extra == "mysql"
|
|
13
|
+
Provides-Extra: mssql
|
|
14
|
+
Requires-Dist: pyodbc>=5.1.0; extra == "mssql"
|
|
15
|
+
Provides-Extra: oracle
|
|
16
|
+
Requires-Dist: oracledb>=2.0.0; extra == "oracle"
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.10.0; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# GAARD - Governed AI Access to Relational Data
|
|
23
|
+
|
|
24
|
+
GAARD is a self-hosted AI SQL Gateway for governed natural-language access to relational data.
|
|
25
|
+
|
|
26
|
+
GAARD allows applications and users to ask questions about relational databases using natural language while keeping SQL generation, validation, execution, prompts, connectors, and auditability under control.
|
|
27
|
+
|
|
28
|
+
For more informacion see https://github.com/pkroliszewski/gaard
|
|
29
|
+
|
|
30
|
+
# This package
|
|
31
|
+
Package gaard-connectors extends gaard functionality by adding support for various database connection schemes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/gaard_connectors/__init__.py
|
|
4
|
+
src/gaard_connectors.egg-info/PKG-INFO
|
|
5
|
+
src/gaard_connectors.egg-info/SOURCES.txt
|
|
6
|
+
src/gaard_connectors.egg-info/dependency_links.txt
|
|
7
|
+
src/gaard_connectors.egg-info/requires.txt
|
|
8
|
+
src/gaard_connectors.egg-info/top_level.txt
|
|
9
|
+
src/gaard_connectors/mssql/__init__.py
|
|
10
|
+
src/gaard_connectors/mysql/__init__.py
|
|
11
|
+
src/gaard_connectors/oracle/__init__.py
|
|
12
|
+
src/gaard_connectors/postgres/__init__.py
|
|
13
|
+
src/gaard_connectors/sqlalchemy/__init__.py
|
|
14
|
+
src/gaard_connectors/sqlalchemy/executor.py
|
|
15
|
+
src/gaard_connectors/sqlalchemy/introspector.py
|
|
16
|
+
src/gaard_connectors/sqlite/__init__.py
|
|
17
|
+
tests/test_sqlalchemy_executor_errors.py
|
|
18
|
+
tests/test_sqlalchemy_introspector.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gaard_connectors
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sqlite3
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from gaard_connectors.sqlalchemy.executor import SQLAlchemyQueryExecutor
|
|
9
|
+
from gaard_core.errors import QueryExecutionError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_sqlalchemy_executor_wraps_database_errors(tmp_path: Path) -> None:
|
|
13
|
+
db_path = tmp_path / "test.db"
|
|
14
|
+
|
|
15
|
+
connection = sqlite3.connect(db_path)
|
|
16
|
+
try:
|
|
17
|
+
connection.execute("CREATE TABLE patients (id INTEGER PRIMARY KEY)")
|
|
18
|
+
connection.commit()
|
|
19
|
+
finally:
|
|
20
|
+
connection.close()
|
|
21
|
+
|
|
22
|
+
executor = SQLAlchemyQueryExecutor(
|
|
23
|
+
database_url=f"sqlite:///{db_path}",
|
|
24
|
+
max_rows=100,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
with pytest.raises(QueryExecutionError):
|
|
28
|
+
executor.execute("SELECT missing_column FROM patients")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_sqlalchemy_executor_normalizes_database_values_to_jsonable_rows() -> None:
|
|
32
|
+
executor = SQLAlchemyQueryExecutor(
|
|
33
|
+
database_url="sqlite:///:memory:",
|
|
34
|
+
max_rows=100,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
row = executor._normalize_row(
|
|
38
|
+
{
|
|
39
|
+
"total_minutes": Decimal("42"),
|
|
40
|
+
"ratio": Decimal("12.5"),
|
|
41
|
+
"created_on": date(2026, 5, 24),
|
|
42
|
+
"created_at": datetime(2026, 5, 24, 10, 15, 30),
|
|
43
|
+
"payload": b"hello",
|
|
44
|
+
"binary_payload": b"\xff",
|
|
45
|
+
"nested": {"amount": Decimal("7.25")},
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
assert row == {
|
|
50
|
+
"total_minutes": 42,
|
|
51
|
+
"ratio": 12.5,
|
|
52
|
+
"created_on": "2026-05-24",
|
|
53
|
+
"created_at": "2026-05-24T10:15:30",
|
|
54
|
+
"payload": "hello",
|
|
55
|
+
"binary_payload": "ff",
|
|
56
|
+
"nested": {"amount": 7.25},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_sqlalchemy_executor_does_not_add_duplicate_limit_with_newline(tmp_path: Path) -> None:
|
|
61
|
+
db_path = tmp_path / "test.db"
|
|
62
|
+
|
|
63
|
+
connection = sqlite3.connect(db_path)
|
|
64
|
+
try:
|
|
65
|
+
connection.execute("CREATE TABLE patients (id INTEGER PRIMARY KEY, status TEXT NOT NULL)")
|
|
66
|
+
connection.execute("INSERT INTO patients (status) VALUES ('active')")
|
|
67
|
+
connection.commit()
|
|
68
|
+
finally:
|
|
69
|
+
connection.close()
|
|
70
|
+
|
|
71
|
+
executor = SQLAlchemyQueryExecutor(
|
|
72
|
+
database_url=f"sqlite:///{db_path}",
|
|
73
|
+
max_rows=100,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
result = executor.execute(
|
|
77
|
+
"""
|
|
78
|
+
SELECT COUNT(*) AS total_active_patients
|
|
79
|
+
FROM patients
|
|
80
|
+
WHERE status = 'active'
|
|
81
|
+
LIMIT 100
|
|
82
|
+
"""
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
assert result.rows == [{"total_active_patients": 1}]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
from gaard_connectors.sqlalchemy.introspector import SQLAlchemySchemaIntrospector
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_sqlalchemy_introspector_reads_tables_columns_and_foreign_keys(tmp_path: Path) -> None:
|
|
8
|
+
db_path = tmp_path / "test.db"
|
|
9
|
+
|
|
10
|
+
connection = sqlite3.connect(db_path)
|
|
11
|
+
try:
|
|
12
|
+
connection.executescript(
|
|
13
|
+
"""
|
|
14
|
+
CREATE TABLE patients (
|
|
15
|
+
id INTEGER PRIMARY KEY,
|
|
16
|
+
status TEXT NOT NULL
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE appointments (
|
|
20
|
+
id INTEGER PRIMARY KEY,
|
|
21
|
+
patient_id INTEGER NOT NULL,
|
|
22
|
+
status TEXT NOT NULL,
|
|
23
|
+
FOREIGN KEY (patient_id) REFERENCES patients(id)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE VIEW active_patients AS
|
|
27
|
+
SELECT id, status
|
|
28
|
+
FROM patients
|
|
29
|
+
WHERE status = 'active';
|
|
30
|
+
"""
|
|
31
|
+
)
|
|
32
|
+
connection.commit()
|
|
33
|
+
finally:
|
|
34
|
+
connection.close()
|
|
35
|
+
|
|
36
|
+
introspector = SQLAlchemySchemaIntrospector(
|
|
37
|
+
database_url=f"sqlite:///{db_path}",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
schema = introspector.introspect()
|
|
41
|
+
|
|
42
|
+
table_names = {table.name for table in schema.tables}
|
|
43
|
+
|
|
44
|
+
assert "patients" in table_names
|
|
45
|
+
assert "appointments" in table_names
|
|
46
|
+
assert "active_patients" in table_names
|
|
47
|
+
|
|
48
|
+
patients = next(table for table in schema.tables if table.name == "patients")
|
|
49
|
+
patient_columns = {column.name for column in patients.columns}
|
|
50
|
+
|
|
51
|
+
assert patients.object_type == "table"
|
|
52
|
+
assert "id" in patient_columns
|
|
53
|
+
assert "status" in patient_columns
|
|
54
|
+
|
|
55
|
+
appointments = next(table for table in schema.tables if table.name == "appointments")
|
|
56
|
+
|
|
57
|
+
assert len(appointments.foreign_keys) == 1
|
|
58
|
+
assert appointments.foreign_keys[0].referred_table == "patients"
|
|
59
|
+
assert appointments.foreign_keys[0].constrained_columns == ["patient_id"]
|
|
60
|
+
|
|
61
|
+
active_patients = next(table for table in schema.tables if table.name == "active_patients")
|
|
62
|
+
active_patient_columns = {column.name for column in active_patients.columns}
|
|
63
|
+
|
|
64
|
+
assert active_patients.object_type == "view"
|
|
65
|
+
assert active_patient_columns == {"id", "status"}
|
|
66
|
+
assert active_patients.foreign_keys == []
|