diff --git a/docs/superpowers/plans/2026-07-03-sql-problem-display.md b/docs/superpowers/plans/2026-07-03-sql-problem-display.md new file mode 100644 index 0000000..f394f80 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-sql-problem-display.md @@ -0,0 +1,652 @@ +# SQL 题数据化展示实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** SQL 题的题目页展示真实的数据表和期望结果(自动从测试点 1 + 标准答案生成),替代不适用的「输入/输出/例子」文本区块。 + +**Architecture:** 后端在 `judge/sql_runner.py` 新增 `build_display()`(内存 sqlite 跑初始化脚本 + 标准答案,导出结构化 JSON),管理端保存题目时在 `ProblemBase.common_checks` 中生成并写入新字段 `Problem.sql_display`。前端学生端用 `sql_config` 判断 SQL 题,隐藏输入/输出/例子区块,改渲染「数据表」「期望结果」表格;管理端编辑页对 SQL 题隐藏无意义的表单项。 + +**Tech Stack:** Django 6 + DRF(后端,`OnlineJudge/` 仓库)、Vue 3 + TypeScript + Naive UI(前端,`ojnext/` 仓库)。 + +**Spec:** `OnlineJudge/docs/superpowers/specs/2026-07-03-sql-problem-display-design.md` + +## Global Constraints + +- **不写任何新测试**(项目测试政策:Do not write new tests)。验证靠 `ruff check`、`python manage.py shell -c` 手动执行、`npm run build`。 +- 两个子项目是**独立 git 仓库**:后端提交在 `OnlineJudge/`,前端提交在 `ojnext/`,根目录没有 git。 +- 后端 lint:`ruff check .`(180 列,双引号)。前端格式化:`npm run fmt`。 +- 展示行数上限 20 行(`DISPLAY_ROW_LIMIT = 20`);判题行数上限沿用现有 `ROW_LIMIT = 10_000`。 +- `sql_display` 只由服务端生成,客户端不可提交该字段(不加入 `CreateOrEditProblemSerializer`)。 +- 错误消息用中文(与 `sql_runner.py` 现有消息一致),面向出题人。 + +--- + +### Task 1: 后端 `build_display()` 生成展示数据 + +**Files:** +- Modify: `OnlineJudge/judge/sql_runner.py`(文件末尾追加) + +**Interfaces:** +- Consumes: 本文件已有的 `_new_db`、`_execute_trusted`、`_dump_tables`、`split_statements`、`_truncate`、`SQLCaseError`、`ROW_LIMIT`、`PROGRESS_STEP` +- Produces: `build_display(init_sql: str, ref_sql: str, mode: str, *, memory_limit_mb: int = 64) -> dict`,失败抛 `SQLCaseError`。返回结构: + - `{"tables": [TableDump, ...], "expected": ExpectedQuery | ExpectedModify}` + - `TableDump = {"name": str, "columns": [{"name": str, "type": str}], "rows": [[原始值]], "total_rows": int, "truncated": bool}` + - `ExpectedQuery = {"columns": [str], "rows": [[原始值]], "total_rows": int, "truncated": bool}`(query 模式) + - `ExpectedModify = {"changed_tables": [TableDump, ...]}`(modify 模式,只含执行标准答案后内容有变化的表) + +- [ ] **Step 1: 在 `sql_runner.py` 末尾追加展示数据生成代码** + +在文件顶部常量区(`ERROR_MESSAGE_MAX_LEN = 200` 之后)加: + +```python +# 题目页展示的行数上限(示例数据/期望结果) +DISPLAY_ROW_LIMIT = 20 +``` + +在文件末尾追加: + +```python +def _display_value(v): + """转为 JSON 可序列化的展示值;bytes 转 hex,None/数值/字符串原样。""" + if isinstance(v, bytes): + return v.hex() + return v + + +def _dump_display_tables(conn, only=None): + """按建表顺序 dump 用户表的原始行用于展示(区别于 _dump_tables 的归一化判题态)。 + + only 为表名集合时只导出这些表。 + """ + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + tables = [r[0] for r in cursor.fetchall()] + result = [] + for table in tables: + if only is not None and table not in only: + continue + quoted = table.replace('"', '""') + columns = [{"name": r[1], "type": r[2] or ""} for r in conn.execute(f'PRAGMA table_info("{quoted}")').fetchall()] + total = conn.execute(f'SELECT COUNT(*) FROM "{quoted}"').fetchone()[0] + rows = conn.execute(f'SELECT * FROM "{quoted}" LIMIT {DISPLAY_ROW_LIMIT}').fetchall() + result.append( + { + "name": table, + "columns": columns, + "rows": [[_display_value(v) for v in row] for row in rows], + "total_rows": total, + "truncated": total > DISPLAY_ROW_LIMIT, + } + ) + return result + + +def build_display(init_sql, ref_sql, mode, *, memory_limit_mb=64): + """生成题目页展示数据:初始数据表 + 期望结果。 + + 供管理端保存题目时调用;任何失败抛 SQLCaseError(出题配置问题,调用方转为报错信息)。 + """ + trusted_limit_s = 10 + conn = _new_db(memory_limit_mb) + try: + _execute_trusted(conn, init_sql, time.monotonic() + trusted_limit_s, "初始化脚本执行失败") + tables = _dump_display_tables(conn) + if mode == "query": + expected = None + deadline = time.monotonic() + trusted_limit_s + conn.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, PROGRESS_STEP) + try: + for stmt in split_statements(ref_sql): + cursor = conn.execute(stmt) + if cursor.description is not None: + columns = [d[0] for d in cursor.description] + rows = cursor.fetchmany(ROW_LIMIT + 1) + if len(rows) > ROW_LIMIT: + raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"标准答案结果超过 {ROW_LIMIT} 行") + expected = { + "columns": columns, + "rows": [[_display_value(v) for v in row] for row in rows[:DISPLAY_ROW_LIMIT]], + "total_rows": len(rows), + "truncated": len(rows) > DISPLAY_ROW_LIMIT, + } + cursor.close() + except sqlite3.Error as e: + msg = "超时" if "interrupted" in str(e) else _truncate(e) + raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, f"标准答案执行失败: {msg}") + finally: + conn.set_progress_handler(None, PROGRESS_STEP) + if expected is None: + raise SQLCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集") + else: + before = _dump_tables(conn) + _execute_trusted(conn, ref_sql, time.monotonic() + trusted_limit_s, "标准答案执行失败") + after = _dump_tables(conn) + changed = {name for name in set(before) | set(after) if before.get(name) != after.get(name)} + expected = {"changed_tables": _dump_display_tables(conn, only=changed)} + return {"tables": tables, "expected": expected} + finally: + conn.close() +``` + +- [ ] **Step 2: 手动验证 query 模式与 modify 模式** + +```bash +cd /home/xuyue/Projects/OJ/OnlineJudge && python manage.py shell -c " +from judge.sql_runner import build_display +init = '''CREATE TABLE students(id INTEGER PRIMARY KEY, name TEXT, score REAL); +INSERT INTO students VALUES (1,'小明',95),(2,'小红',88),(3,'小刚',91);''' +import json +r = build_display(init, \"SELECT name, score FROM students WHERE score > 90;\", 'query') +print(json.dumps(r, ensure_ascii=False, indent=1)) +r2 = build_display(init, \"DELETE FROM students WHERE score < 90;\", 'modify') +print(json.dumps(r2, ensure_ascii=False, indent=1)) +" +``` + +Expected: +- query 输出 `tables` 含 students 表(3 行、columns 带 INTEGER/TEXT/REAL 类型),`expected.columns == ["name", "score"]`、`expected.rows == [["小明", 95.0], ["小刚", 91.0]]` +- modify 输出 `expected.changed_tables` 只含 students(2 行,小红被删) + +再验证错误路径: + +```bash +cd /home/xuyue/Projects/OJ/OnlineJudge && python manage.py shell -c " +from judge.sql_runner import build_display, SQLCaseError +try: + build_display('CREATE TABLE t(a);', 'SELEC bad;', 'query') +except SQLCaseError as e: + print('OK:', e.message) +" +``` + +Expected:打印 `OK: 标准答案执行失败: ...syntax error...` + +- [ ] **Step 3: Lint** + +Run: `cd /home/xuyue/Projects/OJ/OnlineJudge && ruff check judge/sql_runner.py && ruff format --check judge/sql_runner.py` +Expected: 无报错(有格式问题就跑 `ruff format judge/sql_runner.py`) + +- [ ] **Step 4: Commit(OnlineJudge 仓库)** + +```bash +cd /home/xuyue/Projects/OJ/OnlineJudge && git add judge/sql_runner.py && git commit -m "feat: SQL 题展示数据生成 build_display + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: 后端 `sql_display` 字段 + 保存时生成 + +**Files:** +- Modify: `OnlineJudge/problem/models.py`(`sql_config` 字段之后,约 line 90) +- Create: `OnlineJudge/problem/migrations/`(makemigrations 自动生成) +- Modify: `OnlineJudge/problem/serializers.py:62-65`(放宽 input/output/samples 校验) +- Modify: `OnlineJudge/problem/views/admin.py`(`ProblemBase.common_checks`,约 line 192-216) + +**Interfaces:** +- Consumes: Task 1 的 `build_display(init_sql, ref_sql, mode, *, memory_limit_mb=64)` 与 `SQLCaseError`(`e.message` 属性) +- Produces: `Problem.sql_display`(JSONField,null=True,结构即 Task 1 返回值);随现有 `ProblemSerializer` / `ProblemSafeSerializer` / `ProblemAdminSerializer` 自动下发(三者均为 ModelSerializer 且未排除该字段)。`common_checks` 在 SQL 题保存时填充 `data["sql_display"]`,非 SQL 题置 None。 + +- [ ] **Step 1: 模型加字段** + +`problem/models.py`,紧跟 `sql_config` 字段之后加: + +```python + # SQL 题展示数据(保存题目时由测试点1+标准答案自动生成,见 judge/sql_runner.build_display),非 SQL 题为 None + sql_display = models.JSONField(null=True, blank=True, default=None) +``` + +- [ ] **Step 2: 生成并应用迁移** + +Run: `cd /home/xuyue/Projects/OJ/OnlineJudge && python manage.py makemigrations problem && python manage.py migrate problem` +Expected: 生成一个 `Add field sql_display to problem` 迁移并应用成功 + +- [ ] **Step 3: 放宽序列化器校验** + +`problem/serializers.py` 中 `CreateOrEditProblemSerializer` 的三个字段改为(原来 `input_description` / `output_description` 不允许空、`samples` 不允许空列表;SQL 题不填这些,改由 `common_checks` 对非 SQL 题保持强校验): + +```python + input_description = serializers.CharField(allow_blank=True) + output_description = serializers.CharField(allow_blank=True) + samples = serializers.ListField(child=CreateSampleSerializer(), allow_empty=True) +``` + +- [ ] **Step 4: `common_checks` 接入生成逻辑** + +`problem/views/admin.py` 顶部 import 区加: + +```python +from judge.sql_runner import SQLCaseError, build_display +``` + +`ProblemBase` 类改为(替换现有 `common_checks` 的 SQL 分支,并新增 `_build_sql_display` 方法): + +```python +class ProblemBase(APIView): + def common_checks(self, request): + data = request.data + if data["rule_type"] == ProblemRuleType.OI: + total_score = 0 + for item in data["test_case_score"]: + if item["score"] <= 0: + return "Invalid score" + else: + total_score += item["score"] + data["total_score"] = total_score + data["languages"] = list(data["languages"]) + + # SQL 题校验:.sql 测试点与 .in/.out 沙箱判题互斥,SQL 必须是唯一语言 + if "SQL" in data["languages"]: + if data["languages"] != ["SQL"]: + return "SQL problem cannot be mixed with other languages" + if not data.get("sql_config"): + return "SQL problem requires sql_config" + has_sql_answer = any(item.get("language") == "SQL" and item.get("code", "").strip() for item in (data.get("answers") or [])) + if not has_sql_answer: + return "SQL problem requires a SQL reference answer" + return self._build_sql_display(data) + else: + # 序列化器已放宽(SQL 题不填这些),非 SQL 题在此保持原有强校验 + if not data["input_description"] or not data["output_description"]: + return "Input and output description are required" + if not data["samples"]: + return "Samples are required" + # 防脏数据:非 SQL 题不应携带 SQL 配置 + data["sql_config"] = None + data["sql_display"] = None + + def _build_sql_display(self, data): + """SQL 题:用测试点1的初始化脚本 + 标准答案生成题目页展示数据。返回错误信息字符串,成功返回 None。""" + test_case_dir = os.path.join(settings.TEST_CASE_DIR, data["test_case_id"]) + try: + with open(os.path.join(test_case_dir, "info"), encoding="utf-8") as f: + info = json.load(f) + except (OSError, json.JSONDecodeError): + return "测试点信息读取失败,请重新上传测试点" + if not info.get("sql"): + return "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" + keys = sorted(info["test_cases"].keys(), key=natural_sort_key) + if not keys: + return "题目没有任何测试点" + input_name = info["test_cases"][keys[0]]["input_name"] + try: + with open(os.path.join(test_case_dir, input_name), encoding="utf-8") as f: + init_sql = f.read() + except OSError: + return f"测试点脚本 {input_name} 读取失败" + ref_sql = next(item["code"] for item in data["answers"] if item.get("language") == "SQL" and item.get("code", "").strip()) + try: + data["sql_display"] = build_display(init_sql, ref_sql, data["sql_config"]["mode"]) + except SQLCaseError as e: + return f"SQL 展示数据生成失败: {e.message}" +``` + +说明:`os`、`json`、`settings`、`natural_sort_key` 均已在该文件 import。`post`(`Problem.objects.create(**data)`)和 `put`(`setattr` 循环)都直接消费 `data`,`sql_display` 无需再改动创建/更新代码。`ContestProblemAPI` 复用同一 `common_checks`,比赛 SQL 题自动生效。 + +- [ ] **Step 5: 手动验证 `_build_sql_display` 全链路** + +```bash +cd /home/xuyue/Projects/OJ/OnlineJudge && python manage.py shell -c " +import json, os +from django.conf import settings +from problem.views.admin import ProblemBase + +# 造一个假测试点目录 +tcid = 'plan_verify_sql_display' +d = os.path.join(settings.TEST_CASE_DIR, tcid) +os.makedirs(d, exist_ok=True) +open(os.path.join(d, '1.sql'), 'w').write(\"CREATE TABLE t(a INTEGER, b TEXT); INSERT INTO t VALUES (1,'x'),(2,'y');\") +open(os.path.join(d, 'info'), 'w').write(json.dumps({'sql': True, 'test_cases': {'1': {'input_name': '1.sql', 'output_name': '1.sql'}}})) + +data = {'test_case_id': tcid, 'answers': [{'language': 'SQL', 'code': 'SELECT b FROM t ORDER BY a;'}], 'sql_config': {'mode': 'query'}} +err = ProblemBase()._build_sql_display(data) +print('err:', err) +print(json.dumps(data['sql_display'], ensure_ascii=False)) +import shutil; shutil.rmtree(d) +" +``` + +Expected: `err: None`,`sql_display` 含 `tables`(表 t,2 行)与 `expected`(columns `["b"]`,rows `[["x"],["y"]]`) + +- [ ] **Step 6: Lint + Django 检查** + +Run: `cd /home/xuyue/Projects/OJ/OnlineJudge && ruff check problem/ && python manage.py check` +Expected: 无报错 + +- [ ] **Step 7: Commit(OnlineJudge 仓库)** + +```bash +cd /home/xuyue/Projects/OJ/OnlineJudge && git add problem/ && git commit -m "feat: 保存 SQL 题时自动生成 sql_display 展示数据 + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: 学生端「数据表 / 期望结果」渲染 + +**Files:** +- Modify: `ojnext/src/utils/types.ts`(`SQLConfig` 接口之后,约 line 73;`Problem` 接口的 `sql_config` 字段之后,约 line 175) +- Create: `ojnext/src/oj/problem/components/SQLDataTable.vue` +- Modify: `ojnext/src/oj/problem/components/ProblemContent.vue` + +**Interfaces:** +- Consumes: 后端 `problem.sql_display`(结构见 Task 1 Produces)、`problem.sql_config` +- Produces: `SQLDataTable.vue` 组件,props:`columns: { name: string; type?: string }[]`、`rows: (string | number | null)[][]`、`totalRows?: number`、`truncated?: boolean`;类型 `SQLDisplay`、`SQLDisplayTable`、`SQLDisplayColumn`(Task 4 不依赖,仅本任务内使用) + +- [ ] **Step 1: types.ts 加类型** + +`SQLConfig` 接口定义之后追加: + +```typescript +export interface SQLDisplayColumn { + name: string + type?: string +} + +export interface SQLDisplayTable { + name: string + columns: SQLDisplayColumn[] + rows: (string | number | null)[][] + total_rows: number + truncated: boolean +} + +export interface SQLDisplay { + tables: SQLDisplayTable[] + expected: + | { + columns: string[] + rows: (string | number | null)[][] + total_rows: number + truncated: boolean + } + | { changed_tables: SQLDisplayTable[] } +} +``` + +`Problem` 接口中 `sql_config?: SQLConfig | null` 之后加: + +```typescript + // SQL 题展示数据(后端保存题目时自动生成) + sql_display?: SQLDisplay | null +``` + +- [ ] **Step 2: 新建 `SQLDataTable.vue`** + +```vue + + + + + +``` + +(`n-table` 由 unplugin 自动导入,无需手动 import。) + +- [ ] **Step 3: `ProblemContent.vue` 集成** + +script 部分,在 `import { getSimilarProblems } from "oj/api"` 之后加: + +```typescript +import SQLDataTable from "./SQLDataTable.vue" +``` + +在 `const problemSetId = ...` 附近加计算属性: + +```typescript +// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果 +const isSQL = computed(() => !!problem.value?.sql_config) +const sqlDisplay = computed(() => problem.value?.sql_display ?? null) +const sqlExpectedQuery = computed(() => { + const exp = sqlDisplay.value?.expected + return exp && "columns" in exp ? exp : null +}) +const sqlChangedTables = computed(() => { + const exp = sqlDisplay.value?.expected + return exp && "changed_tables" in exp ? exp.changed_tables : [] +}) +``` + +template 部分三处修改: + +(a) 「输入」「输出」两个区块(`

…输入…

` + `MdPreview` 到 `…输出…` + `MdPreview`,现 line 272-294)整体包进 ``。 + +(b) 例子循环 `
`(现 line 337-380)整体包进 ``(注意 v-if 不能与 v-for 放同一元素上)。 + +(c) 在 (a) 的 `` 之后(即描述区块后、提示区块前)插入 SQL 区块: + +```html + +``` + +style 部分追加: + +```css +.sqlTableName { + font-weight: 600; + margin: 8px 0 4px; + font-family: "Monaco"; +} + +.sqlNote { + font-size: 13px; + opacity: 0.65; + margin: 0 0 8px; +} +``` + +- [ ] **Step 4: 构建验证** + +Run: `cd /home/xuyue/Projects/OJ/ojnext && npm run build` +Expected: 构建成功、无 TypeScript 报错 + +- [ ] **Step 5: 格式化 + Commit(ojnext 仓库)** + +```bash +cd /home/xuyue/Projects/OJ/ojnext && npm run fmt && git add src/utils/types.ts src/oj/problem/components/SQLDataTable.vue src/oj/problem/components/ProblemContent.vue && git commit -m "feat: SQL 题展示数据表与期望结果 + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: 管理端编辑页隐藏 SQL 题无关表单 + +**Files:** +- Modify: `ojnext/src/admin/problem/detail.vue` + +**Interfaces:** +- Consumes: 该文件已有的 `isSQLProblem` computed(line 184)、`validateProblem()`、`submit()` +- Produces: 无对外接口;SQL 题提交时 `samples` 置空数组、`input_description`/`output_description` 不做必填校验(后端 Task 2 已放宽) + +- [ ] **Step 1: 模板隐藏三处表单** + +(a) 「输入的描述」「输出的描述」两个 `TextEditor`(line 594-603)的 `v-if="ready"` 改为 `v-if="ready && !isSQLProblem"`。 + +(b) 样例编辑块与「添加用例」按钮(line 604-633,`
` 和 ``)整体包进 ``。 + +- [ ] **Step 2: `validateProblem()` 跳过 SQL 题的相关校验** + +题目描述校验(line 345-352)改为: + +```typescript + else if ( + !problem.value.description || + (!isSQLProblem.value && + (!problem.value.input_description || !problem.value.output_description)) + ) { +``` + +样例两处校验(line 354、359-363)分别加 `!isSQLProblem.value &&` 前缀: + +```typescript + else if (!isSQLProblem.value && problem.value.samples.length == 0) { +``` + +```typescript + else if ( + !isSQLProblem.value && + problem.value.samples.some( + (sample) => sample.output === "" || sample.input === "", + ) + ) { +``` + +- [ ] **Step 3: 提交时清空 SQL 题的样例** + +`filterAnswers()` 函数(line 429-433)之后加: + +```typescript +function filterSamplesForSQL() { + // SQL 题不展示样例;后端 CreateSampleSerializer 也不接受空字符串样例 + if (isSQLProblem.value) problem.value.samples = [] +} +``` + +`submit()` 中在 `filterAnswers()` 调用之后加一行 `filterSamplesForSQL()`。 + +- [ ] **Step 4: 构建验证** + +Run: `cd /home/xuyue/Projects/OJ/ojnext && npm run build` +Expected: 构建成功 + +- [ ] **Step 5: 格式化 + Commit(ojnext 仓库)** + +```bash +cd /home/xuyue/Projects/OJ/ojnext && npm run fmt && git add src/admin/problem/detail.vue && git commit -m "feat: 管理端 SQL 题隐藏输入输出描述与样例表单 + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: 端到端人工验收 + +**Files:** 无代码改动,纯验证。 + +**Interfaces:** +- Consumes: Task 1-4 全部成果;本地服务(`docker-compose up -d oj-postgres oj-redis`、`python dev.py`、`npm start`) + +- [ ] **Step 1: 起服务** + +```bash +cd /home/xuyue/Projects/OJ/OnlineJudge && docker-compose up -d oj-postgres oj-redis +cd /home/xuyue/Projects/OJ/OnlineJudge && python dev.py # 后台/另开终端 +cd /home/xuyue/Projects/OJ/ojnext && npm start # 后台/另开终端 +``` + +- [ ] **Step 2: 管理端建一道 SQL 查询题** + +浏览器(或 agent-browser)操作 `http://localhost:5173`: +1. 管理后台新建题目,语言勾选 SQL → 确认「输入的描述」「输出的描述」「测试样例」表单已隐藏 +2. 上传 SQL 测试点 zip(内含 `1.sql`:建表 + 插数据脚本),填写 SQL 标准答案,保存 +3. Expected: 保存成功;故意把标准答案改成语法错误再保存,Expected: 报错「SQL 展示数据生成失败: 标准答案执行失败…」 + +- [ ] **Step 3: 学生端查看题目页** + +打开该题: +- Expected: 无「输入/输出/例子」区块;有「数据表」区块(表名 + 列类型标注 + 数据行)和「期望结果」区块(结果表 + "结果顺序不限"提示) +- 提交正确 SQL,Expected: Accepted(确认判题链路未受影响) + +- [ ] **Step 4: 建一道 modify 题验证 changed_tables** + +同流程建 `mode=modify` 的题(标准答案为 UPDATE/DELETE),Expected: 题目页「期望结果」显示「执行后的 XX 表」,且只含被修改的表。