新增 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:
18
problem/migrations/0011_problem_sql_config.py
Normal file
18
problem/migrations/0011_problem_sql_config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.4 on 2026-07-02 15:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('problem', '0010_problem_ast_rules'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='problem',
|
||||
name='sql_config',
|
||||
field=models.JSONField(blank=True, default=None, null=True),
|
||||
),
|
||||
]
|
||||
@@ -86,6 +86,9 @@ class Problem(models.Model):
|
||||
# AST 代码结构检查规则
|
||||
ast_rules = models.JSONField(null=True, blank=True, default=None)
|
||||
|
||||
# SQL 题配置: {"mode": "query"|"modify", "order_sensitive": bool},非 SQL 题为 None
|
||||
sql_config = models.JSONField(null=True, blank=True, default=None)
|
||||
|
||||
class Meta:
|
||||
db_table = "problem"
|
||||
constraints = [
|
||||
|
||||
@@ -37,6 +37,11 @@ class CreateProblemCodeTemplateSerializer(serializers.Serializer):
|
||||
pass
|
||||
|
||||
|
||||
class SQLConfigSerializer(serializers.Serializer):
|
||||
mode = serializers.ChoiceField(choices=["query", "modify"])
|
||||
order_sensitive = serializers.BooleanField(default=False)
|
||||
|
||||
|
||||
class ProblemIOModeSerializer(serializers.Serializer):
|
||||
io_mode = serializers.ChoiceField(choices=ProblemIOMode.choices)
|
||||
input = serializers.CharField()
|
||||
@@ -89,6 +94,9 @@ class CreateOrEditProblemSerializer(serializers.Serializer):
|
||||
# AST 规则
|
||||
ast_rules = serializers.JSONField(required=False, allow_null=True, default=None)
|
||||
|
||||
# SQL 题配置
|
||||
sql_config = SQLConfigSerializer(required=False, allow_null=True, default=None)
|
||||
|
||||
|
||||
class CreateProblemSerializer(CreateOrEditProblemSerializer):
|
||||
pass
|
||||
|
||||
@@ -33,13 +33,16 @@ from ..serializers import (
|
||||
|
||||
|
||||
class TestCaseZipProcessor(object):
|
||||
def process_zip(self, uploaded_zip_file, dir=""):
|
||||
def process_zip(self, uploaded_zip_file, dir="", sql=False):
|
||||
try:
|
||||
zip_file = zipfile.ZipFile(uploaded_zip_file, "r")
|
||||
except zipfile.BadZipFile:
|
||||
raise APIError("Bad zip file")
|
||||
name_list = zip_file.namelist()
|
||||
test_case_list = self.filter_name_list(name_list, dir=dir)
|
||||
if sql:
|
||||
test_case_list = self.filter_sql_name_list(name_list, dir=dir)
|
||||
else:
|
||||
test_case_list = self.filter_name_list(name_list, dir=dir)
|
||||
if not test_case_list:
|
||||
raise APIError("Empty file")
|
||||
|
||||
@@ -62,18 +65,33 @@ class TestCaseZipProcessor(object):
|
||||
|
||||
info = []
|
||||
|
||||
# ["1.in", "1.out", "2.in", "2.out"] => [("1.in", "1.out"), ("2.in", "2.out")]
|
||||
test_case_list = zip(*[test_case_list[i::2] for i in range(2)])
|
||||
for index, item in enumerate(test_case_list):
|
||||
data = {
|
||||
"stripped_output_md5": md5_cache[item[1]],
|
||||
"input_size": size_cache[item[0]],
|
||||
"output_size": size_cache[item[1]],
|
||||
"input_name": item[0],
|
||||
"output_name": item[1],
|
||||
}
|
||||
info.append(data)
|
||||
test_case_info["test_cases"][str(index + 1)] = data
|
||||
if sql:
|
||||
# SQL 题:每个 N.sql 是一个测试点的建表+数据脚本,没有期望输出(判题时跑标准答案生成)。
|
||||
# output_name 复用同名、md5 置空,以兼容 CreateTestCaseScoreSerializer 和前端测试点表格。
|
||||
test_case_info["sql"] = True
|
||||
for index, item in enumerate(test_case_list):
|
||||
data = {
|
||||
"stripped_output_md5": "",
|
||||
"input_size": size_cache[item],
|
||||
"output_size": 0,
|
||||
"input_name": item,
|
||||
"output_name": item,
|
||||
}
|
||||
info.append(data)
|
||||
test_case_info["test_cases"][str(index + 1)] = data
|
||||
else:
|
||||
# ["1.in", "1.out", "2.in", "2.out"] => [("1.in", "1.out"), ("2.in", "2.out")]
|
||||
test_case_list = zip(*[test_case_list[i::2] for i in range(2)])
|
||||
for index, item in enumerate(test_case_list):
|
||||
data = {
|
||||
"stripped_output_md5": md5_cache[item[1]],
|
||||
"input_size": size_cache[item[0]],
|
||||
"output_size": size_cache[item[1]],
|
||||
"input_name": item[0],
|
||||
"output_name": item[1],
|
||||
}
|
||||
info.append(data)
|
||||
test_case_info["test_cases"][str(index + 1)] = data
|
||||
|
||||
with open(os.path.join(test_case_dir, "info"), "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(test_case_info, indent=4))
|
||||
@@ -97,6 +115,19 @@ class TestCaseZipProcessor(object):
|
||||
else:
|
||||
return sorted(ret, key=natural_sort_key)
|
||||
|
||||
def filter_sql_name_list(self, name_list, dir=""):
|
||||
# SQL 题测试点:连续编号的 1.sql, 2.sql, ...
|
||||
ret = []
|
||||
prefix = 1
|
||||
while True:
|
||||
name = f"{prefix}.sql"
|
||||
if f"{dir}{name}" in name_list:
|
||||
ret.append(name)
|
||||
prefix += 1
|
||||
continue
|
||||
else:
|
||||
return sorted(ret, key=natural_sort_key)
|
||||
|
||||
|
||||
class TestCaseAPI(CSRFExemptAPIView, TestCaseZipProcessor):
|
||||
request_parsers = ()
|
||||
@@ -118,7 +149,19 @@ class TestCaseAPI(CSRFExemptAPIView, TestCaseZipProcessor):
|
||||
test_case_dir = os.path.join(settings.TEST_CASE_DIR, problem.test_case_id)
|
||||
if not os.path.isdir(test_case_dir):
|
||||
return self.error("Test case does not exists")
|
||||
name_list = self.filter_name_list(os.listdir(test_case_dir))
|
||||
# SQL 题的测试点是 N.sql,需按 info 里的类型标记选择文件列表
|
||||
is_sql = False
|
||||
info_path = os.path.join(test_case_dir, "info")
|
||||
if os.path.isfile(info_path):
|
||||
try:
|
||||
with open(info_path, encoding="utf-8") as f:
|
||||
is_sql = bool(json.load(f).get("sql"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
if is_sql:
|
||||
name_list = self.filter_sql_name_list(os.listdir(test_case_dir))
|
||||
else:
|
||||
name_list = self.filter_name_list(os.listdir(test_case_dir))
|
||||
name_list.append("info")
|
||||
file_name = os.path.join(test_case_dir, problem.test_case_id + ".zip")
|
||||
with zipfile.ZipFile(file_name, "w") as file:
|
||||
@@ -140,7 +183,8 @@ class TestCaseAPI(CSRFExemptAPIView, TestCaseZipProcessor):
|
||||
with open(zip_file, "wb") as f:
|
||||
for chunk in file:
|
||||
f.write(chunk)
|
||||
info, test_case_id = self.process_zip(zip_file)
|
||||
sql = request.POST.get("sql") in ("1", "true", "True")
|
||||
info, test_case_id = self.process_zip(zip_file, sql=sql)
|
||||
os.remove(zip_file)
|
||||
return self.success({"id": test_case_id, "info": info})
|
||||
|
||||
@@ -158,6 +202,19 @@ class ProblemBase(APIView):
|
||||
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"
|
||||
else:
|
||||
# 防脏数据:非 SQL 题不应携带 SQL 配置
|
||||
data["sql_config"] = None
|
||||
|
||||
|
||||
class ProblemAPI(ProblemBase):
|
||||
@problem_permission_required
|
||||
|
||||
Reference in New Issue
Block a user