From 670abe592139d3ad5b8174c602c4816a36b7e0c8 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 23 Oct 2026 09:18:56 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=206=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AD=98=E5=82=A8=EF=BC=9A=E4=BB=BF=E5=86=92=20SqlClient?= =?UTF-8?q?=EF=BC=88T-SQL=20=E8=BD=AC=E5=86=99=E6=88=90=20SQLite=EF=BC=89?= =?UTF-8?q?=E3=80=81=E6=95=B0=E6=8D=AE=E5=BA=93=E9=A1=B5=E9=9D=A2=E3=80=81?= =?UTF-8?q?DataGridView=E3=80=81DateTimePicker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每个学生一套数据库,长期保存(data/databases/<用户编号>/库名.db);学生程序运行时挂进沙箱可读写 - Sim.Data:SqlConnection / SqlCommand / SqlDataReader / SqlDataAdapter / SqlCommandBuilder / SqlParameter; 连接字符串只按库名打开学生自己的库;ScriptDom 解析 T-SQL 后按白名单转写成 SQLite 语句, STRICT 表 + 元数据表还原 SQL Server 的列类型,报错编号和中文信息与 SQL Server 一致; INSERT 不写列名时跳过自增列,日期统一格式(书上 DateTime.ToString() 拼接的日期能正确比较) - 常驻 SQL 服务(Sim.Runner sql-server):数据库页面的查询、对象资源管理器,评分前按实验 database.sql 建库 - 数据库页面(代替 SSMS):对象资源管理器、查询窗口、编辑前 200 行、表设计器、恢复实验初始数据 - DataGridView(绑定 DataView/DataTable/DataSet,单元格修改写回 DataTable)、DateTimePicker;设计器工具箱加入 - 评分:每个检查项一份新建的实验库;新增点 / 修改表格单元格、选日期、表格里有 / 没有某行的检查 - 示例:项目 6 任务 2、3;时间格式改成和中文 Windows 一致(9:05:03);异常堆栈不暴露服务器路径 - 测试:数据库端到端、接口(每人只看到自己的库、恢复实验数据)、评分出错情况、T-SQL 生成 Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- docs/设计方案.md | 15 +- runtime/Directory.Build.props | 2 + runtime/Runtime.slnx | 1 + runtime/Sim.Data/Engine/Database.cs | 168 +++ runtime/Sim.Data/Engine/Functions.cs | 254 +++++ runtime/Sim.Data/Engine/Session.cs | 243 +++++ runtime/Sim.Data/Engine/TSqlType.cs | 83 ++ runtime/Sim.Data/Engine/Translator.cs | 916 ++++++++++++++++ runtime/Sim.Data/Engine/Values.cs | 102 ++ runtime/Sim.Data/Sim.Data.csproj | 17 + runtime/Sim.Data/SqlClient/SqlCommand.cs | 302 ++++++ runtime/Sim.Data/SqlClient/SqlConnection.cs | 169 +++ runtime/Sim.Data/SqlClient/SqlDataAdapter.cs | 120 +++ runtime/Sim.Data/SqlClient/SqlDataReader.cs | 211 ++++ runtime/Sim.Data/SqlClient/SqlException.cs | 60 ++ runtime/Sim.Runner/ApiPolicy.cs | 7 +- runtime/Sim.Runner/Program.cs | 4 +- runtime/Sim.Runner/Sim.Runner.csproj | 1 + runtime/Sim.Runner/SqlService.cs | 174 +++ runtime/Sim.Runner/StudentCompiler.cs | 2 +- runtime/Sim.Runner/StudentProgramHost.cs | 8 +- runtime/Sim.WinForms/Forms/DataGridView.cs | 1003 ++++++++++++++++++ runtime/Sim.WinForms/Forms/DateTimePicker.cs | 143 +++ runtime/Sim.WinForms/Forms/Enums.cs | 1 + runtime/Sim.WinForms/Host/PropValue.cs | 2 + runtime/Sim.WinForms/Host/Protocol.cs | 5 + samples/p6-t2-存储环境数据/Form1.Designer.cs | 282 +++++ samples/p6-t2-存储环境数据/Form1.cs | 129 +++ samples/p6-t2-存储环境数据/Program.cs | 22 + samples/p6-t2-存储环境数据/checks.yaml | 44 + samples/p6-t2-存储环境数据/database.sql | 14 + samples/p6-t2-存储环境数据/meta.json | 4 + samples/p6-t3-按时间查询/Form1.Designer.cs | 138 +++ samples/p6-t3-按时间查询/Form1.cs | 41 + samples/p6-t3-按时间查询/Program.cs | 22 + samples/p6-t3-按时间查询/checks.yaml | 16 + samples/p6-t3-按时间查询/database.sql | 14 + samples/p6-t3-按时间查询/meta.json | 4 + server/src/app.ts | 31 + server/src/assignments.ts | 8 +- server/src/databases.ts | 71 ++ server/src/grading/checks.ts | 31 + server/src/grading/driver.ts | 4 +- server/src/grading/grader.ts | 72 +- server/src/grading/queue.ts | 2 +- server/src/index.ts | 13 +- server/src/run-session.ts | 8 +- server/src/sandbox.ts | 12 +- server/src/sql-service.ts | 108 ++ server/test/api.test.ts | 51 + server/test/grading.test.ts | 44 +- server/test/runner.test.ts | 96 +- shared/protocol.ts | 6 + web/src/database/DesignTab.vue | 290 +++++ web/src/database/EditTab.vue | 288 +++++ web/src/database/ObjectExplorer.vue | 209 ++++ web/src/database/QueryTab.vue | 159 +++ web/src/database/ResultGrid.vue | 76 ++ web/src/database/sql.test.ts | 68 ++ web/src/database/sql.ts | 123 +++ web/src/designer/FormDesigner.vue | 2 +- web/src/designer/catalog.ts | 55 +- web/src/designer/preview.ts | 9 + web/src/editor/monaco.ts | 3 +- web/src/pages/DatabasePage.vue | 333 ++++++ web/src/pages/WorkbenchPage.vue | 1 + web/src/router.ts | 3 +- web/src/winforms/ControlView.vue | 6 + web/src/winforms/DataGridViewControl.vue | 175 +++ web/src/winforms/DateTimePickerControl.vue | 65 ++ web/src/winforms/winforms.css | 130 +++ 72 files changed, 7253 insertions(+), 51 deletions(-) create mode 100644 runtime/Sim.Data/Engine/Database.cs create mode 100644 runtime/Sim.Data/Engine/Functions.cs create mode 100644 runtime/Sim.Data/Engine/Session.cs create mode 100644 runtime/Sim.Data/Engine/TSqlType.cs create mode 100644 runtime/Sim.Data/Engine/Translator.cs create mode 100644 runtime/Sim.Data/Engine/Values.cs create mode 100644 runtime/Sim.Data/Sim.Data.csproj create mode 100644 runtime/Sim.Data/SqlClient/SqlCommand.cs create mode 100644 runtime/Sim.Data/SqlClient/SqlConnection.cs create mode 100644 runtime/Sim.Data/SqlClient/SqlDataAdapter.cs create mode 100644 runtime/Sim.Data/SqlClient/SqlDataReader.cs create mode 100644 runtime/Sim.Data/SqlClient/SqlException.cs create mode 100644 runtime/Sim.Runner/SqlService.cs create mode 100644 runtime/Sim.WinForms/Forms/DataGridView.cs create mode 100644 runtime/Sim.WinForms/Forms/DateTimePicker.cs create mode 100644 samples/p6-t2-存储环境数据/Form1.Designer.cs create mode 100644 samples/p6-t2-存储环境数据/Form1.cs create mode 100644 samples/p6-t2-存储环境数据/Program.cs create mode 100644 samples/p6-t2-存储环境数据/checks.yaml create mode 100644 samples/p6-t2-存储环境数据/database.sql create mode 100644 samples/p6-t2-存储环境数据/meta.json create mode 100644 samples/p6-t3-按时间查询/Form1.Designer.cs create mode 100644 samples/p6-t3-按时间查询/Form1.cs create mode 100644 samples/p6-t3-按时间查询/Program.cs create mode 100644 samples/p6-t3-按时间查询/checks.yaml create mode 100644 samples/p6-t3-按时间查询/database.sql create mode 100644 samples/p6-t3-按时间查询/meta.json create mode 100644 server/src/databases.ts create mode 100644 server/src/sql-service.ts create mode 100644 web/src/database/DesignTab.vue create mode 100644 web/src/database/EditTab.vue create mode 100644 web/src/database/ObjectExplorer.vue create mode 100644 web/src/database/QueryTab.vue create mode 100644 web/src/database/ResultGrid.vue create mode 100644 web/src/database/sql.test.ts create mode 100644 web/src/database/sql.ts create mode 100644 web/src/pages/DatabasePage.vue create mode 100644 web/src/winforms/DataGridViewControl.vue create mode 100644 web/src/winforms/DateTimePickerControl.vue diff --git a/README.md b/README.md index e531710..9398fe6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ | `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 | | `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 | | `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) | -| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、运行器(`Sim.Runner`) | +| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、仿冒 SqlClient(`Sim.Data`,T-SQL 转写成 SQLite)、运行器(`Sim.Runner`) | | `samples/` | 示例工程(按教材任务整理) | | `docs/` | 设计方案(`设计方案.md`)、教材逐任务摘要(`教材摘要/`) | | `deploy/` | 部署:Caddyfile、部署脚本和说明(`deploy/README.md`) | @@ -39,12 +39,13 @@ bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序 Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 GitHub 的 main 后,GitHub Actions 跑完测试,SSH 到服务器执行 `deploy/update.sh`:服务器从 git.xuyue.cc 拉代码、构建镜像、重启。详见 [deploy/README.md](deploy/README.md)。 -## 运行器的三种模式 +## 运行器的四种模式 `Sim.Runner` 通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条): - `compile-server`:常驻编译服务,全服务器共用一个。Roslyn 只加载一次,编译结果写回工程目录。 - `language-server`:常驻语言服务,全服务器共用一个:代码补全、悬停提示、参数提示、边写边检查。 +- `sql-server`:常驻 SQL 服务,全服务器共用一个:数据库页面的查询和对象资源管理器,评分前按实验的 `database.sql` 建库。学生的数据库在 `data/databases/<用户编号>/`,每个库一个 SQLite 文件。 - `run <工程目录>`:运行已编译的学生程序,每次运行一个进程,不加载编译器。 ## 当前进度:第 0 阶段(技术验证)已跑通 @@ -67,7 +68,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 - **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。 - 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox、PictureBox,教材控件库的 Lamp、Alarm。 -- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1–3、项目 5 任务 1–3,共 13 个。网址带 `?sample=示例编号` 可直接打开指定示例。 +- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1–3、项目 5 任务 1–3、项目 6 任务 2–3,共 15 个(项目 6 任务 1 在数据库页面上完成)。网址带 `?sample=示例编号` 可直接打开指定示例。 - 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 @@ -77,4 +78,4 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 尚未完成: -- 教材项目 6–8(需要数据库、TrackBar、资源图片、图表、虚拟时钟)。 +- 教材项目 7–8(需要 TrackBar、资源图片、图表、虚拟时钟、无边框窗体等)。 diff --git a/docs/设计方案.md b/docs/设计方案.md index deb10fb..575d86e 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -167,6 +167,7 @@ IOT/ | 后端 | 参数校验 | 计划:Zod | | C# 运行时 | 平台 | .NET 10 LTS | | C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) | +| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 | | C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 | | C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 | | C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 | @@ -212,7 +213,7 @@ IOT/ | 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 | | 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 已完成 | | 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 | -| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | | +| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | 已完成 | | 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | | | 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | | @@ -236,7 +237,7 @@ flowchart TB P0["阶段 0 · 技术验证(已完成 2026-10-22)
教材项目 2 任务 1 原样运行;实测编译、延迟、内存"] P1["阶段 1 · MVP(进行中,约 3 个月)
虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔
教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔
部署(沙箱、Docker Compose、GitHub Actions)✔"] P2["阶段 2 · 教学闭环(进行中,约 2 个月)
账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔
评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"] - P3["阶段 3 · 扩展(进行中)
教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库、图表、虚拟时钟"] + P3["阶段 3 · 扩展(进行中)
教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、图表、虚拟时钟"] P4["阶段 4 · 进阶(之后)
校园街景场景、故障注入题库、导出 VS 工程"] P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1 P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2 @@ -282,6 +283,14 @@ flowchart TB - 2026-10-22:项目 4 任务 2 网络摄像机,至此教材项目 1–5 全部完成(13 个示例)。摄像头画面是自绘的扁平插画(`runtime/Sim.Devices/Camera/scene.svg`,从校园里望向东围墙,墙头装着红外对射),按云台位置(左右 7 档、上下 3 档)切成 640×480 的 JPEG 帧;`PanUp` 等每次转一格,实训台红外对射被遮挡时画面里出现翻墙的人、光束断开,为书上"发现入侵自动抓拍"的探究题准备。`ipCameraHelper` 的类名、委托名(`CameraImage`)、事件参数(`ImageReadEvenArgs.img`)与教材 dll 一致,和仿真版一样每 250 ms 在后台线程上触发 `ImageRead`,书上不用 Invoke 直接给 `pictureBox1.Image` 赋值也能显示。`Image` / `Bitmap` 保存真实的图片数据:`Image.Save` 写出能打开的 jpg,`FromFile` / `FromStream` 读回,文件夹不存在时和 GDI+ 一样报"一般性错误"。图片数据每张只发给浏览器一次(按内容哈希缓存),之后控件属性里只带编号;同一位置的画面复用同一个对象,画面不变时不产生消息。学生程序的"程序所在目录"(`AppDomain.CurrentDomain.BaseDirectory`,编译检查单独放行)和当前目录都指向可写的 bin/Debug(沙箱里在沙箱自己的临时目录),程序结束后清掉;`Image.Save` 保存的图片显示在运行区的照片列表里,点开看大图,下次运行时清空。新增 PictureBox(SizeMode 五种、BorderStyle),设计器工具箱加"图片框";评分规则加"图片框显示了图片""摄像头开启 / 关闭 / 朝某方向转过""保存了图片"三种检查。 +- 2026-10-23:项目 6 数据存储(3 个任务)。**每个学生一套数据库,长期保存**(`data/databases/<用户编号>/库名.db`,每个库一个 SQLite 文件,最多 20 个库、每个约 50 MB),就像自己电脑上的 SQL Server:任务 1 建的库、任务 2 存的数据,任务 3 能查到。 + - **SqlClient 仿冒**(`runtime/Sim.Data`):SqlConnection / SqlCommand / SqlDataReader / SqlDataAdapter / SqlCommandBuilder / SqlParameter,DataSet、DataTable、DataView 直接用 .NET 自带的。连接字符串只按 Initial Catalog 打开学生自己的同名库(服务器名、账号密码不检查,书上的长服务器名、"." 都能连上),库不存在时报 4060。 + - **T-SQL 转写**:ScriptDom(SQL Server 2008 语法)解析后按白名单逐个节点转写成 SQLite 语句,不认识的语法报"平台暂不支持",生成的 SQL 里只有平台写出的关键字和 tsql_ 函数。表用 STRICT 模式保存,原始的 T-SQL 列类型记在元数据表里,读出时还原成 SQL Server 返回的 .NET 类型(int、DateTime、bool……);varchar(n) 超长、类型不对、NULL、重复主键都报和 SQL Server 一样的错误号和中文信息。`insert into 表 values (...)` 不写列名时跳过自增列(照书上写时值的顺序和列顺序不一致,数据会存进错的列,和真实情况一样);日期统一存成 yyyy-MM-dd HH:mm:ss,书上拼接的 `DateTime.Now.ToString()`(2024/1/16 9:05:03)在插入和 BETWEEN 比较时都转换;支持 TOP(含 UPDATE / DELETE TOP)、CONVERT 样式 120 等、year / getdate / dateadd / datediff、`+` 按类型连接或相加、参数化查询、@@IDENTITY、多条语句。SqlCommandBuilder 根据带中文别名的查询生成 UPDATE。 + - **DataGridView**:绑定 DataView / DataTable / DataSet,自动生成列;在单元格里修改的值写回 DataTable,配合 SqlDataAdapter.Update 保存;当前行 ▶、新增行 *、选中行、CurrentRow.Cells[0].Value;类型转换失败时和 WinForms 一样触发 DataError。**DateTimePicker**:Long / Short / Time / Custom 格式与中文 Windows 一致,弹出浏览器的日期选择框。设计器工具箱加"数据表格""日期时间选择器"。 + - **数据库页面**(代替 SSMS,`/database`):对象资源管理器(库 → 表 → 列,右键新建数据库、新建表、设计、选择前 1000 行、编辑前 200 行、删除);查询窗口(F5 执行、选中部分只执行选中的、USE 切换库,结果网格和消息栏的格式与 SSMS 一致,每批最多 5 秒);编辑前 200 行(改单元格即 UPDATE、* 行新增、右键删除,没有主键的表按所有列的原值定位);表设计器(列名、类型、允许 Null、右键设主键、标识列,"查看 SQL 脚本"显示对应的 CREATE TABLE);"恢复实验初始数据"按实验的 `database.sql` 替换实验用到的库,其他库不动。这些操作都生成 T-SQL 交给常驻的 SQL 服务执行,和学生程序走同一套转写。 + - **评分**:每个实验可以带 `database.sql`,评分时每个检查项用一份新建的实验库,不碰学生自己的数据;评分规则加"点 / 修改表格单元格""在日期时间选择器里选日期""表格里有 / 没有某行"。新增示例项目 6 任务 2、3(任务 1 在数据库页面上完成),共 15 个。 + - 实测:用到数据库的学生程序比不用的多约 11 MB 内存(PSS 17 → 28 MB)。另外修正了平台上的时间格式:Linux 的中文区域数据是 09:05:03,改成和中文 Windows 一样的 9:05:03。 + **其他改进**: - 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。 @@ -296,7 +305,7 @@ flowchart TB | 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 | | 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) | | 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 | -| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 用 ScriptDom 解析后转写;连接串一律指向学生自己的 SQLite 库;日期统一存为标准格式;以教材全部 SQL 语句为测试集 | +| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 已实现:ScriptDom 解析后按白名单转写;连接串只按库名打开学生自己的库;日期统一存为标准格式;STRICT 表和 CHECK 约束让类型、长度错误和 SQL Server 一样报出 | | 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 | | 图片处理 | System.Drawing 在 Linux 上不受支持 | 已实现:自己的 `Image` / `Bitmap` 只保存图片文件数据和尺寸,能显示、保存、读回,不能逐像素绘制 | | 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 | diff --git a/runtime/Directory.Build.props b/runtime/Directory.Build.props index 8c8b537..db08add 100644 --- a/runtime/Directory.Build.props +++ b/runtime/Directory.Build.props @@ -8,5 +8,7 @@ false false + + $(MSBuildThisFileDirectory)=/runtime/ diff --git a/runtime/Runtime.slnx b/runtime/Runtime.slnx index 3445165..bdb9c61 100644 --- a/runtime/Runtime.slnx +++ b/runtime/Runtime.slnx @@ -1,5 +1,6 @@ + diff --git a/runtime/Sim.Data/Engine/Database.cs b/runtime/Sim.Data/Engine/Database.cs new file mode 100644 index 0000000..89db7a3 --- /dev/null +++ b/runtime/Sim.Data/Engine/Database.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; + +namespace Sim.Data.Engine; + +/// 一列的定义(来自元数据表,保留建表时的 T-SQL 写法) +internal sealed record ColumnInfo(string Name, TSqlType Type, bool Nullable, bool Identity, bool PrimaryKey, string? Default); + +internal sealed record TableInfo(string Name, IReadOnlyList Columns) +{ + public ColumnInfo? Find(string column) => Columns.FirstOrDefault(c => string.Equals(c.Name, column, StringComparison.OrdinalIgnoreCase)); + public ColumnInfo? Identity => Columns.FirstOrDefault(c => c.Identity); +} + +/// +/// 一个学生的全部数据库:一个目录,每个库一个 SQLite 文件(库名.db)。 +/// master 是默认库(连接字符串里不写 Initial Catalog 时连到它),和 SQL Server 一样也能在里面建表。 +/// +internal sealed class DatabaseStore +{ + public const string Master = "master"; + /// 元数据表:每列的 T-SQL 类型、是否自增等。学生的 SQL 里不能引用它 + public const string MetaTable = "__sim_columns"; + private const int MaxDatabases = 20; + /// 每个库最多约 50 MB(4 KB 一页) + private const int MaxPages = 12800; + + public DatabaseStore(string directory) => Directory = directory; + + public string Directory { get; } + + /// 学生程序运行时:服务器通过环境变量 SIM_DB_DIR 指定(沙箱里挂载学生自己的数据库目录) + public static DatabaseStore? FromEnvironment() => + Environment.GetEnvironmentVariable("SIM_DB_DIR") is { Length: > 0 } dir ? new DatabaseStore(dir) : null; + + public IEnumerable List() => + System.IO.Directory.Exists(Directory) + ? System.IO.Directory.EnumerateFiles(Directory, "*.db").Select(f => Path.GetFileNameWithoutExtension(f)!).Where(n => n != Master).Order(StringComparer.Ordinal) + : Enumerable.Empty(); + + /// 库名大小写不敏感(和 SQL Server 一样),返回磁盘上实际的名字 + public string? Resolve(string name) + { + if (string.Equals(name, Master, StringComparison.OrdinalIgnoreCase)) return Master; + return List().FirstOrDefault(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)); + } + + public SqliteConnection Open(string name) + { + var resolved = Resolve(name); + if (resolved == null) throw SqlException.CannotOpenDatabase(name); + if (resolved == Master && !File.Exists(PathOf(Master))) Create(Master, allowMaster: true); + // 不用连接池:最后一个连接关闭时 WAL 里的数据写回主文件,复制、恢复数据库文件时不会丢数据 + var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = PathOf(resolved), Mode = SqliteOpenMode.ReadWrite, Pooling = false }.ToString()); + connection.Open(); + Functions.Register(connection); + using var pragma = connection.CreateCommand(); + pragma.CommandText = $"PRAGMA max_page_count = {MaxPages}; PRAGMA case_sensitive_like = OFF;"; + pragma.ExecuteNonQuery(); + return connection; + } + + public void Create(string name, bool allowMaster = false) + { + CheckName(name); + if (!allowMaster && Resolve(name) != null) throw SqlException.DatabaseExists(name); + if (List().Count() >= MaxDatabases) throw new SqlException(1105, $"最多只能建 {MaxDatabases} 个数据库,先删除不用的数据库。"); + System.IO.Directory.CreateDirectory(Directory); + using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = PathOf(name), Mode = SqliteOpenMode.ReadWriteCreate, Pooling = false }.ToString()); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = + "PRAGMA journal_mode = WAL;" + + $"CREATE TABLE IF NOT EXISTS {MetaTable} (table_name TEXT NOT NULL COLLATE NOCASE, column_name TEXT NOT NULL COLLATE NOCASE, " + + "ordinal INTEGER NOT NULL, type_name TEXT NOT NULL, nullable INTEGER NOT NULL, is_identity INTEGER NOT NULL, " + + "is_primary_key INTEGER NOT NULL, default_text TEXT, PRIMARY KEY (table_name, column_name));"; + command.ExecuteNonQuery(); + } + + public void Drop(string name) + { + var resolved = Resolve(name); + if (resolved == null || resolved == Master) throw new SqlException(3701, $"无法对 数据库 '{name}' 执行 删除,因为它不存在,或者您没有所需的权限。"); + foreach (var suffix in new[] { "", "-wal", "-shm" }) File.Delete(PathOf(resolved) + suffix); + } + + private string PathOf(string name) => Path.Combine(Directory, name + ".db"); + + /// 库名就是文件名:不能有路径分隔符等字符 + private static void CheckName(string name) + { + if (name.Length is 0 or > 64 || name.StartsWith('.') || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name.IndexOfAny(['\\', ':', '*', '?', '"', '<', '>', '|']) >= 0) + throw new SqlException(102, $"数据库名 '{name}' 无效:不能为空、不能超过 64 个字,也不能包含 \\ / : * ? \" < > | 等字符。"); + } + + // ---------- 表结构(元数据表) ---------- + + public static TableInfo? GetTable(SqliteConnection connection, string name) + { + if (IsHidden(name)) return null; + using var command = connection.CreateCommand(); + command.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = $name COLLATE NOCASE"; + command.Parameters.AddWithValue("$name", name); + if (command.ExecuteScalar() is not string actual) return null; + + command.CommandText = $"SELECT column_name, type_name, nullable, is_identity, is_primary_key, default_text FROM {MetaTable} WHERE table_name = $name ORDER BY ordinal"; + var columns = new List(); + using (var reader = command.ExecuteReader()) + { + while (reader.Read()) + { + columns.Add(new ColumnInfo(reader.GetString(0), TSqlType.Parse(reader.GetString(1)), reader.GetInt64(2) != 0, + reader.GetInt64(3) != 0, reader.GetInt64(4) != 0, reader.IsDBNull(5) ? null : reader.GetString(5))); + } + } + return new TableInfo(actual, columns); + } + + public static IReadOnlyList ListTables(SqliteConnection connection) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"; + var tables = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var name = reader.GetString(0); + if (!IsHidden(name)) tables.Add(name); + } + return tables; + } + + public static void SaveColumns(SqliteConnection connection, string table, IEnumerable columns, int firstOrdinal = 0) + { + using var command = connection.CreateCommand(); + command.CommandText = $"INSERT OR REPLACE INTO {MetaTable} VALUES ($t, $c, $o, $type, $null, $id, $pk, $def)"; + var ordinal = firstOrdinal; + foreach (var column in columns) + { + command.Parameters.Clear(); + command.Parameters.AddWithValue("$t", table); + command.Parameters.AddWithValue("$c", column.Name); + command.Parameters.AddWithValue("$o", ordinal++); + command.Parameters.AddWithValue("$type", column.Type.ToString()); + command.Parameters.AddWithValue("$null", column.Nullable ? 1 : 0); + command.Parameters.AddWithValue("$id", column.Identity ? 1 : 0); + command.Parameters.AddWithValue("$pk", column.PrimaryKey ? 1 : 0); + command.Parameters.AddWithValue("$def", (object?)column.Default ?? DBNull.Value); + command.ExecuteNonQuery(); + } + } + + public static void DeleteColumns(SqliteConnection connection, string table) + { + using var command = connection.CreateCommand(); + command.CommandText = $"DELETE FROM {MetaTable} WHERE table_name = $t"; + command.Parameters.AddWithValue("$t", table); + command.ExecuteNonQuery(); + } + + /// 平台内部的表和 SQLite 系统表,学生看不到也不能引用 + public static bool IsHidden(string name) => + name.StartsWith("__sim", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sqlite_", StringComparison.OrdinalIgnoreCase); +} diff --git a/runtime/Sim.Data/Engine/Functions.cs b/runtime/Sim.Data/Engine/Functions.cs new file mode 100644 index 0000000..fc3096b --- /dev/null +++ b/runtime/Sim.Data/Engine/Functions.cs @@ -0,0 +1,254 @@ +using System; +using System.Data.SqlClient; +using System.Globalization; +using Microsoft.Data.Sqlite; + +namespace Sim.Data.Engine; + +/// +/// 注册到 SQLite 连接上的 T-SQL 函数和运算(转写后的 SQL 调用它们,学生的 SQL 不能直接调用 tsql_ 开头的名字)。 +/// 函数里抛出的 SqlException 先记在 ,执行语句处取出原样抛给学生(SQLite 只能传回文字信息)。 +/// +internal static class Functions +{ + [ThreadStatic] private static SqlException? _pending; + + public static SqlException? TakePending() + { + var pending = _pending; + _pending = null; + return pending; + } + + private static Exception Fail(SqlException error) + { + _pending = error; + return error; + } + + public static void Register(SqliteConnection c) + { + c.CreateFunction("tsql_add", Add, isDeterministic: true); + c.CreateFunction("tsql_div", Divide, isDeterministic: true); + c.CreateFunction("tsql_mod", Modulo, isDeterministic: true); + c.CreateFunction("tsql_getdate", () => Values.FormatDateTime(TruncateToMilliseconds(DateTime.Now))); + c.CreateFunction("tsql_datepart", DatePart, isDeterministic: true); + c.CreateFunction("tsql_dateadd", DateAdd, isDeterministic: true); + c.CreateFunction("tsql_datediff", DateDiff, isDeterministic: true); + c.CreateFunction("tsql_convert", Convert, isDeterministic: true); + c.CreateFunction("tsql_len", s => s is null ? null : (long)Text(s).TrimEnd(' ').Length, isDeterministic: true); + c.CreateFunction("tsql_left", (s, n) => s is null || n is null ? null : Left(Text(s), ToLong(n)), isDeterministic: true); + c.CreateFunction("tsql_right", (s, n) => s is null || n is null ? null : Right(Text(s), ToLong(n)), isDeterministic: true); + c.CreateFunction("tsql_charindex", (f, s) => f is null || s is null ? null : (long)Text(s).IndexOf(Text(f), StringComparison.OrdinalIgnoreCase) + 1, isDeterministic: true); + c.CreateFunction("tsql_floor", x => x is null ? null : Math.Floor(ToDouble(x)), isDeterministic: true); + c.CreateFunction("tsql_ceiling", x => x is null ? null : Math.Ceiling(ToDouble(x)), isDeterministic: true); + c.CreateFunction("tsql_power", (x, y) => x is null || y is null ? null : Math.Pow(ToDouble(x), ToDouble(y)), isDeterministic: true); + c.CreateFunction("tsql_sqrt", x => x is null ? null : Math.Sqrt(ToDouble(x)), isDeterministic: true); + c.CreateFunction("tsql_newid", () => Guid.NewGuid().ToString().ToUpperInvariant()); + c.CreateFunction("tsql_rand", () => Random.Shared.NextDouble()); + } + + private static DateTime TruncateToMilliseconds(DateTime value) => new(value.Ticks - value.Ticks % TimeSpan.TicksPerMillisecond, value.Kind); + + // ---------- 运算 ---------- + + /// T-SQL 的 +:两边都是字符串时连接,有数字时按数字相加(字符串转不成数字时报错) + private static object? Add(object? a, object? b) + { + if (a is null || b is null) return null; + if (a is string sa && b is string sb) return sa + sb; + if (a is byte[] || b is byte[]) throw Fail(SqlException.NotSupported("二进制数据的 + 运算")); + var x = Number(a); + var y = Number(b); + return x is long lx && y is long ly ? lx + ly : ToDouble(x) + ToDouble(y); + } + + private static object? Divide(object? a, object? b) + { + if (a is null || b is null) return null; + var x = Number(a); + var y = Number(b); + if (ToDouble(y) == 0) throw Fail(SqlException.DivideByZero()); + return x is long lx && y is long ly ? lx / ly : ToDouble(x) / ToDouble(y); + } + + private static object? Modulo(object? a, object? b) + { + if (a is null || b is null) return null; + var x = Number(a); + var y = Number(b); + if (ToDouble(y) == 0) throw Fail(SqlException.DivideByZero()); + return x is long lx && y is long ly ? lx % ly : ToDouble(x) % ToDouble(y); + } + + /// 数字原样返回;字符串按 T-SQL 隐式转换成 int(带小数点时转成 float),转不了时报 245 + private static object Number(object value) => value switch + { + long or double => value, + string s when long.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l, + string s when s.Contains('.') && double.TryParse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => d, + _ => throw Fail(Values.ConvertError(value, "int")), + }; + + private static double ToDouble(object value) => value switch + { + long l => l, + double d => d, + _ => ToDouble(Number(value)), + }; + + private static long ToLong(object value) => value is long l ? l : (long)ToDouble(value); + + private static string Text(object value) => value switch + { + string s => s, + double d => d.ToString(CultureInfo.InvariantCulture), + _ => System.Convert.ToString(value, CultureInfo.InvariantCulture) ?? "", + }; + + private static string Left(string s, long n) => n <= 0 ? "" : s[..(int)Math.Min(n, s.Length)]; + private static string Right(string s, long n) => n <= 0 ? "" : s[^(int)Math.Min(n, s.Length)..]; + + // ---------- 日期 ---------- + + private static DateTime ToDateTime(object value) + { + if (value is string s && Values.TryParseDateTime(s, out var d)) return d; + throw Fail(SqlException.DateConvertFailed()); + } + + private static object? DatePart(string part, object? value) + { + if (value is null) return null; + var d = ToDateTime(value); + return part.ToLowerInvariant() switch + { + "year" or "yy" or "yyyy" => d.Year, + "quarter" or "qq" or "q" => (d.Month - 1) / 3 + 1, + "month" or "mm" or "m" => d.Month, + "dayofyear" or "dy" or "y" => d.DayOfYear, + "day" or "dd" or "d" => d.Day, + "week" or "wk" or "ww" => CultureInfo.InvariantCulture.Calendar.GetWeekOfYear(d, CalendarWeekRule.FirstDay, DayOfWeek.Sunday), + "weekday" or "dw" or "w" => (int)d.DayOfWeek + 1, + "hour" or "hh" => d.Hour, + "minute" or "mi" or "n" => d.Minute, + "second" or "ss" or "s" => d.Second, + "millisecond" or "ms" => d.Millisecond, + _ => throw Fail(new SqlException(155, $"'{part}' 不是可以识别的 datepart 选项。")), + }; + } + + private static object? DateAdd(string part, object? amount, object? value) + { + if (amount is null || value is null) return null; + var d = ToDateTime(value); + var n = ToLong(amount); + var result = part.ToLowerInvariant() switch + { + "year" or "yy" or "yyyy" => d.AddYears((int)n), + "quarter" or "qq" or "q" => d.AddMonths((int)n * 3), + "month" or "mm" or "m" => d.AddMonths((int)n), + "day" or "dd" or "d" or "dayofyear" or "dy" or "y" or "weekday" or "dw" or "w" => d.AddDays(n), + "week" or "wk" or "ww" => d.AddDays(n * 7), + "hour" or "hh" => d.AddHours(n), + "minute" or "mi" or "n" => d.AddMinutes(n), + "second" or "ss" or "s" => d.AddSeconds(n), + "millisecond" or "ms" => d.AddMilliseconds(n), + _ => throw Fail(new SqlException(155, $"'{part}' 不是可以识别的 dateadd 选项。")), + }; + return Values.FormatDateTime(result); + } + + /// 和 SQL Server 一样数跨过的边界:DATEDIFF(day, '2024-01-01 23:00', '2024-01-02 01:00') = 1 + private static object? DateDiff(string part, object? start, object? end) + { + if (start is null || end is null) return null; + var a = ToDateTime(start); + var b = ToDateTime(end); + return part.ToLowerInvariant() switch + { + "year" or "yy" or "yyyy" => (long)(b.Year - a.Year), + "quarter" or "qq" or "q" => (long)((b.Year - a.Year) * 4 + (b.Month - 1) / 3 - (a.Month - 1) / 3), + "month" or "mm" or "m" => (long)((b.Year - a.Year) * 12 + b.Month - a.Month), + "day" or "dd" or "d" or "dayofyear" or "dy" or "y" => (long)(b.Date - a.Date).TotalDays, + "week" or "wk" or "ww" => (long)((b.Date.AddDays(-(int)b.DayOfWeek) - a.Date.AddDays(-(int)a.DayOfWeek)).TotalDays / 7), + "hour" or "hh" => (long)(Trim(b, TimeSpan.TicksPerHour) - Trim(a, TimeSpan.TicksPerHour)).TotalHours, + "minute" or "mi" or "n" => (long)(Trim(b, TimeSpan.TicksPerMinute) - Trim(a, TimeSpan.TicksPerMinute)).TotalMinutes, + "second" or "ss" or "s" => (long)(Trim(b, TimeSpan.TicksPerSecond) - Trim(a, TimeSpan.TicksPerSecond)).TotalSeconds, + "millisecond" or "ms" => (long)(b - a).TotalMilliseconds, + _ => throw Fail(new SqlException(155, $"'{part}' 不是可以识别的 datediff 选项。")), + }; + } + + private static DateTime Trim(DateTime d, long unit) => new(d.Ticks - d.Ticks % unit); + + // ---------- CONVERT / CAST,以及插入、修改时按列类型转换 ---------- + + /// tsql_convert('varchar', 10, 值, 120):转换成 T-SQL 类型;日期按 style 格式化 + private static object? Convert(string type, object? length, object? value, object? style) + { + if (value is null) return null; + var t = TSqlType.Parse(length is null ? type : $"{type}({(ToLong(length) < 0 ? "MAX" : ToLong(length).ToString(CultureInfo.InvariantCulture))})"); + if (t.IsString) + { + var text = style is not null && value is string s && Values.TryParseDateTime(s, out var date) + ? FormatStyle(date, (int)ToLong(style)) + : Text(value); + return t.MaxLength is { } max && text.Length > max ? text[..max] : text; + } + if (t.IsInteger) + { + return value switch + { + long => value, + double d => (long)Math.Truncate(d), + string s when long.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l, + _ => throw Fail(Values.ConvertError(value, t.Name)), + }; + } + if (t.IsBit) + { + return value switch + { + long l => l != 0 ? 1L : 0L, + double d => d != 0 ? 1L : 0L, + string s when s.Trim().Equals("true", StringComparison.OrdinalIgnoreCase) => 1L, + string s when s.Trim().Equals("false", StringComparison.OrdinalIgnoreCase) => 0L, + string s when long.TryParse(s.Trim(), out var l) => l != 0 ? 1L : 0L, + _ => throw Fail(Values.ConvertError(value, "bit")), + }; + } + if (t.IsFloat) + { + return value switch + { + long l => (double)l, + double => value, + string s when double.TryParse(s.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => d, + _ => throw Fail(Values.ConvertError(value, t.Name)), + }; + } + if (t.IsDateTime || t.IsDate) + { + if (value is not string s || !Values.TryParseDateTime(s, out var d)) throw Fail(SqlException.DateConvertFailed()); + return t.IsDate ? Values.FormatDate(d) : Values.FormatDateTime(d); + } + return value; + } + + /// CONVERT 的日期样式(教材用 120:yyyy-mm-dd hh:mi:ss) + private static string FormatStyle(DateTime d, int style) => style switch + { + 20 or 120 => d.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + 21 or 25 or 121 => d.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), + 23 => d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + 101 => d.ToString("MM/dd/yyyy", CultureInfo.InvariantCulture), + 102 => d.ToString("yyyy.MM.dd", CultureInfo.InvariantCulture), + 103 => d.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture), + 108 or 8 => d.ToString("HH:mm:ss", CultureInfo.InvariantCulture), + 111 => d.ToString("yyyy/MM/dd", CultureInfo.InvariantCulture), + 112 => d.ToString("yyyyMMdd", CultureInfo.InvariantCulture), + 126 => d.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture), + _ => d.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + }; +} diff --git a/runtime/Sim.Data/Engine/Session.cs b/runtime/Sim.Data/Engine/Session.cs new file mode 100644 index 0000000..6619c1f --- /dev/null +++ b/runtime/Sim.Data/Engine/Session.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Data.Sqlite; +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace Sim.Data.Engine; + +/// 结果集的一列:名字、T-SQL 类型(表达式列为 null),以及来自哪张表的哪一列(SqlCommandBuilder 生成语句时要用) +internal sealed record ResultColumn(string Name, TSqlType? Type, string? BaseTable, string? BaseColumn, bool IsKey, bool IsIdentity, bool AllowNull) +{ + public Type ClrType => Type?.ClrType is { } t && t != typeof(object) ? t : typeof(object); +} + +/// 一个结果集(读完放在内存里) +internal sealed record QueryResult(IReadOnlyList Columns, List Rows); + +/// 一批语句的执行结果:各个结果集和受影响的行数(只有增删改时才算,否则为 -1) +internal sealed record BatchResult(List Results, int RecordsAffected); + +/// +/// 一个打开的数据库连接:当前库(USE 可以切换)、事务,执行一批 T-SQL。 +/// SqlConnection 和数据库页面的查询窗口都用它。 +/// +internal sealed class Session : IDisposable +{ + private SqliteTransaction? _transaction; + private long _deadline = long.MaxValue; + + public Session(DatabaseStore store, string database, TimeSpan? timeLimit = null) + { + Store = store; + TimeLimit = timeLimit; + Connection = Open(database); + Database = store.Resolve(database)!; + } + + /// 每批语句最长执行时间(数据库页面的查询窗口用;学生程序里由沙箱的 CPU 时间限制兜底) + public TimeSpan? TimeLimit { get; } + + private SqliteConnection Open(string database) + { + var connection = Store.Open(database); + if (TimeLimit != null) + { + // 每执行一千条虚拟机指令检查一次,超时就中断(SQLite 报 interrupted) + SQLitePCL.raw.sqlite3_progress_handler(connection.Handle, 1000, _ => Environment.TickCount64 > _deadline ? 1 : 0, null); + } + return connection; + } + + public DatabaseStore Store { get; } + public SqliteConnection Connection { get; private set; } + public string Database { get; private set; } + public bool InTransaction => _transaction != null; + + public TableInfo? Lookup(string table) => DatabaseStore.GetTable(Connection, table); + + public void ChangeDatabase(string name) + { + if (Store.Resolve(name) == null) throw SqlException.DatabaseNotFound(name); + if (_transaction != null) throw new SqlException(226, "在多语句事务内不允许使用 USE 语句。"); + var next = Open(name); + Connection.Dispose(); + Connection = next; + Database = Store.Resolve(name)!; + } + + public void BeginTransaction() + { + if (_transaction != null) throw new InvalidOperationException("SqlConnection 不支持并行事务。"); + _transaction = Connection.BeginTransaction(); + } + + public void Commit() + { + _transaction?.Commit(); + _transaction = null; + } + + public void Rollback() + { + _transaction?.Rollback(); + _transaction = null; + } + + /// 执行一批语句;parameter 按名字(带不带 @ 都行)取参数值 + public BatchResult Execute(string sql, Func parameter) + { + var statements = Translator.Parse(sql); + if (TimeLimit is { } limit) _deadline = Environment.TickCount64 + (long)limit.TotalMilliseconds; + var results = new List(); + var affected = -1; + foreach (var parsed in statements) + { + var statement = Translate(parsed); + switch (statement) + { + case CreateDatabase create: + Store.Create(create.Name); + break; + case DropDatabases drop: + foreach (var name in drop.Names) + { + if (string.Equals(Store.Resolve(name), Database, StringComparison.OrdinalIgnoreCase)) throw SqlException.DatabaseInUse(name); + Store.Drop(name); + } + break; + case UseDatabase use: + ChangeDatabase(use.Name); + break; + case SqliteStatement run: + var (result, changes) = Run(run, parameter); + if (result != null) results.Add(result); + else if (changes >= 0) affected = Math.Max(affected, 0) + changes; + break; + } + } + return new BatchResult(results, affected); + } + + private Statement Translate(TSqlStatement statement) + { + try + { + return Translator.Translate(statement, Lookup); + } + catch (SqliteException ex) + { + throw Map(ex); + } + } + + private (QueryResult? Result, int Changes) Run(SqliteStatement statement, Func parameter) + { + using var command = Connection.CreateCommand(); + command.Transaction = _transaction; + command.CommandText = statement.Sql; + foreach (var name in statement.Parameters) + { + var (found, value) = parameter(name); + if (!found) throw SqlException.UndeclaredVariable(name); + command.Parameters.AddWithValue(name, Values.ToStorage(value) ?? DBNull.Value); + } + + // 建表等语句和元数据一起提交;已经在学生的事务里时就用那个事务 + var own = _transaction == null && statement.AfterExecute != null ? Connection.BeginTransaction() : null; + if (own != null) command.Transaction = own; + try + { + QueryResult? result = null; + int changes; + if (statement.IsQuery) + { + result = Read(command); + changes = -1; + } + else + { + changes = command.ExecuteNonQuery(); + statement.AfterExecute?.Invoke(Connection); + if (statement.AfterExecute != null) changes = -1; + } + own?.Commit(); + return (result, changes); + } + catch (SqliteException ex) + { + own?.Rollback(); + throw Map(ex); + } + finally + { + own?.Dispose(); + } + } + + private QueryResult Read(SqliteCommand command) + { + using var reader = command.ExecuteReader(); + var schema = reader.GetSchemaTable(); + var tables = new Dictionary(StringComparer.OrdinalIgnoreCase); + var columns = new List(); + for (var i = 0; i < reader.FieldCount; i++) + { + var row = schema.Rows[i]; + var baseTable = row["BaseTableName"] as string; + var baseColumn = row["BaseColumnName"] as string; + ColumnInfo? info = null; + if (!string.IsNullOrEmpty(baseTable) && !string.IsNullOrEmpty(baseColumn)) + { + if (!tables.TryGetValue(baseTable, out var table)) tables[baseTable] = table = Lookup(baseTable); + info = table?.Find(baseColumn); + } + columns.Add(new ResultColumn(reader.GetName(i), info?.Type, info != null ? baseTable : null, info?.Name, + info?.PrimaryKey ?? false, info?.Identity ?? false, info?.Nullable ?? true)); + } + var rows = new List(); + while (reader.Read()) + { + var values = new object[reader.FieldCount]; + reader.GetValues(values); + for (var i = 0; i < values.Length; i++) values[i] = Values.FromStorage(values[i], columns[i].Type); + rows.Add(values); + } + return new QueryResult(columns, rows); + } + + /// SQLite 的报错转成 SQL Server 的错误号和中文信息 + private SqlException Map(SqliteException ex) + { + if (Functions.TakePending() is { } pending) return pending; + var message = ex.Message; + Match m; + if ((m = Regex.Match(message, @"no such table: (?:main\.)?(.+?)'?$")).Success) return SqlException.InvalidObject(m.Groups[1].Value); + // SQLite 对写错的列名会附带"是不是想写字符串"的提示,只取列名本身 + if ((m = Regex.Match(message, @"no such column: ""?([^""\s']+)")).Success) return SqlException.InvalidColumn(m.Groups[1].Value); + if ((m = Regex.Match(message, @"ambiguous column name: (.+?)'?$")).Success) return SqlException.AmbiguousColumn(m.Groups[1].Value); + if ((m = Regex.Match(message, @"UNIQUE constraint failed: (\w+)\.")).Success) return SqlException.DuplicateKey(m.Groups[1].Value); + if ((m = Regex.Match(message, @"NOT NULL constraint failed: (.+?)\.(.+?)'?$")).Success) + return SqlException.NullNotAllowed(m.Groups[2].Value, $"{Database}.dbo.{m.Groups[1].Value}"); + if (message.Contains("CHECK constraint failed: trunc:")) return SqlException.Truncated(); + if ((m = Regex.Match(message, @"CHECK constraint failed: bit:(.+?)'?$")).Success) return new SqlException(245, $"列 '{m.Groups[1].Value}' 是 bit 类型,只能保存 0、1 或 NULL。"); + if ((m = Regex.Match(message, @"cannot store (\w+) value in (\w+) column (.+?)\.(.+?)'?$")).Success) + { + var type = Lookup(m.Groups[3].Value)?.Find(m.Groups[4].Value)?.Type.Name ?? m.Groups[2].Value.ToLowerInvariant(); + return new SqlException(245, $"将数据插入列 '{m.Groups[4].Value}' 时,无法把值转换成数据类型 {type}。"); + } + if (message.Contains("database or disk is full")) return new SqlException(1105, "数据库已满:每个数据库最多约 50 MB。"); + if (message.Contains("misuse of aggregate")) return new SqlException(147, "聚合不应出现在 WHERE 子句中,除非该聚合位于 HAVING 子句或选择列表所包含的子查询中。"); + if (message.Contains("interrupted")) return new SqlException(-2, $"执行超时:查询超过 {TimeLimit?.TotalSeconds ?? 0} 秒,已停止。检查是否缺少 WHERE 条件或连接条件。"); + if (message.Contains("database is locked")) return new SqlException(1222, "已超过了锁请求超时时段:数据库正被其他程序修改,请稍后再试。"); + return new SqlException(50000, "执行出错:" + message.Replace("SQLite Error ", "").Trim()); + } + + public void Dispose() + { + _transaction?.Dispose(); + Connection.Dispose(); + } +} diff --git a/runtime/Sim.Data/Engine/TSqlType.cs b/runtime/Sim.Data/Engine/TSqlType.cs new file mode 100644 index 0000000..90d7079 --- /dev/null +++ b/runtime/Sim.Data/Engine/TSqlType.cs @@ -0,0 +1,83 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Sim.Data.Engine; + +/// +/// 一列的 T-SQL 数据类型,如 varchar(50)、int、datetime。 +/// SQLite 表用 STRICT 模式保存(类型见 ),原始的 T-SQL 类型记在元数据表里, +/// 读取时按它把值还原成 SQL Server 返回的 .NET 类型。 +/// +internal sealed record TSqlType(string Name, int? Length = null, bool IsMax = false, int? Precision = null, int? Scale = null) +{ + public static readonly TSqlType Unknown = new("sql_variant"); + + /// 如 varchar(50)、varchar(MAX)、decimal(10, 2) + public override string ToString() => Name switch + { + _ when IsMax => $"{Name}(MAX)", + _ when Length != null => $"{Name}({Length})", + "decimal" or "numeric" when Precision != null => Scale != null ? $"{Name}({Precision}, {Scale})" : $"{Name}({Precision})", + _ => Name, + }; + + public static TSqlType Parse(string text) + { + var m = Regex.Match(text.Trim(), @"^(\w+)\s*(?:\(\s*(MAX|\d+)\s*(?:,\s*(\d+)\s*)?\))?$", RegexOptions.IgnoreCase); + if (!m.Success) return Unknown; + var name = m.Groups[1].Value.ToLowerInvariant(); + if (!m.Groups[2].Success) return new TSqlType(name); + if (m.Groups[2].Value.Equals("MAX", StringComparison.OrdinalIgnoreCase)) return new TSqlType(name, IsMax: true); + var first = int.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture); + if (name is "decimal" or "numeric") + return new TSqlType(name, Precision: first, Scale: m.Groups[3].Success ? int.Parse(m.Groups[3].Value, CultureInfo.InvariantCulture) : null); + return new TSqlType(name, Length: first); + } + + public bool IsString => Name is "char" or "varchar" or "nchar" or "nvarchar" or "text" or "ntext"; + public bool IsDateTime => Name is "datetime" or "datetime2" or "smalldatetime"; + public bool IsDate => Name == "date"; + public bool IsInteger => Name is "int" or "bigint" or "smallint" or "tinyint"; + public bool IsBit => Name == "bit"; + public bool IsFloat => Name is "float" or "real" or "decimal" or "numeric" or "money" or "smallmoney"; + public bool IsBinary => Name is "binary" or "varbinary" or "image"; + + /// SQLite STRICT 表的列类型 + public string StorageType => this switch + { + { IsInteger: true } or { IsBit: true } => "INTEGER", + { IsFloat: true } => "REAL", + { IsBinary: true } => "BLOB", + { IsString: true } or { IsDateTime: true } or { IsDate: true } or { Name: "time" or "uniqueidentifier" } => "TEXT", + _ => "ANY", + }; + + /// 字符串列的长度上限(超出时和 SQL Server 一样报"将截断字符串") + public int? MaxLength => IsString && !IsMax && Name is not ("text" or "ntext") ? Length ?? 1 : null; + + /// SQL Server 返回这一列时的 .NET 类型(DataTable 的列类型、DataGridView 的显示方式由它决定) + public Type ClrType => Name switch + { + "int" => typeof(int), + "bigint" => typeof(long), + "smallint" => typeof(short), + "tinyint" => typeof(byte), + "bit" => typeof(bool), + "float" => typeof(double), + "real" => typeof(float), + "decimal" or "numeric" or "money" or "smallmoney" => typeof(decimal), + "date" or "datetime" or "datetime2" or "smalldatetime" => typeof(DateTime), + "time" => typeof(TimeSpan), + "uniqueidentifier" => typeof(Guid), + "binary" or "varbinary" or "image" => typeof(byte[]), + "char" or "varchar" or "nchar" or "nvarchar" or "text" or "ntext" => typeof(string), + _ => typeof(object), + }; + + /// 平台支持的 T-SQL 类型(建表时检查) + public static bool IsSupported(string name) => name is + "int" or "bigint" or "smallint" or "tinyint" or "bit" or "float" or "real" or "decimal" or "numeric" or "money" or "smallmoney" + or "date" or "datetime" or "datetime2" or "smalldatetime" or "time" or "uniqueidentifier" + or "binary" or "varbinary" or "image" or "char" or "varchar" or "nchar" or "nvarchar" or "text" or "ntext"; +} diff --git a/runtime/Sim.Data/Engine/Translator.cs b/runtime/Sim.Data/Engine/Translator.cs new file mode 100644 index 0000000..5192095 --- /dev/null +++ b/runtime/Sim.Data/Engine/Translator.cs @@ -0,0 +1,916 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace Sim.Data.Engine; + +/// 翻译好的一条语句 +internal abstract record Statement; + +/// 在 SQLite 上执行的语句;IsQuery 表示有结果集;AfterExecute 在同一事务里更新元数据 +internal sealed record SqliteStatement(string Sql, bool IsQuery, IReadOnlyList Parameters, Action? AfterExecute = null) : Statement; + +internal sealed record CreateDatabase(string Name) : Statement; +internal sealed record DropDatabases(IReadOnlyList Names) : Statement; +internal sealed record UseDatabase(string Name) : Statement; +/// SET NOCOUNT ON 之类不影响结果的语句 +internal sealed record IgnoredStatement : Statement; + +/// +/// 把 T-SQL(SQL Server 2008 语法)转写成 SQLite 语句。只转写认识的语法节点,其余一律报"平台暂不支持", +/// 生成的 SQL 里只会出现这里写出的关键字和 tsql_ 函数,学生的 SQL 无法借此执行 SQLite 特有的命令。 +/// 一批语句先整体解析(语法错误和 SQL Server 一样在执行前报出),执行时再逐条转写(后面的语句能用到前面刚建的表)。 +/// +internal sealed class Translator +{ + private readonly Func _lookup; + private readonly StringBuilder _sql = new(); + private readonly List _parameters = new(); + + private Translator(Func lookup) => _lookup = lookup; + + /// 解析整批语句;有语法错误时抛出 SqlException 102 + public static IReadOnlyList Parse(string sql) + { + var parser = new TSql100Parser(initialQuotedIdentifiers: true); + var fragment = parser.Parse(new StringReader(sql), out var errors); + if (errors.Count > 0) throw SyntaxError(sql, errors[0]); + return ((TSqlScript)fragment).Batches.SelectMany(b => b.Statements).ToList(); + } + + /// 转写一条语句;lookup 按表名取表结构(INSERT 跳过自增列、按列类型转换值时要用) + public static Statement Translate(TSqlStatement statement, Func lookup) => new Translator(lookup).Statement(statement); + + private static SqlException SyntaxError(string sql, ParseError error) + { + // ParseError 的信息是英文,这里取出错位置的词,按 SQL Server 中文版的格式报 + var rest = error.Offset < sql.Length ? sql[error.Offset..] : ""; + var token = new string(rest.TakeWhile(c => !char.IsWhiteSpace(c) && c is not (',' or ')' or '(' or ';')).ToArray()); + if (token.Length == 0 && rest.Length > 0) token = rest[..1]; + if (token.Length == 0) return new SqlException(102, "语句不完整,结尾附近有语法错误。", error.Line); + if (token.StartsWith('\'')) return new SqlException(105, $"字符串 '{token.Trim('\'')}' 后的引号不完整。", error.Line); + return SqlException.Syntax(token, error.Line); + } + + // ---------- 语句 ---------- + + private Statement Statement(TSqlStatement statement) + { + switch (statement) + { + case SelectStatement select: + if (select.Into != null) throw SqlException.NotSupported("SELECT ... INTO"); + if (select.WithCtesAndXmlNamespaces != null) throw SqlException.NotSupported("WITH(公用表表达式)"); + Query(select.QueryExpression); + return Done(isQuery: true); + case InsertStatement insert: + Insert(insert.InsertSpecification); + return Done(isQuery: false); + case UpdateStatement update: + Update(update.UpdateSpecification); + return Done(isQuery: false); + case DeleteStatement delete: + Delete(delete.DeleteSpecification); + return Done(isQuery: false); + case CreateTableStatement create: + return CreateTable(create); + case DropTableStatement drop: + return DropTable(drop); + case AlterTableAddTableElementStatement alter: + return AddColumns(alter); + case TruncateTableStatement truncate: + { + var table = RequireTable(truncate.TableName); + _sql.Append("DELETE FROM ").Append(Values.QuoteName(table.Name)); + return Done(isQuery: false); + } + case CreateDatabaseStatement create: + return new CreateDatabase(create.DatabaseName.Value); + case DropDatabaseStatement drop: + return new DropDatabases(drop.Databases.Select(d => d.Value).ToList()); + case UseStatement use: + return new UseDatabase(use.DatabaseName.Value); + case PredicateSetStatement: + return new IgnoredStatement(); + default: + throw SqlException.NotSupported(StatementName(statement)); + } + } + + private SqliteStatement Done(bool isQuery, Action? after = null) => + new(_sql.ToString(), isQuery, _parameters.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), after); + + private static string StatementName(TSqlStatement statement) => statement.GetType().Name.Replace("Statement", "") switch + { + "CreateProcedure" or "AlterProcedure" => "存储过程", + "CreateView" or "AlterView" => "视图", + "CreateTrigger" => "触发器", + "CreateIndex" => "CREATE INDEX", + "DeclareVariable" => "DECLARE 变量", + "If" => "IF 语句", + "While" => "WHILE 循环", + "BeginEndBlock" => "BEGIN ... END", + "Execute" => "EXEC", + var name => name + " 语句", + }; + + // ---------- 查询 ---------- + + private void Query(QueryExpression query) + { + switch (query) + { + case QuerySpecification spec: + QuerySpec(spec); + break; + case BinaryQueryExpression binary: + Query(binary.FirstQueryExpression); + _sql.Append(binary.BinaryQueryExpressionType switch + { + BinaryQueryExpressionType.Union => binary.All ? " UNION ALL " : " UNION ", + BinaryQueryExpressionType.Except => " EXCEPT ", + _ => " INTERSECT ", + }); + Query(binary.SecondQueryExpression); + OrderBy(binary.OrderByClause); + break; + case QueryParenthesisExpression paren: + _sql.Append("SELECT * FROM ("); + Query(paren.QueryExpression); + _sql.Append(')'); + OrderBy(paren.OrderByClause); + break; + default: + throw SqlException.NotSupported(query.GetType().Name); + } + } + + private void QuerySpec(QuerySpecification spec) + { + _sql.Append("SELECT "); + if (spec.UniqueRowFilter == UniqueRowFilter.Distinct) _sql.Append("DISTINCT "); + for (var i = 0; i < spec.SelectElements.Count; i++) + { + if (i > 0) _sql.Append(", "); + SelectElement(spec.SelectElements[i]); + } + if (spec.FromClause != null) + { + _sql.Append(" FROM "); + for (var i = 0; i < spec.FromClause.TableReferences.Count; i++) + { + if (i > 0) _sql.Append(", "); + TableReference(spec.FromClause.TableReferences[i]); + } + } + Where(spec.WhereClause); + if (spec.GroupByClause != null) + { + _sql.Append(" GROUP BY "); + for (var i = 0; i < spec.GroupByClause.GroupingSpecifications.Count; i++) + { + if (i > 0) _sql.Append(", "); + if (spec.GroupByClause.GroupingSpecifications[i] is not ExpressionGroupingSpecification group) throw SqlException.NotSupported("这种 GROUP BY 写法"); + Expression(group.Expression); + } + } + if (spec.HavingClause != null) + { + _sql.Append(" HAVING "); + Boolean(spec.HavingClause.SearchCondition); + } + OrderBy(spec.OrderByClause); + Top(spec.TopRowFilter); + } + + private void SelectElement(SelectElement element) + { + switch (element) + { + case SelectStarExpression star: + if (star.Qualifier != null) _sql.Append(Values.QuoteName(star.Qualifier.Identifiers[^1].Value)).Append('.'); + _sql.Append('*'); + break; + case SelectScalarExpression scalar: + Expression(scalar.Expression); + if (scalar.ColumnName != null) + { + // as '编号' 和 as 编号 都按列名处理 + _sql.Append(" AS ").Append(Values.QuoteName(scalar.ColumnName.Value)); + } + else if (scalar.Expression is not ColumnReferenceExpression) + { + // 和 SQL Server 一样,没有起别名的表达式列没有列名(DataTable 里叫 Column1) + _sql.Append(" AS \"\""); + } + break; + default: + throw SqlException.NotSupported("SELECT @变量 = ..."); + } + } + + private void TableReference(TableReference reference) + { + switch (reference) + { + case NamedTableReference named: + var table = RequireTable(named.SchemaObject); + _sql.Append(Values.QuoteName(table.Name)); + if (named.Alias != null) _sql.Append(" AS ").Append(Values.QuoteName(named.Alias.Value)); + break; + case QualifiedJoin join: + TableReference(join.FirstTableReference); + _sql.Append(join.QualifiedJoinType switch + { + QualifiedJoinType.Inner => " INNER JOIN ", + QualifiedJoinType.LeftOuter => " LEFT JOIN ", + QualifiedJoinType.RightOuter => " RIGHT JOIN ", + _ => " FULL JOIN ", + }); + TableReference(join.SecondTableReference); + _sql.Append(" ON "); + Boolean(join.SearchCondition); + break; + case UnqualifiedJoin cross when cross.UnqualifiedJoinType == UnqualifiedJoinType.CrossJoin: + TableReference(cross.FirstTableReference); + _sql.Append(" CROSS JOIN "); + TableReference(cross.SecondTableReference); + break; + case QueryDerivedTable derived: + _sql.Append('('); + Query(derived.QueryExpression); + _sql.Append(')'); + if (derived.Alias != null) _sql.Append(" AS ").Append(Values.QuoteName(derived.Alias.Value)); + break; + default: + throw SqlException.NotSupported("这种 FROM 写法"); + } + } + + private void Where(WhereClause? where) + { + if (where == null) return; + if (where.Cursor != null) throw SqlException.NotSupported("游标"); + _sql.Append(" WHERE "); + Boolean(where.SearchCondition); + } + + private void OrderBy(OrderByClause? orderBy) + { + if (orderBy == null) return; + _sql.Append(" ORDER BY "); + for (var i = 0; i < orderBy.OrderByElements.Count; i++) + { + if (i > 0) _sql.Append(", "); + Expression(orderBy.OrderByElements[i].Expression); + if (orderBy.OrderByElements[i].SortOrder == SortOrder.Descending) _sql.Append(" DESC"); + } + } + + private void Top(TopRowFilter? top) + { + if (top == null) return; + if (top.Percent) throw SqlException.NotSupported("TOP ... PERCENT"); + if (top.WithTies) throw SqlException.NotSupported("TOP ... WITH TIES"); + _sql.Append(" LIMIT "); + Expression(top.Expression); + } + + // ---------- 增删改 ---------- + + private void Insert(InsertSpecification spec) + { + if (spec.TopRowFilter != null) throw SqlException.NotSupported("INSERT TOP"); + if (spec.Target is not NamedTableReference target) throw SqlException.NotSupported("这种 INSERT 写法"); + var table = RequireTable(target.SchemaObject); + + // 列:写了列名就按列名;没写就是除自增列以外的全部列(和 SQL Server 一样) + List columns; + if (spec.Columns.Count > 0) + { + columns = spec.Columns.Select(c => RequireColumn(table, c.MultiPartIdentifier.Identifiers[^1].Value)).ToList(); + if (columns.FirstOrDefault(c => c.Identity) != null) throw SqlException.IdentityInsert(table.Name); + } + else + { + columns = table.Columns.Where(c => !c.Identity).ToList(); + } + + _sql.Append("INSERT INTO ").Append(Values.QuoteName(table.Name)); + switch (spec.InsertSource) + { + case ValuesInsertSource { IsDefaultValues: true }: + _sql.Append(" DEFAULT VALUES"); + break; + case ValuesInsertSource values: + _sql.Append(" (").Append(string.Join(", ", columns.Select(c => Values.QuoteName(c.Name)))).Append(") VALUES "); + for (var r = 0; r < values.RowValues.Count; r++) + { + var row = values.RowValues[r].ColumnValues; + if (row.Count != columns.Count) throw SqlException.ColumnCountMismatch(); + if (r > 0) _sql.Append(", "); + _sql.Append('('); + for (var i = 0; i < row.Count; i++) + { + if (i > 0) _sql.Append(", "); + ValueFor(columns[i], row[i]); + } + _sql.Append(')'); + } + break; + case SelectInsertSource select: + _sql.Append(" (").Append(string.Join(", ", columns.Select(c => Values.QuoteName(c.Name)))).Append(") "); + Query(select.Select); + break; + default: + throw SqlException.NotSupported("INSERT ... EXEC"); + } + } + + private void Update(UpdateSpecification spec) + { + if (spec.FromClause != null) throw SqlException.NotSupported("UPDATE ... FROM"); + if (spec.Target is not NamedTableReference target) throw SqlException.NotSupported("这种 UPDATE 写法"); + var table = RequireTable(target.SchemaObject); + _sql.Append("UPDATE ").Append(Values.QuoteName(table.Name)).Append(" SET "); + for (var i = 0; i < spec.SetClauses.Count; i++) + { + if (spec.SetClauses[i] is not AssignmentSetClause { Column: not null } set || set.AssignmentKind != AssignmentKind.Equals) + throw SqlException.NotSupported("这种 SET 写法"); + var column = RequireColumn(table, set.Column.MultiPartIdentifier.Identifiers[^1].Value); + if (column.Identity) throw new SqlException(8102, $"无法更新标识列 '{column.Name}'。"); + if (i > 0) _sql.Append(", "); + _sql.Append(Values.QuoteName(column.Name)).Append(" = "); + ValueFor(column, set.NewValue); + } + LimitedWhere(table, spec.WhereClause, spec.TopRowFilter); + } + + private void Delete(DeleteSpecification spec) + { + if (spec.FromClause != null) throw SqlException.NotSupported("DELETE ... FROM ... JOIN"); + if (spec.Target is not NamedTableReference target) throw SqlException.NotSupported("这种 DELETE 写法"); + var table = RequireTable(target.SchemaObject); + _sql.Append("DELETE FROM ").Append(Values.QuoteName(table.Name)); + LimitedWhere(table, spec.WhereClause, spec.TopRowFilter); + } + + /// UPDATE TOP (n) / DELETE TOP (n):SQLite 不支持,改成按 rowid 限定前 n 行 + private void LimitedWhere(TableInfo table, WhereClause? where, TopRowFilter? top) + { + if (top == null) + { + Where(where); + return; + } + _sql.Append(" WHERE rowid IN (SELECT rowid FROM ").Append(Values.QuoteName(table.Name)); + Where(where); + Top(top); + _sql.Append(')'); + } + + /// 插入、修改的值按目标列的类型转换(日期统一格式、字符串转数字失败时报和 SQL Server 一样的错) + private void ValueFor(ColumnInfo column, ScalarExpression value) + { + if (value is NullLiteral || value is DefaultLiteral) + { + _sql.Append(value is NullLiteral ? "NULL" : throw SqlException.NotSupported("VALUES 里的 DEFAULT")); + return; + } + var type = column.Type; + if (type.IsString || type.IsBinary || type.StorageType == "ANY") + { + Expression(value); + return; + } + _sql.Append("tsql_convert(").Append(Values.Quote(type.Name)).Append(", NULL, "); + Expression(value); + _sql.Append(", NULL)"); + } + + // ---------- 建表、删表、加列 ---------- + + private Statement CreateTable(CreateTableStatement create) + { + var name = TableName(create.SchemaObjectName); + if (DatabaseStore.IsHidden(name)) throw new SqlException(102, $"表名 '{name}' 是平台保留的名字,请换一个。"); + if (_lookup(name) != null) throw SqlException.ObjectExists(name); + var definition = create.Definition; + + var primaryKey = definition.TableConstraints.OfType().FirstOrDefault(c => c.IsPrimaryKey); + var keyColumns = primaryKey?.Columns.Select(c => c.Column.MultiPartIdentifier.Identifiers[^1].Value).ToList() ?? new List(); + var columns = definition.ColumnDefinitions.Select(c => Column(c, keyColumns)).ToList(); + if (columns.Count(c => c.Identity) > 1) throw new SqlException(2744, $"为表 '{name}' 指定了多个标识列。只允许为每个表指定一个标识列。"); + foreach (var key in keyColumns) + if (columns.All(c => !string.Equals(c.Name, key, StringComparison.OrdinalIgnoreCase))) throw SqlException.InvalidColumn(key); + + _sql.Append("CREATE TABLE ").Append(Values.QuoteName(name)).Append(" ("); + _sql.Append(string.Join(", ", columns.Select(c => ColumnSql(c, single: keyColumns.Count <= 1 && c.PrimaryKey)))); + if (keyColumns.Count > 1) _sql.Append(", PRIMARY KEY (").Append(string.Join(", ", keyColumns.Select(Values.QuoteName))).Append(')'); + _sql.Append(") STRICT"); + return Done(isQuery: false, after: c => DatabaseStore.SaveColumns(c, name, columns)); + } + + private Statement AddColumns(AlterTableAddTableElementStatement alter) + { + var table = RequireTable(alter.SchemaObjectName); + if (alter.Definition.TableConstraints.Count > 0) throw SqlException.NotSupported("ALTER TABLE 添加约束"); + var columns = alter.Definition.ColumnDefinitions.Select(c => Column(c, new List())).ToList(); + foreach (var column in columns) + { + if (table.Find(column.Name) != null) throw new SqlException(2705, $"各表中的列名必须唯一。在表 '{table.Name}' 中多次指定了列名 '{column.Name}'。"); + if (column.Identity || column.PrimaryKey) throw SqlException.NotSupported("ALTER TABLE 添加主键或标识列"); + if (_sql.Length > 0) _sql.Append("; "); + _sql.Append("ALTER TABLE ").Append(Values.QuoteName(table.Name)).Append(" ADD COLUMN ").Append(ColumnSql(column, single: false)); + } + return Done(isQuery: false, after: c => DatabaseStore.SaveColumns(c, table.Name, columns, table.Columns.Count)); + } + + private Statement DropTable(DropTableStatement drop) + { + var tables = drop.Objects.Select(o => _lookup(TableName(o)) ?? throw SqlException.CannotDropTable(TableName(o))).ToList(); + _sql.Append(string.Join("; ", tables.Select(t => "DROP TABLE " + Values.QuoteName(t.Name)))); + return Done(isQuery: false, after: c => + { + foreach (var table in tables) DatabaseStore.DeleteColumns(c, table.Name); + }); + } + + private ColumnInfo Column(ColumnDefinition definition, List keyColumns) + { + var name = definition.ColumnIdentifier.Value; + if (definition.DataType is not SqlDataTypeReference dataType) throw new SqlException(2715, $"第 1 个列、参数或变量: 找不到数据类型 {definition.DataType?.Name?.BaseIdentifier?.Value}。"); + var type = TypeOf(dataType); + var identity = definition.IdentityOptions != null; + if (identity && !type.IsInteger) throw new SqlException(2749, $"标识列 '{name}' 的数据类型必须是 int、bigint、smallint 或 tinyint。"); + var primaryKey = keyColumns.Any(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)) + || definition.Constraints.OfType().Any(c => c.IsPrimaryKey); + var nullable = !identity && !primaryKey && definition.Constraints.OfType().All(c => c.Nullable); + if (definition.Constraints.Any(c => c is not (NullableConstraintDefinition or UniqueConstraintDefinition { IsPrimaryKey: true }))) + throw SqlException.NotSupported("PRIMARY KEY、NULL / NOT NULL 以外的列约束"); + + string? defaultText = null; + if (definition.DefaultConstraint != null) defaultText = DefaultSql(definition.DefaultConstraint.Expression); + if (identity && definition.IdentityOptions!.IdentitySeed is IntegerLiteral seed && seed.Value != "1") + throw SqlException.NotSupported("从 1 以外的数开始的 IDENTITY"); + return new ColumnInfo(name, type, nullable, identity, primaryKey, defaultText); + } + + private static TSqlType TypeOf(SqlDataTypeReference dataType) + { + var name = dataType.SqlDataTypeOption.ToString().ToLowerInvariant(); + if (!TSqlType.IsSupported(name)) throw SqlException.NotSupported($"数据类型 {name}"); + var parameters = dataType.Parameters; + if (parameters.Count == 0) return new TSqlType(name); + if (parameters[0] is MaxLiteral) return new TSqlType(name, IsMax: true); + var first = int.Parse(parameters[0].Value, CultureInfo.InvariantCulture); + if (name is "decimal" or "numeric") + return new TSqlType(name, Precision: first, Scale: parameters.Count > 1 ? int.Parse(parameters[1].Value, CultureInfo.InvariantCulture) : null); + return new TSqlType(name, Length: first); + } + + /// SQLite 的列定义:STRICT 类型 + 约束;字符串长度和 bit 取值用 CHECK 实现(约束名用来认出是哪种错误) + private static string ColumnSql(ColumnInfo column, bool single) + { + var sql = new StringBuilder(Values.QuoteName(column.Name)); + if (column.Identity && single) + { + sql.Append(" INTEGER PRIMARY KEY AUTOINCREMENT"); + return sql.ToString(); + } + if (column.Identity) throw SqlException.NotSupported("标识列不是唯一主键的表"); + sql.Append(' ').Append(column.Type.StorageType); + if (single && column.PrimaryKey) sql.Append(" PRIMARY KEY"); + if (!column.Nullable) sql.Append(" NOT NULL"); + if (column.Type.StorageType == "TEXT") sql.Append(" COLLATE NOCASE"); + if (column.Default != null) sql.Append(" DEFAULT ").Append(column.Default); + if (column.Type.MaxLength is { } max) + sql.Append(" CONSTRAINT ").Append(Values.QuoteName("trunc:" + column.Name)).Append(" CHECK (length(").Append(Values.QuoteName(column.Name)).Append(") <= ").Append(max).Append(')'); + if (column.Type.IsBit) + sql.Append(" CONSTRAINT ").Append(Values.QuoteName("bit:" + column.Name)).Append(" CHECK (").Append(Values.QuoteName(column.Name)).Append(" IN (0, 1))"); + return sql.ToString(); + } + + /// DEFAULT 只支持常量和 getdate() + private static string DefaultSql(ScalarExpression expression) => Unparen(expression) switch + { + IntegerLiteral i => i.Value, + NumericLiteral n => n.Value, + RealLiteral r => r.Value, + StringLiteral s => Values.Quote(s.Value), + NullLiteral => "NULL", + UnaryExpression { UnaryExpressionType: UnaryExpressionType.Negative, Expression: IntegerLiteral or NumericLiteral } u => "-" + ((Literal)u.Expression).Value, + FunctionCall f when f.FunctionName.Value.Equals("getdate", StringComparison.OrdinalIgnoreCase) => "(strftime('%Y-%m-%d %H:%M:%S', 'now', 'localtime'))", + _ => throw SqlException.NotSupported("这种 DEFAULT 默认值"), + }; + + private static ScalarExpression Unparen(ScalarExpression expression) => + expression is ParenthesisExpression p ? Unparen(p.Expression) : expression; + + // ---------- 表名、列名 ---------- + + /// dbo.表名、库名.dbo.表名 都取最后一段(跨库访问不支持) + private static string TableName(SchemaObjectName name) + { + if (name.ServerIdentifier != null) throw SqlException.NotSupported("访问其他服务器上的表"); + if (name.SchemaIdentifier != null && !name.SchemaIdentifier.Value.Equals("dbo", StringComparison.OrdinalIgnoreCase)) + throw SqlException.NotSupported($"架构 {name.SchemaIdentifier.Value}(只有 dbo)"); + return name.BaseIdentifier.Value; + } + + private TableInfo RequireTable(SchemaObjectName name) + { + if (name.DatabaseIdentifier != null) throw SqlException.NotSupported("在一条语句里访问其他数据库的表,请先用 USE 切换数据库"); + var table = TableName(name); + return _lookup(table) ?? throw SqlException.InvalidObject(table); + } + + private static ColumnInfo RequireColumn(TableInfo table, string column) => table.Find(column) ?? throw SqlException.InvalidColumn(column); + + // ---------- 表达式 ---------- + + private void Expression(ScalarExpression expression) + { + switch (expression) + { + case ColumnReferenceExpression column: + ColumnReference(column); + break; + case IntegerLiteral or NumericLiteral or RealLiteral or MoneyLiteral: + _sql.Append(((Literal)expression).Value.TrimStart('$', '¥')); + break; + case StringLiteral s: + _sql.Append(Values.Quote(s.Value)); + break; + case NullLiteral: + _sql.Append("NULL"); + break; + case BinaryLiteral b: + _sql.Append("X'").Append(b.Value[2..]).Append('\''); + break; + case VariableReference variable: + _parameters.Add(variable.Name); + _sql.Append(variable.Name); + break; + case GlobalVariableExpression global when global.Name.Equals("@@IDENTITY", StringComparison.OrdinalIgnoreCase): + _sql.Append("last_insert_rowid()"); + break; + case ParenthesisExpression paren: + _sql.Append('('); + Expression(paren.Expression); + _sql.Append(')'); + break; + case UnaryExpression unary: + _sql.Append(unary.UnaryExpressionType switch + { + UnaryExpressionType.Negative => "-", + UnaryExpressionType.Positive => "+", + _ => "~", + }); + Expression(unary.Expression); + break; + case BinaryExpression binary: + Binary(binary); + break; + case FunctionCall call: + Function(call); + break; + case LeftFunctionCall left: + Call("tsql_left", left.Parameters); + break; + case RightFunctionCall right: + Call("tsql_right", right.Parameters); + break; + case CastCall cast: + Conversion(cast.DataType, cast.Parameter, null); + break; + case ConvertCall convert: + Conversion(convert.DataType, convert.Parameter, convert.Style); + break; + case CoalesceExpression coalesce: + Call("coalesce", coalesce.Expressions); + break; + case NullIfExpression nullIf: + Call("nullif", new[] { nullIf.FirstExpression, nullIf.SecondExpression }); + break; + case SearchedCaseExpression searched: + _sql.Append("CASE"); + foreach (var when in searched.WhenClauses) + { + _sql.Append(" WHEN "); + Boolean(when.WhenExpression); + _sql.Append(" THEN "); + Expression(when.ThenExpression); + } + Else(searched.ElseExpression); + break; + case SimpleCaseExpression simple: + _sql.Append("CASE "); + Expression(simple.InputExpression); + foreach (var when in simple.WhenClauses) + { + _sql.Append(" WHEN "); + Expression(when.WhenExpression); + _sql.Append(" THEN "); + Expression(when.ThenExpression); + } + Else(simple.ElseExpression); + break; + case ScalarSubquery subquery: + _sql.Append('('); + Query(subquery.QueryExpression); + _sql.Append(')'); + break; + default: + throw SqlException.NotSupported(expression.GetType().Name.Replace("Expression", "") + " 表达式"); + } + } + + private void Else(ScalarExpression? otherwise) + { + if (otherwise != null) + { + _sql.Append(" ELSE "); + Expression(otherwise); + } + _sql.Append(" END"); + } + + private void ColumnReference(ColumnReferenceExpression column) + { + if (column.ColumnType == ColumnType.Wildcard) + { + _sql.Append('*'); + return; + } + if (column.ColumnType != ColumnType.Regular) throw SqlException.NotSupported(column.ColumnType.ToString()); + var parts = column.MultiPartIdentifier.Identifiers; + // 表名.列名 保留表名(或别名);库名.dbo.表名.列名 只取最后两段 + var take = Math.Min(parts.Count, 2); + _sql.Append(string.Join(".", parts.Skip(parts.Count - take).Select(p => Values.QuoteName(p.Value)))); + } + + private void Binary(BinaryExpression binary) + { + switch (binary.BinaryExpressionType) + { + // + 要在运行时看两边的类型:字符串连接还是数字相加 + case BinaryExpressionType.Add: + Call("tsql_add", new[] { binary.FirstExpression, binary.SecondExpression }); + return; + case BinaryExpressionType.Divide: + Call("tsql_div", new[] { binary.FirstExpression, binary.SecondExpression }); + return; + case BinaryExpressionType.Modulo: + Call("tsql_mod", new[] { binary.FirstExpression, binary.SecondExpression }); + return; + } + _sql.Append('('); + Expression(binary.FirstExpression); + _sql.Append(binary.BinaryExpressionType switch + { + BinaryExpressionType.Subtract => " - ", + BinaryExpressionType.Multiply => " * ", + BinaryExpressionType.BitwiseAnd => " & ", + BinaryExpressionType.BitwiseOr => " | ", + _ => throw SqlException.NotSupported("^ 运算"), + }); + Expression(binary.SecondExpression); + _sql.Append(')'); + } + + private void Call(string name, IList arguments) + { + _sql.Append(name).Append('('); + for (var i = 0; i < arguments.Count; i++) + { + if (i > 0) _sql.Append(", "); + Expression(arguments[i]); + } + _sql.Append(')'); + } + + private void Conversion(DataTypeReference dataType, ScalarExpression value, ScalarExpression? style) + { + if (dataType is not SqlDataTypeReference sqlType) throw SqlException.NotSupported($"转换成 {dataType.Name.BaseIdentifier.Value}"); + var type = TypeOf(sqlType); + _sql.Append("tsql_convert(").Append(Values.Quote(type.Name)).Append(", ") + .Append(type.IsMax ? "-1" : type.Length?.ToString(CultureInfo.InvariantCulture) ?? (type.IsString ? "30" : "NULL")).Append(", "); + Expression(value); + _sql.Append(", "); + if (style != null) Expression(style); + else _sql.Append("NULL"); + _sql.Append(')'); + } + + private void Function(FunctionCall call) + { + if (call.CallTarget != null) throw SqlException.NotSupported("这种函数调用"); + if (call.OverClause != null) throw SqlException.NotSupported("OVER(开窗函数)"); + var name = call.FunctionName.Value.ToLowerInvariant(); + var args = call.Parameters; + var distinct = call.UniqueRowFilter == UniqueRowFilter.Distinct ? "DISTINCT " : ""; + + void Expect(int min, int max) + { + if (args.Count < min || args.Count > max) + throw new SqlException(174, $"{call.FunctionName.Value} 函数要求有 {(min == max ? min.ToString(CultureInfo.InvariantCulture) : $"{min} 到 {max}")} 个参数。"); + } + + switch (name) + { + case "count" or "sum" or "avg" or "min" or "max": + Expect(1, 1); + _sql.Append(name).Append('(').Append(distinct); + if (args[0] is ColumnReferenceExpression { ColumnType: ColumnType.Wildcard }) _sql.Append('*'); + else Expression(args[0]); + _sql.Append(')'); + return; + case "getdate" or "current_timestamp" or "sysdatetime": + Expect(0, 0); + _sql.Append("tsql_getdate()"); + return; + case "year" or "month" or "day": + Expect(1, 1); + _sql.Append("tsql_datepart('").Append(name).Append("', "); + Expression(args[0]); + _sql.Append(')'); + return; + case "datepart" or "datename": + Expect(2, 2); + _sql.Append("tsql_datepart(").Append(Values.Quote(DatePartName(args[0]))).Append(", "); + Expression(args[1]); + _sql.Append(')'); + return; + case "dateadd" or "datediff": + Expect(3, 3); + _sql.Append("tsql_").Append(name).Append('(').Append(Values.Quote(DatePartName(args[0]))).Append(", "); + Expression(args[1]); + _sql.Append(", "); + Expression(args[2]); + _sql.Append(')'); + return; + case "scope_identity" or "ident_current": + _sql.Append("last_insert_rowid()"); + return; + case "len": + Expect(1, 1); + Call("tsql_len", args); + return; + case "datalength": + Expect(1, 1); + Call("length", args); + return; + case "substring": + Expect(3, 3); + Call("substr", args); + return; + case "charindex": + Expect(2, 2); + Call("tsql_charindex", args); + return; + case "upper" or "lower" or "ltrim" or "rtrim" or "replace" or "abs" or "round" or "sign": + Expect(1, 3); + Call(name, name == "round" ? args.Take(2).ToList() : args); + return; + case "isnull": + Expect(2, 2); + Call("ifnull", args); + return; + case "floor" or "ceiling" or "sqrt": + Expect(1, 1); + Call("tsql_" + name, args); + return; + case "power": + Expect(2, 2); + Call("tsql_power", args); + return; + case "newid": + Expect(0, 0); + _sql.Append("tsql_newid()"); + return; + case "rand": + _sql.Append("tsql_rand()"); + return; + default: + throw SqlException.UnknownFunction(call.FunctionName.Value); + } + } + + /// DATEADD(day, ...) 的第一个参数是关键字,解析成列名 + private static string DatePartName(ScalarExpression expression) => expression switch + { + ColumnReferenceExpression c => c.MultiPartIdentifier.Identifiers[^1].Value, + StringLiteral s => s.Value, + _ => throw SqlException.NotSupported("这种 datepart 写法"), + }; + + // ---------- 条件 ---------- + + private void Boolean(BooleanExpression expression) + { + switch (expression) + { + case BooleanComparisonExpression comparison: + _sql.Append('('); + Comparand(comparison.FirstExpression); + _sql.Append(comparison.ComparisonType switch + { + BooleanComparisonType.Equals => " = ", + BooleanComparisonType.GreaterThan => " > ", + BooleanComparisonType.LessThan => " < ", + BooleanComparisonType.GreaterThanOrEqualTo or BooleanComparisonType.NotLessThan => " >= ", + BooleanComparisonType.LessThanOrEqualTo or BooleanComparisonType.NotGreaterThan => " <= ", + BooleanComparisonType.NotEqualToBrackets or BooleanComparisonType.NotEqualToExclamation => " <> ", + _ => throw SqlException.NotSupported(comparison.ComparisonType.ToString()), + }); + Comparand(comparison.SecondExpression); + _sql.Append(')'); + break; + case BooleanBinaryExpression binary: + _sql.Append('('); + Boolean(binary.FirstExpression); + _sql.Append(binary.BinaryExpressionType == BooleanBinaryExpressionType.And ? " AND " : " OR "); + Boolean(binary.SecondExpression); + _sql.Append(')'); + break; + case BooleanNotExpression not: + _sql.Append("NOT ("); + Boolean(not.Expression); + _sql.Append(')'); + break; + case BooleanParenthesisExpression paren: + _sql.Append('('); + Boolean(paren.Expression); + _sql.Append(')'); + break; + case BooleanIsNullExpression isNull: + _sql.Append('('); + Expression(isNull.Expression); + _sql.Append(isNull.IsNot ? " IS NOT NULL)" : " IS NULL)"); + break; + case LikePredicate like: + _sql.Append('('); + Expression(like.FirstExpression); + _sql.Append(like.NotDefined ? " NOT LIKE " : " LIKE "); + Expression(like.SecondExpression); + if (like.EscapeExpression != null) + { + _sql.Append(" ESCAPE "); + Expression(like.EscapeExpression); + } + _sql.Append(')'); + break; + case InPredicate inPredicate: + _sql.Append('('); + Comparand(inPredicate.Expression); + _sql.Append(inPredicate.NotDefined ? " NOT IN (" : " IN ("); + if (inPredicate.Subquery != null) + { + Query(inPredicate.Subquery.QueryExpression); + } + else + { + for (var i = 0; i < inPredicate.Values.Count; i++) + { + if (i > 0) _sql.Append(", "); + Comparand(inPredicate.Values[i]); + } + } + _sql.Append("))"); + break; + case BooleanTernaryExpression between: + _sql.Append('('); + Comparand(between.FirstExpression); + _sql.Append(between.TernaryExpressionType == BooleanTernaryExpressionType.NotBetween ? " NOT BETWEEN " : " BETWEEN "); + Comparand(between.SecondExpression); + _sql.Append(" AND "); + Comparand(between.ThirdExpression); + _sql.Append(')'); + break; + case ExistsPredicate exists: + _sql.Append("EXISTS ("); + Query(exists.Subquery.QueryExpression); + _sql.Append(')'); + break; + default: + throw SqlException.NotSupported(expression.GetType().Name.Replace("Expression", "") + " 条件"); + } + } + + /// 比较里的日期字符串转成标准格式,日期列按文本比较才是按时间先后(BETWEEN '2024/1/16 9:05:03' AND ...) + private void Comparand(ScalarExpression expression) + { + if (expression is StringLiteral s && Values.LooksLikeDate(s.Value)) + { + _sql.Append(Values.Quote(Values.NormalizeDateLiteral(s.Value))); + return; + } + Expression(expression); + } +} diff --git a/runtime/Sim.Data/Engine/Values.cs b/runtime/Sim.Data/Engine/Values.cs new file mode 100644 index 0000000..fad7992 --- /dev/null +++ b/runtime/Sim.Data/Engine/Values.cs @@ -0,0 +1,102 @@ +using System; +using System.Data.SqlClient; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Sim.Data.Engine; + +/// 值在 .NET、SQLite、T-SQL 之间的转换。日期一律以 "yyyy-MM-dd HH:mm:ss" 文本保存,按文本比较即按时间先后。 +internal static class Values +{ + private static readonly string[] DateFormats = + { + "yyyy-M-d", "yyyy-M-d H:m", "yyyy-M-d H:m:s", "yyyy-M-d H:m:s.FFFFFFF", "yyyy-M-dTH:m:s", "yyyy-M-dTH:m:s.FFFFFFF", + "yyyy/M/d", "yyyy/M/d H:m", "yyyy/M/d H:m:s", "yyyy/M/d H:m:s.FFFFFFF", + "yyyyMMdd", "yyyyMMdd H:m:s", + "yyyy.M.d", "yyyy.M.d H:m:s", + }; + + /// 形如日期的字符串字面量(比较时转成标准格式,例如 BETWEEN '2024/1/16 9:05:03' AND ...) + private static readonly Regex DateLike = new(@"^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}([ T]\d{1,2}:\d{1,2}(:\d{1,2}(\.\d+)?)?)?$"); + + public static bool LooksLikeDate(string text) => DateLike.IsMatch(text.Trim()); + + /// 按 SQL Server 接受的写法解析日期(包括中文 Windows 上 DateTime.ToString() 的 2024/1/16 9:05:03) + public static bool TryParseDateTime(string text, out DateTime value) => + DateTime.TryParseExact(text.Trim(), DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out value); + + public static string FormatDateTime(DateTime value) => + value.Millisecond == 0 && value.Ticks % TimeSpan.TicksPerMillisecond == 0 + ? value.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + : value.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + + public static string FormatDate(DateTime value) => value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + /// 字符串里的日期改成标准格式;只有日期没有时间的保持只有日期(与 datetime 比较时相当于当天 0 点) + public static string NormalizeDateLiteral(string text) + { + if (!TryParseDateTime(text, out var value)) return text; + return text.Contains(':') ? FormatDateTime(value) : FormatDate(value); + } + + /// C# 传进来的参数值 → SQLite 能保存的值 + public static object? ToStorage(object? value) => value switch + { + null or DBNull => null, + DateTime d => FormatDateTime(d), + DateTimeOffset d => FormatDateTime(d.LocalDateTime), + TimeSpan t => t.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), + bool b => b ? 1L : 0L, + Guid g => g.ToString().ToUpperInvariant(), + decimal m => (double)m, + Enum e => Convert.ToInt64(e, CultureInfo.InvariantCulture), + _ => value, + }; + + /// SQLite 读出的值 → SQL Server 对这个类型返回的 .NET 值 + public static object FromStorage(object value, TSqlType? type) + { + if (value is DBNull) return value; + if (type == null || type.ClrType == typeof(object)) return FromExpression(value); + try + { + return type.ClrType switch + { + var t when t == typeof(int) => Convert.ToInt32(value, CultureInfo.InvariantCulture), + var t when t == typeof(long) => Convert.ToInt64(value, CultureInfo.InvariantCulture), + var t when t == typeof(short) => Convert.ToInt16(value, CultureInfo.InvariantCulture), + var t when t == typeof(byte) => Convert.ToByte(value, CultureInfo.InvariantCulture), + var t when t == typeof(bool) => Convert.ToInt64(value, CultureInfo.InvariantCulture) != 0, + var t when t == typeof(double) => Convert.ToDouble(value, CultureInfo.InvariantCulture), + var t when t == typeof(float) => Convert.ToSingle(value, CultureInfo.InvariantCulture), + var t when t == typeof(decimal) => Convert.ToDecimal(value, CultureInfo.InvariantCulture), + var t when t == typeof(DateTime) => value is string s && TryParseDateTime(s, out var d) ? d : value, + var t when t == typeof(TimeSpan) => value is string s && TimeSpan.TryParse(s, CultureInfo.InvariantCulture, out var ts) ? ts : value, + var t when t == typeof(Guid) => value is string s && Guid.TryParse(s, out var g) ? g : value, + var t when t == typeof(string) => Convert.ToString(value, CultureInfo.InvariantCulture)!, + _ => value, + }; + } + catch (Exception ex) when (ex is FormatException or OverflowException or InvalidCastException) + { + return value; + } + } + + /// 表达式列(如 count(*)、getdate())没有声明类型:整数按 int 返回,日期样子的文本按 DateTime 返回 + public static object FromExpression(object value) => value switch + { + long l when l is >= int.MinValue and <= int.MaxValue => (int)l, + string s when DateLike.IsMatch(s) && s.Contains(':') && TryParseDateTime(s, out var d) => d, + _ => value, + }; + + /// T-SQL 字符串字面量 + public static string Quote(string text) => "'" + text.Replace("'", "''") + "'"; + + /// SQLite 标识符 + public static string QuoteName(string name) => "\"" + name.Replace("\"", "\"\"") + "\""; + + public static SqlException ConvertError(object? value, string type) => + SqlException.ConvertFailed(Convert.ToString(value, CultureInfo.InvariantCulture) ?? "", type); +} diff --git a/runtime/Sim.Data/Sim.Data.csproj b/runtime/Sim.Data/Sim.Data.csproj new file mode 100644 index 0000000..7964240 --- /dev/null +++ b/runtime/Sim.Data/Sim.Data.csproj @@ -0,0 +1,17 @@ + + + + Sim.Data + System.Data.SqlClient + $(NoWarn);CS1591;CS8618 + + + + + + + + + + diff --git a/runtime/Sim.Data/SqlClient/SqlCommand.cs b/runtime/Sim.Data/SqlClient/SqlCommand.cs new file mode 100644 index 0000000..70b3b46 --- /dev/null +++ b/runtime/Sim.Data/SqlClient/SqlCommand.cs @@ -0,0 +1,302 @@ +using System.Collections; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using Sim.Data.Engine; + +namespace System.Data.SqlClient; + +/// 执行 T-SQL 语句。一条命令里可以有多条语句(用分号或换行分开) +public sealed class SqlCommand : DbCommand, ICloneable +{ + private SqlConnection? _connection; + private string _commandText = ""; + private CommandType _commandType = CommandType.Text; + + public SqlCommand() { } + + public SqlCommand(string cmdText) => CommandText = cmdText; + + public SqlCommand(string cmdText, SqlConnection connection) : this(cmdText) => Connection = connection; + + public SqlCommand(string cmdText, SqlConnection connection, SqlTransaction transaction) : this(cmdText, connection) => Transaction = transaction; + + [Diagnostics.CodeAnalysis.AllowNull] + public override string CommandText + { + get => _commandText; + set => _commandText = value ?? ""; + } + + public override int CommandTimeout { get; set; } = 30; + + public override CommandType CommandType + { + get => _commandType; + set => _commandType = value; + } + + public new SqlConnection? Connection + { + get => _connection; + set => _connection = value; + } + + protected override DbConnection? DbConnection + { + get => _connection; + set => _connection = (SqlConnection?)value; + } + + public new SqlParameterCollection Parameters { get; } = new(); + protected override DbParameterCollection DbParameterCollection => Parameters; + + public new SqlTransaction? Transaction { get; set; } + + protected override DbTransaction? DbTransaction + { + get => Transaction; + set => Transaction = (SqlTransaction?)value; + } + + public override bool DesignTimeVisible { get; set; } = true; + public override UpdateRowSource UpdatedRowSource { get; set; } = UpdateRowSource.Both; + + public override int ExecuteNonQuery() => Run(nameof(ExecuteNonQuery)).RecordsAffected; + + public override object? ExecuteScalar() + { + var result = Run(nameof(ExecuteScalar)); + return result.Results.Count > 0 && result.Results[0].Rows.Count > 0 ? result.Results[0].Rows[0][0] : null; + } + + public new SqlDataReader ExecuteReader() => ExecuteReader(CommandBehavior.Default); + + public new SqlDataReader ExecuteReader(CommandBehavior behavior) => + new(Run(nameof(ExecuteReader)), behavior.HasFlag(CommandBehavior.CloseConnection) ? _connection : null); + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) => ExecuteReader(behavior); + + public override void Cancel() { } + public override void Prepare() { } + + public new SqlParameter CreateParameter() => new(); + protected override DbParameter CreateDbParameter() => CreateParameter(); + + public SqlCommand Clone() + { + var clone = new SqlCommand(_commandText, _connection!) { CommandType = _commandType, CommandTimeout = CommandTimeout, Transaction = Transaction }; + foreach (SqlParameter parameter in Parameters) clone.Parameters.Add(parameter.Clone()); + return clone; + } + + object ICloneable.Clone() => Clone(); + + private BatchResult Run(string operation) + { + if (_connection == null) throw new InvalidOperationException($"{operation}: Connection 属性尚未初始化。"); + var session = _connection.Session(operation); + if (string.IsNullOrWhiteSpace(_commandText)) throw new InvalidOperationException($"{operation}: CommandText 属性尚未初始化"); + if (_commandType == CommandType.StoredProcedure) throw SqlException.NotSupported("存储过程"); + try + { + return session.Execute(_commandText, name => + { + var parameter = Parameters.Find(name); + return parameter == null ? (false, null) : (true, parameter.Value); + }); + } + catch (SqlException ex) + { + throw ex.Rethrow(); + } + } +} + +public sealed class SqlParameter : DbParameter, ICloneable +{ + private SqlDbType _sqlDbType = SqlDbType.NVarChar; + private bool _typeSet; + private string _parameterName = ""; + private string _sourceColumn = ""; + + public SqlParameter() { } + + public SqlParameter(string parameterName, object? value) + { + ParameterName = parameterName; + Value = value; + } + + public SqlParameter(string parameterName, SqlDbType dbType) + { + ParameterName = parameterName; + SqlDbType = dbType; + } + + public SqlParameter(string parameterName, SqlDbType dbType, int size) : this(parameterName, dbType) => Size = size; + + public SqlParameter(string parameterName, SqlDbType dbType, int size, string sourceColumn) : this(parameterName, dbType, size) => SourceColumn = sourceColumn; + + [Diagnostics.CodeAnalysis.AllowNull] + public override string ParameterName + { + get => _parameterName; + set => _parameterName = value ?? ""; + } + + public override object? Value { get; set; } + + public SqlDbType SqlDbType + { + get => _sqlDbType; + set + { + _sqlDbType = value; + _typeSet = true; + } + } + + public override DbType DbType + { + get => _sqlDbType switch + { + SqlDbType.Int => DbType.Int32, + SqlDbType.BigInt => DbType.Int64, + SqlDbType.SmallInt => DbType.Int16, + SqlDbType.TinyInt => DbType.Byte, + SqlDbType.Bit => DbType.Boolean, + SqlDbType.Float => DbType.Double, + SqlDbType.Real => DbType.Single, + SqlDbType.Decimal or SqlDbType.Money => DbType.Decimal, + SqlDbType.DateTime or SqlDbType.SmallDateTime or SqlDbType.Date => DbType.DateTime, + SqlDbType.VarBinary or SqlDbType.Image or SqlDbType.Binary => DbType.Binary, + SqlDbType.UniqueIdentifier => DbType.Guid, + SqlDbType.VarChar or SqlDbType.Char or SqlDbType.Text => DbType.AnsiString, + _ => DbType.String, + }; + set => SqlDbType = value switch + { + DbType.Int32 => SqlDbType.Int, + DbType.Int64 => SqlDbType.BigInt, + DbType.Int16 => SqlDbType.SmallInt, + DbType.Byte => SqlDbType.TinyInt, + DbType.Boolean => SqlDbType.Bit, + DbType.Double => SqlDbType.Float, + DbType.Single => SqlDbType.Real, + DbType.Decimal or DbType.Currency => SqlDbType.Decimal, + DbType.DateTime or DbType.Date => SqlDbType.DateTime, + DbType.Binary => SqlDbType.VarBinary, + DbType.Guid => SqlDbType.UniqueIdentifier, + DbType.AnsiString => SqlDbType.VarChar, + _ => SqlDbType.NVarChar, + }; + } + + public override ParameterDirection Direction { get; set; } = ParameterDirection.Input; + public override bool IsNullable { get; set; } + public override int Size { get; set; } + public new byte Precision { get; set; } + public new byte Scale { get; set; } + + [Diagnostics.CodeAnalysis.AllowNull] + public override string SourceColumn + { + get => _sourceColumn; + set => _sourceColumn = value ?? ""; + } + + public override bool SourceColumnNullMapping { get; set; } + public override DataRowVersion SourceVersion { get; set; } = DataRowVersion.Current; + + public override void ResetDbType() + { + _sqlDbType = SqlDbType.NVarChar; + _typeSet = false; + } + + internal bool TypeSet => _typeSet; + + public SqlParameter Clone() => new(_parameterName, Value) + { + _sqlDbType = _sqlDbType, + _typeSet = _typeSet, + Direction = Direction, + Size = Size, + SourceColumn = SourceColumn, + SourceVersion = SourceVersion, + SourceColumnNullMapping = SourceColumnNullMapping, + IsNullable = IsNullable, + }; + + object ICloneable.Clone() => Clone(); + + public override string ToString() => _parameterName; +} + +public sealed class SqlParameterCollection : DbParameterCollection +{ + private readonly List _items = new(); + + public override int Count => _items.Count; + public override object SyncRoot => this; + + public new SqlParameter this[int index] + { + get => _items[index]; + set => _items[index] = value; + } + + public new SqlParameter this[string parameterName] + { + get => Find(parameterName) ?? throw new IndexOutOfRangeException($"此 SqlParameterCollection 中未包含带有 ParameterName“{parameterName}”的 SqlParameter。"); + set => _items[IndexOf(parameterName)] = value; + } + + /// 按名字找参数,"@id" 和 "id" 视为同一个(和 SqlClient 一样不区分大小写) + internal SqlParameter? Find(string name) + { + var bare = name.TrimStart('@'); + return _items.FirstOrDefault(p => string.Equals(p.ParameterName.TrimStart('@'), bare, StringComparison.OrdinalIgnoreCase)); + } + + public SqlParameter Add(SqlParameter value) + { + _items.Add(value ?? throw new ArgumentNullException(nameof(value))); + return value; + } + + public override int Add(object value) + { + Add(value as SqlParameter ?? throw new InvalidCastException("SqlParameterCollection 只接受非空的 SqlParameter 类型对象。")); + return _items.Count - 1; + } + + public SqlParameter Add(string parameterName, SqlDbType sqlDbType) => Add(new SqlParameter(parameterName, sqlDbType)); + public SqlParameter Add(string parameterName, SqlDbType sqlDbType, int size) => Add(new SqlParameter(parameterName, sqlDbType, size)); + public SqlParameter Add(string parameterName, SqlDbType sqlDbType, int size, string sourceColumn) => Add(new SqlParameter(parameterName, sqlDbType, size, sourceColumn)); + public SqlParameter AddWithValue(string parameterName, object? value) => Add(new SqlParameter(parameterName, value)); + + public override void AddRange(Array values) + { + foreach (var value in values) Add(value); + } + + public void AddRange(SqlParameter[] values) => AddRange((Array)values); + + public override void Clear() => _items.Clear(); + public override bool Contains(object value) => value is SqlParameter p && _items.Contains(p); + public override bool Contains(string value) => Find(value) != null; + public override void CopyTo(Array array, int index) => ((ICollection)_items).CopyTo(array, index); + public override IEnumerator GetEnumerator() => _items.GetEnumerator(); + public override int IndexOf(object value) => value is SqlParameter p ? _items.IndexOf(p) : -1; + public override int IndexOf(string parameterName) => Find(parameterName) is { } p ? _items.IndexOf(p) : -1; + public override void Insert(int index, object value) => _items.Insert(index, (SqlParameter)value); + public override void Remove(object value) => _items.Remove((SqlParameter)value); + public override void RemoveAt(int index) => _items.RemoveAt(index); + public override void RemoveAt(string parameterName) => _items.RemoveAt(IndexOf(parameterName)); + protected override DbParameter GetParameter(int index) => _items[index]; + protected override DbParameter GetParameter(string parameterName) => this[parameterName]; + protected override void SetParameter(int index, DbParameter value) => _items[index] = (SqlParameter)value; + protected override void SetParameter(string parameterName, DbParameter value) => this[parameterName] = (SqlParameter)value; +} diff --git a/runtime/Sim.Data/SqlClient/SqlConnection.cs b/runtime/Sim.Data/SqlClient/SqlConnection.cs new file mode 100644 index 0000000..5400e2a --- /dev/null +++ b/runtime/Sim.Data/SqlClient/SqlConnection.cs @@ -0,0 +1,169 @@ +using System.Collections.Generic; +using System.Data.Common; +using Sim.Data.Engine; + +namespace System.Data.SqlClient; + +/// +/// 数据库连接。连接字符串里的服务器名、账号、密码都不检查(书上的长服务器名、"." 都能连上), +/// 只按 Initial Catalog(或 Database)打开这个学生自己的同名数据库;不写时连到 master。 +/// +public sealed class SqlConnection : DbConnection, ICloneable +{ + private static readonly HashSet KnownKeywords = new(StringComparer.OrdinalIgnoreCase) + { + "data source", "server", "address", "addr", "network address", "initial catalog", "database", "user id", "uid", "user", + "password", "pwd", "integrated security", "trusted_connection", "persist security info", "persistsecurityinfo", + "connect timeout", "connection timeout", "timeout", "pooling", "max pool size", "min pool size", "multipleactiveresultsets", + "encrypt", "trustservercertificate", "application name", "app", "workstation id", "wsid", "packet size", + "asynchronous processing", "async", "user instance", "attachdbfilename", "connection lifetime", "load balance timeout", "current language", "language", + }; + + private string _connectionString = ""; + private ConnectionState _state = ConnectionState.Closed; + private Session? _session; + + public SqlConnection() { } + + public SqlConnection(string connectionString) => ConnectionString = connectionString; + + [Diagnostics.CodeAnalysis.AllowNull] + public override string ConnectionString + { + get => _connectionString; + set + { + if (_state != ConnectionState.Closed) throw new InvalidOperationException("不允许更改“ConnectionString”属性。连接的当前状态为打开。"); + _connectionString = value ?? ""; + } + } + + public override string Database => _session?.Database ?? Setting("initial catalog", "database") ?? DatabaseStore.Master; + public override string DataSource => Setting("data source", "server", "address", "addr", "network address") ?? ""; + public override string ServerVersion => "10.50.1600"; + public override ConnectionState State => _state; + public override int ConnectionTimeout => 15; + public string WorkstationId => Environment.MachineName; + + /// 连接打开时的数据库会话(平台内部使用) + internal Session Session(string operation) => + _state == ConnectionState.Open && _session != null + ? _session + : throw new InvalidOperationException($"{operation} 要求已打开且可用的连接。连接的当前状态为已关闭。"); + + public override void Open() + { + if (_state == ConnectionState.Open) throw new InvalidOperationException("连接未关闭。连接的当前状态为打开。"); + CheckKeywords(); + var store = DatabaseStore.FromEnvironment() + ?? throw new SqlException(-1, "在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误:这次运行没有准备数据库。"); + try + { + _session = new Session(store, Database); + } + catch (SqlException ex) + { + throw ex.Rethrow(); + } + SetState(ConnectionState.Open); + } + + public override void Close() + { + if (_session == null) return; + _session.Dispose(); + _session = null; + SetState(ConnectionState.Closed); + } + + public override void ChangeDatabase(string databaseName) => Session("ChangeDatabase").ChangeDatabase(databaseName); + + public new SqlCommand CreateCommand() => new() { Connection = this }; + + protected override DbCommand CreateDbCommand() => CreateCommand(); + + public new SqlTransaction BeginTransaction() => BeginTransaction(IsolationLevel.ReadCommitted); + + public new SqlTransaction BeginTransaction(IsolationLevel iso) + { + Session("BeginTransaction").BeginTransaction(); + return new SqlTransaction(this, iso); + } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => BeginTransaction(isolationLevel); + + public object Clone() => new SqlConnection(_connectionString); + + public static void ClearAllPools() { } + public static void ClearPool(SqlConnection connection) { } + + protected override void Dispose(bool disposing) + { + if (disposing) Close(); + base.Dispose(disposing); + } + + private void SetState(ConnectionState state) + { + var original = _state; + _state = state; + OnStateChange(new StateChangeEventArgs(original, state)); + } + + private string? Setting(params string[] keys) + { + var builder = Parse(); + foreach (var key in keys) + if (builder.TryGetValue(key, out var value) && value?.ToString() is { Length: > 0 } text) return text.Trim(); + return null; + } + + private DbConnectionStringBuilder Parse() + { + try + { + return new DbConnectionStringBuilder { ConnectionString = _connectionString }; + } + catch (ArgumentException) + { + throw new ArgumentException("初始化字符串的格式不符合从索引 0 处开始的规范。"); + } + } + + private void CheckKeywords() + { + foreach (string key in Parse().Keys) + if (!KnownKeywords.Contains(key)) throw new ArgumentException($"不支持关键字:“{key}”。"); + if (Setting("attachdbfilename") != null) throw new SqlException(-1, "平台不支持 AttachDbFilename:请用 Initial Catalog 指定数据库名。"); + } +} + +public sealed class SqlTransaction : DbTransaction +{ + private readonly SqlConnection _connection; + private bool _done; + + internal SqlTransaction(SqlConnection connection, IsolationLevel isolationLevel) + { + _connection = connection; + IsolationLevel = isolationLevel; + } + + public new SqlConnection Connection => _connection; + protected override DbConnection DbConnection => _connection; + public override IsolationLevel IsolationLevel { get; } + + public override void Commit() + { + if (_done) throw new InvalidOperationException("此 SqlTransaction 已完成;它再也无法使用。"); + _done = true; + _connection.Session("Commit").Commit(); + } + + public override void Rollback() + { + if (_done) throw new InvalidOperationException("此 SqlTransaction 已完成;它再也无法使用。"); + _done = true; + _connection.Session("Rollback").Rollback(); + } +} diff --git a/runtime/Sim.Data/SqlClient/SqlDataAdapter.cs b/runtime/Sim.Data/SqlClient/SqlDataAdapter.cs new file mode 100644 index 0000000..6b751ca --- /dev/null +++ b/runtime/Sim.Data/SqlClient/SqlDataAdapter.cs @@ -0,0 +1,120 @@ +using System.Data.Common; + +namespace System.Data.SqlClient; + +public delegate void SqlRowUpdatingEventHandler(object sender, SqlRowUpdatingEventArgs e); +public delegate void SqlRowUpdatedEventHandler(object sender, SqlRowUpdatedEventArgs e); + +public sealed class SqlRowUpdatingEventArgs : RowUpdatingEventArgs +{ + public SqlRowUpdatingEventArgs(DataRow row, IDbCommand? command, StatementType statementType, DataTableMapping tableMapping) + : base(row, command, statementType, tableMapping) { } + + public new SqlCommand? Command + { + get => (SqlCommand?)base.Command; + set => base.Command = value; + } +} + +public sealed class SqlRowUpdatedEventArgs : RowUpdatedEventArgs +{ + public SqlRowUpdatedEventArgs(DataRow row, IDbCommand? command, StatementType statementType, DataTableMapping tableMapping) + : base(row, command, statementType, tableMapping) { } + + public new SqlCommand? Command => (SqlCommand?)base.Command; +} + +/// 在数据库和 DataSet 之间搬运数据:Fill 读出来,Update 把 DataTable 里改过的行写回去 +public sealed class SqlDataAdapter : DbDataAdapter, ICloneable +{ + public SqlDataAdapter() { } + + public SqlDataAdapter(SqlCommand selectCommand) => SelectCommand = selectCommand; + + public SqlDataAdapter(string selectCommandText, SqlConnection selectConnection) => SelectCommand = new SqlCommand(selectCommandText, selectConnection); + + public SqlDataAdapter(string selectCommandText, string selectConnectionString) => + SelectCommand = new SqlCommand(selectCommandText, new SqlConnection(selectConnectionString)); + + public new SqlCommand? SelectCommand + { + get => (SqlCommand?)base.SelectCommand; + set => base.SelectCommand = value; + } + + public new SqlCommand? InsertCommand + { + get => (SqlCommand?)base.InsertCommand; + set => base.InsertCommand = value; + } + + public new SqlCommand? UpdateCommand + { + get => (SqlCommand?)base.UpdateCommand; + set => base.UpdateCommand = value; + } + + public new SqlCommand? DeleteCommand + { + get => (SqlCommand?)base.DeleteCommand; + set => base.DeleteCommand = value; + } + + public event SqlRowUpdatingEventHandler? RowUpdating; + public event SqlRowUpdatedEventHandler? RowUpdated; + + protected override RowUpdatingEventArgs CreateRowUpdatingEvent(DataRow dataRow, IDbCommand? command, StatementType statementType, DataTableMapping tableMapping) => + new SqlRowUpdatingEventArgs(dataRow, command, statementType, tableMapping); + + protected override RowUpdatedEventArgs CreateRowUpdatedEvent(DataRow dataRow, IDbCommand? command, StatementType statementType, DataTableMapping tableMapping) => + new SqlRowUpdatedEventArgs(dataRow, command, statementType, tableMapping); + + protected override void OnRowUpdating(RowUpdatingEventArgs value) => RowUpdating?.Invoke(this, (SqlRowUpdatingEventArgs)value); + + protected override void OnRowUpdated(RowUpdatedEventArgs value) => RowUpdated?.Invoke(this, (SqlRowUpdatedEventArgs)value); + + object ICloneable.Clone() => new SqlDataAdapter { SelectCommand = SelectCommand, InsertCommand = InsertCommand, UpdateCommand = UpdateCommand, DeleteCommand = DeleteCommand }; +} + +/// 按 SqlDataAdapter 的查询语句自动生成 INSERT / UPDATE / DELETE(要求查询包含主键列) +public sealed class SqlCommandBuilder : DbCommandBuilder +{ + public SqlCommandBuilder() + { + QuotePrefix = "["; + QuoteSuffix = "]"; + } + + public SqlCommandBuilder(SqlDataAdapter adapter) : this() => DataAdapter = adapter; + + public new SqlDataAdapter? DataAdapter + { + get => (SqlDataAdapter?)base.DataAdapter; + set => base.DataAdapter = value; + } + + public new SqlCommand GetInsertCommand() => (SqlCommand)base.GetInsertCommand(); + public new SqlCommand GetInsertCommand(bool useColumnsForParameterNames) => (SqlCommand)base.GetInsertCommand(useColumnsForParameterNames); + public new SqlCommand GetUpdateCommand() => (SqlCommand)base.GetUpdateCommand(); + public new SqlCommand GetUpdateCommand(bool useColumnsForParameterNames) => (SqlCommand)base.GetUpdateCommand(useColumnsForParameterNames); + public new SqlCommand GetDeleteCommand() => (SqlCommand)base.GetDeleteCommand(); + public new SqlCommand GetDeleteCommand(bool useColumnsForParameterNames) => (SqlCommand)base.GetDeleteCommand(useColumnsForParameterNames); + + protected override void ApplyParameterInfo(DbParameter parameter, DataRow row, StatementType statementType, bool whereClause) { } + + protected override string GetParameterName(int parameterOrdinal) => "@p" + parameterOrdinal.ToString(Globalization.CultureInfo.InvariantCulture); + protected override string GetParameterName(string parameterName) => "@" + parameterName; + protected override string GetParameterPlaceholder(int parameterOrdinal) => GetParameterName(parameterOrdinal); + + public override string QuoteIdentifier(string unquotedIdentifier) => "[" + unquotedIdentifier.Replace("]", "]]") + "]"; + + protected override void SetRowUpdatingHandler(DbDataAdapter adapter) + { + if (adapter is not SqlDataAdapter sql) throw new InvalidOperationException("SqlCommandBuilder 只能用于 SqlDataAdapter。"); + if (adapter == base.DataAdapter) sql.RowUpdating += Handler; + else sql.RowUpdating -= Handler; + } + + private void Handler(object sender, SqlRowUpdatingEventArgs e) => RowUpdatingHandler(e); +} diff --git a/runtime/Sim.Data/SqlClient/SqlDataReader.cs b/runtime/Sim.Data/SqlClient/SqlDataReader.cs new file mode 100644 index 0000000..dd01968 --- /dev/null +++ b/runtime/Sim.Data/SqlClient/SqlDataReader.cs @@ -0,0 +1,211 @@ +using System.Collections; +using System.Data.Common; +using System.Globalization; +using Sim.Data.Engine; + +namespace System.Data.SqlClient; + +/// 按行读取查询结果(结果已在内存里,读的时候不再访问数据库) +public sealed class SqlDataReader : DbDataReader +{ + private readonly BatchResult _batch; + private readonly SqlConnection? _closeConnection; + private int _resultIndex; + private int _row = -1; + private bool _closed; + + internal SqlDataReader(BatchResult batch, SqlConnection? closeConnection) + { + _batch = batch; + _closeConnection = closeConnection; + } + + private QueryResult? Current => _resultIndex < _batch.Results.Count ? _batch.Results[_resultIndex] : null; + + private object[] Row + { + get + { + if (_closed) throw new InvalidOperationException("阅读器关闭时尝试调用 Read 无效。"); + var current = Current; + if (current == null || _row < 0 || _row >= current.Rows.Count) throw new InvalidOperationException("不存在数据时进行无效的读取尝试。"); + return current.Rows[_row]; + } + } + + public override int FieldCount => Current?.Columns.Count ?? 0; + public override bool HasRows => Current?.Rows.Count > 0; + public override bool IsClosed => _closed; + public override int RecordsAffected => _batch.RecordsAffected; + public override int Depth => 0; + + public override object this[int ordinal] => GetValue(ordinal); + public override object this[string name] => GetValue(GetOrdinal(name)); + + public override bool Read() + { + if (_closed) throw new InvalidOperationException("阅读器关闭时尝试调用 Read 无效。"); + var current = Current; + if (current == null) return false; + _row++; + return _row < current.Rows.Count; + } + + public override bool NextResult() + { + _resultIndex++; + _row = -1; + return Current != null; + } + + public override void Close() + { + if (_closed) return; + _closed = true; + _closeConnection?.Close(); + } + + public override string GetName(int ordinal) => Column(ordinal).Name; + + public override int GetOrdinal(string name) + { + var columns = Current?.Columns ?? throw new IndexOutOfRangeException(name); + for (var i = 0; i < columns.Count; i++) + if (string.Equals(columns[i].Name, name, StringComparison.OrdinalIgnoreCase)) return i; + throw new IndexOutOfRangeException(name); + } + + public override Type GetFieldType(int ordinal) => Column(ordinal).ClrType; + public override string GetDataTypeName(int ordinal) => Column(ordinal).Type?.Name ?? "sql_variant"; + + public override object GetValue(int ordinal) + { + Column(ordinal); + return Row[ordinal]; + } + + public override int GetValues(object[] values) + { + var row = Row; + var count = Math.Min(values.Length, row.Length); + Array.Copy(row, values, count); + return count; + } + + public override bool IsDBNull(int ordinal) => GetValue(ordinal) is DBNull; + + public override bool GetBoolean(int ordinal) => Cast(ordinal); + public override byte GetByte(int ordinal) => Cast(ordinal); + public override char GetChar(int ordinal) => throw new NotSupportedException("SqlDataReader 不支持 GetChar。"); + public override DateTime GetDateTime(int ordinal) => Cast(ordinal); + public override decimal GetDecimal(int ordinal) => Cast(ordinal); + public override double GetDouble(int ordinal) => Cast(ordinal); + public override float GetFloat(int ordinal) => Cast(ordinal); + public override Guid GetGuid(int ordinal) => Cast(ordinal); + public override short GetInt16(int ordinal) => Cast(ordinal); + public override int GetInt32(int ordinal) => Cast(ordinal); + public override long GetInt64(int ordinal) => Cast(ordinal); + public override string GetString(int ordinal) => Cast(ordinal); + + /// 和 SqlClient 一样严格:列是 varchar 时 GetInt32 报"指定的转换无效",是 NULL 时报"数据为空" + private T Cast(int ordinal) + { + var value = GetValue(ordinal); + if (value is DBNull) throw new InvalidCastException("数据为空。不能对空值调用此方法或属性。"); + if (value is T typed) return typed; + // 表达式列(如 count(*))没有声明类型,数字之间可以互相转换 + if (Column(ordinal).Type == null && value is IConvertible && typeof(T) != typeof(string)) + return (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture); + throw new InvalidCastException("指定的转换无效。"); + } + + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) + { + var bytes = Cast(ordinal); + if (buffer == null) return bytes.Length; + var count = (int)Math.Min(length, bytes.Length - dataOffset); + if (count <= 0) return 0; + Array.Copy(bytes, dataOffset, buffer, bufferOffset, count); + return count; + } + + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) + { + var text = Cast(ordinal); + if (buffer == null) return text.Length; + var count = (int)Math.Min(length, text.Length - dataOffset); + if (count <= 0) return 0; + text.CopyTo((int)dataOffset, buffer, bufferOffset, count); + return count; + } + + public override IEnumerator GetEnumerator() => new DbEnumerator(this, closeReader: false); + + /// 列的结构信息:DbDataAdapter 填充、SqlCommandBuilder 生成增删改语句时要用(主键、自增、来自哪张表的哪一列) + public override DataTable GetSchemaTable() + { + var table = new DataTable("SchemaTable"); + table.Columns.Add(SchemaTableColumn.ColumnName, typeof(string)); + table.Columns.Add(SchemaTableColumn.ColumnOrdinal, typeof(int)); + table.Columns.Add(SchemaTableColumn.ColumnSize, typeof(int)); + table.Columns.Add(SchemaTableColumn.NumericPrecision, typeof(short)); + table.Columns.Add(SchemaTableColumn.NumericScale, typeof(short)); + table.Columns.Add(SchemaTableColumn.IsUnique, typeof(bool)); + table.Columns.Add(SchemaTableColumn.IsKey, typeof(bool)); + table.Columns.Add(SchemaTableOptionalColumn.BaseServerName, typeof(string)); + table.Columns.Add(SchemaTableOptionalColumn.BaseCatalogName, typeof(string)); + table.Columns.Add(SchemaTableColumn.BaseColumnName, typeof(string)); + table.Columns.Add(SchemaTableColumn.BaseSchemaName, typeof(string)); + table.Columns.Add(SchemaTableColumn.BaseTableName, typeof(string)); + table.Columns.Add(SchemaTableColumn.DataType, typeof(Type)); + table.Columns.Add(SchemaTableColumn.AllowDBNull, typeof(bool)); + table.Columns.Add(SchemaTableColumn.ProviderType, typeof(int)); + table.Columns.Add(SchemaTableColumn.IsAliased, typeof(bool)); + table.Columns.Add(SchemaTableColumn.IsExpression, typeof(bool)); + table.Columns.Add("IsIdentity", typeof(bool)); + table.Columns.Add(SchemaTableOptionalColumn.IsAutoIncrement, typeof(bool)); + table.Columns.Add(SchemaTableOptionalColumn.IsRowVersion, typeof(bool)); + table.Columns.Add(SchemaTableOptionalColumn.IsHidden, typeof(bool)); + table.Columns.Add(SchemaTableColumn.IsLong, typeof(bool)); + table.Columns.Add(SchemaTableOptionalColumn.IsReadOnly, typeof(bool)); + table.Columns.Add("DataTypeName", typeof(string)); + + var columns = Current?.Columns ?? Array.Empty(); + for (var i = 0; i < columns.Count; i++) + { + var c = columns[i]; + var row = table.NewRow(); + row[SchemaTableColumn.ColumnName] = c.Name; + row[SchemaTableColumn.ColumnOrdinal] = i; + row[SchemaTableColumn.ColumnSize] = c.Type?.MaxLength ?? (c.Type?.IsMax == true ? int.MaxValue : 0); + row[SchemaTableColumn.NumericPrecision] = (short)(c.Type?.Precision ?? 0); + row[SchemaTableColumn.NumericScale] = (short)(c.Type?.Scale ?? 0); + row[SchemaTableColumn.IsUnique] = c.IsKey; + row[SchemaTableColumn.IsKey] = c.IsKey; + row[SchemaTableColumn.BaseColumnName] = (object?)c.BaseColumn ?? DBNull.Value; + row[SchemaTableColumn.BaseSchemaName] = c.BaseTable != null ? "dbo" : DBNull.Value; + row[SchemaTableColumn.BaseTableName] = (object?)c.BaseTable ?? DBNull.Value; + row[SchemaTableColumn.DataType] = c.ClrType; + row[SchemaTableColumn.AllowDBNull] = c.AllowNull; + row[SchemaTableColumn.ProviderType] = 0; + row[SchemaTableColumn.IsAliased] = c.BaseColumn != null && !string.Equals(c.BaseColumn, c.Name, StringComparison.OrdinalIgnoreCase); + row[SchemaTableColumn.IsExpression] = c.BaseColumn == null; + row["IsIdentity"] = c.IsIdentity; + row[SchemaTableOptionalColumn.IsAutoIncrement] = c.IsIdentity; + row[SchemaTableOptionalColumn.IsRowVersion] = false; + row[SchemaTableOptionalColumn.IsHidden] = false; + row[SchemaTableColumn.IsLong] = c.Type is { IsMax: true } or { Name: "text" or "ntext" or "image" }; + row[SchemaTableOptionalColumn.IsReadOnly] = c.IsIdentity || c.BaseColumn == null; + row["DataTypeName"] = c.Type?.Name ?? "sql_variant"; + table.Rows.Add(row); + } + return table; + } + + private ResultColumn Column(int ordinal) + { + var columns = Current?.Columns; + if (columns == null || ordinal < 0 || ordinal >= columns.Count) throw new IndexOutOfRangeException("索引超出了数组界限。"); + return columns[ordinal]; + } +} diff --git a/runtime/Sim.Data/SqlClient/SqlException.cs b/runtime/Sim.Data/SqlClient/SqlException.cs new file mode 100644 index 0000000..398cdbc --- /dev/null +++ b/runtime/Sim.Data/SqlClient/SqlException.cs @@ -0,0 +1,60 @@ +using System.Data.Common; + +namespace System.Data.SqlClient; + +/// +/// 数据库报错。Number 和 Message 与中文版 SQL Server 一致(如 208"对象名 'X' 无效。"), +/// 学生在网上搜报错信息能找到对应的说明。 +/// +public sealed class SqlException : DbException +{ + internal SqlException(int number, string message, int lineNumber = 1) : base(message, unchecked((int)0x80131904)) + { + Number = number; + LineNumber = lineNumber; + } + + public int Number { get; } + public int LineNumber { get; } + public byte Class => 16; + public byte State => 1; + public string Server => "."; + public string Procedure => ""; + + /// 在 SqlClient 的公开方法里重新抛出:学生看到的调用栈从 SqlCommand.ExecuteNonQuery 等开始,不含平台内部的实现 + internal SqlException Rethrow() => new(Number, Message, LineNumber); + + public override string ToString() => $"{GetType().FullName} (0x80131904): {Message}{(StackTrace != null ? Environment.NewLine + StackTrace : "")}"; + + // ---------- 常见报错 ---------- + + internal static SqlException Syntax(string near, int line = 1) => + new(102, IsKeyword(near) ? $"关键字 '{near}' 附近有语法错误。" : $"'{near}' 附近有语法错误。", line); + + internal static SqlException InvalidObject(string name) => new(208, $"对象名 '{name}' 无效。"); + internal static SqlException InvalidColumn(string name) => new(207, $"列名 '{name}' 无效。"); + internal static SqlException AmbiguousColumn(string name) => new(209, $"列名 '{name}' 不明确。"); + internal static SqlException ColumnCountMismatch() => new(213, "列名或所提供值的数目与表定义不匹配。"); + internal static SqlException IdentityInsert(string table) => new(544, $"当 IDENTITY_INSERT 设置为 OFF 时,不能为表 '{table}' 中的标识列插入显式值。"); + internal static SqlException NullNotAllowed(string column, string table) => new(515, $"不能将值 NULL 插入列 '{column}',表 '{table}';列不允许有 Null 值。INSERT 失败。"); + internal static SqlException DuplicateKey(string table) => new(2627, $"违反了 PRIMARY KEY 约束。不能在对象 'dbo.{table}' 中插入重复键。"); + internal static SqlException Truncated() => new(8152, "将截断字符串或二进制数据。"); + internal static SqlException ConvertFailed(string value, string type) => new(245, $"在将 varchar 值 '{value}' 转换成数据类型 {type} 时失败。"); + internal static SqlException DateConvertFailed() => new(241, "从字符串转换日期和/或时间时,转换失败。"); + internal static SqlException ObjectExists(string name) => new(2714, $"数据库中已存在名为 '{name}' 的对象。"); + internal static SqlException DatabaseExists(string name) => new(1801, $"数据库 '{name}' 已存在。请选择其他数据库名称。"); + internal static SqlException CannotOpenDatabase(string name) => new(4060, $"无法打开登录所请求的数据库 \"{name}\"。登录失败。"); + internal static SqlException DatabaseNotFound(string name) => new(911, $"数据库 '{name}' 不存在。请确保正确地输入了该名称。"); + internal static SqlException DatabaseInUse(string name) => new(3702, $"无法删除数据库 \"{name}\",因为该数据库当前正在使用。"); + internal static SqlException CannotDropTable(string name) => new(3701, $"无法对 表 '{name}' 执行 删除,因为它不存在,或者您没有所需的权限。"); + internal static SqlException UnknownFunction(string name) => new(195, $"'{name}' 不是可以识别的 内置函数名称。"); + internal static SqlException UndeclaredVariable(string name) => new(137, $"必须声明标量变量 \"{name}\"。"); + internal static SqlException DivideByZero() => new(8134, "遇到以零作除数错误。"); + internal static SqlException NotSupported(string what) => new(40000, $"平台暂不支持 {what}。"); + + private static bool IsKeyword(string word) => word.ToUpperInvariant() is + "SELECT" or "FROM" or "WHERE" or "INSERT" or "INTO" or "VALUES" or "UPDATE" or "SET" or "DELETE" or "CREATE" or "TABLE" + or "DROP" or "ALTER" or "AND" or "OR" or "NOT" or "NULL" or "ORDER" or "GROUP" or "BY" or "HAVING" or "AS" or "ON" + or "JOIN" or "INNER" or "LEFT" or "RIGHT" or "OUTER" or "IS" or "IN" or "LIKE" or "BETWEEN" or "TOP" or "DISTINCT" + or "PRIMARY" or "KEY" or "IDENTITY" or "DEFAULT" or "DATABASE" or "USE" or "CASE" or "WHEN" or "THEN" or "ELSE" or "END"; +} diff --git a/runtime/Sim.Runner/ApiPolicy.cs b/runtime/Sim.Runner/ApiPolicy.cs index fcdff20..9381b0a 100644 --- a/runtime/Sim.Runner/ApiPolicy.cs +++ b/runtime/Sim.Runner/ApiPolicy.cs @@ -41,11 +41,16 @@ internal static class ApiPolicy ["System.Runtime.InteropServices.NativeMemory"] = "调用本机代码", }; - /// 不允许的类型里单独放行的成员:教材用 AppDomain.CurrentDomain.BaseDirectory 拼出保存照片的路径 + /// + /// 不允许的类型里单独放行的成员:教材用 AppDomain.CurrentDomain.BaseDirectory 拼出保存照片的路径; + /// 类型的名字(GetType().Name、DataColumn.DataType.Name)只是读名字,没有风险 + /// private static readonly HashSet AllowedMembers = new() { "System.AppDomain.CurrentDomain", "System.AppDomain.BaseDirectory", + "System.Reflection.MemberInfo.Name", + "System.Type.FullName", }; public static IEnumerable Check(CSharpCompilation compilation) diff --git a/runtime/Sim.Runner/Program.cs b/runtime/Sim.Runner/Program.cs index bad4c9f..247022c 100644 --- a/runtime/Sim.Runner/Program.cs +++ b/runtime/Sim.Runner/Program.cs @@ -5,6 +5,7 @@ using Sim.Runner; // 三种模式,都通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条): // dotnet Sim.Runner.dll compile-server 常驻编译服务,全服务器共用一个,编译学生工程并把程序集写回工程目录 // dotnet Sim.Runner.dll language-server 常驻语言服务,全服务器共用一个:代码补全、悬停提示、参数提示、边写边检查 +// dotnet Sim.Runner.dll sql-server 常驻 SQL 服务:数据库页面的查询、对象资源管理器,评分前准备实验数据库 // dotnet Sim.Runner.dll run <工程目录> 运行已编译的学生程序,每次运行一个进程 // 运行模式不加载 Roslyn,单个学生程序的内存占用因此小得多。 @@ -12,12 +13,13 @@ return args switch { ["compile-server"] => CompileServer.Run(), ["language-server"] => LanguageServer.Run(), + ["sql-server"] => SqlService.Run(), ["run", var dir] when Directory.Exists(dir) => StudentProgramHost.Run(dir), _ => Usage(), }; static int Usage() { - Console.Error.WriteLine("用法:Sim.Runner compile-server | Sim.Runner language-server | Sim.Runner run <工程目录>"); + Console.Error.WriteLine("用法:Sim.Runner compile-server | Sim.Runner language-server | Sim.Runner sql-server | Sim.Runner run <工程目录>"); return 2; } diff --git a/runtime/Sim.Runner/Sim.Runner.csproj b/runtime/Sim.Runner/Sim.Runner.csproj index b34b7a3..5e3ebe3 100644 --- a/runtime/Sim.Runner/Sim.Runner.csproj +++ b/runtime/Sim.Runner/Sim.Runner.csproj @@ -17,5 +17,6 @@ + diff --git a/runtime/Sim.Runner/SqlService.cs b/runtime/Sim.Runner/SqlService.cs new file mode 100644 index 0000000..cc37a91 --- /dev/null +++ b/runtime/Sim.Runner/SqlService.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Sim.Data.Engine; + +namespace Sim.Runner; + +/// +/// 常驻 SQL 服务:数据库页面(代替 SSMS)的查询、对象资源管理器,以及评分前按实验预置脚本准备数据库。 +/// 请求里的目录由服务器给出(学生自己的数据库目录或评分用的临时目录),学生的 SQL 经转写后只能操作这个目录里的库。 +/// 每行一个请求 {"id":…,"type":"exec"|"script"|"explorer",…},回复带同样的 id;请求可以并发处理。 +/// +internal static class SqlService +{ + private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + private static readonly TimeSpan TimeLimit = TimeSpan.FromSeconds(5); + /// 查询窗口最多显示的行数 + private const int MaxRows = 1000; + + public static int Run() + { + // 日期等按中文 Windows 上 SSMS 的样子显示 + CultureInfo.DefaultThreadCurrentCulture = CultureInfo.GetCultureInfo("zh-CN"); + using var input = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false)); + var output = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = true }; + var writeLock = new object(); + using var slots = new SemaphoreSlim(4); + + output.WriteLine(new JsonObject { ["type"] = "ready" }.ToJsonString(JsonOptions)); + while (input.ReadLine() is { } line) + { + slots.Wait(); + Task.Run(() => + { + JsonObject reply; + JsonNode? id = null; + try + { + var request = JsonNode.Parse(line)!.AsObject(); + id = request["id"]?.DeepClone(); + reply = Handle(request); + } + catch (SqlException ex) + { + reply = Error(ex); + } + catch (Exception ex) + { + reply = new JsonObject { ["type"] = "error", ["number"] = 0, ["message"] = ex.Message, ["line"] = 1 }; + } + finally + { + slots.Release(); + } + reply["id"] = id; + lock (writeLock) output.WriteLine(reply.ToJsonString(JsonOptions)); + }); + } + return 0; + } + + private static JsonObject Handle(JsonObject request) + { + var dir = (string?)request["dir"] ?? throw new ArgumentException("缺少 dir"); + var store = new DatabaseStore(dir); + return (string?)request["type"] switch + { + "exec" => Exec(store, (string?)request["database"] ?? DatabaseStore.Master, (string?)request["sql"] ?? ""), + "script" => Script(store, (string?)request["sql"] ?? ""), + "explorer" => Explorer(store), + var type => throw new ArgumentException($"不认识的请求 {type}"), + }; + } + + /// 查询窗口执行一批语句:返回各个结果集(最多 1000 行)、受影响的行数,以及执行后的当前库(USE 可能切换了) + private static JsonObject Exec(DatabaseStore store, string database, string sql) + { + var started = Environment.TickCount64; + using var session = new Session(store, database, TimeLimit); + var batch = session.Execute(sql, name => (false, null)); + var results = new JsonArray(); + foreach (var result in batch.Results) + { + var columns = new JsonArray(); + foreach (var column in result.Columns) columns.Add(new JsonObject { ["name"] = column.Name, ["type"] = column.Type?.ToString() }); + var rows = new JsonArray(); + foreach (var row in result.Rows.Take(MaxRows)) + { + var cells = new JsonArray(); + for (var i = 0; i < row.Length; i++) cells.Add(Display(row[i], result.Columns[i].Type?.IsDate == true)); + rows.Add(cells); + } + results.Add(new JsonObject { ["columns"] = columns, ["rows"] = rows, ["total"] = result.Rows.Count }); + } + return new JsonObject + { + ["type"] = "result", + ["database"] = session.Database, + ["results"] = results, + ["affected"] = batch.RecordsAffected, + ["ms"] = Environment.TickCount64 - started, + }; + } + + /// 执行一个脚本(多批语句,用单独一行的 GO 分隔),从 master 开始,USE 对后面的批次有效 + private static JsonObject Script(DatabaseStore store, string sql) + { + var batches = Regex.Split(sql, @"^\s*GO\s*$", RegexOptions.Multiline | RegexOptions.IgnoreCase).Where(b => !string.IsNullOrWhiteSpace(b)); + using var session = new Session(store, DatabaseStore.Master, TimeSpan.FromSeconds(30)); + foreach (var batch in batches) session.Execute(batch, name => (false, null)); + return new JsonObject { ["type"] = "done" }; + } + + /// 对象资源管理器:所有库、库里的表、表的列 + private static JsonObject Explorer(DatabaseStore store) + { + var databases = new JsonArray(); + foreach (var name in store.List()) + { + var tables = new JsonArray(); + using (var connection = store.Open(name)) + { + foreach (var table in DatabaseStore.ListTables(connection)) + { + var info = DatabaseStore.GetTable(connection, table); + var columns = new JsonArray(); + foreach (var column in info?.Columns ?? Array.Empty()) + { + columns.Add(new JsonObject + { + ["name"] = column.Name, + ["type"] = column.Type.ToString(), + ["nullable"] = column.Nullable, + ["identity"] = column.Identity, + ["primaryKey"] = column.PrimaryKey, + }); + } + tables.Add(new JsonObject { ["name"] = table, ["columns"] = columns }); + } + } + databases.Add(new JsonObject { ["name"] = name, ["tables"] = tables }); + } + return new JsonObject { ["type"] = "explorer", ["databases"] = databases }; + } + + /// 结果网格里显示的样子,和 SSMS 一致:日期 2024-01-16 09:05:03.000,bit 显示 1 / 0,二进制显示 0x… + private static JsonNode? Display(object value, bool dateOnly) => value switch + { + DBNull => null, + DateTime d when dateOnly => d.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + DateTime d => d.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture), + bool b => b ? 1 : 0, + byte[] bytes => "0x" + Convert.ToHexString(bytes.AsSpan(0, Math.Min(bytes.Length, 32))) + (bytes.Length > 32 ? "…" : ""), + int i => i, + long l => l, + double x => x, + decimal m => m, + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString(), + }; + + private static JsonObject Error(SqlException ex) => + new() { ["type"] = "error", ["number"] = ex.Number, ["message"] = ex.Message, ["line"] = ex.LineNumber }; +} diff --git a/runtime/Sim.Runner/StudentCompiler.cs b/runtime/Sim.Runner/StudentCompiler.cs index 3fd6a87..eec0450 100644 --- a/runtime/Sim.Runner/StudentCompiler.cs +++ b/runtime/Sim.Runner/StudentCompiler.cs @@ -27,7 +27,7 @@ internal static class StudentCompiler "System.Drawing.dll", }; - private static readonly string[] SimAssemblies = { "Sim.WinForms.dll", "Sim.Devices.dll" }; + private static readonly string[] SimAssemblies = { "Sim.WinForms.dll", "Sim.Devices.dll", "Sim.Data.dll" }; public sealed record Result(bool Success, byte[] Assembly, byte[] Pdb, JsonArray Diagnostics); diff --git a/runtime/Sim.Runner/StudentProgramHost.cs b/runtime/Sim.Runner/StudentProgramHost.cs index 4f79682..17b85d1 100644 --- a/runtime/Sim.Runner/StudentProgramHost.cs +++ b/runtime/Sim.Runner/StudentProgramHost.cs @@ -27,8 +27,12 @@ internal static class StudentProgramHost Console.SetOut(console); Console.SetIn(TextReader.Null); // 标准输入用于协议,学生的 Console.ReadLine 读不到内容 - // 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出 - var zhCN = CultureInfo.GetCultureInfo("zh-CN"); + // 和中文 Windows 一致:DateTime.ToString() 等按 zh-CN 格式输出。 + // Linux 上的区域数据把时间写成 09:05:03,中文 Windows 是 9:05:03(书上的截图也是),这里改成 Windows 的写法 + var zhCN = (CultureInfo)CultureInfo.GetCultureInfo("zh-CN").Clone(); + zhCN.DateTimeFormat.LongTimePattern = "H:mm:ss"; + zhCN.DateTimeFormat.ShortTimePattern = "H:mm"; + zhCN = CultureInfo.ReadOnly(zhCN); CultureInfo.DefaultThreadCurrentCulture = zhCN; CultureInfo.DefaultThreadCurrentUICulture = zhCN; CultureInfo.CurrentCulture = zhCN; diff --git a/runtime/Sim.WinForms/Forms/DataGridView.cs b/runtime/Sim.WinForms/Forms/DataGridView.cs new file mode 100644 index 0000000..266704b --- /dev/null +++ b/runtime/Sim.WinForms/Forms/DataGridView.cs @@ -0,0 +1,1003 @@ +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Globalization; +using System.Linq; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +public enum DataGridViewColumnHeadersHeightSizeMode { EnableResizing, DisableResizing, AutoSize } +public enum DataGridViewRowHeadersWidthSizeMode { EnableResizing, DisableResizing, AutoSizeToAllHeaders, AutoSizeToDisplayedHeaders, AutoSizeToFirstHeader } +public enum DataGridViewSelectionMode { CellSelect, FullRowSelect, FullColumnSelect, RowHeaderSelect, ColumnHeaderSelect } +public enum DataGridViewAutoSizeColumnsMode { None = 1, ColumnHeader = 2, AllCellsExceptHeader = 4, AllCells = 6, DisplayedCellsExceptHeader = 8, DisplayedCells = 10, Fill = 16 } +public enum DataGridViewAutoSizeColumnMode { NotSet = 0, None = 1, ColumnHeader = 2, AllCellsExceptHeader = 4, AllCells = 6, DisplayedCellsExceptHeader = 8, DisplayedCells = 10, Fill = 16 } +public enum DataGridViewEditMode { EditOnEnter, EditOnKeystroke, EditOnKeystrokeOrF2, EditOnF2, EditProgrammatically } +[Flags] public enum DataGridViewDataErrorContexts { Formatting = 1, Display = 2, PreferredSize = 4, RowDeletion = 8, Parsing = 256, Commit = 512, InitialValueRestoration = 1024, LeaveControl = 2048, CurrentCellChange = 4096, Scroll = 8192, ClipboardContent = 16384 } + +public delegate void DataGridViewCellEventHandler(object sender, DataGridViewCellEventArgs e); +public delegate void DataGridViewDataErrorEventHandler(object sender, DataGridViewDataErrorEventArgs e); + +public class DataGridViewCellEventArgs : EventArgs +{ + public DataGridViewCellEventArgs(int columnIndex, int rowIndex) + { + ColumnIndex = columnIndex; + RowIndex = rowIndex; + } + + public int ColumnIndex { get; } + public int RowIndex { get; } +} + +public class DataGridViewDataErrorEventArgs : DataGridViewCellCancelEventArgs +{ + public DataGridViewDataErrorEventArgs(Exception? exception, int columnIndex, int rowIndex, DataGridViewDataErrorContexts context) : base(columnIndex, rowIndex) + { + Exception = exception; + Context = context; + } + + public Exception? Exception { get; } + public DataGridViewDataErrorContexts Context { get; } + public bool ThrowException { get; set; } +} + +public class DataGridViewCellCancelEventArgs : CancelEventArgs +{ + public DataGridViewCellCancelEventArgs(int columnIndex, int rowIndex) + { + ColumnIndex = columnIndex; + RowIndex = rowIndex; + } + + public int ColumnIndex { get; } + public int RowIndex { get; } +} + +public class DataGridViewCellStyle +{ + public Color BackColor { get; set; } + public Color ForeColor { get; set; } + public Color SelectionBackColor { get; set; } + public Color SelectionForeColor { get; set; } + public Font? Font { get; set; } + public string Format { get; set; } = ""; + public DataGridViewContentAlignment Alignment { get; set; } + public DataGridViewTriState WrapMode { get; set; } + public object? NullValue { get; set; } = ""; +} + +public enum DataGridViewContentAlignment { NotSet = 0, TopLeft = 1, TopCenter = 2, TopRight = 4, MiddleLeft = 16, MiddleCenter = 32, MiddleRight = 64, BottomLeft = 256, BottomCenter = 512, BottomRight = 1024 } +public enum DataGridViewTriState { NotSet, True, False } +public enum DataGridViewColumnSortMode { NotSortable, Automatic, Programmatic } + +/// 一列:名字、标题、绑定的数据列、宽度 +public class DataGridViewColumn : Component +{ + private string _headerText = ""; + private int _width = 100; + private bool _visible = true; + private bool _readOnly; + + internal DataGridView? Owner { get; set; } + + public string Name { get; set; } = ""; + + public string HeaderText + { + get => _headerText; + set + { + _headerText = value ?? ""; + Owner?.LayoutChanged(); + } + } + + public string DataPropertyName { get; set; } = ""; + + public int Width + { + get => _width; + set + { + _width = value; + Owner?.LayoutChanged(); + } + } + + public bool Visible + { + get => _visible; + set + { + _visible = value; + Owner?.LayoutChanged(); + } + } + + public bool ReadOnly + { + get => _readOnly; + set + { + _readOnly = value; + Owner?.LayoutChanged(); + } + } + + public int Index => Owner?.Columns.IndexOf(this) ?? -1; + public int DisplayIndex { get => Index; set { } } + public Type? ValueType { get; set; } + public DataGridViewAutoSizeColumnMode AutoSizeMode { get; set; } + public DataGridViewColumnSortMode SortMode { get; set; } = DataGridViewColumnSortMode.Automatic; + public DataGridViewCellStyle DefaultCellStyle { get; set; } = new(); + public int MinimumWidth { get; set; } = 5; + public float FillWeight { get; set; } = 100; + public bool Frozen { get; set; } + public bool Resizable { get; set; } = true; + public string ToolTipText { get; set; } = ""; + public object? Tag { get; set; } + public bool IsDataBound => !string.IsNullOrEmpty(DataPropertyName) && Owner?.DataSource != null; + + internal virtual bool IsCheckBox => false; +} + +public class DataGridViewTextBoxColumn : DataGridViewColumn +{ + public int MaxInputLength { get; set; } = 32767; +} + +public class DataGridViewCheckBoxColumn : DataGridViewColumn +{ + public bool ThreeState { get; set; } + internal override bool IsCheckBox => true; +} + +/// 一个单元格;值保存在绑定的 DataRow 里(绑定时)或行自己的数组里(不绑定时) +public class DataGridViewCell +{ + private readonly DataGridViewRow _row; + + internal DataGridViewCell(DataGridViewRow row, int columnIndex) + { + _row = row; + ColumnIndex = columnIndex; + } + + public int ColumnIndex { get; } + public int RowIndex => _row.Index; + public DataGridViewRow OwningRow => _row; + public DataGridViewColumn? OwningColumn => _row.Grid?.Columns[ColumnIndex]; + public DataGridView? DataGridView => _row.Grid; + + public object? Value + { + get => _row.GetValue(ColumnIndex); + set => _row.SetValue(ColumnIndex, value, fromUser: false); + } + + public object? FormattedValue => DataGridView.Format(Value); + public Type? ValueType => OwningColumn?.ValueType; + public bool ReadOnly { get; set; } + public bool Selected => _row.Grid?.IsCellSelected(RowIndex, ColumnIndex) ?? false; + public object? Tag { get; set; } + public string ToolTipText { get; set; } = ""; + public DataGridViewCellStyle Style { get; set; } = new(); + + public override string ToString() => $"DataGridViewTextBoxCell {{ ColumnIndex={ColumnIndex}, RowIndex={RowIndex} }}"; +} + +public class DataGridViewCellCollection : IEnumerable +{ + private readonly DataGridViewRow _row; + + internal DataGridViewCellCollection(DataGridViewRow row) => _row = row; + + public int Count => _row.Grid?.Columns.Count ?? _row.UnboundValues.Count; + + public DataGridViewCell this[int index] + { + get + { + if (index < 0 || index >= Count) throw new ArgumentOutOfRangeException(nameof(index), "索引超出范围。必须为非负值并小于集合大小。"); + return _row.Cell(index); + } + } + + public DataGridViewCell this[string columnName] + { + get + { + var index = _row.Grid?.Columns.IndexOf(columnName) ?? -1; + if (index < 0) throw new ArgumentException($"找不到名为 {columnName} 的列。列名不能为 null 或为空字符串。"); + return _row.Cell(index); + } + } + + public IEnumerator GetEnumerator() + { + for (var i = 0; i < Count; i++) yield return _row.Cell(i); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +/// 一行。绑定数据时对应一个 DataRowView;最后一行可能是供用户新增数据的空行(IsNewRow) +public class DataGridViewRow +{ + private readonly Dictionary _cells = new(); + private int _height = 23; + + public DataGridViewRow() => Cells = new DataGridViewCellCollection(this); + + internal DataGridView? Grid { get; set; } + internal DataRowView? RowView { get; set; } + internal List UnboundValues { get; } = new(); + + public DataGridViewCellCollection Cells { get; } + public int Index { get; internal set; } = -1; + public bool IsNewRow { get; internal set; } + public object? DataBoundItem => RowView; + public bool ReadOnly { get; set; } + public object? Tag { get; set; } + public bool Visible { get; set; } = true; + + public int Height + { + get => _height; + set + { + _height = value; + Grid?.LayoutChanged(); + } + } + + public bool Selected + { + get => Grid?.IsRowSelected(Index) ?? false; + set => Grid?.SetRowSelected(Index, value); + } + + public DataGridViewCellStyle DefaultCellStyle { get; set; } = new(); + + internal DataGridViewCell Cell(int index) => _cells.TryGetValue(index, out var cell) ? cell : _cells[index] = new DataGridViewCell(this, index); + + internal object? GetValue(int column) + { + if (IsNewRow) return null; + if (RowView != null) + { + var name = Grid!.Columns[column].DataPropertyName; + return string.IsNullOrEmpty(name) || !RowView.Row.Table.Columns.Contains(name) ? null : RowView[name]; + } + return column < UnboundValues.Count ? UnboundValues[column] : null; + } + + internal void SetValue(int column, object? value, bool fromUser) + { + if (RowView != null) + { + var name = Grid!.Columns[column].DataPropertyName; + RowView.BeginEdit(); + RowView[name] = value ?? DBNull.Value; + RowView.EndEdit(); + } + else + { + while (UnboundValues.Count <= column) UnboundValues.Add(null); + UnboundValues[column] = value; + Grid?.RowsChanged(); + } + if (!fromUser) Grid?.RaiseCellValueChanged(column, Index); + } + + public void SetValues(params object?[] values) + { + for (var i = 0; i < values.Length; i++) SetValue(i, values[i], fromUser: false); + } +} + +public class DataGridViewColumnCollection : IEnumerable +{ + private readonly DataGridView _owner; + private readonly List _items = new(); + + internal DataGridViewColumnCollection(DataGridView owner) => _owner = owner; + + public int Count => _items.Count; + public DataGridViewColumn this[int index] => _items[index]; + + public DataGridViewColumn this[string columnName] => + _items.FirstOrDefault(c => string.Equals(c.Name, columnName, StringComparison.OrdinalIgnoreCase))!; + + public int Add(DataGridViewColumn column) + { + column.Owner = _owner; + _items.Add(column); + _owner.ColumnsChanged(); + return _items.Count - 1; + } + + public int Add(string columnName, string headerText) => Add(new DataGridViewTextBoxColumn { Name = columnName, HeaderText = headerText }); + + public void AddRange(params DataGridViewColumn[] columns) + { + foreach (var column in columns) + { + column.Owner = _owner; + _items.Add(column); + } + _owner.ColumnsChanged(); + } + + public void Insert(int index, DataGridViewColumn column) + { + column.Owner = _owner; + _items.Insert(index, column); + _owner.ColumnsChanged(); + } + + public void Remove(DataGridViewColumn column) + { + if (_items.Remove(column)) _owner.ColumnsChanged(); + } + + public void Remove(string columnName) + { + if (this[columnName] is { } column) Remove(column); + } + + public void RemoveAt(int index) + { + _items.RemoveAt(index); + _owner.ColumnsChanged(); + } + + public void Clear() + { + _items.Clear(); + _owner.ColumnsChanged(); + } + + public bool Contains(string columnName) => IndexOf(columnName) >= 0; + public int IndexOf(DataGridViewColumn column) => _items.IndexOf(column); + public int IndexOf(string columnName) => _items.FindIndex(c => string.Equals(c.Name, columnName, StringComparison.OrdinalIgnoreCase)); + + internal void ReplaceAll(IEnumerable columns) + { + _items.Clear(); + foreach (var column in columns) + { + column.Owner = _owner; + _items.Add(column); + } + } + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +public class DataGridViewRowCollection : IEnumerable +{ + private readonly DataGridView _owner; + + internal DataGridViewRowCollection(DataGridView owner) => _owner = owner; + + private List Items => _owner.AllRows; + + /// 和 WinForms 一样包括最下面供新增的空行 + public int Count => Items.Count; + + public DataGridViewRow this[int index] + { + get + { + if (index < 0 || index >= Items.Count) throw new ArgumentOutOfRangeException(nameof(index), "索引超出范围。必须为非负值并小于集合大小。"); + return Items[index]; + } + } + + public int Add() => Add(Array.Empty()); + + public int Add(params object?[] values) + { + if (_owner.DataSource != null) throw new InvalidOperationException("当控件被数据绑定时,无法以编程方式向 DataGridView 的行集合中添加行。"); + if (_owner.Columns.Count == 0) throw new InvalidOperationException("没有列时无法向 DataGridView 控件添加任何行。必须首先添加列。"); + var row = new DataGridViewRow(); + row.UnboundValues.AddRange(values); + return _owner.AddUnboundRow(row); + } + + public int Add(DataGridViewRow row) => _owner.AddUnboundRow(row); + + public void Clear() + { + if (_owner.DataSource != null) throw new ArgumentException("无法清除此列表。"); + _owner.ClearUnboundRows(); + } + + public void RemoveAt(int index) => _owner.RemoveRow(index); + public void Remove(DataGridViewRow row) => _owner.RemoveRow(row.Index); + public int GetRowCount(DataGridViewElementStates state) => Count; + + public IEnumerator GetEnumerator() => Items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +[Flags] public enum DataGridViewElementStates { None = 0, Displayed = 1, Frozen = 2, ReadOnly = 4, Resizable = 8, ResizableSet = 16, Selected = 32, Visible = 64 } + +public class DataGridViewSelectedRowCollection : IEnumerable +{ + private readonly List _items; + + internal DataGridViewSelectedRowCollection(List items) => _items = items; + + public int Count => _items.Count; + public DataGridViewRow this[int index] => _items[index]; + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +/// +/// 数据表格(教材项目 6、8 显示数据库查询结果)。绑定 DataView / DataTable / DataSet 时自动生成列, +/// 用户在单元格里修改的值写回绑定的 DataRow,配合 SqlDataAdapter.Update 保存到数据库。 +/// 浏览器只负责显示和输入,单元格的值、当前单元格、选中的行都由这里决定。 +/// +public class DataGridView : Control, ISupportInitialize +{ + /// 发给浏览器的行数上限(太多时只显示前面这些) + private const int MaxRowsSent = 2000; + + private object? _dataSource; + private string _dataMember = ""; + private DataView? _view; + private readonly List _rows = new(); + private readonly HashSet _selectedRows = new(); + private (int Row, int Column)? _currentCell; + private bool _readOnly; + private bool _allowUserToAddRows = true; + private bool _rowHeadersVisible = true; + private bool _columnHeadersVisible = true; + private DataGridViewSelectionMode _selectionMode = DataGridViewSelectionMode.RowHeaderSelect; + private DataGridViewAutoSizeColumnsMode _autoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None; + private Color _backgroundColor = SystemColors.AppWorkspace; + private bool _initializing; + private bool _rowsDirty; + + public DataGridView() + { + Columns = new DataGridViewColumnCollection(this); + Rows = new DataGridViewRowCollection(this); + RowTemplate = new DataGridViewRow(); + UpdateNewRow(); + } + + protected override Size DefaultSize => new(240, 150); + + public DataGridViewColumnCollection Columns { get; } + public DataGridViewRowCollection Rows { get; } + public DataGridViewRow RowTemplate { get; set; } + internal List AllRows => _rows; + + public bool AutoGenerateColumns { get; set; } = true; + public DataGridViewColumnHeadersHeightSizeMode ColumnHeadersHeightSizeMode { get; set; } = DataGridViewColumnHeadersHeightSizeMode.EnableResizing; + public DataGridViewRowHeadersWidthSizeMode RowHeadersWidthSizeMode { get; set; } = DataGridViewRowHeadersWidthSizeMode.EnableResizing; + public int ColumnHeadersHeight { get; set; } = 23; + public int RowHeadersWidth { get; set; } = 41; + public bool AllowUserToDeleteRows { get; set; } = true; + public bool AllowUserToResizeRows { get; set; } = true; + public bool AllowUserToResizeColumns { get; set; } = true; + public bool AllowUserToOrderColumns { get; set; } + public bool MultiSelect { get; set; } = true; + public DataGridViewEditMode EditMode { get; set; } = DataGridViewEditMode.EditOnKeystrokeOrF2; + public BorderStyle BorderStyle { get; set; } = BorderStyle.FixedSingle; + public Color GridColor { get; set; } = SystemColors.ControlDark; + public DataGridViewCellStyle DefaultCellStyle { get; set; } = new(); + public DataGridViewCellStyle ColumnHeadersDefaultCellStyle { get; set; } = new(); + public DataGridViewCellStyle RowHeadersDefaultCellStyle { get; set; } = new(); + public DataGridViewCellStyle AlternatingRowsDefaultCellStyle { get; set; } = new(); + public DataGridViewCellStyle RowsDefaultCellStyle { get; set; } = new(); + public bool EnableHeadersVisualStyles { get; set; } = true; + + public object? DataSource + { + get => _dataSource; + set + { + _dataSource = value; + Bind(); + } + } + + public string DataMember + { + get => _dataMember; + set + { + _dataMember = value ?? ""; + Bind(); + } + } + + public bool ReadOnly + { + get => _readOnly; + set + { + _readOnly = value; + UpdateNewRow(); + LayoutChanged(); + } + } + + public bool AllowUserToAddRows + { + get => _allowUserToAddRows; + set + { + _allowUserToAddRows = value; + UpdateNewRow(); + RowsChanged(); + } + } + + public bool RowHeadersVisible + { + get => _rowHeadersVisible; + set => SetProp(ref _rowHeadersVisible, value, nameof(RowHeadersVisible)); + } + + public bool ColumnHeadersVisible + { + get => _columnHeadersVisible; + set => SetProp(ref _columnHeadersVisible, value, nameof(ColumnHeadersVisible)); + } + + public DataGridViewSelectionMode SelectionMode + { + get => _selectionMode; + set + { + _selectionMode = value; + SelectionChangedInternal(raise: false); + } + } + + public DataGridViewAutoSizeColumnsMode AutoSizeColumnsMode + { + get => _autoSizeColumnsMode; + set + { + _autoSizeColumnsMode = value; + LayoutChanged(); + } + } + + public Color BackgroundColor + { + get => _backgroundColor; + set => SetProp(ref _backgroundColor, value, nameof(BackgroundColor)); + } + + public int RowCount + { + get => _rows.Count; + set + { + if (_dataSource != null) throw new InvalidOperationException("当控件被数据绑定时,无法设置 RowCount 属性。"); + while (_rows.Count < value) Rows.Add(); + } + } + + public int ColumnCount + { + get => Columns.Count; + set + { + while (Columns.Count < value) Columns.Add($"Column{Columns.Count + 1}", ""); + while (Columns.Count > value) Columns.RemoveAt(Columns.Count - 1); + } + } + + public DataGridViewRow? CurrentRow => _currentCell is { } c && c.Row < _rows.Count ? _rows[c.Row] : null; + + public DataGridViewCell? CurrentCell + { + get => _currentCell is { } c && c.Row < _rows.Count && c.Column < Columns.Count ? _rows[c.Row].Cells[c.Column] : null; + set => SetCurrentCell(value == null ? null : (value.RowIndex, value.ColumnIndex), raise: true); + } + + public Point CurrentCellAddress => _currentCell is { } c ? new Point(c.Column, c.Row) : new Point(-1, -1); + + public DataGridViewSelectedRowCollection SelectedRows => + new(_selectedRows.Order().Where(i => i < _rows.Count).Select(i => _rows[i]).ToList()); + + public bool IsCurrentCellInEditMode => false; + public DataGridViewCell this[int columnIndex, int rowIndex] => Rows[rowIndex].Cells[columnIndex]; + public DataGridViewCell this[string columnName, int rowIndex] => Rows[rowIndex].Cells[columnName]; + + public event DataGridViewCellEventHandler? CellClick; + public event DataGridViewCellEventHandler? CellContentClick; + public event DataGridViewCellEventHandler? CellDoubleClick; + public event DataGridViewCellEventHandler? CellContentDoubleClick; + public event DataGridViewCellEventHandler? CellValueChanged; + public event DataGridViewCellEventHandler? CellEndEdit; + public event DataGridViewCellEventHandler? CellEnter; + public event DataGridViewCellEventHandler? RowEnter; + public event DataGridViewDataErrorEventHandler? DataError; + public event EventHandler? SelectionChanged; + public event EventHandler? CurrentCellChanged; + public event EventHandler? DataSourceChanged; + public event EventHandler? DataBindingComplete; + + protected virtual void OnCellClick(DataGridViewCellEventArgs e) => CellClick?.Invoke(this, e); + protected virtual void OnCellContentClick(DataGridViewCellEventArgs e) => CellContentClick?.Invoke(this, e); + protected virtual void OnCellDoubleClick(DataGridViewCellEventArgs e) => CellDoubleClick?.Invoke(this, e); + protected virtual void OnCellContentDoubleClick(DataGridViewCellEventArgs e) => CellContentDoubleClick?.Invoke(this, e); + protected virtual void OnCellValueChanged(DataGridViewCellEventArgs e) => CellValueChanged?.Invoke(this, e); + protected virtual void OnCellEndEdit(DataGridViewCellEventArgs e) => CellEndEdit?.Invoke(this, e); + protected virtual void OnSelectionChanged(EventArgs e) => SelectionChanged?.Invoke(this, e); + protected virtual void OnCurrentCellChanged(EventArgs e) => CurrentCellChanged?.Invoke(this, e); + + public void BeginInit() => _initializing = true; + + public void EndInit() + { + _initializing = false; + LayoutChanged(); + } + + public void ClearSelection() => SelectionChangedInternal(raise: true, clear: true); + + public void SelectAll() + { + for (var i = 0; i < _rows.Count; i++) _selectedRows.Add(i); + SelectionChangedInternal(raise: true); + } + + public bool EndEdit() => true; + public bool CancelEdit() => true; + public bool BeginEdit(bool selectAll) => false; + public void AutoResizeColumns() { } + public void AutoResizeColumns(DataGridViewAutoSizeColumnsMode mode) { } + public void AutoResizeRows() { } + + // ---------- 绑定 ---------- + + private void Bind() + { + if (_view != null) _view.ListChanged -= OnListChanged; + _view = _dataSource switch + { + DataView view => view, + DataTable table => table.DefaultView, + DataSet set when !string.IsNullOrEmpty(_dataMember) && set.Tables.Contains(_dataMember) => set.Tables[_dataMember]!.DefaultView, + null => null, + DataSet => null, + _ => throw new ArgumentException("复杂的 DataBinding 接受 IList 或 IListSource 作为数据源:平台支持 DataTable、DataView 和 DataSet。"), + }; + if (_view != null) + { + _view.ListChanged += OnListChanged; + if (AutoGenerateColumns) + { + Columns.ReplaceAll(_view.Table!.Columns.Cast().Select(column => (DataGridViewColumn)(column.DataType == typeof(bool) + ? new DataGridViewCheckBoxColumn { Name = column.ColumnName, HeaderText = column.ColumnName, DataPropertyName = column.ColumnName, ValueType = column.DataType } + : new DataGridViewTextBoxColumn { Name = column.ColumnName, HeaderText = column.ColumnName, DataPropertyName = column.ColumnName, ValueType = column.DataType }))); + } + } + else if (_dataSource == null && AutoGenerateColumns) + { + Columns.ReplaceAll(Columns.Where(c => string.IsNullOrEmpty(c.DataPropertyName)).ToList()); + } + RebuildBoundRows(); + _currentCell = _rows.Count > 0 && Columns.Count > 0 && !_rows[0].IsNewRow ? (0, 0) : null; + _selectedRows.Clear(); + if (_currentCell != null && _selectionMode == DataGridViewSelectionMode.FullRowSelect) _selectedRows.Add(0); + LayoutChanged(); + DataSourceChanged?.Invoke(this, EventArgs.Empty); + DataBindingComplete?.Invoke(this, EventArgs.Empty); + } + + private void OnListChanged(object? sender, ListChangedEventArgs e) + { + RebuildBoundRows(); + RowsChanged(); + } + + private void RebuildBoundRows() + { + if (_view == null) + { + if (_dataSource != null) _rows.Clear(); + UpdateNewRow(); + return; + } + _rows.Clear(); + for (var i = 0; i < _view.Count; i++) _rows.Add(new DataGridViewRow { Grid = this, RowView = _view[i], Index = i }); + UpdateNewRow(); + } + + /// 最下面供用户新增的空行(AllowUserToAddRows 且没有只读) + private void UpdateNewRow() + { + _rows.RemoveAll(r => r.IsNewRow); + var allowAdd = _allowUserToAddRows && !_readOnly && Columns.Count > 0 && (_view == null ? _dataSource == null : _view.AllowNew); + if (allowAdd) _rows.Add(new DataGridViewRow { Grid = this, IsNewRow = true, Index = _rows.Count }); + if (_currentCell is { } c && c.Row >= _rows.Count) _currentCell = null; + } + + internal int AddUnboundRow(DataGridViewRow row) + { + row.Grid = this; + var index = _rows.FindIndex(r => r.IsNewRow); + if (index < 0) index = _rows.Count; + _rows.Insert(index, row); + for (var i = 0; i < _rows.Count; i++) _rows[i].Index = i; + if (_currentCell == null && Columns.Count > 0) _currentCell = (index, 0); + RowsChanged(); + return index; + } + + internal void ClearUnboundRows() + { + _rows.RemoveAll(r => !r.IsNewRow); + for (var i = 0; i < _rows.Count; i++) _rows[i].Index = i; + _currentCell = null; + _selectedRows.Clear(); + RowsChanged(); + } + + internal void RemoveRow(int index) + { + var row = Rows[index]; + if (row.IsNewRow) throw new InvalidOperationException("无法删除未提交的新行。"); + if (row.RowView != null) row.RowView.Delete(); + else + { + _rows.RemoveAt(index); + for (var i = 0; i < _rows.Count; i++) _rows[i].Index = i; + RowsChanged(); + } + _selectedRows.Clear(); + if (_currentCell is { } c && c.Row >= _rows.Count) _currentCell = null; + } + + internal void ColumnsChanged() + { + if (_dataSource == null) UpdateNewRow(); + LayoutChanged(); + } + + internal void RaiseCellValueChanged(int column, int row) + { + if (row >= 0) OnCellValueChanged(new DataGridViewCellEventArgs(column, row)); + } + + // ---------- 选择 ---------- + + internal bool IsRowSelected(int row) => _selectedRows.Contains(row); + + internal bool IsCellSelected(int row, int column) => + _selectedRows.Contains(row) || (_selectionMode is DataGridViewSelectionMode.CellSelect or DataGridViewSelectionMode.RowHeaderSelect && _currentCell == (row, column)); + + internal void SetRowSelected(int row, bool selected) + { + if (selected) _selectedRows.Add(row); + else _selectedRows.Remove(row); + SelectionChangedInternal(raise: true); + } + + private void SetCurrentCell((int Row, int Column)? cell, bool raise) + { + if (cell is { } c && (c.Row < 0 || c.Row >= _rows.Count || c.Column < 0 || c.Column >= Columns.Count)) + throw new InvalidOperationException("当前单元格无法设置为不可见单元格。"); + if (_currentCell == cell) return; + _currentCell = cell; + if (raise) OnCurrentCellChanged(EventArgs.Empty); + } + + private void SelectionChangedInternal(bool raise, bool clear = false) + { + if (clear) _selectedRows.Clear(); + NotifySelection(); + if (raise) OnSelectionChanged(EventArgs.Empty); + } + + // ---------- 用户操作 ---------- + + protected override void OnUserAction(UserAction action) + { + switch (action.Action) + { + case "cellClick": + ClickCell(action.Row, action.Column, doubleClick: false); + break; + case "cellDoubleClick": + ClickCell(action.Row, action.Column, doubleClick: true); + break; + case "cellEdit": + EditCell(action.Row, action.Column, action.Value ?? ""); + break; + default: + base.OnUserAction(action); + break; + } + } + + /// 点单元格:先改变当前单元格和选中的行,再依次触发 CellClick、CellContentClick、Click(列号 -1 是行标题) + private void ClickCell(int row, int column, bool doubleClick) + { + if (row < 0 || row >= _rows.Count) return; + if (!doubleClick) + { + var oldCell = _currentCell; + var newCell = (row, Math.Max(column, 0)); + if (Columns.Count > 0) SetCurrentCell(newCell, raise: true); + var selectRow = column < 0 ? _selectionMode is DataGridViewSelectionMode.RowHeaderSelect or DataGridViewSelectionMode.FullRowSelect + : _selectionMode == DataGridViewSelectionMode.FullRowSelect; + var changed = oldCell != _currentCell || _selectedRows.Count != (selectRow ? 1 : 0) || (selectRow && !_selectedRows.Contains(row)); + _selectedRows.Clear(); + if (selectRow) _selectedRows.Add(row); + if (changed) SelectionChangedInternal(raise: true); + var args = new DataGridViewCellEventArgs(column, row); + OnCellClick(args); + if (column >= 0) OnCellContentClick(args); + OnClick(EventArgs.Empty); + } + else + { + var args = new DataGridViewCellEventArgs(column, row); + OnCellDoubleClick(args); + if (column >= 0) OnCellContentDoubleClick(args); + OnDoubleClick(EventArgs.Empty); + } + } + + /// 用户改了单元格:按列的类型转换后写回(转换失败时和 WinForms 一样触发 DataError,没有处理时弹出默认的错误对话框) + private void EditCell(int rowIndex, int column, string text) + { + if (_readOnly || rowIndex < 0 || rowIndex >= _rows.Count || column < 0 || column >= Columns.Count || Columns[column].ReadOnly) return; + var row = _rows[rowIndex]; + var columnType = Columns[column].ValueType ?? (row.RowView != null ? _view!.Table!.Columns[Columns[column].DataPropertyName]?.DataType : null) ?? typeof(object); + object? value; + try + { + value = Parse(text, columnType); + } + catch (Exception ex) when (ex is FormatException or OverflowException or InvalidCastException or ArgumentException) + { + var error = new DataGridViewDataErrorEventArgs(ex, column, rowIndex, DataGridViewDataErrorContexts.Parsing | DataGridViewDataErrorContexts.Commit); + if (DataError != null) DataError(this, error); + else MessageBox.Show($"DataGridView 中发生以下异常:\n\n{ex.GetType().FullName}: {ex.Message}\n\n若要替换此默认对话框,请处理 DataError 事件。", "DataGridView 默认错误对话框"); + RowsChanged(); + return; + } + + if (row.IsNewRow) + { + if (_view != null) + { + // 在新行里输入:向绑定的 DataTable 加一行 + var added = _view.AddNew(); + added[Columns[column].DataPropertyName] = value ?? DBNull.Value; + added.EndEdit(); + } + else + { + var newRow = new DataGridViewRow(); + AddUnboundRow(newRow); + newRow.SetValue(column, value, fromUser: true); + } + rowIndex = _rows.Count - (_rows.Any(r => r.IsNewRow) ? 2 : 1); + } + else + { + row.SetValue(column, value, fromUser: true); + } + RowsChanged(); + var args = new DataGridViewCellEventArgs(column, rowIndex); + OnCellValueChanged(args); + OnCellEndEdit(args); + } + + private static object? Parse(string text, Type type) + { + if (text.Length == 0) return type == typeof(string) || type == typeof(object) ? text : DBNull.Value; + var target = Nullable.GetUnderlyingType(type) ?? type; + if (target == typeof(string) || target == typeof(object)) return text; + if (target == typeof(bool)) return text is "True" or "true" or "1"; + return Convert.ChangeType(text, target, CultureInfo.CurrentCulture); + } + + // ---------- 显示 ---------- + + /// 单元格显示的文字:和 WinForms 一样按当前区域格式化(日期显示为 2024/1/16 9:05:03) + internal static string Format(object? value) => value switch + { + null or DBNull => "", + byte[] => "", + IFormattable f => f.ToString(null, CultureInfo.CurrentCulture), + _ => value.ToString() ?? "", + }; + + internal void LayoutChanged() + { + if (_initializing) return; + NotifyChanged("Columns", ColumnsJson()); + RowsChanged(); + } + + /// 行有变化:攒到这一轮消息处理结束时一起发给浏览器(避免逐行修改时发很多次) + internal void RowsChanged() + { + if (_initializing || !IsAttached) return; + if (_rowsDirty) return; + _rowsDirty = true; + SimHost.Ui.Post(() => + { + _rowsDirty = false; + NotifyChanged("Rows", RowsJson()); + NotifySelection(); + }); + } + + private void NotifySelection() + { + NotifyChanged("CurrentCell", _currentCell is { } c ? new[] { c.Row, c.Column } : null); + NotifyChanged("SelectedRows", _selectedRows.Order().ToArray()); + } + + private JsonArray ColumnsJson() + { + var columns = new JsonArray(); + foreach (var column in Columns) + { + columns.Add(new JsonObject + { + ["name"] = column.Name, + ["header"] = column.HeaderText, + ["width"] = column.Width, + ["visible"] = column.Visible, + ["readOnly"] = column.ReadOnly || _readOnly, + ["check"] = column.IsCheckBox, + ["fill"] = column.AutoSizeMode == DataGridViewAutoSizeColumnMode.Fill || (column.AutoSizeMode == DataGridViewAutoSizeColumnMode.NotSet && _autoSizeColumnsMode == DataGridViewAutoSizeColumnsMode.Fill), + }); + } + return columns; + } + + private JsonObject RowsJson() + { + var rows = new JsonArray(); + foreach (var row in _rows.Take(MaxRowsSent)) + { + var cells = new JsonArray(); + for (var i = 0; i < Columns.Count; i++) + { + var value = row.GetValue(i); + cells.Add(Columns[i].IsCheckBox ? JsonValue.Create(value is true) : JsonValue.Create(Format(value))); + } + rows.Add(new JsonObject { ["cells"] = cells, ["isNew"] = row.IsNewRow }); + } + return new JsonObject { ["items"] = rows, ["total"] = _rows.Count, ["height"] = RowTemplate.Height }; + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["Columns"] = ColumnsJson(); + props["Rows"] = RowsJson(); + props["CurrentCell"] = _currentCell is { } c ? new JsonArray(c.Row, c.Column) : null; + props["SelectedRows"] = PropValue.From(_selectedRows.Order().ToArray()); + props["RowHeadersVisible"] = _rowHeadersVisible; + props["ColumnHeadersVisible"] = _columnHeadersVisible; + props["ReadOnly"] = _readOnly; + props["BackgroundColor"] = PropValue.ColorCss(_backgroundColor); + props["SelectionMode"] = _selectionMode.ToString(); + } +} diff --git a/runtime/Sim.WinForms/Forms/DateTimePicker.cs b/runtime/Sim.WinForms/Forms/DateTimePicker.cs new file mode 100644 index 0000000..4b3e8b3 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/DateTimePicker.cs @@ -0,0 +1,143 @@ +using System.Drawing; +using System.Globalization; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +public enum DateTimePickerFormat { Long = 1, Short = 2, Time = 4, Custom = 8 } + +/// 日期时间选择器(教材项目 6 任务 3 选查询的起止日期)。Text 按 Format 显示,和中文 Windows 一致(Long:2024年1月16日) +public class DateTimePicker : Control +{ + public static readonly DateTime MinimumDateTime = new(1753, 1, 1); + public static readonly DateTime MaximumDateTime = new(9998, 12, 31); + + private DateTime _value = DateTime.Now; + private DateTimePickerFormat _format = DateTimePickerFormat.Long; + private string? _customFormat; + private bool _showUpDown; + private DateTime _minDate = MinimumDateTime; + private DateTime _maxDate = MaximumDateTime; + + protected override Size DefaultSize => new(200, 21); + + public DateTime Value + { + get => _value; + set + { + if (value < _minDate || value > _maxDate) + throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“Value”无效。“Value”应介于“MinDate”和“MaxDate”之间。"); + if (value == _value) return; + LegacyThreads.CheckPoint(); + _value = value; + NotifyValue(); + OnValueChanged(EventArgs.Empty); + } + } + + public DateTimePickerFormat Format + { + get => _format; + set + { + _format = value; + NotifyValue(); + NotifyChanged(nameof(Format), value); + } + } + + public string? CustomFormat + { + get => _customFormat; + set + { + _customFormat = value; + NotifyValue(); + } + } + + public bool ShowUpDown + { + get => _showUpDown; + set => SetProp(ref _showUpDown, value, nameof(ShowUpDown)); + } + + public DateTime MinDate + { + get => _minDate; + set => _minDate = value; + } + + public DateTime MaxDate + { + get => _maxDate; + set => _maxDate = value; + } + + public bool ShowCheckBox { get; set; } + public bool Checked { get; set; } = true; + public LeftRightAlignment DropDownAlign { get; set; } + + /// 显示的文字;赋值时按日期解析 + public override string Text + { + get => Display; + set + { + if (string.IsNullOrEmpty(value)) return; + Value = DateTime.Parse(value, CultureInfo.CurrentCulture); + } + } + + private string Display => _format switch + { + DateTimePickerFormat.Short => _value.ToString("d", CultureInfo.CurrentCulture), + DateTimePickerFormat.Time => _value.ToString("T", CultureInfo.CurrentCulture), + DateTimePickerFormat.Custom when !string.IsNullOrEmpty(_customFormat) => _value.ToString(_customFormat, CultureInfo.CurrentCulture), + _ => _value.ToString("D", CultureInfo.CurrentCulture), + }; + + public event EventHandler? ValueChanged; + public event EventHandler? CloseUp; + public event EventHandler? DropDown; + + protected virtual void OnValueChanged(EventArgs e) => ValueChanged?.Invoke(this, e); + + /// 浏览器里选了新的日期或时间(ISO 格式,本地时间):和 WinForms 一样先 ValueChanged,再 CloseUp + protected override void OnUserAction(UserAction action) + { + switch (action.Action) + { + case "dropDown": + DropDown?.Invoke(this, EventArgs.Empty); + break; + case "pick" when DateTime.TryParse(action.Value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var picked): + var clamped = picked < _minDate ? _minDate : picked > _maxDate ? _maxDate : picked; + if (clamped != _value) Value = clamped; + else NotifyValue(); + CloseUp?.Invoke(this, EventArgs.Empty); + break; + default: + base.OnUserAction(action); + break; + } + } + + private void NotifyValue() + { + NotifyChanged("Text", Display); + NotifyChanged("Value", _value.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture)); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["Value"] = _value.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture); + props["Format"] = _format.ToString(); + props["ShowUpDown"] = _showUpDown; + props["HasTime"] = _format == DateTimePickerFormat.Time || (_format == DateTimePickerFormat.Custom && (_customFormat ?? "").IndexOfAny(['H', 'h', 'm', 's']) >= 0); + props["HasDate"] = _format != DateTimePickerFormat.Time && !(_format == DateTimePickerFormat.Custom && (_customFormat ?? "").IndexOfAny(['y', 'M', 'd']) < 0); + } +} diff --git a/runtime/Sim.WinForms/Forms/Enums.cs b/runtime/Sim.WinForms/Forms/Enums.cs index 7dd9d38..ee669bc 100644 --- a/runtime/Sim.WinForms/Forms/Enums.cs +++ b/runtime/Sim.WinForms/Forms/Enums.cs @@ -98,3 +98,4 @@ public interface IButtonControl void NotifyDefault(bool value); void PerformClick(); } +public enum LeftRightAlignment { Left, Right } diff --git a/runtime/Sim.WinForms/Host/PropValue.cs b/runtime/Sim.WinForms/Host/PropValue.cs index d1a05ae..848b7cf 100644 --- a/runtime/Sim.WinForms/Host/PropValue.cs +++ b/runtime/Sim.WinForms/Host/PropValue.cs @@ -13,6 +13,8 @@ public static class PropValue public static JsonNode? From(object? value) => value switch { null => null, + // 控件自己组织好的结构(如 DataGridView 的列和行)原样发送 + JsonNode node => node.Parent == null ? node : node.DeepClone(), string s => JsonValueOf(s), bool b => JsonValueOf(b), char c => JsonValueOf(c == '\0' ? "" : c.ToString()), diff --git a/runtime/Sim.WinForms/Host/Protocol.cs b/runtime/Sim.WinForms/Host/Protocol.cs index 89ddb5a..d5bd1c0 100644 --- a/runtime/Sim.WinForms/Host/Protocol.cs +++ b/runtime/Sim.WinForms/Host/Protocol.cs @@ -13,6 +13,9 @@ public sealed class UserAction public string Button { get; init; } = "left"; public int X { get; init; } public int Y { get; init; } + /// DataGridView 的单元格:行号、列号(-1 表示行标题) + public int Row { get; init; } = -1; + public int Column { get; init; } = -1; public static UserAction FromJson(JsonObject json) => new() { @@ -22,5 +25,7 @@ public sealed class UserAction Button = (string?)json["button"] ?? "left", X = (int?)json["x"] ?? 0, Y = (int?)json["y"] ?? 0, + Row = (int?)json["row"] ?? -1, + Column = (int?)json["column"] ?? -1, }; } diff --git a/samples/p6-t2-存储环境数据/Form1.Designer.cs b/samples/p6-t2-存储环境数据/Form1.Designer.cs new file mode 100644 index 0000000..805abb0 --- /dev/null +++ b/samples/p6-t2-存储环境数据/Form1.Designer.cs @@ -0,0 +1,282 @@ +namespace 校园数据存储 +{ + partial class Form1 + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.groupBox1 = new System.Windows.Forms.GroupBox(); + this.btnDelete = new System.Windows.Forms.Button(); + this.btnUpdate = new System.Windows.Forms.Button(); + this.btn_save = new System.Windows.Forms.Button(); + this.btn_get = new System.Windows.Forms.Button(); + this.txt_humidity = new System.Windows.Forms.TextBox(); + this.txt_temperature = new System.Windows.Forms.TextBox(); + this.txt_light = new System.Windows.Forms.TextBox(); + this.lbl_humidity = new System.Windows.Forms.Label(); + this.lbl_temperature = new System.Windows.Forms.Label(); + this.lbl_light = new System.Windows.Forms.Label(); + this.groupBox2 = new System.Windows.Forms.GroupBox(); + this.btn_Open = new System.Windows.Forms.Button(); + this.com_port = new System.Windows.Forms.ComboBox(); + this.label4 = new System.Windows.Forms.Label(); + this.groupBox3 = new System.Windows.Forms.GroupBox(); + this.dataGridView1 = new System.Windows.Forms.DataGridView(); + this.groupBox1.SuspendLayout(); + this.groupBox2.SuspendLayout(); + this.groupBox3.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit(); + this.SuspendLayout(); + // + // groupBox1 + // + this.groupBox1.Controls.Add(this.btnDelete); + this.groupBox1.Controls.Add(this.btnUpdate); + this.groupBox1.Controls.Add(this.btn_save); + this.groupBox1.Controls.Add(this.btn_get); + this.groupBox1.Controls.Add(this.txt_humidity); + this.groupBox1.Controls.Add(this.txt_temperature); + this.groupBox1.Controls.Add(this.txt_light); + this.groupBox1.Controls.Add(this.lbl_humidity); + this.groupBox1.Controls.Add(this.lbl_temperature); + this.groupBox1.Controls.Add(this.lbl_light); + this.groupBox1.Location = new System.Drawing.Point(207, 11); + this.groupBox1.Margin = new System.Windows.Forms.Padding(2); + this.groupBox1.Name = "groupBox1"; + this.groupBox1.Padding = new System.Windows.Forms.Padding(2); + this.groupBox1.Size = new System.Drawing.Size(418, 180); + this.groupBox1.TabIndex = 0; + this.groupBox1.TabStop = false; + this.groupBox1.Text = "数据显示"; + // + // btnDelete + // + this.btnDelete.Location = new System.Drawing.Point(229, 138); + this.btnDelete.Margin = new System.Windows.Forms.Padding(2); + this.btnDelete.Name = "btnDelete"; + this.btnDelete.Size = new System.Drawing.Size(72, 30); + this.btnDelete.TabIndex = 9; + this.btnDelete.Text = "删除"; + this.btnDelete.UseVisualStyleBackColor = true; + this.btnDelete.Click += new System.EventHandler(this.btnDelete_Click); + // + // btnUpdate + // + this.btnUpdate.Location = new System.Drawing.Point(106, 138); + this.btnUpdate.Margin = new System.Windows.Forms.Padding(2); + this.btnUpdate.Name = "btnUpdate"; + this.btnUpdate.Size = new System.Drawing.Size(65, 30); + this.btnUpdate.TabIndex = 8; + this.btnUpdate.Text = "修改"; + this.btnUpdate.UseVisualStyleBackColor = true; + this.btnUpdate.Click += new System.EventHandler(this.btnUpdate_Click); + // + // btn_save + // + this.btn_save.Location = new System.Drawing.Point(229, 88); + this.btn_save.Margin = new System.Windows.Forms.Padding(2); + this.btn_save.Name = "btn_save"; + this.btn_save.Size = new System.Drawing.Size(103, 26); + this.btn_save.TabIndex = 7; + this.btn_save.Text = "保存"; + this.btn_save.UseVisualStyleBackColor = true; + this.btn_save.Click += new System.EventHandler(this.btn_save_Click); + // + // btn_get + // + this.btn_get.Location = new System.Drawing.Point(229, 35); + this.btn_get.Margin = new System.Windows.Forms.Padding(2); + this.btn_get.Name = "btn_get"; + this.btn_get.Size = new System.Drawing.Size(103, 26); + this.btn_get.TabIndex = 6; + this.btn_get.Text = "获取"; + this.btn_get.UseVisualStyleBackColor = true; + this.btn_get.Click += new System.EventHandler(this.btn_get_Click); + // + // txt_humidity + // + this.txt_humidity.Location = new System.Drawing.Point(82, 100); + this.txt_humidity.Margin = new System.Windows.Forms.Padding(2); + this.txt_humidity.Name = "txt_humidity"; + this.txt_humidity.Size = new System.Drawing.Size(110, 21); + this.txt_humidity.TabIndex = 5; + // + // txt_temperature + // + this.txt_temperature.Location = new System.Drawing.Point(82, 70); + this.txt_temperature.Margin = new System.Windows.Forms.Padding(2); + this.txt_temperature.Name = "txt_temperature"; + this.txt_temperature.Size = new System.Drawing.Size(110, 21); + this.txt_temperature.TabIndex = 4; + // + // txt_light + // + this.txt_light.Location = new System.Drawing.Point(82, 35); + this.txt_light.Margin = new System.Windows.Forms.Padding(2); + this.txt_light.Name = "txt_light"; + this.txt_light.Size = new System.Drawing.Size(110, 21); + this.txt_light.TabIndex = 3; + // + // lbl_humidity + // + this.lbl_humidity.AutoSize = true; + this.lbl_humidity.Location = new System.Drawing.Point(40, 102); + this.lbl_humidity.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0); + this.lbl_humidity.Name = "lbl_humidity"; + this.lbl_humidity.Size = new System.Drawing.Size(29, 12); + this.lbl_humidity.TabIndex = 2; + this.lbl_humidity.Text = "湿度"; + // + // lbl_temperature + // + this.lbl_temperature.AutoSize = true; + this.lbl_temperature.Location = new System.Drawing.Point(40, 70); + this.lbl_temperature.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0); + this.lbl_temperature.Name = "lbl_temperature"; + this.lbl_temperature.Size = new System.Drawing.Size(29, 12); + this.lbl_temperature.TabIndex = 1; + this.lbl_temperature.Text = "温度"; + // + // lbl_light + // + this.lbl_light.AutoSize = true; + this.lbl_light.Location = new System.Drawing.Point(40, 38); + this.lbl_light.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0); + this.lbl_light.Name = "lbl_light"; + this.lbl_light.Size = new System.Drawing.Size(29, 12); + this.lbl_light.TabIndex = 0; + this.lbl_light.Text = "光照度"; + // + // groupBox2 + // + this.groupBox2.Controls.Add(this.btn_Open); + this.groupBox2.Controls.Add(this.com_port); + this.groupBox2.Controls.Add(this.label4); + this.groupBox2.Location = new System.Drawing.Point(4, 10); + this.groupBox2.Margin = new System.Windows.Forms.Padding(2); + this.groupBox2.Name = "groupBox2"; + this.groupBox2.Padding = new System.Windows.Forms.Padding(2); + this.groupBox2.Size = new System.Drawing.Size(199, 181); + this.groupBox2.TabIndex = 1; + this.groupBox2.TabStop = false; + this.groupBox2.Text = "配置"; + // + // btn_Open + // + this.btn_Open.Location = new System.Drawing.Point(55, 102); + this.btn_Open.Margin = new System.Windows.Forms.Padding(2); + this.btn_Open.Name = "btn_Open"; + this.btn_Open.Size = new System.Drawing.Size(53, 20); + this.btn_Open.TabIndex = 4; + this.btn_Open.Text = "开启"; + this.btn_Open.UseVisualStyleBackColor = true; + this.btn_Open.Click += new System.EventHandler(this.btn_Open_Click); + // + // com_port + // + this.com_port.FormattingEnabled = true; + this.com_port.Location = new System.Drawing.Point(68, 53); + this.com_port.Margin = new System.Windows.Forms.Padding(2); + this.com_port.Name = "com_port"; + this.com_port.Size = new System.Drawing.Size(115, 20); + this.com_port.TabIndex = 3; + // + // label4 + // + this.label4.AutoSize = true; + this.label4.Location = new System.Drawing.Point(12, 56); + this.label4.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0); + this.label4.Name = "label4"; + this.label4.Size = new System.Drawing.Size(29, 12); + this.label4.TabIndex = 2; + this.label4.Text = "串口"; + // + // groupBox3 + // + this.groupBox3.Controls.Add(this.dataGridView1); + this.groupBox3.Location = new System.Drawing.Point(4, 195); + this.groupBox3.Margin = new System.Windows.Forms.Padding(2); + this.groupBox3.Name = "groupBox3"; + this.groupBox3.Padding = new System.Windows.Forms.Padding(2); + this.groupBox3.Size = new System.Drawing.Size(625, 214); + this.groupBox3.TabIndex = 3; + this.groupBox3.TabStop = false; + this.groupBox3.Text = "历史记录"; + // + // dataGridView1 + // + this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dataGridView1.Location = new System.Drawing.Point(7, 19); + this.dataGridView1.Margin = new System.Windows.Forms.Padding(2); + this.dataGridView1.Name = "dataGridView1"; + this.dataGridView1.RowTemplate.Height = 27; + this.dataGridView1.Size = new System.Drawing.Size(614, 191); + this.dataGridView1.TabIndex = 0; + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(640, 412); + this.Controls.Add(this.groupBox3); + this.Controls.Add(this.groupBox2); + this.Controls.Add(this.groupBox1); + this.Margin = new System.Windows.Forms.Padding(2); + this.Name = "Form1"; + this.Text = "校园灾情数据存储"; + this.Load += new System.EventHandler(this.Form1_Load); + this.groupBox1.ResumeLayout(false); + this.groupBox1.PerformLayout(); + this.groupBox2.ResumeLayout(false); + this.groupBox2.PerformLayout(); + this.groupBox3.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit(); + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.GroupBox groupBox1; + private System.Windows.Forms.Button btn_save; + private System.Windows.Forms.Button btn_get; + private System.Windows.Forms.TextBox txt_humidity; + private System.Windows.Forms.TextBox txt_temperature; + private System.Windows.Forms.TextBox txt_light; + private System.Windows.Forms.Label lbl_humidity; + private System.Windows.Forms.Label lbl_temperature; + private System.Windows.Forms.Label lbl_light; + private System.Windows.Forms.GroupBox groupBox2; + private System.Windows.Forms.Button btn_Open; + private System.Windows.Forms.ComboBox com_port; + private System.Windows.Forms.Label label4; + private System.Windows.Forms.Button btnDelete; + private System.Windows.Forms.Button btnUpdate; + private System.Windows.Forms.GroupBox groupBox3; + private System.Windows.Forms.DataGridView dataGridView1; + } +} + diff --git a/samples/p6-t2-存储环境数据/Form1.cs b/samples/p6-t2-存储环境数据/Form1.cs new file mode 100644 index 0000000..0496797 --- /dev/null +++ b/samples/p6-t2-存储环境数据/Form1.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.Data.SqlClient;//引用数据库操作库 +using System.IO.Ports; +using DeviceCtrlLibrary; +using DeviceCtrlLibrary.Model;//引用ADAM4017ResModel + +namespace 校园数据存储 +{ + public partial class Form1 : Form + { + public Form1() + { + InitializeComponent(); + } + + string sqlConnectString = @"Data Source=.;Initial Catalog=环境数据表;User ID=sa;Password=123456";//数据库的连接字符串 + SqlConnection mySqlConnection; + SqlCommand mySqlCmd; + SqlDataAdapter sqldata; + bool IsOpen = false; + ADAM4017ResModel ResModel; + DataSet ds; + + private void Form1_Load(object sender, EventArgs e) + { + mySqlConnection = new SqlConnection(sqlConnectString); + mySqlConnection.Open(); + //将系统串口名依次加入下拉列表框中 + foreach (string item in SerialPort.GetPortNames()) + { + com_port.Items.Add(item); + } + } + + private void btn_Open_Click(object sender, EventArgs e) + { + if (!IsOpen) + { + DeviceCtrlHelper.adam4017Helper.Open(com_port.Text); + btn_Open.Text = "关闭"; + IsOpen = true; + } + else + { + DeviceCtrlHelper.adam4017Helper.Close(); + btn_Open.Text = "开启"; + IsOpen = false; + } + } + + private void btn_get_Click(object sender, EventArgs e) + { + ResModel = DeviceCtrlHelper.adam4017Helper.SetData(); + txt_light.Text = ResModel.lightValue; + txt_humidity.Text = ResModel.humidityValue; + txt_temperature.Text = ResModel.temperatureValue; + } + + //数据绑定:查询表中全部数据,显示在表格中 + private void Databind() + { + mySqlConnection = new SqlConnection(sqlConnectString); + mySqlConnection.Open(); + SqlCommand sqlcmd = new SqlCommand("select ID as '编号',temperature as '温度',humidity as '湿度',light as '光照度', addtime as '时间' from CampusEnvironment", mySqlConnection); + sqldata = new SqlDataAdapter(sqlcmd); + ds = new DataSet(); + sqldata.Fill(ds); + dataGridView1.DataSource = ds.Tables[0].DefaultView; + } + + private void btn_save_Click(object sender, EventArgs e) + { + string light = txt_light.Text; + string temperature = txt_temperature.Text; + string humidity = txt_humidity.Text; + try + { + //写明列名,值按列名的顺序存入对应的列 + mySqlCmd = new SqlCommand("insert into CampusEnvironment (temperature, humidity, light, addtime) values (" + temperature + "," + humidity + "," + light + ", '" + DateTime.Now.ToString() + "')", mySqlConnection); + mySqlCmd.ExecuteNonQuery(); + MessageBox.Show("保存成功!"); + Databind(); + } + catch (Exception ex) + { + MessageBox.Show("保存失败:" + ex.ToString()); + } + } + + private void btnUpdate_Click(object sender, EventArgs e) + { + //根据查询语句自动生成更新语句,把表格中修改过的数据写回数据库 + SqlCommandBuilder cmdb = new SqlCommandBuilder(sqldata); + sqldata.UpdateCommand = cmdb.GetUpdateCommand(); + if (sqldata.Update(ds.Tables[0]) == 1) + { + MessageBox.Show("修改成功!"); + Databind(); + } + else + { + MessageBox.Show("修改失败!"); + } + } + + private void btnDelete_Click(object sender, EventArgs e) + { + try + { + mySqlCmd = new SqlCommand("delete from CampusEnvironment where id=" + dataGridView1.CurrentRow.Cells[0].Value.ToString(), mySqlConnection); + mySqlCmd.ExecuteNonQuery(); + MessageBox.Show("删除成功!"); + Databind(); + } + catch (Exception ex) //捕获数据库操作异常信息 + { + MessageBox.Show("删除失败:" + ex.ToString()); + } + } + } +} diff --git a/samples/p6-t2-存储环境数据/Program.cs b/samples/p6-t2-存储环境数据/Program.cs new file mode 100644 index 0000000..13239e4 --- /dev/null +++ b/samples/p6-t2-存储环境数据/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace 校园数据存储 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/samples/p6-t2-存储环境数据/checks.yaml b/samples/p6-t2-存储环境数据/checks.yaml new file mode 100644 index 0000000..e15018b --- /dev/null +++ b/samples/p6-t2-存储环境数据/checks.yaml @@ -0,0 +1,44 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序(使用 database.sql 建的初始数据库),按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 能正确显示光照度、温度、湿度 + steps: + - select: { control: com_port, item: COM2 } + - click: btn_Open + - expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" } + - board: { light: 733, temperature: 31, humidity: 42 } + - click: btn_get + - expect: { control: txt_light, text: '733' } + - expect: { control: txt_temperature, text: '31' } + - expect: { control: txt_humidity, text: '42' } + +- title: 能把数据保存到数据库,并显示在表格中 + steps: + - select: { control: com_port, item: COM2 } + - click: btn_Open + - board: { light: 733, temperature: 31, humidity: 42 } + - click: btn_get + - click: btn_save + - expect: { messageBox: 保存成功 } + - expect: { control: dataGridView1, row: [温度=31, 湿度=42, 光照度=733], desc: 表格里有刚保存的一行,温度、湿度、光照度各在对应的列 } + +- title: 能删除选中的数据 + steps: + - select: { control: com_port, item: COM2 } + - click: btn_Open + - click: btn_get + - click: btn_save + - cell: { control: dataGridView1, row: 0, column: 0 } + - click: btnDelete + - expect: { messageBox: 删除成功 } + - expect: { control: dataGridView1, noRow: [编号=1], desc: 第 1 行(编号为 1)已被删除 } + +- title: 能在表格中修改数据并保存 + steps: + - select: { control: com_port, item: COM2 } + - click: btn_Open + - click: btn_get + - click: btn_save + - edit: { control: dataGridView1, row: 1, column: 温度, value: '35' } + - click: btnUpdate + - expect: { messageBox: 修改成功 } + - expect: { control: dataGridView1, row: [编号=2, 温度=35], desc: 编号为 2 的一行温度改成了 35 } diff --git a/samples/p6-t2-存储环境数据/database.sql b/samples/p6-t2-存储环境数据/database.sql new file mode 100644 index 0000000..4457729 --- /dev/null +++ b/samples/p6-t2-存储环境数据/database.sql @@ -0,0 +1,14 @@ +-- 实验的初始数据库:任务 1 建的"环境数据表"库和 EnvironmentWork 表,任务 2 第 1 步建的 CampusEnvironment 表。 +-- 学生可以在数据库页面"恢复实验初始数据";评分时每个检查项用一份新的。 +CREATE DATABASE 环境数据表 +GO +USE 环境数据表 +GO +CREATE TABLE EnvironmentWork (light varchar(MAX) NULL, temperature varchar(MAX) NULL, humidity varchar(MAX) NULL) +CREATE TABLE CampusEnvironment (ID int IDENTITY(1,1) PRIMARY KEY, light varchar(50), humidity varchar(50), temperature varchar(50), addtime datetime) +GO +INSERT INTO EnvironmentWork VALUES ('520', '24', '58') +INSERT INTO EnvironmentWork VALUES ('480', '25', '61') +INSERT INTO CampusEnvironment (light, humidity, temperature, addtime) VALUES ('520', '58', '24', '2024-01-16 09:00:00') +INSERT INTO CampusEnvironment (light, humidity, temperature, addtime) VALUES ('610', '55', '26', '2024-01-18 14:30:00') +INSERT INTO CampusEnvironment (light, humidity, temperature, addtime) VALUES ('300', '63', '21', '2024-01-23 08:15:00') diff --git a/samples/p6-t2-存储环境数据/meta.json b/samples/p6-t2-存储环境数据/meta.json new file mode 100644 index 0000000..b673064 --- /dev/null +++ b/samples/p6-t2-存储环境数据/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目6 任务2 存储校园环境数据", + "source": "教材书 p119–124,界面取自配套参考工程,控件名按书上表格修改;修正 True/False 大写、全角引号;INSERT 写明列名(书上不写列名时,值的顺序和建表的列顺序不一致,温度和光照会存反)" +} diff --git a/samples/p6-t3-按时间查询/Form1.Designer.cs b/samples/p6-t3-按时间查询/Form1.Designer.cs new file mode 100644 index 0000000..60a2ac6 --- /dev/null +++ b/samples/p6-t3-按时间查询/Form1.Designer.cs @@ -0,0 +1,138 @@ +namespace 校园数据存储 +{ + partial class Form1 + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.groupBox4 = new System.Windows.Forms.GroupBox(); + this.dataGridView1 = new System.Windows.Forms.DataGridView(); + this.btnCx = new System.Windows.Forms.Button(); + this.LblStartTime = new System.Windows.Forms.Label(); + this.LblEndTime = new System.Windows.Forms.Label(); + this.StartTime = new System.Windows.Forms.DateTimePicker(); + this.EndTime = new System.Windows.Forms.DateTimePicker(); + this.groupBox4.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit(); + this.SuspendLayout(); + // + // groupBox4 + // + this.groupBox4.Controls.Add(this.dataGridView1); + this.groupBox4.Location = new System.Drawing.Point(1, 52); + this.groupBox4.Margin = new System.Windows.Forms.Padding(2); + this.groupBox4.Name = "groupBox4"; + this.groupBox4.Padding = new System.Windows.Forms.Padding(2); + this.groupBox4.Size = new System.Drawing.Size(642, 283); + this.groupBox4.TabIndex = 3; + this.groupBox4.TabStop = false; + this.groupBox4.Text = "历史记录"; + // + // dataGridView1 + // + this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dataGridView1.Location = new System.Drawing.Point(4, 17); + this.dataGridView1.Margin = new System.Windows.Forms.Padding(2); + this.dataGridView1.Name = "dataGridView1"; + this.dataGridView1.RowTemplate.Height = 27; + this.dataGridView1.Size = new System.Drawing.Size(634, 261); + this.dataGridView1.TabIndex = 0; + // + // btnCx + // + this.btnCx.Location = new System.Drawing.Point(541, 19); + this.btnCx.Name = "btnCx"; + this.btnCx.Size = new System.Drawing.Size(79, 23); + this.btnCx.TabIndex = 4; + this.btnCx.Text = "查询"; + this.btnCx.UseVisualStyleBackColor = true; + this.btnCx.Click += new System.EventHandler(this.btnCx_Click); + // + // LblStartTime + // + this.LblStartTime.AutoSize = true; + this.LblStartTime.Location = new System.Drawing.Point(25, 23); + this.LblStartTime.Name = "LblStartTime"; + this.LblStartTime.Size = new System.Drawing.Size(53, 12); + this.LblStartTime.TabIndex = 5; + this.LblStartTime.Text = "开始日期"; + // + // LblEndTime + // + this.LblEndTime.AutoSize = true; + this.LblEndTime.Location = new System.Drawing.Point(272, 25); + this.LblEndTime.Name = "LblEndTime"; + this.LblEndTime.Size = new System.Drawing.Size(53, 12); + this.LblEndTime.TabIndex = 6; + this.LblEndTime.Text = "结束日期"; + // + // StartTime + // + this.StartTime.Location = new System.Drawing.Point(84, 19); + this.StartTime.Name = "StartTime"; + this.StartTime.Size = new System.Drawing.Size(136, 21); + this.StartTime.TabIndex = 7; + // + // EndTime + // + this.EndTime.Location = new System.Drawing.Point(358, 19); + this.EndTime.Name = "EndTime"; + this.EndTime.Size = new System.Drawing.Size(148, 21); + this.EndTime.TabIndex = 8; + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(654, 346); + this.Controls.Add(this.EndTime); + this.Controls.Add(this.StartTime); + this.Controls.Add(this.LblEndTime); + this.Controls.Add(this.LblStartTime); + this.Controls.Add(this.btnCx); + this.Controls.Add(this.groupBox4); + this.Margin = new System.Windows.Forms.Padding(2); + this.Name = "Form1"; + this.Text = "查询校园灾情数据"; + this.groupBox4.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.GroupBox groupBox4; + private System.Windows.Forms.DataGridView dataGridView1; + private System.Windows.Forms.Button btnCx; + private System.Windows.Forms.Label LblStartTime; + private System.Windows.Forms.Label LblEndTime; + private System.Windows.Forms.DateTimePicker StartTime; + private System.Windows.Forms.DateTimePicker EndTime; + } +} + diff --git a/samples/p6-t3-按时间查询/Form1.cs b/samples/p6-t3-按时间查询/Form1.cs new file mode 100644 index 0000000..886dd7a --- /dev/null +++ b/samples/p6-t3-按时间查询/Form1.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.Data.SqlClient; +using System.IO.Ports; + +namespace 校园数据存储 +{ + public partial class Form1 : Form + { + public Form1() + { + InitializeComponent(); + } + + string sqlConnectString = @"Data Source=.;Initial Catalog=环境数据表;User ID=sa;Password=123456";//数据库的连接字符串 + SqlConnection mySqlConnection; + SqlCommand mySqlCmd; + SqlDataAdapter sqldata; + DataSet ds; + + private void btnCx_Click(object sender, EventArgs e) + { + mySqlConnection = new SqlConnection(sqlConnectString);//创建数据库连接对象 + mySqlConnection.Open();//打开数据库 + //查询开始日期和结束日期之间的数据 + SqlCommand sqlcmd = new SqlCommand("select ID as '编号',temperature as '温度',humidity as '湿度',light as '光照度',addtime as '时间' from CampusEnvironment where addtime BETWEEN '" + StartTime.Value.ToString() + "' AND '" + EndTime.Value.ToString() + "'", mySqlConnection); + sqldata = new SqlDataAdapter(sqlcmd); + ds = new DataSet(); + sqldata.Fill(ds); + dataGridView1.DataSource = ds.Tables[0].DefaultView; + mySqlConnection.Close();//关闭数据库 + } + } +} diff --git a/samples/p6-t3-按时间查询/Program.cs b/samples/p6-t3-按时间查询/Program.cs new file mode 100644 index 0000000..13239e4 --- /dev/null +++ b/samples/p6-t3-按时间查询/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace 校园数据存储 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/samples/p6-t3-按时间查询/checks.yaml b/samples/p6-t3-按时间查询/checks.yaml new file mode 100644 index 0000000..9f02cae --- /dev/null +++ b/samples/p6-t3-按时间查询/checks.yaml @@ -0,0 +1,16 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序(使用 database.sql 建的初始数据库),按步骤操作,核对结果。 +# 初始数据有 3 行:2024-01-16 09:00、2024-01-18 14:30、2024-01-23 08:15 +# 格式说明见 server/src/grading/checks.ts +- title: 能实现按日期查询灾情数据 + steps: + - pick: { control: StartTime, value: '2024-01-15' } + - pick: { control: EndTime, value: '2024-01-20' } + - click: btnCx + - expect: { control: dataGridView1, row: [编号=1], desc: 查到 1 月 16 日的数据 } + - expect: { control: dataGridView1, row: [编号=2], desc: 查到 1 月 18 日的数据 } + - expect: { control: dataGridView1, noRow: [编号=3], desc: 不包含时间段以外(1 月 23 日)的数据 } + - pick: { control: StartTime, value: '2024-01-21' } + - pick: { control: EndTime, value: '2024-01-31' } + - click: btnCx + - expect: { control: dataGridView1, row: [编号=3], desc: 改变日期后重新查询,查到 1 月 23 日的数据 } + - expect: { control: dataGridView1, noRow: [编号=1], desc: 改变日期后重新查询,不再包含 1 月 16 日的数据 } diff --git a/samples/p6-t3-按时间查询/database.sql b/samples/p6-t3-按时间查询/database.sql new file mode 100644 index 0000000..4457729 --- /dev/null +++ b/samples/p6-t3-按时间查询/database.sql @@ -0,0 +1,14 @@ +-- 实验的初始数据库:任务 1 建的"环境数据表"库和 EnvironmentWork 表,任务 2 第 1 步建的 CampusEnvironment 表。 +-- 学生可以在数据库页面"恢复实验初始数据";评分时每个检查项用一份新的。 +CREATE DATABASE 环境数据表 +GO +USE 环境数据表 +GO +CREATE TABLE EnvironmentWork (light varchar(MAX) NULL, temperature varchar(MAX) NULL, humidity varchar(MAX) NULL) +CREATE TABLE CampusEnvironment (ID int IDENTITY(1,1) PRIMARY KEY, light varchar(50), humidity varchar(50), temperature varchar(50), addtime datetime) +GO +INSERT INTO EnvironmentWork VALUES ('520', '24', '58') +INSERT INTO EnvironmentWork VALUES ('480', '25', '61') +INSERT INTO CampusEnvironment (light, humidity, temperature, addtime) VALUES ('520', '58', '24', '2024-01-16 09:00:00') +INSERT INTO CampusEnvironment (light, humidity, temperature, addtime) VALUES ('610', '55', '26', '2024-01-18 14:30:00') +INSERT INTO CampusEnvironment (light, humidity, temperature, addtime) VALUES ('300', '63', '21', '2024-01-23 08:15:00') diff --git a/samples/p6-t3-按时间查询/meta.json b/samples/p6-t3-按时间查询/meta.json new file mode 100644 index 0000000..3d87014 --- /dev/null +++ b/samples/p6-t3-按时间查询/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目6 任务3 查询校园安防数据", + "source": "教材书 p127–128,界面取自配套参考工程;补上书上漏写的 ds = new DataSet();(不写时 Fill 报空引用)。数据库和任务 2 相同(环境数据表)" +} diff --git a/server/src/app.ts b/server/src/app.ts index 0ba9d54..a1e618a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -20,7 +20,10 @@ import { parseChecks } from './grading/checks' import { languageService } from './language-service' import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews' import type { GradingQueue } from './grading/queue' +import { config } from './config' +import { restoreExperimentDatabases, userDatabaseDir } from './databases' import { listSamples, loadChecks, loadSample } from './samples' +import { SqlError, sqlService } from './sql-service' import { serveStatic } from './static' import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works' @@ -110,6 +113,34 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ return c.json({}) }) + // ---- 数据库页面(代替 SSMS):每个用户只能访问自己的数据库 ---- + app.get('/api/db', async (c) => c.json(await sqlService.request({ type: 'explorer', dir: await userDatabaseDir(c.get('user').id) }))) + + /** 执行 T-SQL;SQL 报错时返回 200 和 error(和 SSMS 的消息栏一样显示,不是请求失败) */ + app.post('/api/db/query', async (c) => { + const { database, sql } = await body<{ database: string; sql: string }>(c) + if (typeof sql !== 'string' || sql.length > 100_000 || typeof database !== 'string' || database.length > 64) return c.json({ message: '请求格式不对' }, 400) + try { + return c.json(await sqlService.request({ type: 'exec', dir: await userDatabaseDir(c.get('user').id), database, sql })) + } catch (error) { + if (error instanceof SqlError) return c.json({ type: 'error', number: error.number, message: error.message, line: error.line }) + throw error + } + }) + + /** 有预置数据库的实验(数据库页面"恢复实验初始数据"的选项) */ + app.get('/api/db/experiments', async (c) => { + const samples = await listSamples() + const withDb = await Promise.all(samples.map(async (s) => ((await Bun.file(`${config.samplesDir}/${s.id}/database.sql`).exists()) ? s : null))) + return c.json(withDb.filter((s) => s !== null)) + }) + + app.post('/api/db/restore', async (c) => { + const { sampleId } = await body<{ sampleId: string }>(c) + if (typeof sampleId !== 'string' || !(await listSamples()).some((s) => s.id === sampleId)) return c.json({ message: '没有这个实验' }, 400) + return c.json({ databases: await restoreExperimentDatabases(c.get('user').id, sampleId) }) + }) + // ---- 作业(学生) ---- /** 当前用户能不能看这个作业:老师都能看,学生只能看自己班的 */ const id = (c: AppContext) => Number(c.req.param('id')) diff --git a/server/src/assignments.ts b/server/src/assignments.ts index 920cc9a..1b11e8a 100644 --- a/server/src/assignments.ts +++ b/server/src/assignments.ts @@ -168,8 +168,12 @@ export function pendingSubmissionIds(db: Database): number[] { } export function submissionForGrading(db: Database, id: number) { - const row = db.query<{ assignment_id: number; files: string }, [number]>('SELECT assignment_id, files FROM submissions WHERE id = ?').get(id) - return row ? { assignmentId: row.assignment_id, files: JSON.parse(row.files) as SourceFile[] } : null + const row = db + .query<{ assignment_id: number; files: string; sample_id: string }, [number]>( + 'SELECT s.assignment_id, s.files, a.sample_id FROM submissions s JOIN assignments a ON a.id = s.assignment_id WHERE s.id = ?', + ) + .get(id) + return row ? { assignmentId: row.assignment_id, sampleId: row.sample_id, files: JSON.parse(row.files) as SourceFile[] } : null } export function saveGrade(db: Database, id: number, result: GradeResult | null) { diff --git a/server/src/databases.ts b/server/src/databases.ts new file mode 100644 index 0000000..18c29ff --- /dev/null +++ b/server/src/databases.ts @@ -0,0 +1,71 @@ +// 学生的数据库:每个用户一个目录(data/databases/<用户编号>),每个库一个 SQLite 文件,长期保存, +// 就像学生自己电脑上的 SQL Server。学生程序运行时挂进沙箱可读写;数据库页面通过 SQL 服务访问。 +// 评分时不用学生自己的库,而是按实验的 database.sql 新建一套干净的库。 +import { cp, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { config } from './config' +import { sqlService } from './sql-service' + +/** 用户的数据库目录(不存在时创建) */ +export async function userDatabaseDir(userId: number) { + const dir = resolve(config.dataDir, 'databases', String(userId)) + await mkdir(dir, { recursive: true }) + return dir +} + +/** 实验预置的数据库脚本(samples/<实验>/database.sql),没有时返回 undefined */ +export async function experimentScript(sampleId: string): Promise { + const file = Bun.file(join(config.samplesDir, sampleId, 'database.sql')) + return (await file.exists()) ? file.text() : undefined +} + +/** 按脚本建库:先清空目录再执行(脚本里 CREATE DATABASE、CREATE TABLE、INSERT 都可以写) */ +export async function runScript(dir: string, script: string) { + await mkdir(dir, { recursive: true }) + await sqlService.request({ type: 'script', dir, sql: script }) +} + +/** + * 把学生某个实验用到的库恢复成实验的初始数据:只替换脚本里建的库,学生的其他库不动。 + * 返回恢复了哪些库。 + */ +export async function restoreExperimentDatabases(userId: number, sampleId: string) { + const script = await experimentScript(sampleId) + if (!script) return [] + const temp = await mkdtemp(join(config.runsDir, 'db-')) + try { + await runScript(temp, script) + const target = await userDatabaseDir(userId) + const files = (await readdir(temp)).filter((f) => f.endsWith('.db') && f !== 'master.db') + for (const file of files) { + for (const suffix of ['', '-wal', '-shm']) await rm(join(target, file + suffix), { force: true }) + await cp(join(temp, file), join(target, file)) + } + return files.map((f) => f.slice(0, -3)) + } finally { + await rm(temp, { recursive: true, force: true }) + } +} + +/** 评分用:实验数据库的模板按实验缓存,每个检查项复制一份(互不影响) */ +const templates = new Map>() + +export async function gradingDatabaseDir(sampleId: string): Promise { + let template = templates.get(sampleId) + if (!template) { + template = (async () => { + const script = await experimentScript(sampleId) + if (script === undefined) return undefined + const dir = resolve(config.runsDir, 'db-templates', sampleId) + await rm(dir, { recursive: true, force: true }) + await runScript(dir, script) + return dir + })() + templates.set(sampleId, template) + template.catch(() => templates.delete(sampleId)) + } + const source = await template + const copy = await mkdtemp(join(config.runsDir, 'grade-db-')) + if (source && (await stat(source).catch(() => null))) await cp(source, copy, { recursive: true }) + return copy +} diff --git a/server/src/grading/checks.ts b/server/src/grading/checks.ts index deb1a6b..2503d0f 100644 --- a/server/src/grading/checks.ts +++ b/server/src/grading/checks.ts @@ -11,6 +11,12 @@ export type Step = | { kind: 'click'; control: string } | { kind: 'text'; control: string; value: string } | { kind: 'select'; control: string; item: string } + /** 点数据表格的单元格;column 是列号或列标题 */ + | { kind: 'cell'; control: string; row: number; column: number | string } + /** 在数据表格的单元格里输入新值 */ + | { kind: 'edit'; control: string; row: number; column: number | string; value: string } + /** 在日期时间选择器里选日期(和时间):2024-01-15 或 2024-01-15 08:30:00 */ + | { kind: 'pick'; control: string; value: string } | { kind: 'board'; analog: Partial>; digital: Partial> } | { kind: 'wait'; ms: number } /** desc:给学生看的期望说明,如"显示当前时间",不写就按期望自动生成 */ @@ -34,6 +40,8 @@ export type Expectation = | { kind: 'camera'; state: 'on' | 'off' | 'up' | 'down' | 'left' | 'right' } /** 程序用 Image.Save 保存了图片,路径包含这段文字(如 Camera/) */ | { kind: 'savedImage'; path: string } + /** 数据表格里有(present)/ 没有一行同时满足这些"列标题=值" */ + | { kind: 'row'; control: string; cells: Record; present: boolean } | { kind: 'console'; text: string } /** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */ @@ -81,6 +89,17 @@ function parseExpectation(raw: Record, where: string): Expectat return { kind: 'matches', control, pattern: new RegExp(source), source } } if ('unchanged' in raw) return { kind: 'unchanged', control, ms: duration(raw.unchanged, where) } + if ('row' in raw || 'noRow' in raw) { + const list = raw.row ?? raw.noRow + if (!Array.isArray(list) || list.length === 0) throw new Error(`${where}:row 要写成 [列标题=值, ...]`) + const cells: Record = {} + for (const item of list) { + const [key, ...rest] = String(item).split('=') + if (!key || rest.length === 0) throw new Error(`${where}:row 里的 ${item} 要写成 列标题=值`) + cells[key.trim()] = rest.join('=').trim() + } + return { kind: 'row', control, cells, present: 'row' in raw } + } if ('image' in raw) return { kind: 'image', control } for (const [key, prop] of Object.entries(PROP_KEYS)) { if (key in raw) return { kind: 'prop', control, prop, value: STRING_PROPS.has(prop) ? String(raw[key]) : bool(raw[key], where, key) } @@ -121,6 +140,18 @@ function parseStep(raw: unknown, where: string): Step { return { kind, control: str(obj.control, where, '控件名 control'), value: String(obj.value ?? '') } case 'select': return { kind, control: str(obj.control, where, '控件名 control'), item: str(obj.item, where, '要选的项 item') } + case 'pick': { + const value = str(obj.value, where, '日期 value') + if (!/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(value)) throw new Error(`${where}:日期要写成 2024-01-15 或 2024-01-15 08:30:00`) + return { kind, control: str(obj.control, where, '控件名 control'), value } + } + case 'cell': + case 'edit': { + const control = str(obj.control, where, '控件名 control') + if (typeof obj.row !== 'number') throw new Error(`${where}:row 要写行号(从 0 开始)`) + const column = typeof obj.column === 'number' ? obj.column : str(obj.column, where, '列号或列标题 column') + return kind === 'cell' ? { kind, control, row: obj.row, column } : { kind, control, row: obj.row, column, value: String(obj.value ?? '') } + } case 'board': return parseBoard(obj, where) case 'wait': diff --git a/server/src/grading/driver.ts b/server/src/grading/driver.ts index a0c5c49..0751b58 100644 --- a/server/src/grading/driver.ts +++ b/server/src/grading/driver.ts @@ -25,8 +25,8 @@ export class ProgramDriver { private listeners = new Set<() => void>() private lastMessageAt = Date.now() - start(projectDir: string) { - const { cmd, env } = runCommand(projectDir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) }) + start(projectDir: string, dbDir?: string) { + const { cmd, env } = runCommand(projectDir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) }, dbDir) const proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env }) this.proc = proc void readLines(proc.stdout, (line) => { diff --git a/server/src/grading/grader.ts b/server/src/grading/grader.ts index d3eb24a..bd73691 100644 --- a/server/src/grading/grader.ts +++ b/server/src/grading/grader.ts @@ -1,7 +1,9 @@ // 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。 // 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。 import type { CheckResult, ControlNode, GradeResult } from '@iot/shared' +import { rm } from 'node:fs/promises' import { compileService } from '../compile-service' +import { gradingDatabaseDir } from '../databases' import type { Check, Expectation, Step } from './checks' import { ProgramDriver } from './driver' @@ -30,8 +32,8 @@ async function withSlot(task: () => Promise): Promise { } } -/** 给学生工程评分;dir 是已写好源文件的工程目录 */ -export async function grade(dir: string, checks: Check[]): Promise { +/** 给学生工程评分;dir 是已写好源文件的工程目录,sampleId 决定用哪个实验的预置数据库(每个检查项一份) */ +export async function grade(dir: string, checks: Check[], sampleId?: string): Promise { const start = Date.now() const compiled = await compileService.compile(dir) if (!compiled.ok) { @@ -41,16 +43,17 @@ export async function grade(dir: string, checks: Check[]): Promise ms: Date.now() - start, } } - const results = await Promise.all(checks.map((check) => withSlot(() => runCheck(dir, check)))) + const results = await Promise.all(checks.map((check) => withSlot(() => runCheck(dir, check, sampleId)))) return { compiled: true, results, ms: Date.now() - start } } class Failure extends Error {} -async function runCheck(dir: string, check: Check): Promise { +async function runCheck(dir: string, check: Check, sampleId?: string): Promise { const driver = new ProgramDriver() + const dbDir = sampleId ? await gradingDatabaseDir(sampleId) : undefined try { - driver.start(dir) + driver.start(dir, dbDir) if (!(await driver.waitFor(() => driver.forms.length > 0 || driver.exited, START_TIMEOUT)) || driver.forms.length === 0) { throw new Failure(driver.exceptions[0] ? `程序出错:${driver.exceptions[0]}` : '程序启动后没有显示窗体') } @@ -64,6 +67,7 @@ async function runCheck(dir: string, check: Check): Promise { throw error } finally { driver.stop() + if (dbDir) await rm(dbDir, { recursive: true, force: true }) } } @@ -103,6 +107,28 @@ async function runStep(driver: ProgramDriver, step: Step) { await driver.settle() break } + case 'cell': + case 'edit': { + const node = need(driver, step.control) + if (node.type !== 'DataGridView') throw new Failure(`${step.control} 不是数据表格`) + const rows = gridRows(node) + if (step.row >= rows.length) throw new Failure(`数据表格 ${step.control} 里没有第 ${step.row + 1} 行(现在有 ${rows.length} 行数据)`) + const column = typeof step.column === 'number' ? step.column : gridColumns(node).findIndex((c) => c.header === step.column || c.name === step.column) + if (column < 0) throw new Failure(`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${gridColumns(node).map((c) => c.header).join('、') || '没有'})`) + driver.act(node, { action: 'cellClick', row: step.row, column }) + if (step.kind === 'edit') driver.act(node, { action: 'cellEdit', row: step.row, column, value: step.value }) + await driver.settle() + break + } + case 'pick': { + const node = need(driver, step.control) + if (node.type !== 'DateTimePicker') throw new Failure(`${step.control} 不是日期时间选择器`) + // 只给日期时:时间取中午,查询"某天到某天"时不受时分影响 + const [date, time = '12:00:00'] = step.value.split(' ') + driver.act(node, { action: 'pick', value: `${date}T${time.length === 5 ? `${time}:00` : time}` }) + await driver.settle() + break + } case 'board': driver.setBoard(step) await driver.settle() @@ -116,6 +142,28 @@ async function runStep(driver: ProgramDriver, step: Step) { } } +interface GridColumn { + name: string + header: string +} + +function gridColumns(node: ControlNode): GridColumn[] { + return (node.props.Columns as GridColumn[] | undefined) ?? [] +} + +/** 数据表格的数据行(不含最下面供新增的空行),每行按列序号排列的显示文字 */ +function gridRows(node: ControlNode): string[][] { + const rows = (node.props.Rows as { items: { cells: unknown[]; isNew: boolean }[] } | undefined)?.items ?? [] + return rows.filter((r) => !r.isNew).map((r) => r.cells.map((c) => (c === true ? 'True' : c === false ? 'False' : String(c ?? '')))) +} + +function rowMatches(node: ControlNode, cells: Record) { + const columns = gridColumns(node) + const index = Object.keys(cells).map((key) => columns.findIndex((c) => c.header === key || c.name === key)) + if (index.some((i) => i < 0)) return false + return gridRows(node).some((row) => Object.values(cells).every((value, i) => row[index[i]] === value)) +} + const seconds = (ms: number) => `${Math.round(ms / 100) / 10} 秒` const quote = (value: unknown) => (value === undefined || value === null || value === '' ? '空的' : `"${String(value)}"`) @@ -171,6 +219,10 @@ function satisfied(driver: ProgramDriver, e: Exclude path.includes(e.path)) + case 'row': { + const node = driver.find(e.control) + return node !== undefined && rowMatches(node, e.cells) === e.present + } } } @@ -214,5 +266,15 @@ function describeFailure(driver: ProgramDriver, e: Exclude `"${p}"`).join('、') return `期望${prefix}${desc ?? `保存图片到"${e.path}"`},${saved ? `实际保存的是 ${saved}` : '实际没有保存图片'}` } + case 'row': { + const node = need(driver, e.control) + const wanted = Object.entries(e.cells).map(([k, v]) => `${k}=${v}`).join(',') + const columns = gridColumns(node) + const missing = Object.keys(e.cells).filter((key) => !columns.some((c) => c.header === key || c.name === key)) + if (missing.length) return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}` + const rows = gridRows(node) + const shown = rows.slice(0, 5).map((r) => r.join(' | ')).join(';') + return `期望${prefix}数据表格 ${e.control} ${desc ?? (e.present ? `有一行 ${wanted}` : `没有 ${wanted} 的行`)},实际表格里${rows.length ? `是:${shown}${rows.length > 5 ? ' ……' : ''}` : '没有数据'}` + } } } diff --git a/server/src/grading/queue.ts b/server/src/grading/queue.ts index 6e52eff..e5bd28f 100644 --- a/server/src/grading/queue.ts +++ b/server/src/grading/queue.ts @@ -27,7 +27,7 @@ export function createGradingQueue(db: Database, onGraded: (submissionId: number await mkdir(dirname(target), { recursive: true }) await Bun.write(target, file.content) } - saveGrade(db, id, await grade(dir, checks)) + saveGrade(db, id, await grade(dir, checks, submission.sampleId)) onGraded(id) } catch (error) { console.error(`评分出错(提交 ${id}):`, error) diff --git a/server/src/index.ts b/server/src/index.ts index 11518ff..caefc13 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,7 +7,9 @@ import { languageService } from './language-service' import { config } from './config' import { openDatabase } from './db' import { createGradingQueue } from './grading/queue' +import { userDatabaseDir } from './databases' import { cleanupStaleRuns, RunSession } from './run-session' +import { sqlService } from './sql-service' import { checkSandbox, sandboxError } from './sandbox' await cleanupStaleRuns() @@ -20,6 +22,7 @@ const aiQueue = createAiQueue(db) const app = createApp(db, createGradingQueue(db, (id) => aiQueue.enqueue(id)), aiQueue) interface SocketData { + userId?: number session?: RunSession } @@ -29,8 +32,9 @@ const server = Bun.serve({ const url = new URL(req.url) if (url.pathname === '/ws') { // 浏览器的 WebSocket 不能带请求头,令牌放在网址参数里 - if (!(await tokenUser(db, url.searchParams.get('token')))) return new Response('请先登录', { status: 401 }) - if (server.upgrade(req, { data: {} })) return + const user = await tokenUser(db, url.searchParams.get('token')) + if (!user) return new Response('请先登录', { status: 401 }) + if (server.upgrade(req, { data: { userId: user.id } })) return return new Response('WebSocket 升级失败', { status: 400 }) } return app.fetch(req, server) @@ -38,7 +42,8 @@ const server = Bun.serve({ websocket: { data: {} as SocketData, open(ws) { - ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message))) + const userId = ws.data.userId! + ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)), () => userDatabaseDir(userId)) }, async message(ws, raw) { let message: ClientMessage @@ -72,5 +77,5 @@ const server = Bun.serve({ }) console.log(`服务器已启动:http://localhost:${server.port}`) -await Promise.all([compileService.start(), languageService.start()]) +await Promise.all([compileService.start(), languageService.start(), sqlService.start()]) console.log('C# 编译服务已就绪') diff --git a/server/src/run-session.ts b/server/src/run-session.ts index 9eba59a..a5f3be0 100644 --- a/server/src/run-session.ts +++ b/server/src/run-session.ts @@ -31,7 +31,11 @@ export class RunSession { /** 同一会话的多次"运行"排队依次执行,避免两次启动交错 */ private queue = Promise.resolve() - constructor(private readonly send: (message: ServerMessage) => void) { + /** dbDir:这个用户的数据库目录(运行时挂进沙箱,程序可以读写) */ + constructor( + private readonly send: (message: ServerMessage) => void, + private readonly dbDir?: () => Promise, + ) { this.sendBoard() } @@ -75,7 +79,7 @@ export class RunSession { } // 启动时就带上传感器读数,学生程序第一次 SetData() 前不必等消息 - const { cmd, env } = runCommand(dir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) }) + const { cmd, env } = runCommand(dir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) }, await this.dbDir?.()) const proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env }) this.proc = proc liveRuns++ diff --git a/server/src/sandbox.ts b/server/src/sandbox.ts index 01f26d9..c3821a8 100644 --- a/server/src/sandbox.ts +++ b/server/src/sandbox.ts @@ -8,6 +8,7 @@ import { config } from './config' const RUNNER_DIR = '/sim/runner' const APP_DIR = '/sim/app' +const DB_DIR = '/sim/db' /** 学生程序的全部环境变量(不继承服务器的) */ function runnerEnv(extra: Record) { @@ -62,18 +63,23 @@ export async function checkSandbox() { 'Ubuntu 23.10 及以上的宿主机还要执行 sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0(见 deploy/README.md)' } -/** 启动一次学生程序运行的命令行和环境变量 */ -export function runCommand(projectDir: string, extraEnv: Record) { - const env = runnerEnv(extraEnv) +/** + * 启动一次学生程序运行的命令行和环境变量。 + * dbDir:这次运行使用的数据库目录(学生自己的,或评分时的临时目录),学生程序可以读写;不给时程序连不上数据库。 + */ +export function runCommand(projectDir: string, extraEnv: Record, dbDir?: string) { if (config.sandbox === 'none') { + const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: dbDir } : {}) }) return { cmd: ['dotnet', config.runnerDll, 'run', projectDir], env } } + const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: DB_DIR } : {}) }) const cmd = [ // 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅 'nice', '-n', '10', // CPU 时间用完先收到 SIGXCPU 结束,过几秒还在就强制结束 'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds + 5}`, ...bwrapArgs(projectDir), + ...(dbDir ? ['--bind', dbDir, DB_DIR] : []), 'dotnet', `${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`, 'run', APP_DIR, ] return { cmd, env } diff --git a/server/src/sql-service.ts b/server/src/sql-service.ts new file mode 100644 index 0000000..ad8625f --- /dev/null +++ b/server/src/sql-service.ts @@ -0,0 +1,108 @@ +import type { Subprocess } from 'bun' +import { config } from './config' +import { readLines } from './runner-process' + +/** 查询窗口的一个结果集 */ +export interface SqlResultSet { + columns: { name: string; type: string | null }[] + rows: unknown[][] + /** 实际行数(只返回前 1000 行) */ + total: number +} + +export type SqlReply = + | { type: 'result'; database: string; results: SqlResultSet[]; affected: number; ms: number } + | { type: 'done' } + | { type: 'explorer'; databases: { name: string; tables: { name: string; columns: SqlColumnInfo[] }[] }[] } + | { type: 'error'; number: number; message: string; line: number } + +export interface SqlColumnInfo { + name: string + type: string + nullable: boolean + identity: boolean + primaryKey: boolean +} + +export type SqlRequest = + | { type: 'exec'; dir: string; database: string; sql: string } + | { type: 'script'; dir: string; sql: string } + | { type: 'explorer'; dir: string } + +/** SQL 报错(编号和信息与 SQL Server 一致) */ +export class SqlError extends Error { + constructor( + readonly number: number, + message: string, + readonly line: number, + ) { + super(message) + } +} + +/** + * 常驻 SQL 服务(C# 进程,T-SQL 转写成 SQLite 执行):数据库页面的查询和对象资源管理器、评分前准备实验数据库。 + * 进程意外退出时挂起的请求全部失败,下一次请求自动重启。 + */ +class SqlService { + private proc?: Subprocess<'pipe', 'pipe', 'inherit'> + private ready?: Promise + private nextId = 1 + private readonly pending = new Map void; reject: (e: Error) => void }>() + + /** 发出请求;SQL 报错时抛出 SqlError */ + async request(request: SqlRequest): Promise> { + await this.start() + const id = this.nextId++ + const reply = await new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.proc!.stdin.write(`${JSON.stringify({ id, ...request })}\n`) + this.proc!.stdin.flush() + }) + if (reply.type === 'error') throw new SqlError(reply.number, reply.message, reply.line) + return reply + } + + stop() { + const proc = this.proc + this.proc = undefined + this.ready = undefined + for (const waiter of this.pending.values()) waiter.reject(new Error('SQL 服务已停止')) + this.pending.clear() + proc?.kill() + } + + start() { + if (this.ready) return this.ready + const proc = Bun.spawn(['dotnet', config.runnerDll, 'sql-server'], { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'inherit', + env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', TZ: config.timeZone }, + }) + this.proc = proc + this.ready = new Promise((resolveReady) => { + void readLines(proc.stdout, (line) => { + const message = JSON.parse(line) as ({ type: 'ready' } | SqlReply) & { id?: number } + if (message.type === 'ready') { + resolveReady() + return + } + const waiter = this.pending.get(message.id!) + if (!waiter) return + this.pending.delete(message.id!) + waiter.resolve(message) + }) + }) + proc.exited.then((code) => { + if (this.proc !== proc) return + this.proc = undefined + this.ready = undefined + for (const waiter of this.pending.values()) waiter.reject(new Error(`SQL 服务意外退出(${code})`)) + this.pending.clear() + }) + return this.ready + } +} + +export const sqlService = new SqlService() diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 4ec7cdb..7359c70 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -1,5 +1,6 @@ // HTTP 接口测试:内存数据库,直接调用 app.fetch。 import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test' +import { rm } from 'node:fs/promises' import { createAiQueue, normalizeAiResult } from '../src/ai-review' import { createApp } from '../src/app' import { ensureTeacher } from '../src/auth' @@ -7,6 +8,7 @@ import { config } from '../src/config' import { openDatabase } from '../src/db' import { createGradingQueue, type GradingQueue } from '../src/grading/queue' import { languageService } from '../src/language-service' +import { sqlService } from '../src/sql-service' import { loadSample } from '../src/samples' let app: ReturnType @@ -434,6 +436,55 @@ describe('语言服务', () => { }, 60000) }) +describe('数据库页面', () => { + const saved = config.dataDir + // 测试的用户编号会和本机开发环境重复,数据库放到单独的目录 + beforeAll(async () => { + config.dataDir = `${config.runsDir}/test-api-data` + await rm(config.dataDir, { recursive: true, force: true }) + }) + afterAll(() => { + config.dataDir = saved + sqlService.stop() + }) + + test('建库建表、查询;SQL 报错和 SSMS 一样返回编号和信息;每个人只看到自己的库', async () => { + const t = await teacher() + await classWithStudents(t, [{ username: 's1', name: '甲', password: 'p1' }]) + const student = new Browser() + await student.login('s1', 'p1') + + expect((await t.post('/api/db/query', { database: 'master', sql: 'CREATE DATABASE 测试库' })).body).toMatchObject({ type: 'result', affected: -1 }) + const created = await t.post('/api/db/query', { + database: '测试库', + sql: "CREATE TABLE T (ID int IDENTITY(1,1) PRIMARY KEY, name nvarchar(20)); INSERT INTO T (name) VALUES (N'张三'), (N'李四'); SELECT * FROM T WHERE name LIKE N'张%'", + }) + expect(created.body).toMatchObject({ type: 'result', database: '测试库', affected: 2, results: [{ columns: [{ name: 'ID', type: 'int' }, { name: 'name', type: 'nvarchar(20)' }], rows: [[1, '张三']], total: 1 }] }) + + const error = await t.post('/api/db/query', { database: '测试库', sql: 'SELECT * FROM 不存在的表' }) + expect(error.body).toEqual({ type: 'error', number: 208, message: "对象名 '不存在的表' 无效。", line: 1 }) + + const explorer = (await t.get('/api/db')).body + expect(explorer.databases).toEqual([{ name: '测试库', tables: [{ name: 'T', columns: [ + { name: 'ID', type: 'int', nullable: false, identity: true, primaryKey: true }, + { name: 'name', type: 'nvarchar(20)', nullable: true, identity: false, primaryKey: false }, + ] }] }]) + expect((await student.get('/api/db')).body.databases).toEqual([]) + expect((await new Browser().get('/api/db')).status).toBe(401) + }, 60000) + + test('恢复实验初始数据:只替换实验用到的库', async () => { + const t = await teacher() + await t.post('/api/db/query', { database: 'master', sql: 'CREATE DATABASE 我的库' }) + expect((await t.get('/api/db/experiments')).body.map((e: { id: string }) => e.id)).toContain('p6-t2-存储环境数据') + expect((await t.post('/api/db/restore', { sampleId: 'p6-t2-存储环境数据' })).body).toEqual({ databases: ['环境数据表'] }) + const rows = await t.post('/api/db/query', { database: '环境数据表', sql: 'SELECT COUNT(*) FROM CampusEnvironment' }) + expect(rows.body.results[0].rows).toEqual([[3]]) + expect((await t.get('/api/db')).body.databases.map((d: { name: string }) => d.name)).toEqual(['我的库', '测试库', '环境数据表']) + expect((await t.post('/api/db/restore', { sampleId: '../etc' })).status).toBe(400) + }, 60000) +}) + describe('AI 评阅', () => { const SAMPLE = 'p4-t3-报警灯' const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }, { title: '注释规范', points: 5 }] } diff --git a/server/test/grading.test.ts b/server/test/grading.test.ts index e34c527..c808a76 100644 --- a/server/test/grading.test.ts +++ b/server/test/grading.test.ts @@ -3,11 +3,15 @@ import { afterAll, describe, expect, test } from 'bun:test' import { cp, rm } from 'node:fs/promises' import { resolve } from 'node:path' import { compileService } from '../src/compile-service' +import { sqlService } from '../src/sql-service' import { ROOT } from '../src/config' import { parseChecks } from '../src/grading/checks' import { grade } from '../src/grading/grader' -afterAll(() => compileService.stop()) +afterAll(() => { + compileService.stop() + sqlService.stop() +}) const SAMPLES = resolve(ROOT, 'samples') @@ -31,10 +35,10 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st describe('参考答案全部通过', async () => { const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort() - test('每个示例都有评分规则', () => expect(ids.length).toBe(13)) + test('每个示例都有评分规则', () => expect(ids.length).toBe(15)) for (const id of ids) { test(id, async () => { - const result = await grade(await prepare(id, id), await checksOf(id)) + const result = await grade(await prepare(id, id), await checksOf(id), id) expect(result.compiled).toBe(true) expect(result.results.filter((r) => !r.passed)).toEqual([]) }, 60000) @@ -45,7 +49,7 @@ describe('改坏的代码', () => { test('编译不通过时所有项都不通过', async () => { const id = 'p2-t1-LED显示屏' const dir = await prepare(id, 'compile-error', { file: 'Form1.cs', from: 'led.setLedText(res);', to: 'led.setLedText(res)' }) - const result = await grade(dir, await checksOf(id)) + const result = await grade(dir, await checksOf(id), id) expect(result.compiled).toBe(false) expect(result.results.every((r) => !r.passed && r.message === '编译没有通过,先改正编译错误')).toBe(true) }, 60000) @@ -56,14 +60,14 @@ describe('改坏的代码', () => { // 同时把 Designer.cs 里的控件改名,模拟学生没按书上命名 const designer = resolve(dir, 'Form1.Designer.cs') await Bun.write(designer, (await Bun.file(designer).text()).replaceAll('txt_SendText', 'textBox1')) - const result = await grade(dir, await checksOf(id)) + const result = await grade(dir, await checksOf(id), id) expect(result.results[0]).toMatchObject({ passed: false, message: '找不到名为 txt_SendText 的控件(书上规定的控件名,注意大小写)' }) }, 60000) test('判断写反:入侵时显示"正常"', async () => { const id = 'p4-t1-红外对射' const dir = await prepare(id, 'reversed', { file: 'Form1.cs', from: 'if (DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)', to: 'if (!DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)' }) - const result = await grade(dir, await checksOf(id)) + const result = await grade(dir, await checksOf(id), id) const realtime = result.results.find((r) => r.title === '能实时显示入侵数据')! expect(realtime.passed).toBe(false) expect(realtime.message).toBe('期望6 秒内 txt_even 的文字包含"发现入侵",实际是"正常"') @@ -74,7 +78,7 @@ describe('改坏的代码', () => { test('关闭时忘了停定时器', async () => { const id = 'p4-t1-红外对射' const dir = await prepare(id, 'timer', { file: 'Form1.cs', from: 'timer1.Stop();', to: '' }) - const result = await grade(dir, await checksOf(id)) + const result = await grade(dir, await checksOf(id), id) const stop = result.results.find((r) => r.title === '再点按钮能关闭串口、停止定时器')! expect(stop.passed).toBe(false) expect(stop.message).toStartWith('期望 txt_time 不再更新时间(定时器应已停止),实际从') @@ -83,15 +87,33 @@ describe('改坏的代码', () => { test('摄像机:忘了绑定画面事件', async () => { const id = 'p4-t2-网络摄像机' const dir = await prepare(id, 'camera', { file: 'Form1.cs', from: 'DeviceCtrlHelper.ipCameraHelper.ImageRead += ipCameraHelper_ImageRead;', to: '' }) - const result = await grade(dir, await checksOf(id)) + const result = await grade(dir, await checksOf(id), id) expect(result.results.map((r) => r.passed)).toEqual([true, false, true, false]) expect(result.results[1].message).toBe('期望3 秒内 pictureBox1 显示摄像机拍到的图像,实际是空的') }, 60000) + test('数据库:照书上 INSERT 不写列名,温度和光照存反了', async () => { + const id = 'p6-t2-存储环境数据' + const dir = await prepare(id, 'insert-order', { file: 'Form1.cs', from: 'insert into CampusEnvironment (temperature, humidity, light, addtime) values', to: 'insert into CampusEnvironment values' }) + const result = await grade(dir, await checksOf(id), id) + const save = result.results.find((r) => r.title === '能把数据保存到数据库,并显示在表格中')! + expect(save.passed).toBe(false) + expect(save.message).toContain('期望数据表格 dataGridView1 表格里有刚保存的一行,温度、湿度、光照度各在对应的列,实际表格里是:') + expect(save.message).toContain('4 | 733 | 42 | 31') + }, 60000) + + test('数据库:照书上漏写 ds = new DataSet(),查询时程序出错', async () => { + const id = 'p6-t3-按时间查询' + const dir = await prepare(id, 'no-dataset', { file: 'Form1.cs', from: ' ds = new DataSet();\n', to: '' }) + const result = await grade(dir, await checksOf(id), id) + expect(result.results[0].passed).toBe(false) + expect(result.results[0].message).toStartWith('程序出错:System.ArgumentNullException') + }, 60000) + test('关闭时忘了挂起线程', async () => { const id = 'p5-t2-烟雾' const dir = await prepare(id, 'thread', { file: 'Form1.cs', from: 'thd.Suspend();//挂起线程', to: '' }) - const result = await grade(dir, await checksOf(id)) + const result = await grade(dir, await checksOf(id), id) const stop = result.results.find((r) => r.title === '再点按钮能关闭串口、挂起线程,再次打开时线程继续')! expect(stop.passed).toBe(false) expect(stop.message).toStartWith('期望 txt_time 不再更新时间(线程应已挂起),实际从') @@ -100,14 +122,14 @@ describe('改坏的代码', () => { test('历史灾情窗体没有把数组里的记录加入列表框', async () => { const id = 'p5-t3-推送灾情' const dir = await prepare(id, 'listbox', { file: 'Form2.cs', from: 'lstbRecored.Items.Add(', to: 'Console.WriteLine(' }) - const result = await grade(dir, await checksOf(id)) + const result = await grade(dir, await checksOf(id), id) expect(result.results[2]).toMatchObject({ passed: false, message: '期望 lstbRecored 列表里有"发现火情"的记录,实际列表是空的' }) }, 60000) test('报警灯接错了 DO 口', async () => { const id = 'p4-t3-报警灯' const dir = await prepare(id, 'relay', { file: 'Form1.cs', from: 'DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);', to: 'DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen);' }) - const result = await grade(dir, await checksOf(id)) + const result = await grade(dir, await checksOf(id), id) expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' }) }, 60000) }) diff --git a/server/test/runner.test.ts b/server/test/runner.test.ts index 62089e4..cfe008d 100644 --- a/server/test/runner.test.ts +++ b/server/test/runner.test.ts @@ -1,7 +1,7 @@ // 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材示例工程的行为。 // 运行前先编译运行时:bun run build:runtime import { afterAll, afterEach, describe, expect, test } from 'bun:test' -import { cp, rm } from 'node:fs/promises' +import { cp, mkdir, rm } from 'node:fs/promises' import { resolve } from 'node:path' import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared' import { compileService } from '../src/compile-service' @@ -15,12 +15,12 @@ class Runner { private waiters: { match: (m: RunnerMessage) => boolean; resolve: (m: RunnerMessage) => void }[] = [] /** 与服务器相同的流程:先由编译服务编译,成功后启动运行进程 */ - static async start(projectDir: string) { + static async start(projectDir: string, dbDir?: string) { const runner = new Runner() const compiled = await compileService.compile(projectDir) runner.receive({ type: 'compiled', ...compiled }) if (compiled.ok) { - const { cmd, env } = runCommand(projectDir, {}) + const { cmd, env } = runCommand(projectDir, {}, dbDir) runner.proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit', env }) runner.reading = runner.read() } @@ -417,14 +417,14 @@ test('项目5 任务2:照书上直接 Abort 挂起的线程,和 .NET Framewo }, 30000) /** 把一段 Main 方法体写成工程并运行,收集它的控制台输出 */ -async function runMain(name: string, body: string, usings = '') { +async function runMain(name: string, body: string, usings = '', dbDir?: string) { const dir = resolve(ROOT, '.runs', `test-${name}`) await rm(dir, { recursive: true, force: true }) await Bun.write( `${dir}/Program.cs`, `using System;\nusing System.IO;\nusing System.Linq;\n${usings}\nstatic class Program\n{\n static void Main()\n {\n${body}\n }\n}\n`, ) - runner = await Runner.start(dir) + runner = await Runner.start(dir, dbDir) return { runner, dir } } @@ -544,6 +544,92 @@ test('图片:程序所在目录可写,Image.Save / FromFile 往返,文件 expect(runner.messages.filter((m) => m.type === 'imageSaved').map((m) => (m as { path: string }).path)).toEqual(['Pics/a.png']) }, 30000) +test('数据库:照书上的写法建库建表、插入、查询、SqlCommandBuilder 修改、删除、按时间段查询;报错和 SQL Server 一样', async () => { + const dbDir = resolve(ROOT, '.runs', 'test-db-data') + await rm(dbDir, { recursive: true, force: true }) + await mkdir(dbDir, { recursive: true }) + const { runner } = await runMain( + 'database', + ` + var master = new SqlConnection(@"Data Source= PC-20150908SCJF\\SQLSERVER1026;User ID=sa;Password=123456"); + master.Open(); + new SqlCommand("CREATE DATABASE 环境数据表", master).ExecuteNonQuery(); + master.Close(); + + string cs = @"Data Source=.;Initial Catalog=环境数据表;User ID=sa;Password=123456"; + var conn = new SqlConnection(cs); + conn.Open(); + new SqlCommand("CREATE TABLE CampusEnvironment (ID int IDENTITY(1,1) PRIMARY KEY, light varchar(50), humidity varchar(50), temperature varchar(50), addtime datetime)", conn).ExecuteNonQuery(); + // 书上的写法:不写列名,值的顺序是温度、湿度、光照(和建表的列顺序不一样) + string temperature = "25", humidity = "60", light = "500"; + int n = new SqlCommand("insert into CampusEnvironment values (" + temperature + "," + humidity + "," + light + ", '" + new DateTime(2024, 1, 16, 9, 5, 3).ToString() + "')", conn).ExecuteNonQuery(); + Console.WriteLine("inserted=" + n); + new SqlCommand("insert into CampusEnvironment (light, humidity, temperature, addtime) values ('300', '55', '18', '2024/1/20 14:42:00')", conn).ExecuteNonQuery(); + new SqlCommand("insert into CampusEnvironment (light, humidity, temperature, addtime) values ('100', '50', '20', '2024-01-25 08:00:00')", conn).ExecuteNonQuery(); + + var adapter = new SqlDataAdapter(new SqlCommand("select ID as '编号',temperature as '温度',humidity as '湿度',light as '光照度', addtime as '时间' from CampusEnvironment", conn)); + var ds = new DataSet(); + adapter.Fill(ds); + var t = ds.Tables[0]; + Console.WriteLine("columns=" + string.Join(",", t.Columns.Cast().Select(c => c.ColumnName + ":" + c.DataType.Name))); + Console.WriteLine("row0=" + string.Join("|", t.Rows[0].ItemArray)); + + // 修改:改 DataTable 里的一格,SqlCommandBuilder 生成 UPDATE + var builder = new SqlCommandBuilder(adapter); + adapter.UpdateCommand = builder.GetUpdateCommand(); + t.Rows[0]["温度"] = "26"; + Console.WriteLine("updated=" + adapter.Update(t)); + Console.WriteLine("check=" + new SqlCommand("select temperature from CampusEnvironment where ID=1", conn).ExecuteScalar()); + + Console.WriteLine("deleted=" + new SqlCommand("delete from CampusEnvironment where id=" + t.Rows[2][0].ToString(), conn).ExecuteNonQuery()); + + // 任务 3:BETWEEN 拼接 DateTime.ToString()(中文格式) + var ds2 = new DataSet(); + new SqlDataAdapter(new SqlCommand("select * from CampusEnvironment where addtime BETWEEN '" + new DateTime(2024, 1, 16).ToString() + "' AND '" + new DateTime(2024, 1, 18).ToString() + "'", conn)).Fill(ds2); + Console.WriteLine("between=" + ds2.Tables[0].Rows.Count); + var reader = new SqlCommand("select count(*), max(addtime), CONVERT(varchar(10), addtime, 120) as d from CampusEnvironment group by CONVERT(varchar(10), addtime, 120) order by d", conn).ExecuteReader(); + while (reader.Read()) Console.WriteLine("group=" + reader.GetInt32(0) + "," + reader.GetDateTime(1).ToString("HH:mm") + "," + reader["d"]); + reader.Close(); + + foreach (var sql in new[] { "select * from NoSuchTable", "selec * from CampusEnvironment", "insert into CampusEnvironment values (1, 2)", "insert into CampusEnvironment (ID, light) values (5, '1')", "select nosuch from CampusEnvironment", "insert into CampusEnvironment (addtime) values ('abc')" }) + { + try { new SqlCommand(sql, conn).ExecuteNonQuery(); } + catch (SqlException ex) { Console.WriteLine("error=" + ex.Number + " " + ex.Message); } + } + try { new SqlConnection("Data Source=.;Initial Catalog=NoDb").Open(); } + catch (SqlException ex) { Console.WriteLine("error=" + ex.Number + " " + ex.Message); }`, + 'using System.Data;\nusing System.Data.SqlClient;', + dbDir, + ) + const compiled = await runner.waitFor('compiled') + expect(compiled.diagnostics.filter((d) => d.severity === 'error')).toEqual([]) + await runner.finished() + const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('') + const errors = runner.messages.filter((m) => m.type === 'exception') + expect(errors).toEqual([]) + for (const line of [ + 'inserted=1', + 'columns=编号:Int32,温度:String,湿度:String,光照度:String,时间:DateTime', + // 书上的列顺序错位:光照存了温度的值 + 'row0=1|500|60|25|2024/1/16 9:05:03', + 'updated=1', + 'check=26', + 'deleted=1', + 'between=1', + 'group=1,09:05,2024-01-16', + 'group=1,14:42,2024-01-20', + "error=208 对象名 'NoSuchTable' 无效。", + "error=102 'selec' 附近有语法错误。", + 'error=213 列名或所提供值的数目与表定义不匹配。', + "error=544 当 IDENTITY_INSERT 设置为 OFF 时,不能为表 'CampusEnvironment' 中的标识列插入显式值。", + "error=207 列名 'nosuch' 无效。", + 'error=241 从字符串转换日期和/或时间时,转换失败。', + 'error=4060 无法打开登录所请求的数据库 "NoDb"。登录失败。', + ]) { + expect(output).toContain(line) + } +}, 30000) + test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => { process.env.SIM_TEST_SECRET = '不该被看到' const { runner } = await runMain( diff --git a/shared/protocol.ts b/shared/protocol.ts index 86a8906..562045a 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -114,6 +114,12 @@ export type UserAction = | { action: 'dropDown' } | { action: 'select'; index: number } | { action: 'doubleClick' } + /** DataGridView:点、双击、修改单元格;column 为 -1 表示行标题 */ + | { action: 'cellClick'; row: number; column: number } + | { action: 'cellDoubleClick'; row: number; column: number } + | { action: 'cellEdit'; row: number; column: number; value: string } + /** DateTimePicker 选了日期或时间:value 是本地时间 yyyy-MM-ddTHH:mm:ss */ + | { action: 'pick'; value: string } | { action: 'mouseDown'; button: 'left' | 'right'; x: number; y: number } | { action: 'mouseUp'; button: 'left' | 'right'; x: number; y: number } | { action: 'close' } diff --git a/web/src/database/DesignTab.vue b/web/src/database/DesignTab.vue new file mode 100644 index 0000000..b11960c --- /dev/null +++ b/web/src/database/DesignTab.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/web/src/database/EditTab.vue b/web/src/database/EditTab.vue new file mode 100644 index 0000000..3f9b72c --- /dev/null +++ b/web/src/database/EditTab.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/web/src/database/ObjectExplorer.vue b/web/src/database/ObjectExplorer.vue new file mode 100644 index 0000000..950bdcc --- /dev/null +++ b/web/src/database/ObjectExplorer.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/web/src/database/QueryTab.vue b/web/src/database/QueryTab.vue new file mode 100644 index 0000000..044feb5 --- /dev/null +++ b/web/src/database/QueryTab.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/web/src/database/ResultGrid.vue b/web/src/database/ResultGrid.vue new file mode 100644 index 0000000..bdff4fb --- /dev/null +++ b/web/src/database/ResultGrid.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/web/src/database/sql.test.ts b/web/src/database/sql.test.ts new file mode 100644 index 0000000..7d6d4f7 --- /dev/null +++ b/web/src/database/sql.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test' +import { addColumns, createTable, deleteRow, insertRow, literal, messages, parseInput, rowCondition, updateCell, type ExplorerTable } from './sql' + +const campus: ExplorerTable = { + name: 'CampusEnvironment', + columns: [ + { name: 'ID', type: 'int', nullable: false, identity: true, primaryKey: true }, + { name: 'light', type: 'varchar(50)', nullable: true, identity: false, primaryKey: false }, + { name: 'addtime', type: 'datetime', nullable: true, identity: false, primaryKey: false }, + ], +} + +const noKey: ExplorerTable = { + name: 'EnvironmentWork', + columns: [ + { name: 'light', type: 'varchar(MAX)', nullable: true, identity: false, primaryKey: false }, + { name: 'temperature', type: 'varchar(MAX)', nullable: true, identity: false, primaryKey: false }, + { name: 'photo', type: 'varbinary(MAX)', nullable: true, identity: false, primaryKey: false }, + ], +} + +describe('编辑表数据生成的 T-SQL', () => { + test('字面量:字符串用 N 前缀并转义单引号,NULL 原样', () => { + expect(literal("it's")).toBe("N'it''s'") + expect(literal(null)).toBe('NULL') + expect(literal(25)).toBe('25') + expect(parseInput('NULL')).toBeNull() + expect(parseInput('null')).toBe('null') + }) + + test('有主键时按主键定位', () => { + expect(updateCell(campus, 1, '600', [3, '500', '2024-01-16 09:00:00.000'])).toBe('UPDATE TOP (1) [CampusEnvironment] SET [light] = N\'600\' WHERE [ID] = 3') + expect(deleteRow(campus, [3, '500', null])).toBe('DELETE TOP (1) FROM [CampusEnvironment] WHERE [ID] = 3') + }) + + test('没有主键时按所有列的原值定位(二进制列除外),空值用 IS NULL', () => { + expect(rowCondition(noKey, ['520', null, '0xFF'])).toBe("[light] = N'520' AND [temperature] IS NULL") + }) + + test('新增行跳过自增列;什么都没填时用 DEFAULT VALUES', () => { + expect(insertRow(campus, new Map([[2, '2024-01-20 08:00:00'], [0, '9'], [1, null]]))).toBe( + "INSERT INTO [CampusEnvironment] ([light], [addtime]) VALUES (NULL, N'2024-01-20 08:00:00')", + ) + expect(insertRow(campus, new Map())).toBe('INSERT INTO [CampusEnvironment] DEFAULT VALUES') + }) +}) + +describe('表设计器生成的 T-SQL', () => { + test('建表:主键和标识列不允许 Null', () => { + expect( + createTable('EnvironmentWorkNew', [ + { name: 'id', type: 'int', nullable: true, identity: true, primaryKey: true }, + { name: 'EnvironmentDay', type: 'date', nullable: true, identity: false, primaryKey: false }, + { name: 'EnvironmentWork', type: 'varchar(200)', nullable: true, identity: false, primaryKey: false }, + ]), + ).toBe('CREATE TABLE [EnvironmentWorkNew]\n(\n [id] int IDENTITY(1,1) NOT NULL,\n [EnvironmentDay] date NULL,\n [EnvironmentWork] varchar(200) NULL,\n PRIMARY KEY ([id])\n)') + }) + + test('给已有的表加列', () => { + expect(addColumns('T', [{ name: 'memo', type: 'nvarchar(50)', nullable: true, identity: false, primaryKey: false }])).toBe('ALTER TABLE [T] ADD [memo] nvarchar(50) NULL') + }) +}) + +test('消息栏和 SSMS 一致', () => { + expect(messages({ type: 'error', number: 208, message: "对象名 'X' 无效。", line: 2 })).toEqual([{ text: "消息 208,级别 16,状态 1,第 2 行\n对象名 'X' 无效。", error: true }]) + expect(messages({ type: 'result', database: 'db', results: [{ columns: [], rows: [], total: 3 }], affected: 1, ms: 1 }).map((m) => m.text)).toEqual(['(3 行受影响)', '(1 行受影响)']) + expect(messages({ type: 'result', database: 'db', results: [], affected: -1, ms: 1 }).map((m) => m.text)).toEqual(['命令已成功完成。']) +}) diff --git a/web/src/database/sql.ts b/web/src/database/sql.ts new file mode 100644 index 0000000..e13ac73 --- /dev/null +++ b/web/src/database/sql.ts @@ -0,0 +1,123 @@ +// 数据库页面(代替 SSMS)的数据和 T-SQL 生成:编辑表数据、表设计器的操作都生成 T-SQL 交给服务器执行, +// 和学生在查询窗口里写的语句走同一套转写和报错。 +import { api } from '../api' + +export interface ExplorerColumn { + name: string + type: string + nullable: boolean + identity: boolean + primaryKey: boolean +} + +export interface ExplorerTable { + name: string + columns: ExplorerColumn[] +} + +export interface ExplorerDatabase { + name: string + tables: ExplorerTable[] +} + +export interface ResultSet { + columns: { name: string; type: string | null }[] + rows: (string | number | null)[][] + total: number +} + +export type QueryReply = + | { type: 'result'; database: string; results: ResultSet[]; affected: number; ms: number } + | { type: 'error'; number: number; message: string; line: number } + +export const loadExplorer = () => api<{ databases: ExplorerDatabase[] }>('GET', '/api/db') +export const runQuery = (database: string, sql: string) => api('POST', '/api/db/query', { database, sql }) + +/** 执行并在出错时抛出(编辑表数据、设计器用);消息和 SQL Server 一样 */ +export async function execute(database: string, sql: string) { + const reply = await runQuery(database, sql) + if (reply.type === 'error') throw new Error(reply.message) + return reply +} + +export const quoteName = (name: string) => `[${name.replace(/]/g, ']]')}]` + +/** 值写成 T-SQL 字面量;null 是 NULL(和 SSMS 一样,在单元格里输入 NULL 表示空值) */ +export const literal = (value: string | number | null) => + value === null ? 'NULL' : typeof value === 'number' ? String(value) : `N'${value.replace(/'/g, "''")}'` + +/** 编辑表格里输入的文字转成要保存的值 */ +export const parseInput = (text: string): string | null => (text === 'NULL' ? null : text) + +const isBinary = (column: ExplorerColumn) => /^(binary|varbinary|image)/i.test(column.type) + +/** + * 定位一行的条件:有主键时按主键;没有主键时按所有列的原值(配合 TOP (1),有完全相同的行时只改一行,和 SSMS 一样)。 + * 二进制列无法按显示的值比较,不放进条件。 + */ +export function rowCondition(table: ExplorerTable, original: (string | number | null)[]) { + const keys = table.columns.map((column, i) => ({ column, value: original[i] })).filter(({ column }) => column.primaryKey) + const parts = (keys.length > 0 ? keys : table.columns.map((column, i) => ({ column, value: original[i] })).filter(({ column }) => !isBinary(column))).map( + ({ column, value }) => (value === null ? `${quoteName(column.name)} IS NULL` : `${quoteName(column.name)} = ${literal(value)}`), + ) + return parts.join(' AND ') +} + +export function updateCell(table: ExplorerTable, columnIndex: number, value: string | null, original: (string | number | null)[]) { + return `UPDATE TOP (1) ${quoteName(table.name)} SET ${quoteName(table.columns[columnIndex].name)} = ${literal(value)} WHERE ${rowCondition(table, original)}` +} + +export function deleteRow(table: ExplorerTable, original: (string | number | null)[]) { + return `DELETE TOP (1) FROM ${quoteName(table.name)} WHERE ${rowCondition(table, original)}` +} + +/** 新增一行:只写用户填过的列(自增列和没填的列由数据库决定) */ +export function insertRow(table: ExplorerTable, values: Map) { + const entries = [...values].filter(([i]) => !table.columns[i].identity).sort(([a], [b]) => a - b) + if (entries.length === 0) return `INSERT INTO ${quoteName(table.name)} DEFAULT VALUES` + return `INSERT INTO ${quoteName(table.name)} (${entries.map(([i]) => quoteName(table.columns[i].name)).join(', ')}) VALUES (${entries.map(([, v]) => literal(v)).join(', ')})` +} + +/** 表设计器里的一列 */ +export interface DesignColumn { + name: string + type: string + nullable: boolean + identity: boolean + primaryKey: boolean +} + +/** 设计器"数据类型"下拉里的常用类型(和 SSMS 一样也可以直接输入,如 varchar(20)) */ +export const COMMON_TYPES = [ + 'int', 'bigint', 'smallint', 'tinyint', 'bit', 'float', 'real', 'decimal(18, 0)', 'money', + 'char(10)', 'varchar(50)', 'varchar(MAX)', 'nchar(10)', 'nvarchar(50)', 'nvarchar(MAX)', 'text', 'ntext', + 'date', 'datetime', 'smalldatetime', 'time(7)', 'uniqueidentifier', 'varbinary(MAX)', 'image', +] + +export function columnDefinition(column: DesignColumn) { + const identity = column.identity ? ' IDENTITY(1,1)' : '' + const nullable = column.nullable && !column.primaryKey && !column.identity ? ' NULL' : ' NOT NULL' + return `${quoteName(column.name)} ${column.type.replace(/^time\(7\)$/i, 'time')}${identity}${nullable}` +} + +export function createTable(name: string, columns: DesignColumn[]) { + const keys = columns.filter((c) => c.primaryKey) + const lines = columns.map((c) => ` ${columnDefinition(c)}`) + if (keys.length > 0) lines.push(` PRIMARY KEY (${keys.map((c) => quoteName(c.name)).join(', ')})`) + return `CREATE TABLE ${quoteName(name)}\n(\n${lines.join(',\n')}\n)` +} + +export function addColumns(table: string, columns: DesignColumn[]) { + return columns.map((c) => `ALTER TABLE ${quoteName(table)} ADD ${columnDefinition(c)}`).join('\n') +} + +/** 消息栏的文字,和 SSMS 一致:每个结果集、增删改各一行"(n 行受影响)";出错时"消息 208,级别 16,状态 1,第 1 行" */ +export function messages(reply: QueryReply): { text: string; error: boolean }[] { + if (reply.type === 'error') { + return [{ text: `消息 ${reply.number},级别 16,状态 1,第 ${reply.line} 行\n${reply.message}`, error: true }] + } + const lines = reply.results.map((r) => ({ text: `(${r.total} 行受影响)`, error: false })) + if (reply.affected >= 0) lines.push({ text: `(${reply.affected} 行受影响)`, error: false }) + if (lines.length === 0) lines.push({ text: '命令已成功完成。', error: false }) + return lines +} diff --git a/web/src/designer/FormDesigner.vue b/web/src/designer/FormDesigner.vue index d49839b..925c59a 100644 --- a/web/src/designer/FormDesigner.vue +++ b/web/src/designer/FormDesigner.vue @@ -277,7 +277,7 @@ function onKeyDown(event: KeyboardEvent) { } // ---------- 工具箱 ---------- -const TOOLBOX_GROUPS = ['常用控件', '容器', '组件', '物联网设备'] as const +const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const const ICONS: Record = { 按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢', 定时器: '⏱', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨', diff --git a/web/src/designer/catalog.ts b/web/src/designer/catalog.ts index 120283d..63c2c00 100644 --- a/web/src/designer/catalog.ts +++ b/web/src/designer/catalog.ts @@ -37,7 +37,7 @@ export interface ControlDef { type: string /** 工具箱和属性窗口里显示的中文名(英文类名在鼠标悬停时显示) */ label: string - group: '常用控件' | '容器' | '组件' | '物联网设备' + group: '常用控件' | '容器' | '数据' | '组件' | '物联网设备' /** 新控件的名字前缀:button → button1 */ namePrefix: string /** 非可视组件(如 Timer):不在窗体上,显示在窗体下方的组件栏 */ @@ -201,6 +201,59 @@ export const CONTROLS: ControlDef[] = [ events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), initial: () => [['Size', size(100, 50)], ['TabStop', bool(false)]], }, + { + type: `${FORMS}.DataGridView`, + label: '数据表格', + group: '数据', + namePrefix: 'dataGridView', + defaultEvent: 'CellContentClick', + props: [ + ...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor'), + { name: 'ReadOnly', label: '只读', description: '只读:不能在表格里修改数据', type: 'bool', default: bool(false), category: '行为' }, + { name: 'AllowUserToAddRows', label: '允许添加行', description: '最下面显示一行空行,可以在里面输入新数据', type: 'bool', default: bool(true), category: '行为' }, + { name: 'AllowUserToDeleteRows', label: '允许删除行', description: '允许删除行', type: 'bool', default: bool(true), category: '行为' }, + { name: 'RowHeadersVisible', label: '显示行标题', description: '显示最左边的行标题列', type: 'bool', default: bool(true), category: '外观' }, + { + name: 'SelectionMode', label: '选择方式', description: '点单元格时选中什么', type: 'enum', enumType: `${FORMS}.DataGridViewSelectionMode`, + options: ['CellSelect', 'FullRowSelect', 'FullColumnSelect', 'RowHeaderSelect', 'ColumnHeaderSelect'], + optionLabels: { CellSelect: '单元格', FullRowSelect: '整行', FullColumnSelect: '整列', RowHeaderSelect: '单元格(点行标题选整行)', ColumnHeaderSelect: '单元格(点列标题选整列)' }, + default: enumValue(`${FORMS}.DataGridViewSelectionMode`, 'RowHeaderSelect'), category: '行为', + }, + { + name: 'AutoSizeColumnsMode', label: '列宽', description: '列宽怎样自动调整', type: 'enum', enumType: `${FORMS}.DataGridViewAutoSizeColumnsMode`, + options: ['None', 'Fill'], optionLabels: { None: '固定宽度', Fill: '填满表格宽度' }, + default: enumValue(`${FORMS}.DataGridViewAutoSizeColumnsMode`, 'None'), category: '布局', + }, + ], + events: [ + ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), + { name: 'CellClick', label: '单击单元格', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' }, + { name: 'CellContentClick', label: '单击单元格内容', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' }, + { name: 'CellDoubleClick', label: '双击单元格', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' }, + { name: 'CellValueChanged', label: '单元格的值改变', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' }, + { name: 'SelectionChanged', label: '选中改变', ...EVENT }, + ], + initial: () => [['ColumnHeadersHeightSizeMode', enumValue(`${FORMS}.DataGridViewColumnHeadersHeightSizeMode`, 'AutoSize')], ['Size', size(240, 150)]], + }, + { + type: `${FORMS}.DateTimePicker`, + label: '日期时间选择器', + group: '常用控件', + namePrefix: 'dateTimePicker', + defaultEvent: 'ValueChanged', + props: [ + ...COMMON_PROPS.filter((p) => p.name !== 'Text'), + { + name: 'Format', label: '格式', description: '日期和时间的显示格式', type: 'enum', enumType: `${FORMS}.DateTimePickerFormat`, + options: ['Long', 'Short', 'Time', 'Custom'], optionLabels: { Long: '长日期(2024年1月16日)', Short: '短日期(2024/1/16)', Time: '时间(9:05:03)', Custom: '自定义' }, + default: enumValue(`${FORMS}.DateTimePickerFormat`, 'Long'), category: '外观', + }, + { name: 'CustomFormat', label: '自定义格式', description: '格式为"自定义"时使用,如 yyyy-MM-dd HH:mm', type: 'string', category: '外观' }, + { name: 'ShowUpDown', label: '上下箭头', description: '用上下箭头调整,代替下拉日历', type: 'bool', default: bool(false), category: '外观' }, + ], + events: [...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), { name: 'ValueChanged', label: '值改变', ...EVENT }], + initial: () => [['Size', size(200, 21)]], + }, { type: `${FORMS}.CheckBox`, label: '复选框', diff --git a/web/src/designer/preview.ts b/web/src/designer/preview.ts index 36e1c33..ffdc902 100644 --- a/web/src/designer/preview.ts +++ b/web/src/designer/preview.ts @@ -69,6 +69,8 @@ export function previewTree(doc: DesignerDocument): ControlNode { }) // 设计器里不需要滚动:LED 只显示黑底 if (type === 'WinFormsDeviceControlLibrary.LED') props.LedText = '' + // 和 VS 一样显示今天的日期 + if (type === 'System.Windows.Forms.DateTimePicker') props.Text = previewDate(String(props.Format ?? 'Long')) return { id: name, type: clientType(type), name, props, children: childrenOf(doc, name).filter((c) => types.has(c)).map(build) } } const formProps = propsOf(doc, '') @@ -81,6 +83,13 @@ export function previewTree(doc: DesignerDocument): ControlNode { } } +function previewDate(format: string) { + const now = new Date() + const [y, m, d] = [now.getFullYear(), now.getMonth() + 1, now.getDate()] + const time = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}` + return format === 'Short' ? `${y}/${m}/${d}` : format === 'Time' ? time : `${y}年${m}月${d}日` +} + /** 非可视组件(Timer 等),显示在窗体下方的组件栏 */ export function componentsOf(doc: DesignerDocument) { return listControls(doc).filter((c) => c.parent === null && controlDef(c.type).component === true) diff --git a/web/src/editor/monaco.ts b/web/src/editor/monaco.ts index f67b236..6f95597 100644 --- a/web/src/editor/monaco.ts +++ b/web/src/editor/monaco.ts @@ -1,8 +1,9 @@ -// 按需加载 Monaco(0.57 的模块布局):编辑器 API + 全部编辑功能(查找、折叠、括号匹配等)+ C# 语法高亮, +// 按需加载 Monaco(0.57 的模块布局):编辑器 API + 全部编辑功能(查找、折叠、括号匹配等)+ C# 和 SQL 语法高亮, // 不加载其他几十种语言和 JS/TS/CSS 语言服务,体积小很多。 import * as monaco from 'monaco-editor/editor/editor.api' import 'monaco-editor/features/register.all' import 'monaco-editor/languages/definitions/csharp/register' +import 'monaco-editor/languages/definitions/sql/register' import EditorWorker from 'monaco-editor/editor/editor.worker?worker' self.MonacoEnvironment = { diff --git a/web/src/pages/DatabasePage.vue b/web/src/pages/DatabasePage.vue new file mode 100644 index 0000000..e95502b --- /dev/null +++ b/web/src/pages/DatabasePage.vue @@ -0,0 +1,333 @@ + + + + + diff --git a/web/src/pages/WorkbenchPage.vue b/web/src/pages/WorkbenchPage.vue index f799dd3..e9e1578 100644 --- a/web/src/pages/WorkbenchPage.vue +++ b/web/src/pages/WorkbenchPage.vue @@ -409,6 +409,7 @@ function run() { 我的作业 + 数据库 {{ currentUser?.name }} diff --git a/web/src/router.ts b/web/src/router.ts index d48e4a5..4bca49e 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -1,4 +1,4 @@ -// 页面路由:登录页、实验页(自由练习、做作业、老师运行学生的提交共用)、学生的作业列表、老师的班级管理页和评阅页。 +// 页面路由:登录页、实验页(自由练习、做作业、老师运行学生的提交共用)、学生的作业列表、数据库页面、老师的班级管理页和评阅页。 import { createRouter, createWebHistory } from 'vue-router' import { apiHooks } from './api' import { currentUser } from './auth' @@ -9,6 +9,7 @@ export const router = createRouter({ { path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { public: true } }, { path: '/', component: () => import('./pages/WorkbenchPage.vue') }, { path: '/assignments', component: () => import('./pages/AssignmentsPage.vue') }, + { path: '/database', component: () => import('./pages/DatabasePage.vue') }, { path: '/assignments/:id', component: () => import('./pages/WorkbenchPage.vue'), props: (route) => ({ assignmentId: String(route.params.id) }) }, { path: '/teacher', component: () => import('./pages/TeacherPage.vue'), meta: { teacher: true } }, { diff --git a/web/src/winforms/ControlView.vue b/web/src/winforms/ControlView.vue index 2744840..d04194f 100644 --- a/web/src/winforms/ControlView.vue +++ b/web/src/winforms/ControlView.vue @@ -5,6 +5,8 @@ import { computed, inject, ref, type CSSProperties } from 'vue' import type { ControlNode, UserAction } from '@iot/shared' import { boxCss, displayText } from './format' import { RUN_IMAGES, type ImageValue } from './images' +import DataGridViewControl from './DataGridViewControl.vue' +import DateTimePickerControl from './DateTimePickerControl.vue' import LedControl from './LedControl.vue' const props = defineProps<{ @@ -162,6 +164,10 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined) + + + +
+// 数据表格:显示运行器发来的列和行;单元格的值、当前单元格、选中的行都以运行器为准。 +// 编辑:双击、按 F2 或直接打字开始,回车或离开时提交(运行器按列的类型转换后写回),Esc 取消。 +import { computed, nextTick, ref, type CSSProperties } from 'vue' +import type { ControlNode, UserAction } from '@iot/shared' + +interface Column { + name: string + header: string + width: number + visible: boolean + readOnly: boolean + check: boolean + fill: boolean +} + +interface Rows { + items: { cells: (string | boolean)[]; isNew: boolean }[] + total: number + height: number +} + +const props = defineProps<{ + node: ControlNode + style: CSSProperties + enabled: boolean + act: (action: UserAction) => void +}>() + +const p = computed(() => props.node.props) +const columns = computed(() => ((p.value.Columns as Column[] | undefined) ?? []).map((c, index) => ({ ...c, index })).filter((c) => c.visible)) +const rows = computed(() => (p.value.Rows as Rows | undefined) ?? { items: [], total: 0, height: 23 }) +const current = computed(() => (p.value.CurrentCell as [number, number] | null | undefined) ?? null) +const selectedRows = computed(() => new Set((p.value.SelectedRows as number[] | undefined) ?? [])) +const rowHeaders = computed(() => p.value.RowHeadersVisible !== false) +const columnHeaders = computed(() => p.value.ColumnHeadersVisible !== false) +const hasFill = computed(() => columns.value.some((c) => c.fill)) + +const editing = ref<{ row: number; column: number; value: string } | null>(null) +const editor = ref([]) + +function isCurrent(row: number, column: number) { + return current.value?.[0] === row && current.value?.[1] === column +} + +function click(row: number, column: number) { + if (!props.enabled) return + commit() + props.act({ action: 'cellClick', row, column }) + const col = columns.value.find((c) => c.index === column) + const cell = rows.value.items[row]?.cells[column] + // 复选框列:点一下就切换 + if (col?.check && !col.readOnly && p.value.ReadOnly !== true) props.act({ action: 'cellEdit', row, column, value: cell === true ? 'False' : 'True' }) +} + +function startEdit(row: number, column: number, initial?: string) { + const col = columns.value.find((c) => c.index === column) + if (!props.enabled || !col || col.readOnly || col.check || p.value.ReadOnly === true) return + const cell = rows.value.items[row]?.cells[column] + editing.value = { row, column, value: initial ?? String(cell ?? '') } + void nextTick(() => { + const input = editor.value[0] + input?.focus() + if (initial === undefined) input?.select() + }) +} + +function commit() { + const edit = editing.value + if (!edit) return + editing.value = null + const original = String(rows.value.items[edit.row]?.cells[edit.column] ?? '') + if (edit.value !== original || rows.value.items[edit.row]?.isNew) { + if (edit.value !== '' || !rows.value.items[edit.row]?.isNew) props.act({ action: 'cellEdit', row: edit.row, column: edit.column, value: edit.value }) + } +} + +function cancel() { + editing.value = null +} + +/** 表格获得焦点时:方向键移动当前单元格,F2 / 回车编辑,直接打字开始编辑 */ +function keydown(event: KeyboardEvent) { + if (editing.value || !current.value) return + const [row, column] = current.value + const visible = columns.value.map((c) => c.index) + const at = visible.indexOf(column) + const move = (r: number, c: number) => { + if (r >= 0 && r < rows.value.items.length && c >= 0) props.act({ action: 'cellClick', row: r, column: c }) + } + switch (event.key) { + case 'ArrowDown': + move(row + 1, column) + break + case 'ArrowUp': + move(row - 1, column) + break + case 'ArrowLeft': + if (at > 0) move(row, visible[at - 1]) + break + case 'ArrowRight': + if (at < visible.length - 1) move(row, visible[at + 1]) + break + case 'F2': + case 'Enter': + startEdit(row, column) + break + default: + if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) { + startEdit(row, column, event.key) + event.preventDefault() + return + } + return + } + event.preventDefault() +} + +function columnStyle(column: Column): CSSProperties { + return column.fill ? {} : { width: `${column.width}px`, minWidth: `${column.width}px`, maxWidth: `${column.width}px` } +} + + + diff --git a/web/src/winforms/DateTimePickerControl.vue b/web/src/winforms/DateTimePickerControl.vue new file mode 100644 index 0000000..75c7d6f --- /dev/null +++ b/web/src/winforms/DateTimePickerControl.vue @@ -0,0 +1,65 @@ + + + diff --git a/web/src/winforms/winforms.css b/web/src/winforms/winforms.css index a16b2ac..5fd7da7 100644 --- a/web/src/winforms/winforms.css +++ b/web/src/winforms/winforms.css @@ -171,6 +171,136 @@ border: 2px inset #a0a0a0; } +/* DataGridView:灰色背景上的白色表格,蓝色选中行,当前单元格虚线框 */ +.wf-dgv { + overflow: auto; + border: 1px solid #646464; + outline: none; + cursor: default; +} + +.wf-dgv-table { + border-collapse: collapse; + background: #fff; + table-layout: fixed; +} + +.wf-dgv-table.wf-dgv-fill { + width: 100%; +} + +.wf-dgv-table th, +.wf-dgv-table td { + border: 1px solid #a0a0a0; + padding: 0 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: normal; + text-align: left; + height: inherit; +} + +.wf-dgv-table thead th { + height: 23px; + background: #fff; + border-color: #dadada #a0a0a0 #a0a0a0 #dadada; +} + +.wf-dgv-corner, +.wf-dgv-rowheader { + width: 41px; + min-width: 41px; + max-width: 41px; + background: #fff; + font-size: 9px; + text-align: center !important; +} + +.wf-dgv-selected td, +.wf-dgv-selected .wf-dgv-rowheader { + background: #0078d7; + color: #fff; +} + +.wf-dgv-current { + outline: 1px dotted #000; + outline-offset: -2px; +} + +.wf-dgv-editor { + width: 100%; + box-sizing: border-box; + border: none; + padding: 0; + font: inherit; + color: #000; + background: #fff; + outline: none; +} + +.wf-dgv-check { + display: inline-block; + vertical-align: middle; +} + +.wf-dgv-more { + padding: 2px 4px; + color: #fff; +} + +.wf-dtp { + display: flex; + align-items: center; + border: 1px solid #7a7a7a; + background: #fff; + overflow: hidden; +} + +.wf-dtp-text { + flex: 1; + padding: 0 3px; + white-space: nowrap; + overflow: hidden; +} + +.wf-dtp-button { + width: 17px; + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + border-left: 1px solid #dadada; +} + +.wf-dtp-spin { + display: flex; + flex-direction: column; + width: 15px; + align-self: stretch; + font-size: 8px; + line-height: 1; + text-align: center; +} + +.wf-dtp-spin span { + flex: 1; + border-left: 1px solid #dadada; +} + +.wf-dtp-native { + position: absolute; + left: 0; + bottom: 0; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; + border: 0; + padding: 0; +} + .wf-listbox { overflow-y: auto; overflow-x: hidden;