"""Read-only SQLAlchemy connectors with server-side credential references.""" import os from pathlib import Path from datetime import date, datetime from decimal import Decimal import sqlite3 def normalize(value, exact_decimals=False): if isinstance(value, (date, datetime)): return value.isoformat() if isinstance(value, Decimal): return str(value) if exact_decimals else float(value) if isinstance(value, bytes): raise ValueError("查询包含二进制数据,请返回附件索引而非文件内容") return value def validate_query(sql, allowed_tables, dialect): import sqlglot from sqlglot import exp try: statements = sqlglot.parse(sql, read=dialect) except Exception: raise ValueError("SQL 无法解析,请使用单条只读 SELECT") if len(statements) != 1 or not isinstance(statements[0], (exp.Select, exp.Union)): raise ValueError("只允许单条 SELECT 查询") tree = statements[0] if any(tree.find_all(exp.Insert, exp.Update, exp.Delete, exp.Create, exp.Drop, exp.Command, exp.Into, exp.Lock)): raise ValueError("不允许写入、锁定或导出语句") # User-defined functions may have side effects even when embedded in SELECT. if any(tree.find_all(exp.Anonymous)): raise ValueError("不允许自定义函数,请使用标准聚合和表达式") aliases = {c.alias for c in tree.find_all(exp.CTE)} tables = {".".join(part.name for part in t.parts).lower() for t in tree.find_all(exp.Table) if t.name not in aliases} if not allowed_tables or not tables.issubset({t.lower() for t in allowed_tables}): raise ValueError("查询使用了未授权的库表或视图") def execute(connection, sql, project_id, exact_decimals=False): from sqlalchemy import create_engine, text from sqlalchemy.engine import URL kind = connection.get("type") dialects = {"PostgreSQL": ("postgresql+psycopg", "postgres"), "MySQL": ("mysql+pymysql", "mysql"), "SQLite": ("sqlite", "sqlite")} if kind not in dialects: raise ValueError("该数据库连接器暂不支持") driver, dialect = dialects[kind] validate_query(sql, connection.get("allowed_tables", []), dialect) seconds = max(1, min(int(connection.get("timeout", 15)), 60)) max_rows = max(1, min(int(connection.get("max_rows", 1000)), 10000)) if kind == "SQLite": root = os.getenv("XINGBANG_SQLITE_ROOT") if not root and connection.get("data_domain") == "demo": root = str(Path(__file__).resolve().parents[1] / "xingbang_data") if not root: raise ValueError("服务器未配置 XINGBANG_SQLITE_ROOT") file = Path(connection.get("database", "")).resolve() if not file.is_relative_to(Path(root).resolve()) or not file.is_file(): raise ValueError("SQLite 文件不存在或不在服务器允许目录内") def creator(): db = sqlite3.connect(file.as_uri()+"?mode=ro", uri=True) def authorize(action, arg1, arg2, *_): if action in {sqlite3.SQLITE_SELECT, sqlite3.SQLITE_READ, sqlite3.SQLITE_FUNCTION}: return sqlite3.SQLITE_OK # SQLAlchemy reads the isolation mode during connection initialization. if action == sqlite3.SQLITE_PRAGMA and arg1 == "read_uncommitted" and arg2 is None: return sqlite3.SQLITE_OK return sqlite3.SQLITE_DENY db.set_authorizer(authorize) import time deadline=time.monotonic()+seconds db.set_progress_handler(lambda: int(time.monotonic()>deadline),1000) return db engine = create_engine("sqlite://", creator=creator) else: ref = connection.get("password_env", "") if not ref.startswith("XINGBANG_DB_") or not os.getenv(ref): raise ValueError("请配置 XINGBANG_DB_ 前缀的服务器密码引用") url = URL.create(driver, username=connection.get("username"),password=os.environ[ref],host=connection.get("host"),port=int(connection.get("port") or (5432 if kind=="PostgreSQL" else 3306)),database=connection.get("database")) args = {"connect_timeout": seconds} if kind == "PostgreSQL": args["sslmode"]="require" else: args.update(read_timeout=seconds,write_timeout=seconds) engine = create_engine(url,connect_args=args,pool_pre_ping=True) try: with engine.connect() as db: if kind == "PostgreSQL": db.exec_driver_sql("SET TRANSACTION READ ONLY") db.exec_driver_sql(f"SET LOCAL statement_timeout = {seconds*1000}") elif kind == "MySQL": db.exec_driver_sql("SET SESSION TRANSACTION READ ONLY") db.exec_driver_sql(f"SET SESSION MAX_EXECUTION_TIME = {seconds*1000}") rows = db.execute(text(sql), {"project_id": project_id}).mappings().fetchmany(max_rows+1) if len(rows)>max_rows: raise ValueError(f"查询结果超过 {max_rows} 行,请缩小范围") return [{k:normalize(v, exact_decimals) for k,v in row.items()} for row in rows] except ValueError: raise except Exception: raise ValueError("数据库连接或查询失败,请检查驱动、只读账号、网络与字段配置") from None finally: engine.dispose()