File size: 7,741 Bytes
2dd2de0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
"""Database access layer.

Two responsibilities:
  1. Introspect the schema once at startup (cached) so the bot can be told
     what tables/columns/relationships exist.
  2. Execute model-generated SQL safely: READ-ONLY, single statement, row-capped.

The read-only guard is the core safety mechanism. We connect with a single
login, so we cannot rely on database-level permissions; instead every query is
validated to be a single SELECT/WITH statement before it ever reaches the server.
"""
from __future__ import annotations

import datetime
import decimal
import re
import struct
import uuid
from collections import OrderedDict

import pyodbc

import config

# SQL Server's datetimeoffset (ODBC type -155) isn't decoded by pyodbc natively;
# without this converter, selecting any CreatedDate/ModifiedDate/DeletedDate
# column raises "ODBC SQL type -155 is not yet supported". Decode the 20-byte
# SQL_SS_TIMESTAMPOFFSET struct into a tz-aware datetime.
SQL_SS_TIMESTAMPOFFSET = -155


def _decode_datetimeoffset(raw: bytes):
    try:
        y, mo, d, h, mi, s, frac, tzh, tzm = struct.unpack("<6hI2h", raw)
        return datetime.datetime(
            y, mo, d, h, mi, s, frac // 1000,
            datetime.timezone(datetime.timedelta(hours=tzh, minutes=tzm)),
        )
    except Exception:
        return None


class UnsafeQueryError(Exception):
    """Raised when a query fails the read-only safety checks."""


# Whole-word keywords that must never appear in a query. SELECT INTO (which
# creates a table) is covered by the INTO entry.
_FORBIDDEN = [
    "INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE",
    "MERGE", "EXEC", "EXECUTE", "GRANT", "REVOKE", "DENY", "BACKUP",
    "RESTORE", "INTO", "SHUTDOWN", "RECONFIGURE", "WAITFOR",
]
_FORBIDDEN_RE = re.compile(r"\b(" + "|".join(_FORBIDDEN) + r")\b", re.IGNORECASE)
# Stored-procedure prefixes (sp_, xp_) used for system access.
_PROC_RE = re.compile(r"\b(sp_|xp_)\w+", re.IGNORECASE)
_COMMENT_BLOCK = re.compile(r"/\*.*?\*/", re.DOTALL)
_COMMENT_LINE = re.compile(r"--[^\n]*")


def _connect() -> pyodbc.Connection:
    conn = pyodbc.connect(config.connection_string(), timeout=15)
    conn.timeout = config.QUERY_TIMEOUT  # query (command) timeout in seconds
    conn.add_output_converter(SQL_SS_TIMESTAMPOFFSET, _decode_datetimeoffset)
    return conn


def _strip_comments(sql: str) -> str:
    sql = _COMMENT_BLOCK.sub(" ", sql)
    sql = _COMMENT_LINE.sub(" ", sql)
    return sql


def validate_readonly(sql: str) -> str:
    """Validate that `sql` is a single read-only statement. Returns the cleaned
    SQL (no trailing semicolon) or raises UnsafeQueryError."""
    if not sql or not sql.strip():
        raise UnsafeQueryError("Empty query.")

    cleaned = _strip_comments(sql).strip()

    # Disallow stacked statements: a semicolon is only allowed as the very
    # last character.
    body = cleaned[:-1] if cleaned.endswith(";") else cleaned
    if ";" in body:
        raise UnsafeQueryError(
            "Multiple statements are not allowed. Send a single SELECT query."
        )

    first = body.lstrip().split(None, 1)[0].upper() if body.strip() else ""
    if first not in ("SELECT", "WITH"):
        raise UnsafeQueryError(
            "Only SELECT (or WITH ... SELECT) queries are allowed."
        )

    if _FORBIDDEN_RE.search(body):
        bad = _FORBIDDEN_RE.search(body).group(1).upper()
        raise UnsafeQueryError(f"Disallowed keyword in query: {bad}.")

    if _PROC_RE.search(body):
        raise UnsafeQueryError("Stored-procedure calls are not allowed.")

    return body


def _jsonify(value):
    """Convert SQL Server values into JSON-serialisable Python values."""
    if value is None:
        return None
    if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
        return value.isoformat()
    if isinstance(value, decimal.Decimal):
        # keep integers as ints, others as float
        return int(value) if value == value.to_integral_value() else float(value)
    if isinstance(value, uuid.UUID):
        return str(value)
    if isinstance(value, (bytes, bytearray)):
        return value.hex()
    return value


def run_query(sql: str) -> dict:
    """Validate and execute a read-only query.

    Returns a dict: {columns: [...], rows: [[...]], row_count, truncated}.
    """
    body = validate_readonly(sql)
    conn = _connect()
    try:
        cur = conn.cursor()
        cur.execute(body)
        if cur.description is None:
            return {"columns": [], "rows": [], "row_count": 0, "truncated": False}
        columns = [d[0] for d in cur.description]
        cap = config.MAX_RESULT_ROWS
        raw = cur.fetchmany(cap + 1)
        truncated = len(raw) > cap
        raw = raw[:cap]
        rows = [[_jsonify(v) for v in row] for row in raw]
        return {
            "columns": columns,
            "rows": rows,
            "row_count": len(rows),
            "truncated": truncated,
        }
    finally:
        conn.close()


def introspect_schema() -> str:
    """Build a compact text description of the schema for the system prompt:
    every table with its columns, plus foreign-key relationships."""
    conn = _connect()
    try:
        cur = conn.cursor()
        cur.execute(
            """
            SELECT t.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE
            FROM INFORMATION_SCHEMA.TABLES t
            JOIN INFORMATION_SCHEMA.COLUMNS c
              ON t.TABLE_NAME = c.TABLE_NAME AND t.TABLE_SCHEMA = c.TABLE_SCHEMA
            WHERE t.TABLE_TYPE = 'BASE TABLE'
            ORDER BY t.TABLE_NAME, c.ORDINAL_POSITION
            """
        )
        # Boilerplate audit columns present on nearly every table — omitted from
        # the listing to save prompt tokens (IsActive is kept; it's meaningful).
        AUDIT_COLS = {
            "CreatedBy", "CreatedDate", "ModifiedBy", "ModifiedDate",
            "DeletedBy", "DeletedDate",
        }
        tables: "OrderedDict[str, list[str]]" = OrderedDict()
        for tname, cname, dtype in cur.fetchall():
            if cname in AUDIT_COLS:
                continue
            tables.setdefault(tname, []).append(f"{cname} {dtype}")

        # Foreign keys for join hints.
        cur.execute(
            """
            SELECT
                fk_tab.name AS fk_table, fk_col.name AS fk_column,
                pk_tab.name AS pk_table, pk_col.name AS pk_column
            FROM sys.foreign_key_columns fkc
            JOIN sys.tables fk_tab ON fkc.parent_object_id = fk_tab.object_id
            JOIN sys.columns fk_col
              ON fkc.parent_object_id = fk_col.object_id
             AND fkc.parent_column_id = fk_col.column_id
            JOIN sys.tables pk_tab ON fkc.referenced_object_id = pk_tab.object_id
            JOIN sys.columns pk_col
              ON fkc.referenced_object_id = pk_col.object_id
             AND fkc.referenced_column_id = pk_col.column_id
            ORDER BY fk_tab.name, fk_col.name
            """
        )
        fks = [
            f"{r.fk_table}.{r.fk_column} -> {r.pk_table}.{r.pk_column}"
            for r in cur.fetchall()
        ]
    finally:
        conn.close()

    lines = ["# Tables (table(column type, ...))", ""]
    for tname, cols in tables.items():
        lines.append(f"{tname}({', '.join(cols)})")
    if fks:
        lines.append("")
        lines.append("# Foreign keys (from -> to)")
        lines.append("")
        lines.extend(fks)
    return "\n".join(lines)


def ping() -> str:
    """Quick connectivity check; returns the server version."""
    conn = _connect()
    try:
        cur = conn.cursor()
        cur.execute("SELECT @@VERSION")
        return cur.fetchone()[0]
    finally:
        conn.close()