feat(SQL 题): 增删改题除了第 1 组,其余测试数据可以一行都不改——用来抓学生多改(WHERE 写漏、写宽)
Deploy / deploy (push) Canceled after 0s

原来每组数据都必须有被标准答案改到的行,所以「这组里没有研发部,看学生会不会把别的部门也改了」
这种测试数据建不了。现在只有第 1 组(题面上给学生看的那组)还要求改到行;其余组一行都没改时
出题页写「一行都没改」、预览写「执行后所有表都没有变化」。删掉第 1 组、别的组顶上来时自动按
新位置重查。判题本来就不管这一条,不受影响。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 19:17:42 +08:00
1 parent 2cb39fd96b
commit 033a245abb
9 files changed
+54 -9

No files matched your search

+8 -1
View File
@@ -34,6 +34,7 @@ export type SqlJob =
initSql: string
refSql: string
mode: "query" | "modify"
requireChange: boolean
}
/**
@@ -76,7 +77,13 @@ export async function runSqlChild() {
try {
if (job.kind === "display") {
markPhase("display")
const display = await buildDisplay(job.initSql, job.refSql, job.mode)
const display = await buildDisplay(
job.initSql,
job.refSql,
job.mode,
undefined,
job.requireChange,
)
finish({ ok: true, display })
}
// 阶段由 runCase 内部回调标记:prepare(受信脚本)→ student(学生 SQL)
+9 -1
View File
@@ -584,12 +584,19 @@ function queryResultColumns(names: string[], tables: DisplayTable[]) {
return names.map((name) => ({ name, type: types.get(name) ?? "" }))
}
/** 生成题目页展示数据:初始数据表 + 期望结果。失败一律抛 SqlCaseError(出题配置问题) */
/**
* 生成题目页展示数据:初始数据表 + 期望结果。失败一律抛 SqlCaseError(出题配置问题)。
*
* `requireChange`:增删改题的标准答案必须改到行。题面上展示的那组(第 1 组)必须 ——
* 学生要看着「执行后的表」知道该改什么;其余组可以一行都不改(看学生会不会多改),
* 这时 changed_tables 是空数组。判题(runCase)本来就不管这一条
*/
export async function buildDisplay(
initSql: string,
refSql: string,
mode: "query" | "modify",
memoryLimitMb = 64,
requireChange = true,
) {
const SQL = await sqlEngine()
const db = newDatabase(SQL, memoryLimitMb)
@@ -648,6 +655,7 @@ export async function buildDisplay(
if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name)
}
if (changed.size === 0) {
if (!requireChange) return { tables, expected: { changed_tables: [] } }
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置")
}
const changedTables = dumpDisplayTables(db, changed)
+8 -2
View File
@@ -184,11 +184,17 @@ export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
})
}
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") {
/** `requireChange` 见 engine.ts 的 buildDisplay:只有题面上展示的那组必须改到行 */
export function buildSqlDisplay(
initSql: string,
refSql: string,
mode: "query" | "modify",
requireChange = true,
) {
// 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的
// SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍
return runJob<SqlDisplay>(
{ kind: "display", initSql, refSql, mode },
{ kind: "display", initSql, refSql, mode, requireChange },
{ trustedMs: DISPLAY_BUDGET_MS, studentMs: null },
)
}
+6 -1
View File
@@ -867,7 +867,12 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as
adminProblemRoutes.post("/sql-test-cases/preview", requireProblemPermission, async (c) => {
const parsed = await parseBody(c, sqlPreviewRequestSchema)
if (!parsed.success) return parsed.response
const outcome = await buildSqlDisplay(parsed.data.initSql, parsed.data.refSql, parsed.data.mode)
const outcome = await buildSqlDisplay(
parsed.data.initSql,
parsed.data.refSql,
parsed.data.mode,
parsed.data.shown,
)
if (!outcome.ok) return failure(c, 400, "sql-preview-failed", outcome.message)
return success(c, outcome.value)
})
+2
View File
@@ -236,6 +236,8 @@ export function previewSQLTestcase(data: {
initSql: string
refSql: string
mode: "query" | "modify"
/** 是不是题面上展示的那组(第 1 组):增删改题只有它必须改到行 */
shown: boolean
}) {
return api.post<SqlDisplay>("admin/sql-test-cases/preview", data)
}
+9 -3
View File
@@ -221,8 +221,12 @@ function onTagsChange(tags: string[]) {
// ---------------------------------------------------------------- 测试数据:自动重跑
/** 一组数据跑出来的结果取决于这三样,任何一样变了,原来的结果就作废 */
const signatureOf = (g: ScriptGroup) => JSON.stringify([form.answer, form.sqlConfig.mode, g.sql])
/**
* 一组数据跑出来的结果取决于这几样,任何一样变了,原来的结果就作废。
* 排在第一也算:删掉第 1 组之后,原来的第 2 组成了题面展示的那组,增删改题得重查它改没改到行
*/
const signatureOf = (g: ScriptGroup) =>
JSON.stringify([form.answer, form.sqlConfig.mode, g.sql, groups.value[0] === g])
function statusOf(g: ScriptGroup): GroupStatus {
if (!g.sql.trim()) return "empty"
@@ -243,6 +247,7 @@ async function run(g: ScriptGroup) {
initSql: g.sql,
refSql: form.answer,
mode: form.sqlConfig.mode,
shown: groups.value[0] === g,
})
} catch (err) {
errorRaw =
@@ -261,7 +266,8 @@ function runAll() {
}
watchDebounced(
() => [form.answer, form.sqlConfig.mode, groups.value.map((g) => g.sql)] as const,
// 带上 key:删组、挪了谁排第一也要重跑(见 signatureOf)
() => [form.answer, form.sqlConfig.mode, groups.value.map((g) => `${g.key}:${g.sql}`)] as const,
runAll,
{ debounce: 600, deep: true },
)
@@ -33,7 +33,7 @@ export function explainSqlError(message: string) {
[/(\d+) values for (\d+) columns/, (m) => `一行给了 ${m[1]} 个值,但表有 ${m[2]} 列`],
]
if (message.includes("标准答案未修改任何表数据")) {
return "标准答案在这组数据上一行都没改:增删改题的每组数据里都要有会被改到的行"
return "标准答案在这组数据上一行都没改:第 1 组是题面上给学生看的,里面要有会被改到的行"
}
for (const [pattern, say] of rules) {
const match = message.match(pattern)
@@ -33,6 +33,11 @@ const changedTables = computed(() => {
}))
})
/** 增删改题里不是题面那组的测试数据可以一行都不改(看学生会不会多改),出题页预览会碰到 */
const noChange = computed(
() => "changed_tables" in props.display.expected && !props.display.expected.changed_tables.length,
)
const anyChanged = computed(() =>
changedTables.value.some(
({ changes }) => changes && (changes.cells.size > 0 || changes.newRows.size > 0),
@@ -83,6 +88,7 @@ const anyChanged = computed(() =>
</p>
</template>
</div>
<p v-if="noChange" class="sqlNote">执行后所有表都没有变化</p>
<p v-if="anyChanged" class="sqlNote legend">
<span class="swatch" aria-hidden="true"></span>执行后变了的格子
</p>
+5
View File
@@ -819,6 +819,11 @@ export const sqlPreviewRequestSchema = z.object({
.min(1)
.max(1024 * 1024),
mode: z.enum(["query", "modify"]),
/**
* 这组是不是题面上给学生看的那组(第 1 组)。增删改题只有这一组必须改到行 ——
* 其余组可以一行都不改,用来看学生会不会多改(比如 WHERE 写漏了)
*/
shown: z.boolean().default(true),
})
export const generateSqlTestCaseRequestSchema = z.object({