新增 SQL 题型:SQLite 内联判题(不依赖外部沙箱)

- SQL 作为语言接入现有提交流程,judge_task 按 language 分流到 SQLJudgeDispatcher
- judge/sql_runner.py:内存 SQLite 判题核心,查询题比结果集/增删改题比表状态,
  authorizer + progress_handler + max_page_count 三重防护
- dispatcher 提取 _process_judge_result/_push_status 供 SQL 判题复用(行为不变)
- 测试点通道支持 1.sql..N.sql 压缩包,出题只需数据脚本+标准答案
- Problem 增加 sql_config 字段;options 数据迁移注册 SQL 语言

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 17:23:13 -06:00
parent d1a1c05b9f
commit 9f7f818d51
10 changed files with 551 additions and 93 deletions

246
judge/sql_runner.py Normal file
View File

@@ -0,0 +1,246 @@
"""SQL 题判题核心:在内存 SQLite 中分别执行标准答案和学生 SQL 并比对结果。
查询题mode="query")比对最后一条 SELECT 的结果集;
增删改题mode="modify")比对执行后所有用户表的最终状态。
学生 SQL 通过 authorizer禁 ATTACH/PRAGMA查询题只读
progress_handler墙钟超时和 max_page_count内存上限三重防护。
"""
import sqlite3
import time
from submission.models import JudgeStatus
# 单结果集/单表最大行数,防 CROSS JOIN 撑爆 worker 内存
ROW_LIMIT = 10_000
# progress_handler 检查粒度SQLite VM 指令数)
PROGRESS_STEP = 1_000
ERROR_MESSAGE_MAX_LEN = 200
# prepare 阶段的语法类错误,映射为 COMPILE_ERROR
_SYNTAX_ERROR_MARKERS = ("syntax error", "unrecognized token", "incomplete input")
# 两种模式都禁止的授权码:挂载外部库 / 数据库参数
_DENIED_ALWAYS = {sqlite3.SQLITE_ATTACH, sqlite3.SQLITE_DETACH, sqlite3.SQLITE_PRAGMA}
# 查询题允许的授权码(白名单外一律拒绝,防先 INSERT 伪造数据再 SELECT
_QUERY_MODE_ALLOWED = {getattr(sqlite3, name) for name in ("SQLITE_SELECT", "SQLITE_READ", "SQLITE_FUNCTION", "SQLITE_RECURSIVE", "SQLITE_TRANSACTION") if hasattr(sqlite3, name)}
class SQLCaseError(Exception):
"""携带 JudgeStatus 的判题异常。SYSTEM_ERROR 级别(出题配置问题)会传播到 dispatcher。"""
def __init__(self, result, message):
super().__init__(message)
self.result = result
self.message = message
def _truncate(message):
message = str(message)
if len(message) > ERROR_MESSAGE_MAX_LEN:
return message[:ERROR_MESSAGE_MAX_LEN] + "..."
return message
def split_statements(script):
"""用 sqlite3.complete_statement 累积切分多条语句,正确处理字符串/注释里的分号;末尾缺分号自动补。"""
statements = []
buf = ""
for part in script.split(";"):
buf += part + ";"
if sqlite3.complete_statement(buf):
stmt = buf.strip()
buf = ""
if stmt and stmt != ";":
statements.append(stmt)
# 残句(未闭合的引号/注释,或末尾缺分号但上面已补),交给 execute 报错或执行
remainder = buf.strip()
if remainder and remainder != ";":
statements.append(remainder)
return statements
def _canonical_value(v):
"""值归一化并打类型标签,防止 NULL/"NULL"、1/"1" 碰撞数值统一比对1 == 1.0,浮点 6 位有效数字)。"""
if v is None:
return ("null",)
if isinstance(v, int):
return ("num", str(v))
if isinstance(v, float):
if v.is_integer() and abs(v) < 2**53:
return ("num", str(int(v)))
return ("num", format(v, ".6g"))
if isinstance(v, bytes):
return ("blob", v.hex())
return ("str", str(v))
def _canonical_row(row):
return tuple(_canonical_value(v) for v in row)
def _new_db(memory_limit_mb):
conn = sqlite3.connect(":memory:", isolation_level=None) # autocommit脚本行为可预期
conn.execute("PRAGMA page_size=4096")
# 4096B/页 × 256 页/MB超限报 "database or disk is full"
conn.execute(f"PRAGMA max_page_count={max(int(memory_limit_mb), 1) * 256}")
return conn
def _execute_statements(conn, script, deadline=None):
"""逐条执行,返回最后一条产生结果集的语句的 (列数, 行列表);无结果集返回 None。"""
last_result = None
for stmt in split_statements(script):
if deadline is not None and time.monotonic() > deadline:
raise sqlite3.OperationalError("interrupted")
cursor = conn.execute(stmt)
if cursor.description is not None:
rows = cursor.fetchmany(ROW_LIMIT + 1)
if len(rows) > ROW_LIMIT:
raise SQLCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, f"查询结果超过 {ROW_LIMIT}")
last_result = (len(cursor.description), [_canonical_row(r) for r in rows])
cursor.close()
return last_result
def _dump_tables(conn):
"""dump 所有用户表:{表名: (列数, 行多重集)},行内排序,表状态天然无序。"""
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
tables = [r[0] for r in cursor.fetchall()]
state = {}
for table in tables:
quoted = table.replace('"', '""')
cur = conn.execute(f'SELECT * FROM "{quoted}"')
rows = cur.fetchmany(ROW_LIMIT + 1)
if len(rows) > ROW_LIMIT:
raise SQLCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, f"{table} 超过 {ROW_LIMIT}")
state[table] = (len(cur.description), sorted(_canonical_row(r) for r in rows))
return state
def _execute_trusted(conn, script, deadline, error_prefix):
"""执行受信脚本,任何失败都是出题问题 → SYSTEM_ERROR。"""
conn.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, PROGRESS_STEP)
try:
return _execute_statements(conn, script, deadline)
except SQLCaseError as e:
raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"{error_prefix}: {e.message}")
except sqlite3.Error as e:
raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"{error_prefix}: {_truncate(e)}")
finally:
conn.set_progress_handler(None, PROGRESS_STEP)
def _run_student(conn, script, mode, deadline):
"""带三重防护执行学生 SQL异常映射为学生级 JudgeStatus。"""
denied_hints = []
def authorizer(action, arg1, arg2, db_name, trigger):
if action in _DENIED_ALWAYS:
denied_hints.append("禁止使用 ATTACH/DETACH/PRAGMA 等数据库管理语句")
return sqlite3.SQLITE_DENY
if mode == "query" and action not in _QUERY_MODE_ALLOWED:
denied_hints.append("本题为查询题禁止修改数据或表结构INSERT/UPDATE/DELETE/CREATE 等)")
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
conn.set_authorizer(authorizer)
conn.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, PROGRESS_STEP)
try:
last_result = _execute_statements(conn, script, deadline)
except sqlite3.Error as e:
# 注意authorizer 拒绝抛的是 DatabaseError 基类而非 OperationalError统一按消息映射
msg = str(e)
if "interrupted" in msg:
raise SQLCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时")
if "database or disk is full" in msg:
raise SQLCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制")
if "not authorized" in msg or "prohibited" in msg:
hint = denied_hints[-1] if denied_hints else "本题禁止使用该语句"
raise SQLCaseError(JudgeStatus.RUNTIME_ERROR, hint)
if any(marker in msg for marker in _SYNTAX_ERROR_MARKERS):
raise SQLCaseError(JudgeStatus.COMPILE_ERROR, _truncate(msg))
raise SQLCaseError(JudgeStatus.RUNTIME_ERROR, _truncate(msg))
finally:
conn.set_progress_handler(None, PROGRESS_STEP)
conn.set_authorizer(None)
if mode == "query":
return last_result
# dump 是我们自己的读取,不应吃学生的超时/授权限制(上面已清除)
return _dump_tables(conn)
def _compare(expected, actual, mode, order_sensitive):
if mode == "query":
exp_cols, exp_rows = expected
act_cols, act_rows = actual
if exp_cols != act_cols:
return False
if order_sensitive:
return exp_rows == act_rows
return sorted(exp_rows) == sorted(act_rows)
# modify: dump dict 里行已排序
return expected == actual
def run_case(init_sql, ref_sql, student_sql, *, mode, order_sensitive, time_limit_ms, memory_limit_mb):
"""判一个测试点,返回与外部 judger 单测试点同构的 dict。
学生错误CE/WA/TLE/MLE/RE体现在返回值里
出题配置错误(初始化/标准答案失败)抛 SQLCaseError(SYSTEM_ERROR),由 dispatcher 处理。
"""
time_limit_s = time_limit_ms / 1000
# 受信脚本(初始化/标准答案)的运行上限放宽,避免出题数据较大时误报;仍防 worker 永久阻塞
trusted_limit_s = max(time_limit_s * 5, 10)
ref_conn = _new_db(memory_limit_mb)
try:
_execute_trusted(ref_conn, init_sql, time.monotonic() + trusted_limit_s, "初始化脚本执行失败")
last_result = _execute_trusted(ref_conn, ref_sql, time.monotonic() + trusted_limit_s, "标准答案执行失败")
if mode == "query":
expected = last_result
else:
try:
expected = _dump_tables(ref_conn)
except SQLCaseError as e:
raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"标准答案结果超出限制: {e.message}")
finally:
ref_conn.close()
if mode == "query" and expected is None:
raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集")
case = {
"test_case": "",
"result": JudgeStatus.ACCEPTED,
"cpu_time": 0,
"real_time": 0,
"memory": 0,
"signal": 0,
"exit_code": 0,
"error": 0,
"output_md5": "",
"error_message": None,
}
stu_conn = _new_db(memory_limit_mb)
try:
_execute_trusted(stu_conn, init_sql, time.monotonic() + trusted_limit_s, "初始化脚本执行失败")
start = time.monotonic()
try:
actual = _run_student(stu_conn, student_sql, mode, start + time_limit_s)
except SQLCaseError as e:
elapsed = int((time.monotonic() - start) * 1000)
case.update(result=e.result, error_message=e.message, cpu_time=elapsed, real_time=elapsed)
return case
elapsed = int((time.monotonic() - start) * 1000)
finally:
stu_conn.close()
case["cpu_time"] = case["real_time"] = elapsed
if mode == "query" and actual is None:
case.update(result=JudgeStatus.WRONG_ANSWER, error_message="提交的 SQL 未产生查询结果集")
elif not _compare(expected, actual, mode, order_sensitive):
case["result"] = JudgeStatus.WRONG_ANSWER
return case