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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )
@@ -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,19 @@
1
+ gaard-core==0.1.0
2
+ sqlalchemy>=2.0.0
3
+
4
+ [dev]
5
+ pytest>=8.0.0
6
+ ruff>=0.5.0
7
+ mypy>=1.10.0
8
+
9
+ [mssql]
10
+ pyodbc>=5.1.0
11
+
12
+ [mysql]
13
+ pymysql>=1.1.0
14
+
15
+ [oracle]
16
+ oracledb>=2.0.0
17
+
18
+ [postgres]
19
+ psycopg[binary]>=3.2.0
@@ -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 == []