自制SQL学习工具,闯关

自制SQL学习工具,闯关
陈鑫宇不知道该学什么SQL,和AI交流下来,我一个非计算机本专业生,学习基础的,简单的SQLite好像就够了。简单在哪里啦!
想练SQL一直卡在环境上。在线基本只有长文教程,本地装个数据库又麻烦,适配性不强。不如自己写一个:纯静态页面,双击HTML就能用,离线使用。
现在有50关,从SELECT一路排到递归CTE。每题写真正的SQL,由SQLite执行后比对结果集,对了才放行。
仓库在4choor3/sql-quest,在线版https://4choor3.github.io/sql-quest/。
底子
sql.js把SQLite编译成了WebAssembly,查询全在浏览器本地跑,数据不出机器。
麻烦在分发。常规做法是一个index.html加一堆js再加个640KB的.wasm,还得起HTTP服务——file://下fetch拿不到本地文件。我把wasm转成base64塞进js,npm run build出一个1.2MB的sql-quest.html,依赖全在里面。base64会让体积涨三分之一,换来的是不用起服务,值。
题库
基础篇是书店业务:作者、书籍、客户、订单、订单明细五张表,25本书、30笔订单。数据里埋了几个坑。有个客户从没下过单,练LEFT JOIN的时候正好拿来分辨COUNT(o.id)和COUNT(*)——后者会把没订单的那行数成1。有本书的genre是NULL,IS NULL和相关子查询里的IS(不是=)都得用它。订单有已完成、已取消、待付款三种状态,聚合之前不先过滤就算错。
进阶篇换成校园选课,也是五张表,但courses.prereq_id指向同一张表的id,自连接和递归CTE有真实的层级能展开。另外特意留了5个一节课都没选的学生,不然NOT EXISTS这类题没有正确答案。
进阶关不难在语法,难在组合。表一多,得自己想清楚从哪张出发、怎么连过去;而且多半要拆两步,先算中间结果再基于它算答案。
判题
答案写法定不下来,没法做文本比对。每关存一份参考SQL,运行时现场算出期望结果,再和你的结果比。四种比法:columns是列名加无序行集合,ordered要求行顺序也对,set只看行集合,scalar比单个数值带浮点容差。
不通过时给差异清单。测COUNT(*)那个坑的时候,面板直接列出「冯磊0」和「冯磊1」两行,比说一句答案错误有用得多。
另外加了关键字约束。题目要求用LEFT JOIN,你写死WHERE name = '冯磊'凑答案会被拦下。结果对了,但这题就白做了。
编辑器
一开始就是个textarea。后来想加高亮和补全,发现两个都不好办。
高亮最后做成了「文字透明的textarea叠在一层<pre>上面」。选区、光标、输入法都还是textarea的原生行为,看到的高亮来自底下那层。两层必须像素对齐,否则文字会重影,所以排版规则只写一处、两层共用,另外textarea用scrollbar-gutter: stable常驻预留滚动条槽位,高亮层补上同样的右内边距,两层的内容盒才等宽。测试里卡的是内容盒宽度、scrollHeight和13项排版属性全等。
补全要贴着光标弹,得先知道光标在textarea里的像素坐标。用了个镜像div:复刻textarea的计算样式,把光标之前的文本灌进去,再插一个span量它的位置。好处是字号改了自动跟随,不用改代码。
排序按光标位置分。语句开头给关键字(sel → SELECT),FROM或JOIN后面给表名(bo → books),查询中段给列名(st → stock),打了b.就只给books的列。别名也认:写了COUNT(*) AS book_count,后面打book_co能补出来;WITH city_stat AS (...)之后能补CTE名;派生表后面打b.ti,能从内层SELECT推出title。
格式化用sql-formatter。全量包312KB,带20多种方言,这项目只用SQLite,就写了个esbuild插件把方言注册表换成只含sqlite的版本,压到57KB。
几个坑
WASM实例那块最要命。切关卡要恢复到该关的初始状态,我最初的写法是这样:
1 | const old = this.db; |
this.db平时就是数据集注册表里的常驻实例,把旧的关掉,那套数据就永久废了——之后任何查询都抛ErrnoError: FS error,而且不可恢复。这个bug只在反复跨数据集切换时才触发,只做一套库的话永远碰不到。改成原地替换注册表引用就好了:新实例顶上,注册表指向新的,再关旧的。
CSS表格一次踩了仨。
max-width对table cell不生效。我给td设了max-width: 320px想截断长文本,实测宽度360px。CSS 2.1里这个属性对表格单元格就是没用,最后把截断压在内层元素上。
数值列右对齐好看,但表头默认左对齐,两者能差50px以上。得先按列判断是不是数值列(该列所有非空值都是数字),给th和td加同一个class才对齐。这个问题之前一直没被测出来,因为我的几何断言只比了单元格盒子的坐标——盒子当然对得齐,但文字能差一大截。后来改用Range量文字的实际位置,按对齐方式比对应边缘。
还有width: 100%会把多余空间按比例摊给每一列。id列内容只要40px,被撑到90px,看着间距很怪。改成让最后一个文本列吸收剩余宽度——文本列变宽只有好处,数值列变宽只会让数字贴着右边、中间空一大截。
还有个正则在补全里作祟。在FROM orders o JOIN o后面打下去,候选里给的是order_date(一个列名)、ON、OR、ORDER BY,偏偏没有orders。
原因是我用一条正则从第一个FROM开始匹配:
1 | FROM orders o JOIN o |
JOIN落在被吞掉的部分里,被当成「这个子句里已经有别的关键字了」,于是判定失败、退化成列名上下文。改成取最后一个引导词再往后看就行。顺带把表名位置的候选也收干净了——那儿本来就只有表名合法,现在JOIN order_d干脆什么都不给,而不是递上一个order_date误导人。
测试
这类工具有个麻烦:错了不一定看得出来。所以写了一套脚本,改完必跑。verify检查50关的参考答案能不能过自己的校验、起始代码会不会被误判成通关;e2e是浏览器端到端的46项;editor管格式化、补全、高亮那56项;fullplay把50关在浏览器里逐个打通;offline验单文件版在file://加断网下能不能跑。
最后一个是这次新加的:live,部署完实测线上站点能不能真答题。之前只看HTTP 200就以为发布成功了,其实接口通了不代表页面能跑。
仓库
代码、题库、两套示例库都在4choor3/sql-quest。不构建也能用,dist/sql-quest.html下载下来双击就开,放U盘或者发给别人都行。两套库也单独导出了dist/bookstore.sql和dist/campus.sql,可以拖进DBeaver自己练。MIT协议。
主题做了深浅两套,浅色不是简单反色,强调色压暗到#9a6200,六类语法色在白底上的对比度都在4.8:1以上。











