Compare commits

..
3 Commits
Author SHA1 Message Date
xuyue be1b9a97a0 update
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
2026-07-05 03:12:36 -06:00
xuyueandClaude Fable 5 02c523b32f docs: SQL 题强制至少 2 个测试点设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:11:53 -06:00
xuyueandClaude Fable 5 f6d4d9bcb2 docs: SQL 题目表名/字段名自动补全设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:05:32 -06:00
4 changed files with 118 additions and 2 deletions
@@ -0,0 +1,28 @@
# SQL 题强制至少 2 个测试点 — 设计
日期:2026-07-05
## 背景
题目页的 `sql_display` 用**测试点 1** 的数据生成期望结果展示(截断到 20 行)。如果 SQL 题只有 1 个测试点且结果 ≤20 行,页面展示的期望结果就是完整答案输出,学生可用 `SELECT ... UNION ALL ...`query 模式)或硬编码 INSERT/UPDATEmodify 模式)对照抄写直接 AC。多测试点时数据不同,硬编码只能过测试点 1。
## 决定
SQL 题**强制至少 2 个数据不同的初始化脚本**,前后端双重拦截:
- **前端** `ojnext/src/admin/problem/components/SQLTestcaseEditor.vue`
- `canUpload` 要求非空脚本数 ≥ 2,不满足时上传按钮禁用;
- 上传按钮 tooltip 在脚本不足时显示原因("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")。
- **后端** `OnlineJudge/problem/views/admin.py``TestCaseZipProcessor.process_zip`
- `sql=True` 且测试点数 < 2 时 `raise APIError(...)`,兜底直接调 API 的情况。
## 影响范围
- 只在**重新上传/保存测试点**时拦截,已有的单测试点老题目不受影响、不回溯校验。
- 非 SQL 题(.in/.out 沙箱判题)不受影响。
- "数据不同"不做内容级校验(两个脚本内容相同也能过),只保证数量下限,YAGNI。
## 验证
前端 `vue-tsc --noEmit`、Prettier 通过;后端 `ruff check` / `ruff format --check` 通过。
人工验证:出题页只填 1 个脚本 → 上传按钮禁用且 tooltip 说明原因;填 2 个并预览通过 → 可上传。
@@ -0,0 +1,50 @@
# SQL 题目表名/字段名自动补全 — 设计
日期:2026-07-05
## 目标
学生在 SQL 题目的代码编辑器里输入时,自动补全列表中除现有的 SQL 关键字/函数外,还出现**当前题目的表名和字段名**(带类型提示),减少抄写表名字段名的负担和拼写错误。
## 背景
- SQL 题目详情页已下发 `problem.sql_display``SQLDisplay` 类型),其中 `tables: SQLDisplayTable[]` 包含每张表的 `name``columns[{name, type}]`。数据在前端齐全,**无需后端改动**。
- 编辑器补全入口是 `shared/extensions/autocompletion.ts``enhanceCompletion(language)``CodeEditor.vue``SyncCodeEditor.vue` 都用它,且都叠加了 `completeAnyWord`
- SQL 静态关键字补全表在 `shared/extensions/sql.ts`
- `shared` 直接 import `oj/store/problem` 已有先例(`FlowchartEditor/index.vue`)。
## 方案(已选:方案 A
`enhanceCompletion` 中,当 `language === "SQL"` 时,从 `useProblemStore().problem?.sql_display?.tables` 动态生成补全项,追加到静态关键字列表后:
- **表名**`type: "class"``detail: "数据表"``info` 列出该表全部字段(如 `字段:id INTEGER, name TEXT, score REAL`),`boost` 高于所有关键字(如 110)。
- **字段名**`type: "property"``detail` 标注来源表和类型(如 `students 的字段 · TEXT`),`boost` 略低于表名、高于关键字(如 105)。
- **同名字段每表一条**,靠 detail 区分来源表。
- store 在补全回调内惰性读取(每次按键执行),题目切换后自动反映最新表结构。
- 非 SQL 语言、无题目上下文(如 admin/tutorial/learn 页面)或 `sql_display` 为空时,不追加任何项,行为与现状一致。
### 不做的事(YAGNI
- 不改后端;不改题目描述展示(`SQLDataTable` 已展示表结构)。
- 管理端出题的 SQL 编辑器(`SQLTestcaseEditor`)不接入。
- 不做基于 SQL 语法位置的智能上下文补全(如 FROM 后只补表名)。
## 改动文件
| 文件 | 改动 |
|---|---|
| `src/shared/extensions/autocompletion.ts` | SQL 分支追加由 `sql_display.tables` 生成的动态补全项 |
(如生成逻辑较长,可拆一个小函数放同文件或 `sql.ts`,保持单一职责。)
## 错误处理
- `problem``sql_display``tables` 任一为空 → 返回纯静态列表(可选链兜底)。
- Pinia store 在组件上下文外调用的风险:补全回调在编辑器运行期触发,此时 Pinia 已安装;与 FlowchartEditor 的既有用法一致。
## 测试
项目无测试套件(政策:不写新测试)。人工验证:
1. 打开一道 SQL 题,编辑器中输入表名/字段名前缀,确认补全项出现且 detail/info 正确。
2. 打开非 SQL 题,确认补全行为无变化。
3. 协作编辑(SyncCodeEditor)场景同样生效。
@@ -51,11 +51,16 @@ const isGenerating = ref(false)
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim())) const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim())) const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim()))
const filledCount = computed(
() => scripts.value.filter((s) => s.sql.trim()).length,
)
const canUpload = computed(() => { const canUpload = computed(() => {
const filled = scripts.value.filter((s) => s.sql.trim()) const filled = scripts.value.filter((s) => s.sql.trim())
return ( return (
!isPreviewing.value && !isPreviewing.value &&
filled.length > 0 && // 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
filled.length >= 2 &&
filled.every((s) => s.display && !s.error && !s.stale) filled.every((s) => s.display && !s.error && !s.stale)
) )
}) })
@@ -241,7 +246,11 @@ async function upload() {
</n-button> </n-button>
</span> </span>
</template> </template>
所有脚本预览验证通过后才能上传 {{
filledCount < 2
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
: "所有脚本预览验证通过后才能上传"
}}
</n-tooltip> </n-tooltip>
</n-flex> </n-flex>
+29
View File
@@ -5,6 +5,7 @@ import type {
CompletionSource, CompletionSource,
} from "@codemirror/autocomplete" } from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view" import type { EditorView } from "@codemirror/view"
import { useProblemStore } from "oj/store/problem"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import { c } from "./c" import { c } from "./c"
import { python } from "./python" import { python } from "./python"
@@ -22,6 +23,30 @@ const chineseAnnotations: Record<string, ChineseCompletion[]> = {
sql, sql,
} }
// SQL 题:当前题目的表名和字段名补全,数据来自 sql_display
function sqlSchemaCompletions(): Completion[] {
const tables = useProblemStore().problem?.sql_display?.tables ?? []
return tables.flatMap((table) => [
{
label: table.name,
detail: "数据表",
type: "class",
info: `字段:${table.columns
.map((col) => (col.type ? `${col.name} ${col.type}` : col.name))
.join(", ")}`,
boost: 110,
},
...table.columns.map((col) => ({
label: col.name,
detail: col.type
? `${table.name} 的字段 · ${col.type}`
: `${table.name} 的字段`,
type: "property",
boost: 105,
})),
])
}
export function enhanceCompletion(language: LANGUAGE): CompletionSource { export function enhanceCompletion(language: LANGUAGE): CompletionSource {
return async function ( return async function (
context: CompletionContext, context: CompletionContext,
@@ -69,6 +94,10 @@ export function enhanceCompletion(language: LANGUAGE): CompletionSource {
return completion return completion
}) })
if (trulyLanguage === "sql") {
completions.push(...sqlSchemaCompletions())
}
return { return {
from: word ? word.from : context.pos, from: word ? word.from : context.pos,
options: completions, options: completions,