Skill: jami-lin/dbskill (version 1.0.0)

--- name: database-skill version: 1.0.0 type: skill description: "Python-based database connectivity skill supporting MySQL, PostgreSQL, Oracle, SQL Server, and SQLite. Provides connection management, parameterized query execution, schema introspection, and transaction management. Requires Python 3.8+." --- # database-skill — Python Database Connectivity Skill English | [中文](#database-skill-中文文档) This skill guides an AI Agent to connect to relational databases, execute queries, manage transactions, and introspect schema using Python. --- <a id="database-skill-中文文档"></a> # database-skill — Python 数据库连接技能 本技能指导 AI Agent 使用 Python 连接关系型数据库、执行 SQL 查询、管理事务以及探查 Schema 元数据。 --- ## When to call / When not to call · 何时调用 | Scenario | Call | |---|---| | User needs to run SQL queries | Yes | | User needs schema introspection (tables, columns, indexes, FK) | Yes | | User needs transaction control (commit / rollback) | Yes | | Python 3.8+ not available or drivers not installed | No | | User only needs text/regex analysis | No | | 场景 | 调用 | |---|---| | 用户需要执行 SQL 查询 | 是 | | 用户需要 Schema 探查（表、列、索引、外键） | 是 | | 用户需要事务控制（提交/回滚） | 是 | | 无 Python 3.8+ 或驱动未安装 | 否 | | 用户仅需文本/正则分析 | 否 | --- ## Prerequisites · 前提条件 | Requirement | Check | |---|---| | Python 3.8+ | `python --version` | | Target database reachable | `telnet <host> <port>` | | Dependencies | `pip install pymysql psycopg2-binary oracledb pymssql pyyaml` | --- ## Quick start · 快速开始 ```bash # List all tables python scripts/main.py \ --url "jdbc:mysql://localhost:3306/mydb" \ --user "root" \ --password "${DB_PASS}" \ --tables # Parameterized query python scripts/main.py \ --url "jdbc:mysql://localhost:3306/mydb" \ --user "root" \ --password "${DB_PASS}" \ --query "SELECT * FROM user WHERE name = ?" "zhangsan" ``` --- ## Configuration (3 options) · 配置方式（三种） ### A — CLI arguments (recommended) · 命令行参数（推荐） ```bash python scripts/main.py \ --url "jdbc:mysql://host:3306/db" \ --user "admin" \ --password "${DB_PASS}" \ --query "SELECT 1" ``` ### B — YAML config file · 配置文件 ```yaml # datasource.yml datasource: url: "jdbc:mysql://localhost:3306/mydb" username: "${DB_USER}" [REDACTED_SECRET]" ``` ```bash python scripts/main.py --config datasource.yml --tables ``` ### C — Built-in defaults · 内置默认 ```bash python scripts/main.py --tables ``` --- ## Operations · 操作说明 | Flag | Description | 说明 | |---|---|---| | `--query <sql> [params...]` | SELECT with `?` placeholders | SELECT 查询，支持参数化 | | `--update <sql> [params...]` | UPDATE/INSERT/DELETE | 更新/插入/删除 | | `--batch <file>` | Execute SQL file | 批量执行文件中的 SQL | | `--tables` | List all tables | 列出所有表 | | `--columns <table>` | Show column metadata | 查看表结构 | | `--list-connections` | Show saved connections | 查看已保存的连接 | | `--forget <url>` | Remove a saved connection | 删除已保存的连接 | Examples · 示例： ```bash python scripts/main.py --url "jdbc:mysql://host:3306/db" --user root --password x --tables python scripts/main.py --url "..." --user root --password x --query "SELECT * FROM t WHERE id = ?" 42 python scripts/main.py --url "..." --user root --password x --update "UPDATE t SET x = ? WHERE id = ?" "new" 1 python scripts/main.py --list-connections ``` --- ## Scripts · 脚本结构 ``` scripts/ ├── main.py # CLI entry point · 命令行入口 ├── connection_manager.py # Connect / disconnect · 连接管理 ├── connections_store.py # Persist connections · 连接记录持久化 ├── query_executor.py # Query, update, batch, transaction · 查询执行 ├── schema_inspector.py # Schema introspection · Schema 探查 └── exceptions.py # Exception hierarchy · 异常层次 ``` --- ## API overview · API 概览 ### ConnectionManager · 连接管理器 ```python from connection_manager import ConnectionManager cm = ConnectionManager(url="jdbc:mysql://localhost:3306/db", username="root", [REDACTED_SECRET]", driver="pymysql") conn = cm.get_connection() # auto-commit tx_conn = cm.get_connection_for_transaction() # manual-commit cm.close_connection(conn) ``` ### QueryExecutor · 查询执行器 ```python from query_executor import QueryExecutor qe = QueryExecutor(cm) rows = qe.execute_query("SELECT * FROM users WHERE status = ?", "ACTIVE") affected = qe.execute_update("UPDATE users SET status = ? WHERE id = ?", "INACTIVE", 1) qe.execute_batch(["INSERT INTO log VALUES (1)", "INSERT INTO log VALUES (2)"]) qe.execute_transaction(lambda tx: ( tx.execute_update("UPDATE accounts SET balance = balance - 100 WHERE id = ?", 1), tx.execute_update("UPDATE accounts SET balance = balance + 100 WHERE id = ?", 2), )) ``` ### CaseInsensitiveDict · 大小写不敏感字典 Query results are wrapped in ``CaseInsensitiveDict`` so column names are case-insensitive: 查询结果的列名大小写不敏感： ```python row["name"] == row["NAME"] == row["Name"] ``` ### SchemaInspector · Schema 检查器 ```python from schema_inspector import SchemaInspector si = SchemaInspector(cm) si.get_tables() # 获取所有表 si.get_columns("users") # 获取表结构 si.get_indexes("users") # 获取索引 si.get_foreign_keys("users") # 获取外键 ``` --- ## Saved connections · 连接记录 After a successful connection, the skill saves the URL, username, and driver to ``%TEMP%/.database-skill-connections.json`` (``/tmp/`` on Linux/macOS). **Passwords are never stored.** 连接成功后，技能会将 URL、用户名和驱动类型保存到用户临时目录。 **密码不会被保存。** When no ``--url`` or ``--config`` is provided, the skill shows a numbered list: 不提供 ``--url`` 或 ``--config`` 时，显示已保存的连接供选择： ``` Saved connections: [0] MySQL@host1 [1] Oracle@orahost [N] Enter a new connection ``` ```bash python scripts/main.py --list-connections # 查看所有连接 python scripts/main.py --forget "jdbc:mysql://..." # 删除连接 ``` --- ## Security rules · 安全守则 1. **Never hard-code passwords** — use environment variables `${DB_PASS}` or CLI. **禁止硬编码密码** — 使用环境变量或命令行参数。 2. **Always use parameterized queries** — values go as `?` placeholders. **始终使用参数化查询** — 值通过 `?` 占位符传递。 3. **Transactions auto-commit/rollback** — handled transparently. **事务自动提交/回滚** — 无需手动处理。 4. **Passwords never persisted** — saved connections store only URL and username. **密码不持久化** — 只保存 URL 和用户名。 --- ## Building & testing · 构建与测试 ```bash pip install -e ".[dev]" pytest ```

path: scripts/connections_store.py
content: from __future__ import annotations

import json
import logging
import os
import tempfile
from typing import Dict, List, Optional

logger = logging.getLogger(__name__)

_STORE_FILE = os.path.join(tempfile.gettempdir(), ".database-skill-connections.json")


def _store_path() -> str:
    return _STORE_FILE


class ConnectionRecord:
    """A saved database connection record.

    Passwords are handled as follows:
    - If ``password_env_var`` is set (e.g. ``DB_PASS``), the value is read
      from the environment at connection time.  This is the **safe** path.
    - If ``password_env_var`` is empty, the password was entered as plain text
      and is **never** persisted to disk.
    """

    def __init__(self, url: str, username: str, driver: str,
                 label: str = "", password_env_var: str = "") -> None:
        self.url = url
        self.username = username
        self.driver = driver
        self.label = label or _auto_label(url, username)
        self.password_env_var = password_env_var

    def has_password(self) -> bool:
        return bool(self.password_env_var)

    def get_password(self) -> str:
        if self.password_env_var:
            return os.environ.get(self.password_env_var, "")
        return ""

    def identity(self) -> str:
        """Return a stable key: ``host:port/database`` — ignores URL query params."""
        return f"{self.username}@{_extract_host_db(self.url)}"

    def to_dict(self) -> Dict[str, str]:
        d: Dict[str, str] = {
            "url": self.url,
            "username": self.username,
            "driver": self.driver,
            "label": self.label,
        }
        if self.password_env_var:
            d["password_env_var"] = self.password_env_var
        return d

    @staticmethod
    def from_dict(d: Dict[str, str]) -> ConnectionRecord:
        return ConnectionRecord(
            url=d["url"],
            username=d.get("username", ""),
            driver=d.get("driver", "pymysql"),
            label=d.get("label", ""),
            password_env_var=d.get("password_env_var", ""),
        )


def _extract_host_db(url: str) -> str:
    """Extract ``host:port/database`` from a JDBC URL."""
    url_lower = url.lower()
    for scheme in ("mysql", "postgresql", "oracle:thin", "oracle", "sqlserver", "sqlite", "h2"):
        prefix = f"jdbc:{scheme}"
        if url_lower.startswith(prefix):
            rest = url[len(prefix):].lstrip(":/@; ")
            if scheme == "sqlserver":
                host = rest.split(":")[0] if ":" in rest else rest.split(";")[0]
                port = "1433"
                db = ""
                for part in rest.split(";"):
                    part = part.strip()
                    if "=" in part:
                        k, v = part.split("=", 1)
                        if k.lower() in ("databasename", "database"):
                            db = v
                return f"{host}:{port}/{db}"
            host = rest.split(":")[0]
            port = "1521" if "oracle" in scheme else "3306"
            tail = rest[len(host):].lstrip(":")
            return f"{host}:{port}/"
    return url


def _auto_label(url: str, username: str) -> str:
    driver_map = {
        "mysql": "MySQL",
        "postgresql": "PostgreSQL",
        "oracle": "Oracle",
        "sqlserver": "SQL Server",
        "sqlite": "SQLite",
        "h2": "H2",
    }
    url_lower = url.lower()
    driver_name = "DB"
    for keyword, name in driver_map.items():
        if keyword in url_lower:
            driver_name = name
            break
    host = "unknown"
    for sep in ("://", ":@", ":@"):
        if sep in url:
            part = url.split(sep, 1)[1]
            host = part.split(":")[0].split(";")[0].split("/")[0]
            break
    return f"{driver_name}@{host}"


class ConnectionsStore:
    """Persists connection records to a JSON file in the user temp directory.

    The file is stored at ``os.environ['TEMP']/.database-skill-connections.json``.
    This allows the skill to remember previously used databases across sessions.
    """

    def __init__(self) -> None:
        self._path = _store_path()

    def load_all(self) -> List[ConnectionRecord]:
        """Load all saved connections."""
        try:
            if not os.path.exists(self._path):
                return []
            with open(self._path, "r") as f:
                data = json.load(f)
            return [ConnectionRecord.from_dict(item) for item in data]
        except Exception as exc:
            logger.debug("Failed to load connections: %s", exc)
            return []

    def save(self, record: ConnectionRecord) -> None:
        """Save (or update) a connection record.

        Uses ``identity()`` (username@host:port/database) as the unique key.
        If a record for the same database already exists, it is replaced.
        """
        records = self.load_all()
        key = record.identity()
        records = [r for r in records if r.identity() != key]
        records.append(record)
        self._save_all(records)

    def find_by_url(self, url: str) -> Optional[ConnectionRecord]:
        for r in self.load_all():
            if r.url == url:
                return r
        return None

    def list_labels(self) -> List[str]:
        return [r.label for r in self.load_all()]

    def get_by_index(self, index: int) -> Optional[ConnectionRecord]:
        records = self.load_all()
        if 0 <= index < len(records):
            return records[index]
        return None

    def remove(self, url: str) -> None:
        records = [r for r in self.load_all() if r.url != url]
        self._save_all(records)

    def clear(self) -> None:
        self._save_all([])

    def _save_all(self, records: List[ConnectionRecord]) -> None:
        try:
            with open(self._path, "w") as f:
                json.dump([r.to_dict() for r in records], f, indent=2)
        except Exception as exc:
            logger.debug("Failed to save connections: %s", exc)


path: scripts/connection_manager.py
content: from __future__ import annotations

import logging
import os
import re
import sqlite3
import urllib.parse
from typing import Dict, Optional

import yaml

from exceptions import (
    DatabaseConnectionError,
    ConfigurationError,
    UnsupportedDatabaseError,
)

logger = logging.getLogger(__name__)

# Lazy-loaded driver references
_pymysql = None
_psycopg2 = None
_oracledb = None
_pymssql = None

_DRIVER_REGISTRY: Dict[str, str] = {
    "jdbc:mysql:": "pymysql",
    "jdbc:postgresql:": "psycopg2",
    "jdbc:oracle:": "oracledb",
    "jdbc:sqlserver:": "pymssql",
    "jdbc:microsoft:sqlserver:": "pymssql",
    "jdbc:h2:": "sqlite3",
    "jdbc:sqlite:": "sqlite3",
}

_ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}")


def resolve_env_vars(value: Optional[str]) -> Optional[str]:
    if value is None:
        return None

    def _replacer(match: re.Match) -> str:
        var_name = match.group(1)
        return os.environ.get(var_name, "")

    return _ENV_VAR_PATTERN.sub(_replacer, value)


def load_config(path: str) -> dict:
    try:
        with open(path, "r") as f:
            return yaml.safe_load(f)
    except FileNotFoundError:
        logger.debug("Config file not found: %s", path)
    try:
        script_dir = os.path.dirname(os.path.abspath(__file__))
        fallback = os.path.join(script_dir, path)
        with open(fallback, "r") as f:
            return yaml.safe_load(f)
    except Exception as exc:
        raise ConfigurationError(
            f"Failed to load config from {path}: {exc}"
        ) from exc


def resolve_driver(jdbc_url: str) -> str:
    for prefix, driver in _DRIVER_REGISTRY.items():
        if jdbc_url.startswith(prefix):
            return driver
    raise UnsupportedDatabaseError(f"Unsupported JDBC URL prefix: {jdbc_url}")


def _import_pymysql():
    global _pymysql
    if _pymysql is None:
        try:
            import pymysql as _pymysql
        except ImportError:
            raise ImportError(
                "pymysql is required for MySQL connections. "
                "Install it with: pip install pymysql"
            )
    return _pymysql


def _import_psycopg2():
    global _psycopg2
    if _psycopg2 is None:
        try:
            import psycopg2 as _psycopg2
        except ImportError:
            raise ImportError(
                "psycopg2 is required for PostgreSQL connections. "
                "Install it with: pip install psycopg2-binary"
            )
    return _psycopg2


def _import_oracledb():
    global _oracledb
    if _oracledb is None:
        try:
            import oracledb
            _oracledb = oracledb
            _oracledb.defaults.fetch_lobs = False
        except ImportError:
            raise ImportError(
                "oracledb is required for Oracle connections. "
                "Install it with: pip install oracledb"
            )
    return _oracledb


def _import_pymssql():
    global _pymssql
    if _pymssql is None:
        try:
            import pymssql as _pymssql
        except ImportError:
            raise ImportError(
                "pymssql is required for SQL Server connections. "
                "Install it with: pip install pymssql"
            )
    return _pymssql


def _jdbc_url_to_python_url(jdbc_url: str) -> str:
    """Strip the 'jdbc:' prefix so urllib can parse the URL."""
    return jdbc_url.replace("jdbc:", "", 1)


class ConnectionManager:
    """Manages database connection lifecycle.

    Provides two connection modes:
    - ``get_connection()`` — returns an auto-commit connection for regular queries.
    - ``get_connection_for_transaction()`` — returns a manual-commit connection.
    """

    def __init__(
        self,
        url: str,
        username: str = "",
        password: str = "",
        driver: str = "pymysql",
    ) -> None:
        self._url = url
        self._username = username
        self._password = password
        self._driver = driver

    def get_connection(self):
        conn = self._connect()
        conn.autocommit = True
        return conn

    def get_connection_for_transaction(self):
        conn = self._connect()
        conn.autocommit = False
        return conn

    @staticmethod
    def close_connection(conn) -> None:
        if conn is None:
            return
        try:
            conn.close()
        except Exception:
            logger.debug("Ignored error while closing connection", exc_info=True)

    def shutdown(self) -> None:
        pass

    def _connect(self):
        python_url = _jdbc_url_to_python_url(self._url)

        if self._driver == "pymysql":
            pymysql = _import_pymysql()
            parsed = urllib.parse.urlparse(python_url)
            host = parsed.hostname or "localhost"
            port = parsed.port or 3306
            database = parsed.path.lstrip("/").split("?")[0] if parsed.path else ""
            try:
                return pymysql.connect(
                    host=host,
                    port=port,
                    database=database,
                    user=self._username,
                    [REDACTED_SECRET],
                    cursorclass=pymysql.cursors.DictCursor,
                    charset="utf8mb4",
                )
            except Exception as exc:
                raise DatabaseConnectionError(
                    f"Failed to connect to MySQL at {host}:{port}/{database}: {exc}"
                ) from exc

        if self._driver == "psycopg2":
            psycopg2 = _import_psycopg2()
            try:
                return psycopg2.connect(
                    python_url,
                    user=self._username,
                    [REDACTED_SECRET],
                )
            except Exception as exc:
                raise DatabaseConnectionError(
                    f"Failed to connect to PostgreSQL at {python_url}: {exc}"
                ) from exc

        if self._driver == "oracledb":
            oracledb = _import_oracledb()
            try:
                python_url = python_url.replace("oracle:", "oracle+cx_oracle:")
                return oracledb.connect(
                    user=self._username,
                    [REDACTED_SECRET],
                    dsn=python_url.replace("oracle:thin:@", "").replace("oracle:@", ""),
                )
            except Exception as exc:
                raise DatabaseConnectionError(
                    f"Failed to connect to Oracle: {exc}"
                ) from exc

        if self._driver == "pymssql":
            pymssql = _import_pymssql()
            try:
                host = "localhost"
                port = 1433
                database = ""
                # SQL Server JDBC URL uses semicolons, not standard URL query
                raw = python_url.replace("sqlserver://", "", 1)
                for part in raw.split(";"):
                    part = part.strip()
                    if "=" in part:
                        key, val = part.split("=", 1)
                        kl = key.lower()
                        if kl in ("databasename", "database"):
                            database = val
                    elif ":" in part:
                        h, p = part.split(":", 1)
                        if h:
                            host = h
                        try:
                            port = int(p)
                        except ValueError:
                            pass
                    elif part:
                        host = part
                return pymssql.connect(
                    server=host,
                    port=port,
                    database=database,
                    user=self._username,
                    [REDACTED_SECRET],
                )
            except Exception as exc:
                raise DatabaseConnectionError(
                    f"Failed to connect to SQL Server at {host}:{port}/{database}: {exc}"
                ) from exc

        if self._driver == "sqlite3":
            path = python_url.replace("sqlite:", "", 1) if python_url.startswith("sqlite:") else python_url
            if ":mem:" in path or ":memory:" in path:
                return sqlite3.connect(":memory:")
            return sqlite3.connect(path)

        raise UnsupportedDatabaseError(f"Unsupported driver: {self._driver}")


path: scripts/exceptions.py
content: class DatabaseSkillError(Exception):
    """Base exception for all database-skill errors."""


class DatabaseConnectionError(DatabaseSkillError):
    """Raised when database connection fails."""


class QueryError(DatabaseSkillError):
    """Raised when SQL query execution fails."""


class ConfigurationError(DatabaseSkillError):
    """Raised when configuration loading fails."""


class UnsupportedDatabaseError(DatabaseSkillError):
    """Raised when the JDBC URL does not match any supported database."""


path: scripts/main.py
content: """Command-line entry point for database-skill.

Usage::

    python main.py --url jdbc:mysql://localhost:3306/mydb \\
        --user root --password '${DB_PASS}' --tables

See ``--help`` for full documentation.
"""
import argparse
import logging
import sys
import os
from typing import Optional

from connection_manager import (
    ConnectionManager,
    load_config,
    resolve_driver,
    resolve_env_vars,
)
from connections_store import ConnectionsStore, ConnectionRecord, _extract_host_db
from exceptions import DatabaseSkillError
from query_executor import QueryExecutor
from schema_inspector import SchemaInspector

logger = logging.getLogger(__name__)

_store = ConnectionsStore()


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="database-skill",
        description="Database connectivity tool for opencode — MySQL, PostgreSQL, Oracle, SQL Server, SQLite.",
        epilog="Password can be passed via environment variable ${VAR_NAME} in config files.",
    )
    parser.add_argument(
        "--config",
        default="datasource.yml",
        help="Path to YAML config file (default: datasource.yml, falls back to classpath).",
    )
    parser.add_argument("--url", help="JDBC URL (e.g. jdbc:mysql://host:3306/db).")
    parser.add_argument("--user", help="Database username.")
    parser.add_argument("--password", help="Database password.")
    parser.add_argument(
        "--query",
        nargs="+",
        help="SELECT query with optional parameterized values. "
        "Use '?' as placeholder: --query \"SELECT * FROM t WHERE x = ?\" val1",
    )
    parser.add_argument(
        "--update",
        nargs="+",
        help="UPDATE/INSERT/DELETE with optional parameterized values.",
    )
    parser.add_argument(
        "--batch",
        help="File containing one SQL statement per line (no parameterized bindings).",
    )
    parser.add_argument(
        "--tables",
        action="store_true",
        help="List all tables in the current database.",
    )
    parser.add_argument(
        "--columns",
        help="Show column metadata for a specific table.",
    )
    parser.add_argument(
        "--list-connections",
        action="store_true",
        help="Show all saved database connections.",
    )
    parser.add_argument(
        "--forget",
        help="Remove a saved connection by URL.",
    )
    return parser


def _interactive_select_connection() -> bool:
    """If no --url or --config is given, show saved connections and let user pick.

    Returns True if a connection was selected (args updated via side-channel),
    False if caller should fall through.
    """
    records = _store.load_all()
    if not records:
        return False

    print("Saved connections:")
    for i, r in enumerate(records):
        display_url = r.url.split("?")[0].replace(r.username, "***") if r.username else r.url.split("?")[0]
        print(f"  [{i}] {r.label}  ({display_url})")
    print("  [N] Enter a new connection")
    try:
        choice = input("\nSelect a connection (number or N): ").strip()
        if choice.upper() == "N":
            return False
        idx = int(choice)
        record = _store.get_by_index(idx)
        if record is None:
            print("Invalid selection.")
            return False
        # Override via module-level trick: we return the record
        # and the caller applies it
        _apply_record(record)
        return True
    except (ValueError, IndexError):
        return False


_ACTIVE_RECORD: Optional["ConnectionRecord"] = None


def _apply_record(record: ConnectionRecord) -> None:
    global _ACTIVE_RECORD
    _ACTIVE_RECORD = record


def entry_point() -> None:
    logging.basicConfig(
        level=logging.WARNING,
        format="%(levelname)s: %(message)s",
    )
    parser = _build_parser()
    args = parser.parse_args()

    # --- handle connection metadata commands ---
    if args.list_connections:
        records = _store.load_all()
        if not records:
            print("No saved connections.")
            return
        print("Saved connections:")
        for i, r in enumerate(records):
            pwd_info = f"env:${r.password_env_var}" if r.has_password() else "manual input"
            print(f"  [{i}] {r.label}")
            print(f"       URL:  {r.url}")
            print(f"       User: {r.username}")
            print(f"       Type: {r.driver}")
            print(f"       [REDACTED_SECRET]")
        return

    if args.forget:
        _store.remove(args.forget)
        print(f"Removed connection: {args.forget}")
        return

    # --- resolve connection parameters ---
    url = ""
    username = ""
    password = ""
    password_env_var = ""

    def _fill_from_cache(u: str, usr: str) -> None:
        nonlocal password, password_env_var
        for r in _store.load_all():
            if r.identity() == f"{usr}@{_extract_host_db(u)}":
                if r.has_password():
                    password_env_var = r.password_env_var
                    [REDACTED_SECRET])
                    print(f"Using saved connection: {r.label}")
                    print(f"  Password: from env ${password_env_var}")
                return

    import re as _re
    _ENV_RE = _re.compile(r"\$\{(\w+)\}")

    if args.url:
        url = args.url
        username = args.user or ""
        raw_password = args.password or ""
        # Detect "${VAR_NAME}", "${env:VAR_NAME}", "$env:VAR_NAME", or plain "VAR_NAME"
        m = _re.match(r"^\$\{(?:env:)?(\w+)\}$", raw_password)
        if m:
            password_env_var = m.group(1)
            [REDACTED_SECRET], "")
        elif _re.match(r"^\$env:(\w+)$", raw_password, _re.IGNORECASE):
            password_env_var = _re.match(r"^\$env:(\w+)$", raw_password, _re.IGNORECASE).group(1)
            [REDACTED_SECRET], "")
        elif _re.match(r"^[A-Z_][A-Z0-9_]*$", raw_password.upper()) and not _re.match(r"^\d", raw_password):
            password_env_var = raw_password
            [REDACTED_SECRET], "")
        elif raw_password:
            [REDACTED_SECRET]
        if not [REDACTED_SECRET], username)
    elif _ACTIVE_RECORD is not None:
        url = _ACTIVE_RECORD.url
        username = _ACTIVE_RECORD.username
        if _ACTIVE_RECORD.has_password():
            password_env_var = _ACTIVE_RECORD.password_env_var
            [REDACTED_SECRET])
            print(f"Using saved connection: {_ACTIVE_RECORD.label}")
            print(f"  URL:  {url}  User: {username}")
            print(f"  Password: from env ${password_env_var}")
        else:
            print(f"Using saved connection: {_ACTIVE_RECORD.label}")
            print(f"  URL:  {url}  User: {username}")
            [REDACTED_SECRET]"  Password (leave blank if none): ").strip()
            password = pwd
    else:
        config = load_config(args.config)
        ds = config.get("datasource", {})
        raw_url = ds.get("url", "")
        raw_username = ds.get("username", "")
        raw_password = ds.get("password", "")
        cfg_url = resolve_env_vars(raw_url)
        cfg_user = resolve_env_vars(raw_username)
        if cfg_url:
            url = cfg_url
            username = cfg_user
            pm = _ENV_RE.search(raw_password or "")
            if pm:
                password_env_var = pm.group(1)
                [REDACTED_SECRET])
            if not [REDACTED_SECRET], username)
        else:
            if _interactive_select_connection() and _ACTIVE_RECORD is not None:
                url = _ACTIVE_RECORD.url
                username = _ACTIVE_RECORD.username
                if _ACTIVE_RECORD.has_password():
                    password_env_var = _ACTIVE_RECORD.password_env_var
                    [REDACTED_SECRET])
                    print(f"Using saved connection: {_ACTIVE_RECORD.label}")
                    print(f"  URL:  {url}  User: {username}")
                    print(f"  Password: from env ${password_env_var}")
                else:
                    print(f"Using saved connection: {_ACTIVE_RECORD.label}")
                    print(f"  URL:  {url}  User: {username}")
                    [REDACTED_SECRET]"  Password (leave blank if none): ").strip()
                    password = pwd

    driver = resolve_driver(url)
    cm = ConnectionManager(url, username, password, driver)
    executor = QueryExecutor(cm)
    inspector = SchemaInspector(cm)

    try:
        if args.query:
            _do_query(executor, args.query)
        elif args.update:
            _do_update(executor, args.update)
        elif args.batch:
            _do_batch(executor, args.batch)
        elif args.tables:
            _do_tables(inspector)
        elif args.columns:
            _do_columns(inspector, args.columns)
        else:
            parser.print_help()
        # Save connection on success
        record = ConnectionRecord(
            url=url, username=username, driver=driver,
            password_env_var=password_env_var,
        )
        _store.save(record)
    except DatabaseSkillError as exc:
        logger.error(str(exc))
        sys.exit(1)
    finally:
        cm.shutdown()


def _do_query(executor: QueryExecutor, query_args: list) -> None:
    sql = query_args[0]
    params = query_args[1:]
    results = executor.execute_query(sql, *params)
    if not results:
        print("(no rows)")
        return
    print(f"({len(results)} rows)\n")
    for row in results:
        print(dict(row))


def _do_update(executor: QueryExecutor, update_args: list) -> None:
    sql = update_args[0]
    params = update_args[1:]
    affected = executor.execute_update(sql, *params)
    print(f"OK ({affected} rows affected)")


def _do_batch(executor: QueryExecutor, file_path: str) -> None:
    with open(file_path, "r") as f:
        sql_list = [line.strip() for line in f if line.strip()]
    results = executor.execute_batch(sql_list)
    print(f"Batch complete: {len(results)} statements executed")


def _do_tables(inspector: SchemaInspector) -> None:
    tables = inspector.get_tables()
    if not tables:
        print("(no tables)")
        return
    print(f"Tables ({len(tables)}):")
    for t in tables:
        print(f"  {t['name']} ({t['type']})")


def _do_columns(inspector: SchemaInspector, table_name: str) -> None:
    columns = inspector.get_columns(table_name)
    if not columns:
        print("(no columns or table not found)")
        return
    print(f"Columns of {table_name}:")
    for col in columns:
        pk = " PK" if col.get("primaryKey") else ""
        auto = " AUTO" if col.get("autoIncrement") else ""
        print(f"  {col['name']} {col['type']}{pk}{auto}")


if __name__ == "__main__":
    entry_point()


path: scripts/query_executor.py
content: from __future__ import annotations

import logging
import re
from typing import Any, Callable, Dict, List, Optional

from connection_manager import ConnectionManager
from exceptions import QueryError

logger = logging.getLogger(__name__)

# Match '?' that is NOT preceded by another '?' (i.e. not a literal '??')
_PLACEHOLDER_RE = re.compile(r"(?<!\?)\?(?!\?)")


def convert_placeholders(sql: str) -> str:
    """Replace JDBC-style '?' placeholders with Python-style '%s'.

    '??' can be used as an escaped literal question mark (not recommended).
    """
    return _PLACEHOLDER_RE.sub("%s", sql)


class CaseInsensitiveDict(Dict[str, Any]):
    """A dictionary whose string-key lookups are case-insensitive.

    Stores the original casing as the canonical key and maintains a
    mapping from both ``upper()`` and ``lower()`` forms back to it.

    Example::

        d = CaseInsensitiveDict({"Name": "Alice"})
        d["name"]   # "Alice"
        d["NAME"]   # "Alice"
        d["Name"]   # "Alice"
    """

    def __init__(self, mapping: Optional[Dict[str, Any]] = None) -> None:
        super().__init__()
        self._key_map: Dict[str, str] = {}
        if mapping:
            for k, v in mapping.items():
                super().__setitem__(k, v)
                if isinstance(k, str):
                    self._key_map[k.upper()] = k
                    self._key_map[k.lower()] = k

    def __getitem__(self, key: Any) -> Any:
        if isinstance(key, str):
            mapped = self._key_map.get(key)
            if mapped is not None:
                return super().__getitem__(mapped)
            mapped = self._key_map.get(key.upper())
            if mapped is not None:
                return super().__getitem__(mapped)
            mapped = self._key_map.get(key.lower())
            if mapped is not None:
                return super().__getitem__(mapped)
        return super().__getitem__(key)

    def get(self, key: Any, default: Optional[Any] = None) -> Optional[Any]:
        try:
            return self[key]
        except (KeyError, TypeError):
            return default


class QueryExecutor:
    """Executes SQL queries with parameterized bindings.

    All user-supplied values MUST be passed as separate parameters,
    never concatenated into the SQL string, to prevent SQL injection.
    """

    def __init__(self, connection_manager: ConnectionManager) -> None:
        self._cm = connection_manager

    def execute_query(self, sql: str, *params: Any) -> List[Dict[str, Any]]:
        """Execute a SELECT query and return the result rows.

        Args:
            sql: SQL statement with ``?`` placeholders.
            *params: Values to bind to each placeholder.

        Returns:
            A list of dicts (each dict is a row). Column names are
            case-insensitive via :class:`CaseInsensitiveDict`.

        Raises:
            QueryError: If the database returns an error.
        """
        conn = None
        try:
            conn = self._cm.get_connection()
            with conn.cursor() as cursor:
                converted_sql = convert_placeholders(sql)
                cursor.execute(converted_sql, params)
                columns = [desc[0] for desc in cursor.description] if cursor.description else []
                rows = []
                for row in cursor.fetchall():
                    row_dict = {}
                    if isinstance(row, dict):
                        for col in columns:
                            row_dict[col] = row[col]
                    else:
                        for i, col in enumerate(columns):
                            row_dict[col] = row[i]
                    rows.append(CaseInsensitiveDict(row_dict))
                return rows
        except Exception as exc:
            raise QueryError(f"Query execution failed: {exc}") from exc
        finally:
            self._cm.close_connection(conn)

    def execute_update(self, sql: str, *params: Any) -> int:
        """Execute an UPDATE, INSERT, or DELETE statement.

        Args:
            sql: SQL statement with ``?`` placeholders.
            *params: Values to bind to each placeholder.

        Returns:
            Number of affected rows.

        Raises:
            QueryError: If the database returns an error.
        """
        conn = None
        try:
            conn = self._cm.get_connection()
            with conn.cursor() as cursor:
                cursor.execute(convert_placeholders(sql), params)
                affected = cursor.rowcount
                if not conn.autocommit:
                    conn.commit()
                return affected
        except Exception as exc:
            raise QueryError(f"Update execution failed: {exc}") from exc
        finally:
            self._cm.close_connection(conn)

    def execute_batch(self, sql_list: List[str]) -> List[int]:
        """Execute multiple SQL statements sequentially.

        .. warning::
            Statements are executed **without** parameterized binding.
            Only use this with trusted, hard-coded SQL strings.

        Args:
            sql_list: Plain SQL statements (one per element).

        Returns:
            List of row counts, one per statement.

        Raises:
            QueryError: If any statement fails.
        """
        conn = None
        try:
            conn = self._cm.get_connection()
            with conn.cursor() as cursor:
                results: List[int] = []
                for sql in sql_list:
                    cursor.execute(sql)
                    results.append(cursor.rowcount)
                if not conn.autocommit:
                    conn.commit()
                return results
        except Exception as exc:
            raise QueryError(f"Batch execution failed: {exc}") from exc
        finally:
            self._cm.close_connection(conn)

    def execute_transaction(
        self, transaction_block: Callable[["QueryExecutor"], None]
    ) -> None:
        """Execute a block of operations inside a single transaction.

        The transaction is **committed** if the block returns normally,
        or **rolled back** if any exception propagates out of the block.

        Args:
            transaction_block: A callable that receives a ``QueryExecutor``
                whose connections are all bound to the same transaction.

        Raises:
            QueryError: On transaction failure (includes the original cause).
        """
        conn = None
        try:
            conn = self._cm.get_connection_for_transaction()
            tx_executor = QueryExecutor(_TxConnectionManager(conn))
            transaction_block(tx_executor)
            conn.commit()
        except Exception as exc:
            if conn is not None:
                try:
                    conn.rollback()
                except Exception:
                    logger.debug("Ignored rollback error", exc_info=True)
            raise QueryError(f"Transaction failed: {exc}") from exc
        finally:
            if conn is not None:
                try:
                    conn.autocommit = True
                except Exception:
                    logger.debug("Failed to reset autocommit", exc_info=True)
                self._cm.close_connection(conn)


class _TxConnectionManager(ConnectionManager):
    """ConnectionManager that routes every call to the transaction connection."""

    def __init__(self, tx_conn: Any) -> None:
        super().__init__("")
        self._tx_conn = tx_conn

    def get_connection(self):
        return self._tx_conn

    def get_connection_for_transaction(self):
        return self._tx_conn

    @staticmethod
    def close_connection(conn: Any) -> None:
        pass


path: scripts/schema_inspector.py
content: from __future__ import annotations

import logging
from typing import Any, Dict, List

from connection_manager import ConnectionManager
from exceptions import QueryError

logger = logging.getLogger(__name__)

_DB_DIALECT = {
    "pymysql": "mysql",
    "psycopg2": "postgresql",
    "oracledb": "oracle",
    "pymssql": "sqlserver",
    "sqlite3": "sqlite",
}


def _detect_dialect(cm: ConnectionManager) -> str:
    return _DB_DIALECT.get(cm._driver, "mysql")


class SchemaInspector:
    """Introspects database schema metadata.

    Supports MySQL, PostgreSQL, Oracle, SQL Server, and SQLite
    via ``information_schema`` or database-specific queries.
    """

    def __init__(self, connection_manager: ConnectionManager) -> None:
        self._cm = connection_manager

    def get_tables(self) -> List[Dict[str, str]]:
        conn = None
        try:
            conn = self._cm.get_connection()
            dialect = _detect_dialect(self._cm)
            with conn.cursor() as cursor:
                if dialect == "mysql":
                    cursor.execute("SHOW FULL TABLES")
                elif dialect == "postgresql":
                    cursor.execute(
                        "SELECT table_name, table_type FROM information_schema.tables "
                        "WHERE table_schema = 'public'"
                    )
                elif dialect == "oracle":
                    cursor.execute(
                        "SELECT table_name, 'TABLE' FROM user_tables "
                        "UNION ALL "
                        "SELECT view_name, 'VIEW' FROM user_views"
                    )
                elif dialect == "sqlserver":
                    cursor.execute(
                        "SELECT table_name, table_type FROM information_schema.tables "
                        "WHERE table_catalog = DB_NAME()"
                    )
                else:
                    cursor.execute(
                        "SELECT name, type FROM sqlite_master "
                        "WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'"
                    )
                rows = []
                for row in cursor.fetchall():
                    if isinstance(row, dict):
                        values = list(row.values())
                        rows.append({"name": values[0], "type": values[1]})
                    else:
                        rows.append({"name": row[0], "type": row[1]})
                return rows
        except Exception as exc:
            raise QueryError(f"Failed to retrieve tables: {exc}") from exc
        finally:
            self._cm.close_connection(conn)

    def get_columns(self, table_name: str) -> List[Dict[str, Any]]:
        conn = None
        try:
            conn = self._cm.get_connection()
            dialect = _detect_dialect(self._cm)
            safe_name = table_name.replace("'", "''")
            with conn.cursor() as cursor:
                if dialect == "mysql":
                    cursor.execute("SHOW FULL COLUMNS FROM `" + table_name.replace("`", "``") + "`")
                elif dialect == "postgresql":
                    cursor.execute(f"""
                        SELECT c.column_name, c.data_type, c.is_nullable,
                               c.column_default, tc.constraint_type
                        FROM information_schema.columns c
                        LEFT JOIN information_schema.key_column_usage kcu
                            ON c.table_name = kcu.table_name
                            AND c.column_name = kcu.column_name
                            AND kcu.table_schema = 'public'
                        LEFT JOIN information_schema.table_constraints tc
                            ON kcu.constraint_name = tc.constraint_name
                            AND tc.constraint_type = 'PRIMARY KEY'
                        WHERE c.table_name = '{safe_name}'
                    """)
                elif dialect == "oracle":
                    cursor.execute(f"""
                        SELECT c.column_name, c.data_type || '(' || c.data_length || ')',
                               c.nullable, c.data_default,
                               CASE WHEN pk.column_name IS NOT NULL THEN 'PRI' ELSE '' END
                        FROM user_tab_columns c
                        LEFT JOIN (
                            SELECT cc.column_name FROM user_cons_columns cc
                            JOIN user_constraints uc ON cc.constraint_name = uc.constraint_name
                            WHERE uc.constraint_type = 'P' AND cc.table_name = '{safe_name}'
                        ) pk ON c.column_name = pk.column_name
                        WHERE c.table_name = '{safe_name}'
                        ORDER BY c.column_id
                    """)
                elif dialect == "sqlserver":
                    cursor.execute(f"""
                        SELECT c.column_name, c.data_type + '(' + CAST(c.character_maximum_length AS VARCHAR) + ')',
                               c.is_nullable, c.column_default,
                               CASE WHEN pk.column_name IS NOT NULL THEN 'PRI' ELSE '' END
                        FROM information_schema.columns c
                        LEFT JOIN (
                            SELECT kcu.column_name FROM information_schema.key_column_usage kcu
                            JOIN information_schema.table_constraints tc
                                ON kcu.constraint_name = tc.constraint_name
                            WHERE tc.constraint_type = 'PRIMARY KEY'
                              AND kcu.table_name = '{safe_name}'
                        ) pk ON c.column_name = pk.column_name
                        WHERE c.table_name = '{safe_name}'
                        ORDER BY c.ordinal_position
                    """)
                else:
                    cursor.execute(f"PRAGMA table_info('{safe_name}')")

                columns: List[Dict[str, Any]] = []
                for row in cursor.fetchall():
                    if isinstance(row, dict):
                        if dialect == "mysql":
                            columns.append({
                                "name": row["Field"],
                                "type": row["Type"],
                                "nullable": row["Null"] == "YES",
                                "defaultValue": row.get("Default"),
                                "primaryKey": row["Key"] == "PRI",
                                "autoIncrement": "auto_increment" in (row.get("Extra") or "").lower(),
                            })
                        elif dialect == "sqlite":
                            columns.append({
                                "name": row["name"],
                                "type": row["type"],
                                "nullable": not row["notnull"],
                                "defaultValue": row.get("dflt_value"),
                                "primaryKey": bool(row["pk"]),
                                "autoIncrement": False,
                            })
                        else:
                            columns.append({
                                "name": row[0] if not isinstance(row, dict) else row.get("column_name"),
                                "type": row[1] if not isinstance(row, dict) else row.get("data_type"),
                                "nullable": (row[2] if not isinstance(row, dict) else row.get("is_nullable")) == "YES",
                                "defaultValue": row[3] if not isinstance(row, dict) else row.get("column_default"),
                                "primaryKey": (row[4] if not isinstance(row, dict) else row.get("constraint_type", "")) in ("PRI", "PRIMARY KEY", "P"),
                                "autoIncrement": False,
                            })
                    else:
                        if dialect == "mysql":
                            columns.append({
                                "name": row[0],
                                "type": row[1],
                                "nullable": row[3] == "YES",
                                "defaultValue": row[4],
                                "primaryKey": row[3] == "PRI",
                                "autoIncrement": "auto_increment" in str(row[5] or "").lower(),
                            })
                        elif dialect == "sqlite":
                            columns.append({
                                "name": row[1],
                                "type": row[2],
                                "nullable": not row[3],
                                "defaultValue": row[4],
                                "primaryKey": bool(row[5]),
                                "autoIncrement": False,
                            })
                        else:
                            columns.append({
                                "name": row[0],
                                "type": row[1],
                                "nullable": row[2] == "YES",
                                "defaultValue": row[3],
                                "primaryKey": row[4] in ("PRI", "PRIMARY KEY", "P"),
                                "autoIncrement": False,
                            })
                return columns
        except Exception as exc:
            raise QueryError(f"Failed to retrieve columns for {table_name}: {exc}") from exc
        finally:
            self._cm.close_connection(conn)

    def get_indexes(self, table_name: str) -> List[Dict[str, Any]]:
        conn = None
        try:
            conn = self._cm.get_connection()
            dialect = _detect_dialect(self._cm)
            safe_name = table_name.replace("'", "''")
            with conn.cursor() as cursor:
                if dialect == "mysql":
                    cursor.execute("SHOW INDEX FROM `" + table_name.replace("`", "``") + "`")
                elif dialect == "postgresql":
                    cursor.execute(f"""
                        SELECT i.relname, a.attname, ix.indisunique, ix.indisprimary
                        FROM pg_class t, pg_class i, pg_index ix, pg_attribute a
                        WHERE t.oid = ix.indrelid AND i.oid = ix.indexrelid
                          AND a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
                          AND t.relname = '{safe_name}'
                    """)
                elif dialect == "oracle":
                    cursor.execute(f"""
                        SELECT index_name, column_name, uniqueness
                        FROM user_ind_columns
                        JOIN user_indexes USING (index_name)
                        WHERE table_name = '{safe_name}'
                    """)
                elif dialect == "sqlserver":
                    cursor.execute(f"""
                        SELECT i.name, c.name, i.is_unique, i.is_primary_key
                        FROM sys.indexes i
                        JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id
                        JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
                        WHERE i.object_id = OBJECT_ID('{safe_name}')
                    """)
                else:
                    cursor.execute(f"PRAGMA index_list('{safe_name}')")
                    pragma_rows = cursor.fetchall()
                    indexes = []
                    for idx_row in pragma_rows:
                        idx_name = idx_row[1] if not isinstance(idx_row, dict) else idx_row["name"]
                        unique = not idx_row[2] if not isinstance(idx_row, dict) else not idx_row["unique"]
                        cursor.execute(f"PRAGMA index_info('{idx_name.replace(chr(39), chr(39)+chr(39))}')")
                        for col_row in cursor.fetchall():
                            col_name = col_row[2] if not isinstance(col_row, dict) else col_row["name"]
                            ordinal = col_row[1] if not isinstance(col_row, dict) else col_row["seqno"]
                            indexes.append({
                                "name": idx_name,
                                "column": col_name,
                                "unique": unique,
                                "ordinal": ordinal,
                            })
                    return indexes
                indexes = []
                for row in cursor.fetchall():
                    if isinstance(row, dict):
                        if dialect == "mysql":
                            indexes.append({
                                "name": row["Key_name"],
                                "column": row["Column_name"],
                                "unique": not row["Non_unique"],
                                "ordinal": row["Seq_in_index"],
                            })
                        else:
                            indexes.append({
                                "name": row[0] if not isinstance(row, dict) else row.get("index_name") or row.get("name"),
                                "column": row[1] if not isinstance(row, dict) else row.get("column_name") or row.get("attname"),
                                "unique": not row[2] if not isinstance(row, dict) else not row.get("indisunique") or row.get("is_unique"),
                                "ordinal": 0,
                            })
                    else:
                        indexes.append({
                            "name": row[0],
                            "column": row[1],
                            "unique": not row[2],
                            "ordinal": 0,
                        })
                return indexes
        except Exception as exc:
            raise QueryError(f"Failed to retrieve indexes for {table_name}: {exc}") from exc
        finally:
            self._cm.close_connection(conn)

    def get_foreign_keys(self, table_name: str) -> List[Dict[str, Any]]:
        conn = None
        try:
            conn = self._cm.get_connection()
            dialect = _detect_dialect(self._cm)
            safe_name = table_name.replace("'", "''")
            with conn.cursor() as cursor:
                if dialect == "mysql":
                    cursor.execute(
                        "SELECT COLUMN_NAME, REFERENCED_TABLE_NAME, "
                        "REFERENCED_COLUMN_NAME, CONSTRAINT_NAME "
                        "FROM information_schema.KEY_COLUMN_USAGE "
                        "WHERE TABLE_NAME = %s AND REFERENCED_TABLE_NAME IS NOT NULL",
                        (table_name,),
                    )
                elif dialect == "postgresql":
                    cursor.execute(f"""
                        SELECT kcu.column_name, ccu.table_name, ccu.column_name, rc.constraint_name
                        FROM information_schema.referential_constraints rc
                        JOIN information_schema.key_column_usage kcu
                            ON rc.constraint_name = kcu.constraint_name
                        JOIN information_schema.constraint_column_usage ccu
                            ON rc.unique_constraint_name = ccu.constraint_name
                        WHERE kcu.table_name = '{safe_name}'
                    """)
                elif dialect == "oracle":
                    cursor.execute(f"""
                        SELECT c.column_name, uc.table_name, uc.column_name, c.constraint_name
                        FROM user_cons_columns c
                        JOIN user_constraints u ON c.constraint_name = u.constraint_name
                        JOIN user_cons_columns uc ON u.r_constraint_name = uc.constraint_name
                        WHERE u.constraint_type = 'R' AND c.table_name = '{safe_name}'
                    """)
                elif dialect == "sqlserver":
                    cursor.execute(f"""
                        SELECT COL_NAME(fkc.parent_object_id, fkc.parent_column_id),
                               OBJECT_NAME(fkc.referenced_object_id),
                               COL_NAME(fkc.referenced_object_id, fkc.referenced_column_id),
                               fk.name
                        FROM sys.foreign_keys fk
                        JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id
                        WHERE OBJECT_NAME(fk.parent_object_id) = '{safe_name}'
                    """)
                else:
                    cursor.execute(f"PRAGMA foreign_key_list('{safe_name}')")
                    fks = []
                    for row in cursor.fetchall():
                        if isinstance(row, dict):
                            fks.append({
                                "fkColumn": row["from"],
                                "pkTable": row["table"],
                                "pkColumn": row["to"],
                                "fkName": "",
                            })
                        else:
                            fks.append({
                                "fkColumn": row[3],
                                "pkTable": row[2],
                                "pkColumn": row[4],
                                "fkName": "",
                            })
                    return fks

                fks: List[Dict[str, Any]] = []
                for row in cursor.fetchall():
                    if isinstance(row, dict):
                        fks.append({
                            "fkColumn": row.get("COLUMN_NAME") or row.get("column_name"),
                            "pkTable": row.get("REFERENCED_TABLE_NAME") or row.get("table_name") or row.get("TABLE_NAME"),
                            "pkColumn": row.get("REFERENCED_COLUMN_NAME") or row.get("column_name") or row.get("COLUMN_NAME"),
                            "fkName": row.get("CONSTRAINT_NAME") or row.get("constraint_name") or row.get("name"),
                        })
                    else:
                        fks.append({
                            "fkColumn": row[0],
                            "pkTable": row[1],
                            "pkColumn": row[2],
                            "fkName": row[3],
                        })
                return fks
        except Exception as exc:
            raise QueryError(
                f"Failed to retrieve foreign keys for {table_name}: {exc}"
            ) from exc
        finally:
            self._cm.close_connection(conn)


path: references/connection-pooling.md
content: # Connection management · 连接管理

English | [中文](#连接管理)

The connector uses **direct driver connections** (no connection pool).
`ConnectionManager` handles connect / disconnect lifecycle.

<a id="连接管理"></a>

连接器使用**直连模式**（无连接池），由 `ConnectionManager` 统一管理连接生命周期。

---

## Driver mapping · 驱动映射

| Database | Python driver | Install |
|---|---|---|
| MySQL | `pymysql` | `pip install pymysql` |
| PostgreSQL | `psycopg2` | `pip install psycopg2-binary` |
| Oracle | `oracledb` | `pip install oracledb` |
| SQL Server | `pymssql` | `pip install pymssql` |
| SQLite / H2 | `sqlite3` | Built-in |

## Connection modes · 连接模式

| Scenario | Method | autocommit |
|---|---|---|
| Normal queries | `get_connection()` | `True` |
| Transactions | `get_connection_for_transaction()` | `False` |

## Transaction isolation · 事务隔离

`execute_transaction` uses `_TxConnectionManager` to route all connections
to the same underlying connection:

```python
class _TxConnectionManager(ConnectionManager):
    def get_connection(self): return tx_conn
    def get_connection_for_transaction(self): return tx_conn
    def close_connection(self, conn): pass
```

## Safe close · 安全关闭

```python
@staticmethod
def close_connection(conn):
    if conn is None:
        return
    try:
        conn.close()
    except Exception:
        logger.debug("Ignored error while closing connection", exc_info=True)
```


path: references/query-execution.md
content: # Query execution · 查询执行

English | [中文](#查询执行)

`QueryExecutor` provides four execution modes, all supporting **parameterized
bindings** to prevent SQL injection.

<a id="查询执行"></a>

`QueryExecutor` 提供四种执行模式，全部支持**参数化绑定**以防止 SQL 注入。

---

## Placeholder conversion · 占位符转换

Accepts JDBC-style `?` placeholders, auto-converted to Python `%s`:

支持 JDBC 风格的 `?` 占位符，自动转为 Python 的 `%s`：

```python
_PLACEHOLDER_RE = re.compile(r"(?<!\?)\?(?!\?)")
def convert_placeholders(sql: str) -> str:
    return _PLACEHOLDER_RE.sub("%s", sql)
```

## Result wrapping · 结果包装

All query results are wrapped in `CaseInsensitiveDict`:

```python
d = CaseInsensitiveDict({"Name": "Alice"})
d["name"]   # "Alice"
d["NAME"]   # "Alice"
d["Name"]   # "Alice"
```

## Four execution modes · 四种执行模式

```python
# SELECT
results = executor.execute_query("SELECT * FROM users WHERE status = ?", "ACTIVE")

# UPDATE / INSERT / DELETE
affected = executor.execute_update("UPDATE users SET status = ? WHERE id = ?", "INACTIVE", 1)

# Batch · 批量
results = executor.execute_batch(["INSERT INTO log VALUES (1)", "INSERT INTO log VALUES (2)"])

# Transaction · 事务
executor.execute_transaction(lambda tx: (
    tx.execute_update("UPDATE accounts SET balance = balance - 100 WHERE id = ?", 1),
    tx.execute_update("UPDATE accounts SET balance = balance + 100 WHERE id = ?", 2),
))
```


path: references/supported-databases.md
content: # Supported databases · 支持的数据库

English | [中文](#支持的数据库)

<a id="支持的数据库"></a>

---

## MySQL 8+

| Property | Value |
|---|---|
| Driver | `pymysql` |
| JDBC URL | `jdbc:mysql://<host>:3306/<db>?useSSL=false&charset=utf8mb4` |
| Placeholder | `?` (auto-converted to `%s`) |
| Python lib | `pymysql>=1.1.0` |
| Schema | `SHOW FULL TABLES`, `SHOW FULL COLUMNS`, `SHOW INDEX` |

## PostgreSQL

| Property | Value |
|---|---|
| Driver | `psycopg2` |
| JDBC URL | `jdbc:postgresql://<host>:5432/<db>` |
| Placeholder | `?` (auto-converted to `%s`) |
| Python lib | `psycopg2-binary>=2.9.9` |
| Schema | `information_schema.tables`, `pg_class` |

## Oracle

| Property | Value |
|---|---|
| Driver | `oracledb` |
| JDBC URL | `jdbc:oracle:thin:@<host>:1521/<service>` |
| Placeholder | `?` (auto-converted to `%s`) |
| Python lib | `oracledb>=2.0.0` |
| Schema | `user_tables`, `user_tab_columns` |

## SQL Server

| Property | Value |
|---|---|
| Driver | `pymssql` |
| JDBC URL | `jdbc:sqlserver://<host>:1433;databaseName=<db>` |
| Placeholder | `?` (auto-converted to `%s`) |
| Python lib | `pymssql>=2.2.0` |
| Schema | `information_schema.tables`, `sys.indexes` |

## SQLite

| Property | Value |
|---|---|
| Driver | `sqlite3` (built-in) |
| JDBC URL | `jdbc:sqlite:<path>/file.db` or `jdbc:h2:mem:test` |
| Placeholder | `?` (auto-converted to `%s`) |
| Python lib | None (stdlib) |
| Schema | `sqlite_master`, `PRAGMA table_info` |

## Column name case sensitivity · 列名大小写

| Driver | Native casing | Wrapper |
|---|---|---|
| pymysql | lowercase | CaseInsensitiveDict |
| psycopg2 | lowercase | CaseInsensitiveDict |
| oracledb | uppercase | CaseInsensitiveDict |
| pymssql | lowercase | CaseInsensitiveDict |
| sqlite3 | as-defined | CaseInsensitiveDict |

## Adding a new database · 扩展新数据库

1. Add JDBC URL prefix to `_DRIVER_REGISTRY` in `connection_manager.py`.
2. Add lazy import function.
3. Add `_connect()` branch in `ConnectionManager`.
4. Add schema queries in `SchemaInspector`.
5. Add dependency to `pyproject.toml`.

1. 在 `connection_manager.py` 的 `_DRIVER_REGISTRY` 中注册 URL 前缀。
2. 添加惰性导入函数。
3. 在 `ConnectionManager._connect()` 中添加分支。
4. 在 `SchemaInspector` 中适配 Schema 查询。
5. 在 `pyproject.toml` 中添加依赖。


path: config/datasource-test.yml
content: datasource:
  url: "jdbc:mysql://localhost:3306/orange?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC"
  driver: "com.mysql.cj.jdbc.Driver"
  username: "${DB_USER}"
  [REDACTED_SECRET]"
  pool:
    maxSize: 5
    minIdle: 1
    connectionTimeout: 5000


path: config/datasource.yml
content: datasource:
  url: "jdbc:mysql://localhost:3306/your_database?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC"
  driver: "com.mysql.cj.jdbc.Driver"
  username: "${DB_USER}"
  [REDACTED_SECRET]"
  pool:
    maxSize: 10
    minIdle: 2
    connectionTimeout: 30000
    idleTimeout: 600000
    maxLifetime: 1800000
    validationTimeout: 5000


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)