项目 6 数据存储:仿冒 SqlClient(T-SQL 转写成 SQLite)、数据库页面、DataGridView、DateTimePicker
- 每个学生一套数据库,长期保存(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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b088240da
commit
670abe5921
72 files changed
+7253
-51
No files matched your search
@@ -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、资源图片、图表、虚拟时钟、无边框窗体等)。
|
||||
+12
-3
@@ -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)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
||||
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
||||
P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"]
|
||||
P3["阶段 3 · 扩展(进行中)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库、图表、虚拟时钟"]
|
||||
P3["阶段 3 · 扩展(进行中)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、图表、虚拟时钟"]
|
||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 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 小时 | 提供可调、可加速的虚拟时钟 |
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
<!-- 需要 zh-CN 区域设置:日期格式要和中文 Windows 一致 -->
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<!-- 异常堆栈里的源文件路径不暴露服务器上的目录(StudentStackTrace 按 /runtime/Sim. 识别平台自己的代码) -->
|
||||
<PathMap>$(MSBuildThisFileDirectory)=/runtime/</PathMap>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,5 +1,6 @@
|
||||
<Solution>
|
||||
<Project Path="Sim.WinForms/Sim.WinForms.csproj" />
|
||||
<Project Path="Sim.Devices/Sim.Devices.csproj" />
|
||||
<Project Path="Sim.Data/Sim.Data.csproj" />
|
||||
<Project Path="Sim.Runner/Sim.Runner.csproj" />
|
||||
</Solution>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>一列的定义(来自元数据表,保留建表时的 T-SQL 写法)</summary>
|
||||
internal sealed record ColumnInfo(string Name, TSqlType Type, bool Nullable, bool Identity, bool PrimaryKey, string? Default);
|
||||
|
||||
internal sealed record TableInfo(string Name, IReadOnlyList<ColumnInfo> Columns)
|
||||
{
|
||||
public ColumnInfo? Find(string column) => Columns.FirstOrDefault(c => string.Equals(c.Name, column, StringComparison.OrdinalIgnoreCase));
|
||||
public ColumnInfo? Identity => Columns.FirstOrDefault(c => c.Identity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一个学生的全部数据库:一个目录,每个库一个 SQLite 文件(库名.db)。
|
||||
/// master 是默认库(连接字符串里不写 Initial Catalog 时连到它),和 SQL Server 一样也能在里面建表。
|
||||
/// </summary>
|
||||
internal sealed class DatabaseStore
|
||||
{
|
||||
public const string Master = "master";
|
||||
/// <summary>元数据表:每列的 T-SQL 类型、是否自增等。学生的 SQL 里不能引用它</summary>
|
||||
public const string MetaTable = "__sim_columns";
|
||||
private const int MaxDatabases = 20;
|
||||
/// <summary>每个库最多约 50 MB(4 KB 一页)</summary>
|
||||
private const int MaxPages = 12800;
|
||||
|
||||
public DatabaseStore(string directory) => Directory = directory;
|
||||
|
||||
public string Directory { get; }
|
||||
|
||||
/// <summary>学生程序运行时:服务器通过环境变量 SIM_DB_DIR 指定(沙箱里挂载学生自己的数据库目录)</summary>
|
||||
public static DatabaseStore? FromEnvironment() =>
|
||||
Environment.GetEnvironmentVariable("SIM_DB_DIR") is { Length: > 0 } dir ? new DatabaseStore(dir) : null;
|
||||
|
||||
public IEnumerable<string> 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<string>();
|
||||
|
||||
/// <summary>库名大小写不敏感(和 SQL Server 一样),返回磁盘上实际的名字</summary>
|
||||
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");
|
||||
|
||||
/// <summary>库名就是文件名:不能有路径分隔符等字符</summary>
|
||||
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<ColumnInfo>();
|
||||
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<string> 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<string>();
|
||||
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<ColumnInfo> 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();
|
||||
}
|
||||
|
||||
/// <summary>平台内部的表和 SQLite 系统表,学生看不到也不能引用</summary>
|
||||
public static bool IsHidden(string name) =>
|
||||
name.StartsWith("__sim", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sqlite_", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.Data.SqlClient;
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Sim.Data.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 注册到 SQLite 连接上的 T-SQL 函数和运算(转写后的 SQL 调用它们,学生的 SQL 不能直接调用 tsql_ 开头的名字)。
|
||||
/// 函数里抛出的 SqlException 先记在 <see cref="Pending"/>,执行语句处取出原样抛给学生(SQLite 只能传回文字信息)。
|
||||
/// </summary>
|
||||
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<object?, object?, object?>("tsql_add", Add, isDeterministic: true);
|
||||
c.CreateFunction<object?, object?, object?>("tsql_div", Divide, isDeterministic: true);
|
||||
c.CreateFunction<object?, object?, object?>("tsql_mod", Modulo, isDeterministic: true);
|
||||
c.CreateFunction("tsql_getdate", () => Values.FormatDateTime(TruncateToMilliseconds(DateTime.Now)));
|
||||
c.CreateFunction<string, object?, object?>("tsql_datepart", DatePart, isDeterministic: true);
|
||||
c.CreateFunction<string, object?, object?, object?>("tsql_dateadd", DateAdd, isDeterministic: true);
|
||||
c.CreateFunction<string, object?, object?, object?>("tsql_datediff", DateDiff, isDeterministic: true);
|
||||
c.CreateFunction<string, object?, object?, object?, object?>("tsql_convert", Convert, isDeterministic: true);
|
||||
c.CreateFunction<object?, object?>("tsql_len", s => s is null ? null : (long)Text(s).TrimEnd(' ').Length, isDeterministic: true);
|
||||
c.CreateFunction<object?, object?, object?>("tsql_left", (s, n) => s is null || n is null ? null : Left(Text(s), ToLong(n)), isDeterministic: true);
|
||||
c.CreateFunction<object?, object?, object?>("tsql_right", (s, n) => s is null || n is null ? null : Right(Text(s), ToLong(n)), isDeterministic: true);
|
||||
c.CreateFunction<object?, object?, object?>("tsql_charindex", (f, s) => f is null || s is null ? null : (long)Text(s).IndexOf(Text(f), StringComparison.OrdinalIgnoreCase) + 1, isDeterministic: true);
|
||||
c.CreateFunction<object?, object?>("tsql_floor", x => x is null ? null : Math.Floor(ToDouble(x)), isDeterministic: true);
|
||||
c.CreateFunction<object?, object?>("tsql_ceiling", x => x is null ? null : Math.Ceiling(ToDouble(x)), isDeterministic: true);
|
||||
c.CreateFunction<object?, object?, object?>("tsql_power", (x, y) => x is null || y is null ? null : Math.Pow(ToDouble(x), ToDouble(y)), isDeterministic: true);
|
||||
c.CreateFunction<object?, object?>("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);
|
||||
|
||||
// ---------- 运算 ----------
|
||||
|
||||
/// <summary>T-SQL 的 +:两边都是字符串时连接,有数字时按数字相加(字符串转不成数字时报错)</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>数字原样返回;字符串按 T-SQL 隐式转换成 int(带小数点时转成 float),转不了时报 245</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>和 SQL Server 一样数跨过的边界:DATEDIFF(day, '2024-01-01 23:00', '2024-01-02 01:00') = 1</summary>
|
||||
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,以及插入、修改时按列类型转换 ----------
|
||||
|
||||
/// <summary>tsql_convert('varchar', 10, 值, 120):转换成 T-SQL 类型;日期按 style 格式化</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>CONVERT 的日期样式(教材用 120:yyyy-mm-dd hh:mi:ss)</summary>
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>结果集的一列:名字、T-SQL 类型(表达式列为 null),以及来自哪张表的哪一列(SqlCommandBuilder 生成语句时要用)</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>一个结果集(读完放在内存里)</summary>
|
||||
internal sealed record QueryResult(IReadOnlyList<ResultColumn> Columns, List<object[]> Rows);
|
||||
|
||||
/// <summary>一批语句的执行结果:各个结果集和受影响的行数(只有增删改时才算,否则为 -1)</summary>
|
||||
internal sealed record BatchResult(List<QueryResult> Results, int RecordsAffected);
|
||||
|
||||
/// <summary>
|
||||
/// 一个打开的数据库连接:当前库(USE 可以切换)、事务,执行一批 T-SQL。
|
||||
/// SqlConnection 和数据库页面的查询窗口都用它。
|
||||
/// </summary>
|
||||
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)!;
|
||||
}
|
||||
|
||||
/// <summary>每批语句最长执行时间(数据库页面的查询窗口用;学生程序里由沙箱的 CPU 时间限制兜底)</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>执行一批语句;parameter 按名字(带不带 @ 都行)取参数值</summary>
|
||||
public BatchResult Execute(string sql, Func<string, (bool Found, object? Value)> parameter)
|
||||
{
|
||||
var statements = Translator.Parse(sql);
|
||||
if (TimeLimit is { } limit) _deadline = Environment.TickCount64 + (long)limit.TotalMilliseconds;
|
||||
var results = new List<QueryResult>();
|
||||
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<string, (bool Found, object? Value)> 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<string, TableInfo?>(StringComparer.OrdinalIgnoreCase);
|
||||
var columns = new List<ResultColumn>();
|
||||
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<object[]>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>SQLite 的报错转成 SQL Server 的错误号和中文信息</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Sim.Data.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 一列的 T-SQL 数据类型,如 varchar(50)、int、datetime。
|
||||
/// SQLite 表用 STRICT 模式保存(类型见 <see cref="StorageType"/>),原始的 T-SQL 类型记在元数据表里,
|
||||
/// 读取时按它把值还原成 SQL Server 返回的 .NET 类型。
|
||||
/// </summary>
|
||||
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");
|
||||
|
||||
/// <summary>如 varchar(50)、varchar(MAX)、decimal(10, 2)</summary>
|
||||
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";
|
||||
|
||||
/// <summary>SQLite STRICT 表的列类型</summary>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>字符串列的长度上限(超出时和 SQL Server 一样报"将截断字符串")</summary>
|
||||
public int? MaxLength => IsString && !IsMax && Name is not ("text" or "ntext") ? Length ?? 1 : null;
|
||||
|
||||
/// <summary>SQL Server 返回这一列时的 .NET 类型(DataTable 的列类型、DataGridView 的显示方式由它决定)</summary>
|
||||
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),
|
||||
};
|
||||
|
||||
/// <summary>平台支持的 T-SQL 类型(建表时检查)</summary>
|
||||
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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>翻译好的一条语句</summary>
|
||||
internal abstract record Statement;
|
||||
|
||||
/// <summary>在 SQLite 上执行的语句;IsQuery 表示有结果集;AfterExecute 在同一事务里更新元数据</summary>
|
||||
internal sealed record SqliteStatement(string Sql, bool IsQuery, IReadOnlyList<string> Parameters, Action<Microsoft.Data.Sqlite.SqliteConnection>? AfterExecute = null) : Statement;
|
||||
|
||||
internal sealed record CreateDatabase(string Name) : Statement;
|
||||
internal sealed record DropDatabases(IReadOnlyList<string> Names) : Statement;
|
||||
internal sealed record UseDatabase(string Name) : Statement;
|
||||
/// <summary>SET NOCOUNT ON 之类不影响结果的语句</summary>
|
||||
internal sealed record IgnoredStatement : Statement;
|
||||
|
||||
/// <summary>
|
||||
/// 把 T-SQL(SQL Server 2008 语法)转写成 SQLite 语句。只转写认识的语法节点,其余一律报"平台暂不支持",
|
||||
/// 生成的 SQL 里只会出现这里写出的关键字和 tsql_ 函数,学生的 SQL 无法借此执行 SQLite 特有的命令。
|
||||
/// 一批语句先整体解析(语法错误和 SQL Server 一样在执行前报出),执行时再逐条转写(后面的语句能用到前面刚建的表)。
|
||||
/// </summary>
|
||||
internal sealed class Translator
|
||||
{
|
||||
private readonly Func<string, TableInfo?> _lookup;
|
||||
private readonly StringBuilder _sql = new();
|
||||
private readonly List<string> _parameters = new();
|
||||
|
||||
private Translator(Func<string, TableInfo?> lookup) => _lookup = lookup;
|
||||
|
||||
/// <summary>解析整批语句;有语法错误时抛出 SqlException 102</summary>
|
||||
public static IReadOnlyList<TSqlStatement> 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();
|
||||
}
|
||||
|
||||
/// <summary>转写一条语句;lookup 按表名取表结构(INSERT 跳过自增列、按列类型转换值时要用)</summary>
|
||||
public static Statement Translate(TSqlStatement statement, Func<string, TableInfo?> 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<Microsoft.Data.Sqlite.SqliteConnection>? 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<ColumnInfo> 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);
|
||||
}
|
||||
|
||||
/// <summary>UPDATE TOP (n) / DELETE TOP (n):SQLite 不支持,改成按 rowid 限定前 n 行</summary>
|
||||
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(')');
|
||||
}
|
||||
|
||||
/// <summary>插入、修改的值按目标列的类型转换(日期统一格式、字符串转数字失败时报和 SQL Server 一样的错)</summary>
|
||||
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<UniqueConstraintDefinition>().FirstOrDefault(c => c.IsPrimaryKey);
|
||||
var keyColumns = primaryKey?.Columns.Select(c => c.Column.MultiPartIdentifier.Identifiers[^1].Value).ToList() ?? new List<string>();
|
||||
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<string>())).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<string> 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<UniqueConstraintDefinition>().Any(c => c.IsPrimaryKey);
|
||||
var nullable = !identity && !primaryKey && definition.Constraints.OfType<NullableConstraintDefinition>().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);
|
||||
}
|
||||
|
||||
/// <summary>SQLite 的列定义:STRICT 类型 + 约束;字符串长度和 bit 取值用 CHECK 实现(约束名用来认出是哪种错误)</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>DEFAULT 只支持常量和 getdate()</summary>
|
||||
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;
|
||||
|
||||
// ---------- 表名、列名 ----------
|
||||
|
||||
/// <summary>dbo.表名、库名.dbo.表名 都取最后一段(跨库访问不支持)</summary>
|
||||
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<ScalarExpression> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>DATEADD(day, ...) 的第一个参数是关键字,解析成列名</summary>
|
||||
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", "") + " 条件");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>比较里的日期字符串转成标准格式,日期列按文本比较才是按时间先后(BETWEEN '2024/1/16 9:05:03' AND ...)</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Data.SqlClient;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Sim.Data.Engine;
|
||||
|
||||
/// <summary>值在 .NET、SQLite、T-SQL 之间的转换。日期一律以 "yyyy-MM-dd HH:mm:ss" 文本保存,按文本比较即按时间先后。</summary>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>形如日期的字符串字面量(比较时转成标准格式,例如 BETWEEN '2024/1/16 9:05:03' AND ...)</summary>
|
||||
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());
|
||||
|
||||
/// <summary>按 SQL Server 接受的写法解析日期(包括中文 Windows 上 DateTime.ToString() 的 2024/1/16 9:05:03)</summary>
|
||||
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);
|
||||
|
||||
/// <summary>字符串里的日期改成标准格式;只有日期没有时间的保持只有日期(与 datetime 比较时相当于当天 0 点)</summary>
|
||||
public static string NormalizeDateLiteral(string text)
|
||||
{
|
||||
if (!TryParseDateTime(text, out var value)) return text;
|
||||
return text.Contains(':') ? FormatDateTime(value) : FormatDate(value);
|
||||
}
|
||||
|
||||
/// <summary>C# 传进来的参数值 → SQLite 能保存的值</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>SQLite 读出的值 → SQL Server 对这个类型返回的 .NET 值</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>表达式列(如 count(*)、getdate())没有声明类型:整数按 int 返回,日期样子的文本按 DateTime 返回</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>T-SQL 字符串字面量</summary>
|
||||
public static string Quote(string text) => "'" + text.Replace("'", "''") + "'";
|
||||
|
||||
/// <summary>SQLite 标识符</summary>
|
||||
public static string QuoteName(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
|
||||
|
||||
public static SqlException ConvertError(object? value, string type) =>
|
||||
SqlException.ConvertFailed(Convert.ToString(value, CultureInfo.InvariantCulture) ?? "", type);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- 仿真的 System.Data.SqlClient:教材用 SQL Server,平台底层用 SQLite(每个学生一套数据库文件),
|
||||
T-SQL 语句用 ScriptDom 解析后转写成 SQLite 语句。学生代码编译时引用本程序集。 -->
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Sim.Data</AssemblyName>
|
||||
<RootNamespace>System.Data.SqlClient</RootNamespace>
|
||||
<NoWarn>$(NoWarn);CS1591;CS8618</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- 运行器的 SQL 服务(数据库页面、准备实验数据)直接使用执行层 -->
|
||||
<InternalsVisibleTo Include="Sim.Runner" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.13" />
|
||||
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="180.140.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>执行 T-SQL 语句。一条命令里可以有多条语句(用分号或换行分开)</summary>
|
||||
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<SqlParameter> _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;
|
||||
}
|
||||
|
||||
/// <summary>按名字找参数,"@id" 和 "id" 视为同一个(和 SqlClient 一样不区分大小写)</summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data.Common;
|
||||
using Sim.Data.Engine;
|
||||
|
||||
namespace System.Data.SqlClient;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库连接。连接字符串里的服务器名、账号、密码都不检查(书上的长服务器名、"." 都能连上),
|
||||
/// 只按 Initial Catalog(或 Database)打开这个学生自己的同名数据库;不写时连到 master。
|
||||
/// </summary>
|
||||
public sealed class SqlConnection : DbConnection, ICloneable
|
||||
{
|
||||
private static readonly HashSet<string> 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;
|
||||
|
||||
/// <summary>连接打开时的数据库会话(平台内部使用)</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>在数据库和 DataSet 之间搬运数据:Fill 读出来,Update 把 DataTable 里改过的行写回去</summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>按 SqlDataAdapter 的查询语句自动生成 INSERT / UPDATE / DELETE(要求查询包含主键列)</summary>
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Collections;
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using Sim.Data.Engine;
|
||||
|
||||
namespace System.Data.SqlClient;
|
||||
|
||||
/// <summary>按行读取查询结果(结果已在内存里,读的时候不再访问数据库)</summary>
|
||||
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<bool>(ordinal);
|
||||
public override byte GetByte(int ordinal) => Cast<byte>(ordinal);
|
||||
public override char GetChar(int ordinal) => throw new NotSupportedException("SqlDataReader 不支持 GetChar。");
|
||||
public override DateTime GetDateTime(int ordinal) => Cast<DateTime>(ordinal);
|
||||
public override decimal GetDecimal(int ordinal) => Cast<decimal>(ordinal);
|
||||
public override double GetDouble(int ordinal) => Cast<double>(ordinal);
|
||||
public override float GetFloat(int ordinal) => Cast<float>(ordinal);
|
||||
public override Guid GetGuid(int ordinal) => Cast<Guid>(ordinal);
|
||||
public override short GetInt16(int ordinal) => Cast<short>(ordinal);
|
||||
public override int GetInt32(int ordinal) => Cast<int>(ordinal);
|
||||
public override long GetInt64(int ordinal) => Cast<long>(ordinal);
|
||||
public override string GetString(int ordinal) => Cast<string>(ordinal);
|
||||
|
||||
/// <summary>和 SqlClient 一样严格:列是 varchar 时 GetInt32 报"指定的转换无效",是 NULL 时报"数据为空"</summary>
|
||||
private T Cast<T>(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<byte[]>(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<string>(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);
|
||||
|
||||
/// <summary>列的结构信息:DbDataAdapter 填充、SqlCommandBuilder 生成增删改语句时要用(主键、自增、来自哪张表的哪一列)</summary>
|
||||
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<ResultColumn>();
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Data.Common;
|
||||
|
||||
namespace System.Data.SqlClient;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库报错。Number 和 Message 与中文版 SQL Server 一致(如 208"对象名 'X' 无效。"),
|
||||
/// 学生在网上搜报错信息能找到对应的说明。
|
||||
/// </summary>
|
||||
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 => "";
|
||||
|
||||
/// <summary>在 SqlClient 的公开方法里重新抛出:学生看到的调用栈从 SqlCommand.ExecuteNonQuery 等开始,不含平台内部的实现</summary>
|
||||
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";
|
||||
}
|
||||
@@ -41,11 +41,16 @@ internal static class ApiPolicy
|
||||
["System.Runtime.InteropServices.NativeMemory"] = "调用本机代码",
|
||||
};
|
||||
|
||||
/// <summary>不允许的类型里单独放行的成员:教材用 AppDomain.CurrentDomain.BaseDirectory 拼出保存照片的路径</summary>
|
||||
/// <summary>
|
||||
/// 不允许的类型里单独放行的成员:教材用 AppDomain.CurrentDomain.BaseDirectory 拼出保存照片的路径;
|
||||
/// 类型的名字(GetType().Name、DataColumn.DataType.Name)只是读名字,没有风险
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> AllowedMembers = new()
|
||||
{
|
||||
"System.AppDomain.CurrentDomain",
|
||||
"System.AppDomain.BaseDirectory",
|
||||
"System.Reflection.MemberInfo.Name",
|
||||
"System.Type.FullName",
|
||||
};
|
||||
|
||||
public static IEnumerable<Diagnostic> Check(CSharpCompilation compilation)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,5 +17,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../Sim.WinForms/Sim.WinForms.csproj" />
|
||||
<ProjectReference Include="../Sim.Devices/Sim.Devices.csproj" />
|
||||
<ProjectReference Include="../Sim.Data/Sim.Data.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 常驻 SQL 服务:数据库页面(代替 SSMS)的查询、对象资源管理器,以及评分前按实验预置脚本准备数据库。
|
||||
/// 请求里的目录由服务器给出(学生自己的数据库目录或评分用的临时目录),学生的 SQL 经转写后只能操作这个目录里的库。
|
||||
/// 每行一个请求 {"id":…,"type":"exec"|"script"|"explorer",…},回复带同样的 id;请求可以并发处理。
|
||||
/// </summary>
|
||||
internal static class SqlService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
private static readonly TimeSpan TimeLimit = TimeSpan.FromSeconds(5);
|
||||
/// <summary>查询窗口最多显示的行数</summary>
|
||||
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}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>查询窗口执行一批语句:返回各个结果集(最多 1000 行)、受影响的行数,以及执行后的当前库(USE 可能切换了)</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>执行一个脚本(多批语句,用单独一行的 GO 分隔),从 master 开始,USE 对后面的批次有效</summary>
|
||||
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" };
|
||||
}
|
||||
|
||||
/// <summary>对象资源管理器:所有库、库里的表、表的列</summary>
|
||||
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<ColumnInfo>())
|
||||
{
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>结果网格里显示的样子,和 SSMS 一致:日期 2024-01-16 09:05:03.000,bit 显示 1 / 0,二进制显示 0x…</summary>
|
||||
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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 }
|
||||
|
||||
/// <summary>日期时间选择器(教材项目 6 任务 3 选查询的起止日期)。Text 按 Format 显示,和中文 Windows 一致(Long:2024年1月16日)</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>显示的文字;赋值时按日期解析</summary>
|
||||
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);
|
||||
|
||||
/// <summary>浏览器里选了新的日期或时间(ISO 格式,本地时间):和 WinForms 一样先 ValueChanged,再 CloseUp</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -98,3 +98,4 @@ public interface IButtonControl
|
||||
void NotifyDefault(bool value);
|
||||
void PerformClick();
|
||||
}
|
||||
public enum LeftRightAlignment { Left, Right }
|
||||
@@ -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()),
|
||||
|
||||
@@ -13,6 +13,9 @@ public sealed class UserAction
|
||||
public string Button { get; init; } = "left";
|
||||
public int X { get; init; }
|
||||
public int Y { get; init; }
|
||||
/// <summary>DataGridView 的单元格:行号、列号(-1 表示行标题)</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
Generated
+282
@@ -0,0 +1,282 @@
|
||||
namespace 校园数据存储
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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')
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目6 任务2 存储校园环境数据",
|
||||
"source": "教材书 p119–124,界面取自配套参考工程,控件名按书上表格修改;修正 True/False 大写、全角引号;INSERT 写明列名(书上不写列名时,值的顺序和建表的列顺序不一致,温度和光照会存反)"
|
||||
}
|
||||
Generated
+138
@@ -0,0 +1,138 @@
|
||||
namespace 校园数据存储
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();//关闭数据库
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 日的数据 }
|
||||
@@ -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')
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目6 任务3 查询校园安防数据",
|
||||
"source": "教材书 p127–128,界面取自配套参考工程;补上书上漏写的 ds = new DataSet();(不写时 Fill 报空引用)。数据库和任务 2 相同(环境数据表)"
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<string, Promise<string | undefined>>()
|
||||
|
||||
export async function gradingDatabaseDir(sampleId: string): Promise<string | undefined> {
|
||||
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
|
||||
}
|
||||
@@ -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<Record<AnalogKey, number>>; digital: Partial<Record<DigitalKey, boolean>> }
|
||||
| { 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<string, string>; present: boolean }
|
||||
| { kind: 'console'; text: string }
|
||||
|
||||
/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */
|
||||
@@ -81,6 +89,17 @@ function parseExpectation(raw: Record<string, unknown>, 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<string, string> = {}
|
||||
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':
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<T>(task: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 给学生工程评分;dir 是已写好源文件的工程目录 */
|
||||
export async function grade(dir: string, checks: Check[]): Promise<GradeResult> {
|
||||
/** 给学生工程评分;dir 是已写好源文件的工程目录,sampleId 决定用哪个实验的预置数据库(每个检查项一份) */
|
||||
export async function grade(dir: string, checks: Check[], sampleId?: string): Promise<GradeResult> {
|
||||
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<GradeResult>
|
||||
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<CheckResult> {
|
||||
async function runCheck(dir: string, check: Check, sampleId?: string): Promise<CheckResult> {
|
||||
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<CheckResult> {
|
||||
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<string, string>) {
|
||||
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<Expectation, { kind: 'uncha
|
||||
}
|
||||
case 'savedImage':
|
||||
return driver.savedImages.some((path) => 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<Expectation, { kind:
|
||||
const saved = driver.savedImages.map((p) => `"${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 ? ' ……' : ''}` : '没有数据'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+9
-4
@@ -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# 编译服务已就绪')
|
||||
@@ -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<string>,
|
||||
) {
|
||||
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++
|
||||
|
||||
@@ -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<string, string>) {
|
||||
@@ -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<string, string>) {
|
||||
const env = runnerEnv(extraEnv)
|
||||
/**
|
||||
* 启动一次学生程序运行的命令行和环境变量。
|
||||
* dbDir:这次运行使用的数据库目录(学生自己的,或评分时的临时目录),学生程序可以读写;不给时程序连不上数据库。
|
||||
*/
|
||||
export function runCommand(projectDir: string, extraEnv: Record<string, string>, 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 }
|
||||
|
||||
@@ -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<void>
|
||||
private nextId = 1
|
||||
private readonly pending = new Map<number, { resolve: (r: SqlReply) => void; reject: (e: Error) => void }>()
|
||||
|
||||
/** 发出请求;SQL 报错时抛出 SqlError */
|
||||
async request(request: SqlRequest): Promise<Exclude<SqlReply, { type: 'error' }>> {
|
||||
await this.start()
|
||||
const id = this.nextId++
|
||||
const reply = await new Promise<SqlReply>((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<void>((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()
|
||||
@@ -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<typeof createApp>
|
||||
@@ -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 }] }
|
||||
|
||||
+33
-11
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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<DataColumn>().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(
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
// 表设计器(仿 SSMS):每行一列(列名、数据类型、允许 Null 值),右键行号设置主键,下方"列属性"里设置标识(自增)。
|
||||
// 新表保存时输入表名(CREATE TABLE);已有的表只能添加新列(ALTER TABLE ADD),已有的列只读。
|
||||
// "查看 SQL 脚本"显示保存时要执行的 T-SQL,方便对照书上的 SQL 语句。
|
||||
import { NButton, NCheckbox, NDropdown, NInput, NModal, useMessage } from 'naive-ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { addColumns, COMMON_TYPES, createTable, execute, type DesignColumn, type ExplorerTable } from './sql'
|
||||
|
||||
const props = defineProps<{ database: string; table?: ExplorerTable }>()
|
||||
const emit = defineEmits<{ saved: [table: string] }>()
|
||||
const message = useMessage()
|
||||
|
||||
interface Row extends DesignColumn {
|
||||
existing: boolean
|
||||
}
|
||||
|
||||
const blank = (): Row => ({ name: '', type: 'nchar(10)', nullable: true, identity: false, primaryKey: false, existing: false })
|
||||
const rows = ref<Row[]>([...(props.table?.columns.map((c) => ({ ...c, existing: true })) ?? []), blank()])
|
||||
const selected = ref(0)
|
||||
const menu = ref<{ x: number; y: number; row: number } | null>(null)
|
||||
const naming = ref<string | null>(null)
|
||||
const script = ref<string | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
/** 填了列名的新列(最后一行空行用来添加) */
|
||||
const newColumns = computed(() => rows.value.filter((r) => !r.existing && r.name.trim() !== ''))
|
||||
const current = computed(() => rows.value[selected.value])
|
||||
|
||||
function edited(index: number) {
|
||||
// 在最后一行填了列名:下面再补一行空行
|
||||
if (index === rows.value.length - 1 && rows.value[index].name.trim() !== '') rows.value.push(blank())
|
||||
}
|
||||
|
||||
function rowMenu(event: MouseEvent, row: number) {
|
||||
event.preventDefault()
|
||||
selected.value = row
|
||||
menu.value = null
|
||||
void nextTick(() => (menu.value = { x: event.clientX, y: event.clientY, row }))
|
||||
}
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
const row = menu.value ? rows.value[menu.value.row] : null
|
||||
if (!row || row.existing || !row.name.trim()) return [{ label: '(已有的列不能修改)', key: 'none', disabled: true }]
|
||||
return [
|
||||
{ label: row.primaryKey ? '删除主键' : '设置主键', key: 'key' },
|
||||
{ label: '删除列', key: 'delete' },
|
||||
]
|
||||
})
|
||||
|
||||
function chooseMenu(key: string) {
|
||||
const index = menu.value!.row
|
||||
menu.value = null
|
||||
const row = rows.value[index]
|
||||
if (key === 'key') {
|
||||
row.primaryKey = !row.primaryKey
|
||||
if (row.primaryKey) row.nullable = false
|
||||
} else if (key === 'delete') {
|
||||
rows.value.splice(index, 1)
|
||||
selected.value = Math.min(selected.value, rows.value.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function setIdentity(value: boolean) {
|
||||
const row = current.value
|
||||
if (value && !/^(int|bigint|smallint|tinyint)$/i.test(row.type.trim())) {
|
||||
message.warning('只有 int、bigint、smallint、tinyint 类型的列可以设为标识列(自增)')
|
||||
return
|
||||
}
|
||||
row.identity = value
|
||||
if (value) row.nullable = false
|
||||
}
|
||||
|
||||
/** 保存前检查:列名不能重复,新表至少要有一列 */
|
||||
function problem(): string | null {
|
||||
const names = rows.value.filter((r) => r.name.trim()).map((r) => r.name.trim().toLowerCase())
|
||||
if (names.length === 0) return '表里至少要有一列'
|
||||
const duplicate = names.find((n, i) => names.indexOf(n) !== i)
|
||||
if (duplicate) return `列名 "${duplicate}" 重复了`
|
||||
if (rows.value.filter((r) => r.identity).length > 1) return '一张表只能有一个标识列'
|
||||
if (props.table && newColumns.value.length === 0) return '没有要保存的新列(已有的列不能修改)'
|
||||
return null
|
||||
}
|
||||
|
||||
const sqlFor = (name: string) =>
|
||||
props.table ? addColumns(props.table.name, newColumns.value) : createTable(name, rows.value.filter((r) => r.name.trim()).map((r) => ({ ...r, name: r.name.trim(), type: r.type.trim() })))
|
||||
|
||||
function save() {
|
||||
const error = problem()
|
||||
if (error) {
|
||||
message.warning(error)
|
||||
return
|
||||
}
|
||||
if (props.table) void run(props.table.name)
|
||||
else naming.value = 'Table_1'
|
||||
}
|
||||
|
||||
async function run(name: string) {
|
||||
saving.value = true
|
||||
try {
|
||||
await execute(props.database, sqlFor(name))
|
||||
message.success(props.table ? '已添加新列' : `已创建表 ${name}`)
|
||||
naming.value = null
|
||||
emit('saved', name)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showScript() {
|
||||
const error = problem()
|
||||
script.value = error ? `-- ${error}` : sqlFor(props.table?.name ?? 'Table_1')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="design">
|
||||
<div class="bar">
|
||||
<span>{{ database }} - {{ table ? `dbo.${table.name}` : '新表' }}</span>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
|
||||
<NButton size="small" @click="showScript">查看 SQL 脚本</NButton>
|
||||
<span class="hint">右键行号设置主键;选中一列后在下方设置标识(自增){{ table ? ';已有的列不能修改,可以添加新列' : '' }}</span>
|
||||
</div>
|
||||
<div class="grid-wrap">
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="head" />
|
||||
<th>列名</th>
|
||||
<th>数据类型</th>
|
||||
<th>允许 Null 值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in rows" :key="i" :class="{ selected: selected === i, existing: row.existing }" @click="selected = i">
|
||||
<th class="head" @contextmenu="rowMenu($event, i)">{{ row.primaryKey ? '🔑' : selected === i ? '▶' : '' }}</th>
|
||||
<td><input v-model="row.name" :readonly="row.existing" @input="edited(i)" /></td>
|
||||
<td>
|
||||
<input v-model="row.type" :readonly="row.existing" list="sim-sql-types" />
|
||||
</td>
|
||||
<td class="center">
|
||||
<input v-model="row.nullable" type="checkbox" :disabled="row.existing || row.primaryKey || row.identity" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<datalist id="sim-sql-types">
|
||||
<option v-for="type in COMMON_TYPES" :key="type" :value="type" />
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="properties">
|
||||
<div class="properties-title">列属性{{ current?.name ? `:${current.name}` : '' }}</div>
|
||||
<div v-if="current && current.name.trim()" class="property">
|
||||
<NCheckbox :checked="current.identity" :disabled="current.existing" @update:checked="setIdentity">
|
||||
标识规范(是标识):插入数据时自动编号,从 1 开始,每次加 1
|
||||
</NCheckbox>
|
||||
</div>
|
||||
<div v-if="current && current.name.trim()" class="property">主键:{{ current.primaryKey ? '是' : '否' }}(右键行号设置)</div>
|
||||
</div>
|
||||
|
||||
<NModal :show="naming !== null" preset="card" title="选择名称" style="width: 360px" @update:show="naming = null">
|
||||
<div style="margin-bottom: 8px">输入表名称:</div>
|
||||
<NInput v-if="naming !== null" v-model:value="naming" autofocus @keydown.enter="naming?.trim() && run(naming.trim())" />
|
||||
<template #footer>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 8px">
|
||||
<NButton type="primary" :disabled="!naming?.trim()" :loading="saving" @click="run(naming!.trim())">确定</NButton>
|
||||
<NButton @click="naming = null">取消</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<NModal :show="script !== null" preset="card" title="SQL 脚本" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="script = null">
|
||||
<pre class="script">{{ script }}</pre>
|
||||
</NModal>
|
||||
|
||||
<NDropdown
|
||||
v-if="menu"
|
||||
show
|
||||
trigger="manual"
|
||||
placement="bottom-start"
|
||||
:x="menu.x"
|
||||
:y="menu.y"
|
||||
:options="menuOptions"
|
||||
size="small"
|
||||
@select="chooseMenu"
|
||||
@clickoutside="menu = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grid-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.grid th,
|
||||
.grid td {
|
||||
border: 1px solid #d4d4d4;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.grid thead th {
|
||||
background: #f3f3f3;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.grid .head {
|
||||
width: 26px;
|
||||
text-align: center;
|
||||
background: #f3f3f3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.grid input:not([type]),
|
||||
.grid input[list] {
|
||||
width: 180px;
|
||||
border: none;
|
||||
padding: 3px 6px;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.grid tr.selected td {
|
||||
background: #e5f1fb;
|
||||
}
|
||||
|
||||
.grid tr.existing input {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center !important;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.properties {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.properties-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.property {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.script {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
// 编辑前 200 行(仿 SSMS):改单元格立即写回数据库(UPDATE),最下面的 * 行输入新数据、离开这一行或按回车时插入(INSERT),
|
||||
// 右键行号删除(DELETE)。输入 NULL 表示空值。自增列和二进制列不能改。出错时显示数据库的报错并恢复原值。
|
||||
import { NButton, NDropdown, useDialog, useMessage } from 'naive-ui'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { deleteRow, execute, insertRow, parseInput, quoteName, updateCell, type ExplorerTable } from './sql'
|
||||
|
||||
const props = defineProps<{ database: string; table: ExplorerTable }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
type Cell = string | number | null
|
||||
const rows = ref<Cell[][]>([])
|
||||
const loading = ref(false)
|
||||
const current = ref<{ row: number; column: number } | null>(null)
|
||||
const editing = ref<{ row: number; column: number; value: string } | null>(null)
|
||||
const editor = ref<HTMLInputElement[]>([])
|
||||
/** 新行里已经填的值(列序号 → 值) */
|
||||
const newRow = ref(new Map<number, string | null>())
|
||||
const menu = ref<{ x: number; y: number; row: number } | null>(null)
|
||||
|
||||
const columns = computed(() => props.table.columns)
|
||||
const newRowIndex = computed(() => rows.value.length)
|
||||
const readOnly = (column: number) => columns.value[column].identity || /^(binary|varbinary|image)/i.test(columns.value[column].type)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const sql = `SELECT TOP (200) ${columns.value.map((c) => quoteName(c.name)).join(', ')} FROM ${quoteName(props.table.name)}`
|
||||
const reply = await execute(props.database, sql)
|
||||
rows.value = (reply.type === 'result' ? reply.results[0]?.rows : []) as Cell[][]
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行一条修改语句,然后重新读取(出错时提示,表格恢复成数据库里的样子) */
|
||||
async function apply(sql: string) {
|
||||
try {
|
||||
await execute(props.database, sql)
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
dialog.error({ title: 'Microsoft SQL Server Management Studio', content: `未更新任何行。\n${(error as Error).message}`, positiveText: '确定' })
|
||||
}
|
||||
await load()
|
||||
}
|
||||
|
||||
async function select(row: number, column: number) {
|
||||
await commitEdit()
|
||||
if (current.value?.row === newRowIndex.value && row !== newRowIndex.value) await commitNewRow()
|
||||
current.value = { row, column }
|
||||
}
|
||||
|
||||
function startEdit(row: number, column: number, initial?: string) {
|
||||
if (readOnly(column)) return
|
||||
const value = row === newRowIndex.value ? newRow.value.get(column) : rows.value[row]?.[column]
|
||||
editing.value = { row, column, value: initial ?? (value === null || value === undefined ? (row === newRowIndex.value ? '' : 'NULL') : String(value)) }
|
||||
void nextTick(() => {
|
||||
editor.value[0]?.focus()
|
||||
if (initial === undefined) editor.value[0]?.select()
|
||||
})
|
||||
}
|
||||
|
||||
async function commitEdit() {
|
||||
const edit = editing.value
|
||||
if (!edit) return
|
||||
editing.value = null
|
||||
if (edit.row === newRowIndex.value) {
|
||||
if (edit.value !== '') newRow.value.set(edit.column, parseInput(edit.value))
|
||||
return
|
||||
}
|
||||
const original = rows.value[edit.row]
|
||||
const value = parseInput(edit.value)
|
||||
if (String(original[edit.column] ?? 'NULL') === edit.value) return
|
||||
await apply(updateCell(props.table, edit.column, value, original))
|
||||
}
|
||||
|
||||
async function commitNewRow() {
|
||||
if (newRow.value.size === 0) return
|
||||
const values = newRow.value
|
||||
newRow.value = new Map()
|
||||
await apply(insertRow(props.table, values))
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (editing.value || !current.value) return
|
||||
const { row, column } = current.value
|
||||
const move = (r: number, c: number) => {
|
||||
if (r >= 0 && r <= newRowIndex.value && c >= 0 && c < columns.value.length) void select(r, c)
|
||||
}
|
||||
if (event.key === 'ArrowDown') move(row + 1, column)
|
||||
else if (event.key === 'ArrowUp') move(row - 1, column)
|
||||
else if (event.key === 'ArrowLeft') move(row, column - 1)
|
||||
else if (event.key === 'ArrowRight' || event.key === 'Tab') move(row, column + 1)
|
||||
else if (event.key === 'F2') startEdit(row, column)
|
||||
else if (event.key === 'Enter') {
|
||||
if (row === newRowIndex.value) void commitNewRow()
|
||||
else startEdit(row, column)
|
||||
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) startEdit(row, column, event.key)
|
||||
else return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
async function editorKeydown(event: KeyboardEvent) {
|
||||
event.stopPropagation()
|
||||
if (event.key === 'Escape') editing.value = null
|
||||
else if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
const row = editing.value?.row
|
||||
await commitEdit()
|
||||
if (row === newRowIndex.value && event.key === 'Enter') await commitNewRow()
|
||||
}
|
||||
}
|
||||
|
||||
function rowMenu(event: MouseEvent, row: number) {
|
||||
event.preventDefault()
|
||||
if (row >= rows.value.length) return
|
||||
menu.value = null
|
||||
void nextTick(() => (menu.value = { x: event.clientX, y: event.clientY, row }))
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
const row = menu.value!.row
|
||||
menu.value = null
|
||||
dialog.warning({
|
||||
title: 'Microsoft SQL Server Management Studio',
|
||||
content: '将永久删除 1 行。\n确实要删除这些行吗?',
|
||||
positiveText: '是',
|
||||
negativeText: '否',
|
||||
onPositiveClick: () => apply(deleteRow(props.table, rows.value[row])),
|
||||
})
|
||||
}
|
||||
|
||||
const display = (value: Cell | undefined) => (value === null || value === undefined ? 'NULL' : String(value))
|
||||
|
||||
onMounted(load)
|
||||
defineExpose({ reload: load })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit">
|
||||
<div class="bar">
|
||||
<span>{{ database }} - dbo.{{ table.name }}</span>
|
||||
<NButton size="small" :loading="loading" @click="load">刷新</NButton>
|
||||
<span class="hint">双击或直接打字修改;输入 NULL 表示空值;在 * 行输入新数据,按回车保存;右键行号删除</span>
|
||||
</div>
|
||||
<div class="grid-wrap" tabindex="0" @keydown="keydown">
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="head" />
|
||||
<th v-for="(column, c) in columns" :key="c" :title="column.type">{{ column.name }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rows.length + 1" :key="r - 1">
|
||||
<th class="head" @click="select(r - 1, 0)" @contextmenu="rowMenu($event, r - 1)">
|
||||
{{ r - 1 === newRowIndex ? '*' : current?.row === r - 1 ? '▶' : '' }}
|
||||
</th>
|
||||
<td
|
||||
v-for="(column, c) in columns"
|
||||
:key="c"
|
||||
:class="{
|
||||
current: current?.row === r - 1 && current?.column === c,
|
||||
null: r - 1 < rows.length ? rows[r - 1][c] === null : !newRow.has(c) || newRow.get(c) === null,
|
||||
readonly: readOnly(c),
|
||||
}"
|
||||
@click="select(r - 1, c)"
|
||||
@dblclick="startEdit(r - 1, c)"
|
||||
>
|
||||
<input
|
||||
v-if="editing && editing.row === r - 1 && editing.column === c"
|
||||
ref="editor"
|
||||
v-model="editing.value"
|
||||
class="editor"
|
||||
@keydown="editorKeydown"
|
||||
@blur="commitEdit"
|
||||
/>
|
||||
<template v-else-if="r - 1 < rows.length">{{ display(rows[r - 1][c]) }}</template>
|
||||
<template v-else>{{ newRow.has(c) ? display(newRow.get(c)) : 'NULL' }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<NDropdown
|
||||
v-if="menu"
|
||||
show
|
||||
trigger="manual"
|
||||
placement="bottom-start"
|
||||
:x="menu.x"
|
||||
:y="menu.y"
|
||||
:options="[{ label: '删除', key: 'delete' }]"
|
||||
size="small"
|
||||
@select="confirmDelete"
|
||||
@clickoutside="menu = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.grid-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
outline: none;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.grid th,
|
||||
.grid td {
|
||||
border: 1px solid #d4d4d4;
|
||||
padding: 2px 6px;
|
||||
min-width: 80px;
|
||||
max-width: 280px;
|
||||
height: 22px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.grid thead th,
|
||||
.grid .head {
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
.grid .head {
|
||||
min-width: 24px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.grid td.null {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.grid td.readonly {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.grid td.current {
|
||||
outline: 2px solid #0078d7;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
// 对象资源管理器(仿 SSMS):数据库 → 库 → 表 → 列。右键菜单里的操作交给页面处理。
|
||||
import { NDropdown, type DropdownOption } from 'naive-ui'
|
||||
import { nextTick, reactive, ref } from 'vue'
|
||||
import type { ExplorerDatabase, ExplorerTable } from './sql'
|
||||
|
||||
export type ExplorerAction =
|
||||
| { kind: 'newDatabase' }
|
||||
| { kind: 'refresh' }
|
||||
| { kind: 'newQuery'; database: string }
|
||||
| { kind: 'newTable'; database: string }
|
||||
| { kind: 'dropDatabase'; database: string }
|
||||
| { kind: 'editRows'; database: string; table: ExplorerTable }
|
||||
| { kind: 'selectRows'; database: string; table: ExplorerTable }
|
||||
| { kind: 'design'; database: string; table: ExplorerTable }
|
||||
| { kind: 'dropTable'; database: string; table: ExplorerTable }
|
||||
|
||||
defineProps<{ databases: ExplorerDatabase[] }>()
|
||||
const emit = defineEmits<{ action: [action: ExplorerAction] }>()
|
||||
|
||||
/** 展开的节点:root、db:库名、tables:库名、table:库名/表名、columns:库名/表名 */
|
||||
const expanded = reactive(new Set<string>(['root']))
|
||||
const toggle = (key: string) => (expanded.has(key) ? expanded.delete(key) : expanded.add(key))
|
||||
|
||||
// ---------- 右键菜单 ----------
|
||||
const menu = ref<{ x: number; y: number; options: DropdownOption[]; actions: Map<string, ExplorerAction> } | null>(null)
|
||||
|
||||
function openMenu(event: MouseEvent, items: [string, ExplorerAction][]) {
|
||||
event.preventDefault()
|
||||
menu.value = null
|
||||
void nextTick(() => {
|
||||
menu.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
options: items.map(([label], i) => (label === '-' ? { type: 'divider', key: `d${i}` } : { label, key: String(i) })),
|
||||
actions: new Map(items.map(([, action], i) => [String(i), action])),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function choose(key: string) {
|
||||
const action = menu.value?.actions.get(key)
|
||||
menu.value = null
|
||||
if (action) emit('action', action)
|
||||
}
|
||||
|
||||
const REFRESH: ExplorerAction = { kind: 'refresh' }
|
||||
|
||||
function rootMenu(event: MouseEvent) {
|
||||
openMenu(event, [['新建数据库…', { kind: 'newDatabase' }], ['-', REFRESH], ['刷新', REFRESH]])
|
||||
}
|
||||
|
||||
function databaseMenu(event: MouseEvent, database: string) {
|
||||
openMenu(event, [
|
||||
['新建查询', { kind: 'newQuery', database }],
|
||||
['新建表…', { kind: 'newTable', database }],
|
||||
['-', REFRESH],
|
||||
['删除', { kind: 'dropDatabase', database }],
|
||||
['刷新', REFRESH],
|
||||
])
|
||||
}
|
||||
|
||||
function tablesMenu(event: MouseEvent, database: string) {
|
||||
openMenu(event, [['新建表…', { kind: 'newTable', database }], ['-', REFRESH], ['刷新', REFRESH]])
|
||||
}
|
||||
|
||||
function tableMenu(event: MouseEvent, database: string, table: ExplorerTable) {
|
||||
openMenu(event, [
|
||||
['新建表…', { kind: 'newTable', database }],
|
||||
['设计', { kind: 'design', database, table }],
|
||||
['选择前 1000 行', { kind: 'selectRows', database, table }],
|
||||
['编辑前 200 行', { kind: 'editRows', database, table }],
|
||||
['-', REFRESH],
|
||||
['删除', { kind: 'dropTable', database, table }],
|
||||
['刷新', REFRESH],
|
||||
])
|
||||
}
|
||||
|
||||
function columnText(column: ExplorerTable['columns'][number]) {
|
||||
const flags = [column.primaryKey ? 'PK' : '', column.type, column.nullable ? 'null' : 'not null'].filter(Boolean)
|
||||
return `${column.name} (${flags.join(', ')})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="explorer">
|
||||
<div class="title">对象资源管理器</div>
|
||||
<ul class="tree">
|
||||
<li>
|
||||
<div class="node" @click="toggle('root')" @contextmenu="rootMenu">
|
||||
<span class="arrow">{{ expanded.has('root') ? '▾' : '▸' }}</span>📁 数据库
|
||||
</div>
|
||||
<ul v-if="expanded.has('root')">
|
||||
<li v-if="databases.length === 0" class="empty">(还没有数据库:右键"数据库"新建,或在上方恢复实验初始数据)</li>
|
||||
<li v-for="db in databases" :key="db.name">
|
||||
<div class="node" @click="toggle(`db:${db.name}`)" @contextmenu="databaseMenu($event, db.name)">
|
||||
<span class="arrow">{{ expanded.has(`db:${db.name}`) ? '▾' : '▸' }}</span>🛢 {{ db.name }}
|
||||
</div>
|
||||
<ul v-if="expanded.has(`db:${db.name}`)">
|
||||
<li>
|
||||
<div class="node" @click="toggle(`tables:${db.name}`)" @contextmenu="tablesMenu($event, db.name)">
|
||||
<span class="arrow">{{ expanded.has(`tables:${db.name}`) ? '▾' : '▸' }}</span>📁 表
|
||||
</div>
|
||||
<ul v-if="expanded.has(`tables:${db.name}`)">
|
||||
<li v-if="db.tables.length === 0" class="empty">(没有表)</li>
|
||||
<li v-for="table in db.tables" :key="table.name">
|
||||
<div
|
||||
class="node"
|
||||
title="右键:编辑前 200 行、设计、删除"
|
||||
@click="toggle(`table:${db.name}/${table.name}`)"
|
||||
@contextmenu="tableMenu($event, db.name, table)"
|
||||
>
|
||||
<span class="arrow">{{ expanded.has(`table:${db.name}/${table.name}`) ? '▾' : '▸' }}</span>▦ dbo.{{ table.name }}
|
||||
</div>
|
||||
<ul v-if="expanded.has(`table:${db.name}/${table.name}`)">
|
||||
<li>
|
||||
<div class="node" @click="toggle(`columns:${db.name}/${table.name}`)">
|
||||
<span class="arrow">{{ expanded.has(`columns:${db.name}/${table.name}`) ? '▾' : '▸' }}</span>📁 列
|
||||
</div>
|
||||
<ul v-if="expanded.has(`columns:${db.name}/${table.name}`)">
|
||||
<li v-for="column in table.columns" :key="column.name" class="node leaf">
|
||||
{{ column.primaryKey ? '🔑' : '▫' }} {{ columnText(column) }}
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<NDropdown
|
||||
v-if="menu"
|
||||
show
|
||||
trigger="manual"
|
||||
placement="bottom-start"
|
||||
:x="menu.x"
|
||||
:y="menu.y"
|
||||
:options="menu.options"
|
||||
size="small"
|
||||
@select="choose"
|
||||
@clickoutside="menu = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 6px 8px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.tree,
|
||||
.tree ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tree {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.tree ul {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.node {
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
background: #e5f1fb;
|
||||
}
|
||||
|
||||
.leaf {
|
||||
padding-left: 20px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2px 6px 2px 22px;
|
||||
color: #9ca3af;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
// 查询窗口(仿 SSMS):选数据库、写 T-SQL、F5 执行(选中一部分时只执行选中的),下面是结果和消息。
|
||||
// USE 语句会切换当前数据库,和 SSMS 一样执行后下拉框跟着变。
|
||||
import { NButton, NSelect, NTab, NTabs } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
|
||||
import { monaco } from '../editor/monaco'
|
||||
import ResultGrid from './ResultGrid.vue'
|
||||
import { messages, runQuery, type QueryReply } from './sql'
|
||||
|
||||
const props = defineProps<{ databases: string[]; initialSql?: string; runOnOpen?: boolean }>()
|
||||
const database = defineModel<string>('database', { required: true })
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const container = ref<HTMLDivElement>()
|
||||
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor>()
|
||||
const reply = ref<QueryReply | null>(null)
|
||||
const running = ref(false)
|
||||
const view = ref<'results' | 'messages'>('results')
|
||||
|
||||
const options = computed(() => ['master', ...props.databases].map((name) => ({ label: name, value: name })))
|
||||
const messageLines = computed(() => (reply.value ? messages(reply.value) : []))
|
||||
|
||||
async function execute() {
|
||||
if (!editor.value || running.value) return
|
||||
const model = editor.value.getModel()!
|
||||
const selection = editor.value.getSelection()
|
||||
const sql = selection && !selection.isEmpty() ? model.getValueInRange(selection) : model.getValue()
|
||||
if (!sql.trim()) return
|
||||
running.value = true
|
||||
try {
|
||||
reply.value = await runQuery(database.value, sql)
|
||||
if (reply.value.type === 'result') {
|
||||
database.value = reply.value.database
|
||||
view.value = reply.value.results.length > 0 ? 'results' : 'messages'
|
||||
// 可能建了库、建了表:让对象资源管理器刷新
|
||||
if (/\b(create|drop|alter)\b/i.test(sql)) emit('changed')
|
||||
} else {
|
||||
view.value = 'messages'
|
||||
}
|
||||
} catch (error) {
|
||||
reply.value = { type: 'error', number: 0, message: (error as Error).message, line: 1 }
|
||||
view.value = 'messages'
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 这个查询窗口是当前标签页时,在页面任何地方按 F5 都执行(并阻止浏览器刷新),和 SSMS 一样 */
|
||||
function f5(event: KeyboardEvent) {
|
||||
if (event.key !== 'F5' || event.defaultPrevented || !container.value?.offsetParent) return
|
||||
event.preventDefault()
|
||||
void execute()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', f5)
|
||||
editor.value = monaco.editor.create(container.value!, {
|
||||
value: props.initialSql ?? '',
|
||||
language: 'sql',
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize: 4,
|
||||
})
|
||||
editor.value.addCommand(monaco.KeyCode.F5, () => void execute())
|
||||
editor.value.focus()
|
||||
if (props.runOnOpen) void execute()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', f5)
|
||||
editor.value?.getModel()?.dispose()
|
||||
editor.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="query">
|
||||
<div class="bar">
|
||||
<NSelect v-model:value="database" :options="options" size="small" style="width: 180px" />
|
||||
<NButton size="small" type="primary" :loading="running" title="F5(选中一部分时只执行选中的语句)" @click="execute">▶ 执行</NButton>
|
||||
<span v-if="reply?.type === 'result'" class="time">用时 {{ reply.ms }} ms</span>
|
||||
</div>
|
||||
<div ref="container" class="editor" />
|
||||
<div v-if="reply" class="output">
|
||||
<NTabs v-model:value="view" size="small" type="line">
|
||||
<NTab name="results" :disabled="reply.type !== 'result' || reply.results.length === 0">结果</NTab>
|
||||
<NTab name="messages">消息</NTab>
|
||||
</NTabs>
|
||||
<div v-if="view === 'results' && reply.type === 'result'" class="results">
|
||||
<ResultGrid v-for="(result, i) in reply.results" :key="i" :result="result" />
|
||||
</div>
|
||||
<div v-else class="messages">
|
||||
<pre v-for="(line, i) in messageLines" :key="i" :class="{ error: line.error }">{{ line.text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.query {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1 1 45%;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.output {
|
||||
flex: 1 1 55%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.results {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.messages pre {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.messages pre.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
// 查询结果网格(只读),和 SSMS 一样:左边是行号,NULL 显示成浅黄底的 "NULL"。
|
||||
import type { ResultSet } from './sql'
|
||||
|
||||
defineProps<{ result: ResultSet }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid-wrap">
|
||||
<table class="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num" />
|
||||
<th v-for="(column, i) in result.columns" :key="i" :title="column.type ?? ''">{{ column.name || '(无列名)' }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, r) in result.rows" :key="r">
|
||||
<th class="num">{{ r + 1 }}</th>
|
||||
<td v-for="(cell, c) in row" :key="c" :class="{ null: cell === null }">{{ cell === null ? 'NULL' : cell }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="result.total > result.rows.length" class="more">共 {{ result.total }} 行,只显示前 {{ result.rows.length }} 行</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid-wrap {
|
||||
overflow: auto;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.grid {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.grid th,
|
||||
.grid td {
|
||||
border: 1px solid #d4d4d4;
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.grid thead th {
|
||||
background: #f3f3f3;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.grid .num {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.grid td.null {
|
||||
background: #ffffe1;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.more {
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -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(['命令已成功完成。'])
|
||||
})
|
||||
@@ -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<QueryReply>('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<number, string | null>) {
|
||||
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
|
||||
}
|
||||
@@ -277,7 +277,7 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
// ---------- 工具箱 ----------
|
||||
const TOOLBOX_GROUPS = ['常用控件', '容器', '组件', '物联网设备'] as const
|
||||
const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const
|
||||
const ICONS: Record<string, string> = {
|
||||
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢',
|
||||
定时器: '⏱', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
|
||||
|
||||
@@ -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: '复选框',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
// 数据库页面(代替教材里的 SSMS):左边对象资源管理器,右边查询窗口、编辑前 200 行、表设计器的标签页。
|
||||
// 每个学生一套自己的数据库,长期保存;可以按实验恢复初始数据。
|
||||
import { NButton, NInput, NModal, NSelect, NTabPane, NTabs, useDialog, useMessage } from 'naive-ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { currentUser } from '../auth'
|
||||
import DesignTab from '../database/DesignTab.vue'
|
||||
import EditTab from '../database/EditTab.vue'
|
||||
import ObjectExplorer, { type ExplorerAction } from '../database/ObjectExplorer.vue'
|
||||
import QueryTab from '../database/QueryTab.vue'
|
||||
import { execute, loadExplorer, quoteName, type ExplorerDatabase, type ExplorerTable } from '../database/sql'
|
||||
|
||||
type Tab =
|
||||
| { key: string; kind: 'query'; title: string; database: string; sql?: string; run?: boolean }
|
||||
| { key: string; kind: 'edit'; title: string; database: string; table: ExplorerTable }
|
||||
| { key: string; kind: 'design'; title: string; database: string; table?: ExplorerTable }
|
||||
|
||||
/** 新开的标签页(编号由 open 分配):对联合类型的每一种分别去掉 key */
|
||||
type WithoutKey<T> = T extends unknown ? Omit<T, 'key'> : never
|
||||
type NewTab = WithoutKey<Tab>
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const databases = ref<ExplorerDatabase[]>([])
|
||||
const tabs = ref<Tab[]>([])
|
||||
const active = ref<string>()
|
||||
let nextKey = 1
|
||||
let queryCount = 0
|
||||
|
||||
const databaseNames = computed(() => databases.value.map((d) => d.name))
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
databases.value = (await loadExplorer()).databases
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
function open(tab: NewTab) {
|
||||
const key = String(nextKey++)
|
||||
tabs.value.push({ ...tab, key } as Tab)
|
||||
active.value = key
|
||||
}
|
||||
|
||||
function newQuery(database = databaseNames.value[0] ?? 'master', sql = '', run = false, title?: string) {
|
||||
open({ kind: 'query', title: title ?? `SQLQuery${++queryCount}.sql`, database, sql, run })
|
||||
}
|
||||
|
||||
function close(key: string) {
|
||||
const index = tabs.value.findIndex((t) => t.key === key)
|
||||
tabs.value.splice(index, 1)
|
||||
if (active.value === key) active.value = tabs.value[Math.max(0, index - 1)]?.key
|
||||
}
|
||||
|
||||
// ---------- 对象资源管理器的操作 ----------
|
||||
|
||||
const creatingDatabase = ref<string | null>(null)
|
||||
|
||||
async function onAction(action: ExplorerAction) {
|
||||
switch (action.kind) {
|
||||
case 'refresh':
|
||||
await refresh()
|
||||
break
|
||||
case 'newDatabase':
|
||||
creatingDatabase.value = ''
|
||||
break
|
||||
case 'newQuery':
|
||||
newQuery(action.database)
|
||||
break
|
||||
case 'newTable':
|
||||
open({ kind: 'design', title: `${action.database} - 新表`, database: action.database })
|
||||
break
|
||||
case 'design':
|
||||
open({ kind: 'design', title: `${action.database} - dbo.${action.table.name}`, database: action.database, table: action.table })
|
||||
break
|
||||
case 'editRows':
|
||||
open({ kind: 'edit', title: `${action.database} - dbo.${action.table.name}`, database: action.database, table: action.table })
|
||||
break
|
||||
case 'selectRows': {
|
||||
// 和 SSMS 生成的语句格式一样
|
||||
const columns = action.table.columns.map((c) => quoteName(c.name)).join('\n ,')
|
||||
newQuery(action.database, `SELECT TOP 1000 ${columns}\n FROM [dbo].${quoteName(action.table.name)}`, true)
|
||||
break
|
||||
}
|
||||
case 'dropDatabase':
|
||||
confirmDrop(`删除数据库"${action.database}"?库里的所有表和数据都会被删除,不能恢复。`, 'master', `DROP DATABASE ${quoteName(action.database)}`, () => {
|
||||
tabs.value = tabs.value.filter((t) => t.database !== action.database)
|
||||
})
|
||||
break
|
||||
case 'dropTable':
|
||||
confirmDrop(`删除表"dbo.${action.table.name}"?表里的数据会一起删除,不能恢复。`, action.database, `DROP TABLE ${quoteName(action.table.name)}`, () => {
|
||||
tabs.value = tabs.value.filter((t) => !(t.database === action.database && 'table' in t && t.table?.name === action.table.name))
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDrop(content: string, database: string, sql: string, after: () => void) {
|
||||
dialog.warning({
|
||||
title: '删除对象',
|
||||
content,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await execute(database, sql)
|
||||
after()
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function createDatabase() {
|
||||
const name = creatingDatabase.value?.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await execute('master', `CREATE DATABASE ${quoteName(name)}`)
|
||||
creatingDatabase.value = null
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/** 表设计器保存后:刷新树,标签页换成已保存的表 */
|
||||
async function designSaved(tab: Tab, table: string) {
|
||||
await refresh()
|
||||
const saved = databases.value.find((d) => d.name === tab.database)?.tables.find((t) => t.name === table)
|
||||
if (saved && tab.kind === 'design') {
|
||||
tab.table = saved
|
||||
tab.title = `${tab.database} - dbo.${saved.name}`
|
||||
tab.key = `${tab.key}*`
|
||||
active.value = tab.key
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 恢复实验初始数据 ----------
|
||||
|
||||
const experiments = ref<{ id: string; title: string }[]>([])
|
||||
const restoring = ref<string | null>(null)
|
||||
const restoreBusy = ref(false)
|
||||
|
||||
async function openRestore() {
|
||||
experiments.value = await api('GET', '/api/db/experiments')
|
||||
restoring.value = experiments.value[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!restoring.value) return
|
||||
restoreBusy.value = true
|
||||
try {
|
||||
const { databases: restored } = await api<{ databases: string[] }>('POST', '/api/db/restore', { sampleId: restoring.value })
|
||||
message.success(`已恢复:${restored.join('、') || '(这个实验没有数据库)'}`)
|
||||
restoring.value = null
|
||||
tabs.value = tabs.value.filter((t) => !restored.includes(t.database))
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error((error as Error).message)
|
||||
} finally {
|
||||
restoreBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<RouterLink to="/" class="nav-link">← 返回实验</RouterLink>
|
||||
<strong>数据库</strong>
|
||||
<NButton size="small" type="primary" @click="newQuery()">新建查询</NButton>
|
||||
<NButton size="small" @click="creatingDatabase = ''">新建数据库</NButton>
|
||||
<NButton size="small" @click="openRestore">恢复实验初始数据</NButton>
|
||||
<span class="spacer" />
|
||||
<span class="user">{{ currentUser?.name }}的数据库(只有自己能看到)</span>
|
||||
</header>
|
||||
<div class="workspace">
|
||||
<ObjectExplorer class="explorer" :databases="databases" @action="onAction" />
|
||||
<main class="documents">
|
||||
<NTabs v-if="tabs.length" v-model:value="active" type="card" size="small" closable class="tabs" @close="close">
|
||||
<NTabPane v-for="tab in tabs" :key="tab.key" :name="tab.key" :tab="tab.title" display-directive="show">
|
||||
<QueryTab
|
||||
v-if="tab.kind === 'query'"
|
||||
v-model:database="tab.database"
|
||||
:databases="databaseNames"
|
||||
:initial-sql="tab.sql"
|
||||
:run-on-open="tab.run"
|
||||
@changed="refresh"
|
||||
/>
|
||||
<EditTab v-else-if="tab.kind === 'edit'" :database="tab.database" :table="tab.table" />
|
||||
<DesignTab v-else :key="tab.key" :database="tab.database" :table="tab.table" @saved="designSaved(tab, $event)" />
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
<div v-else class="empty">
|
||||
<p>右键左边的"数据库"新建数据库;右键表可以编辑数据、设计表结构。</p>
|
||||
<p>也可以点"新建查询",用 SQL 语句建库、建表、增删改查。</p>
|
||||
<p>做教材项目 6 的任务 2、3 前,可以点"恢复实验初始数据"准备好"环境数据表"库。</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<NModal :show="creatingDatabase !== null" preset="card" title="新建数据库" style="width: 380px" @update:show="creatingDatabase = null">
|
||||
<div style="margin-bottom: 8px">数据库名称:</div>
|
||||
<NInput v-if="creatingDatabase !== null" v-model:value="creatingDatabase" autofocus placeholder="例如:环境数据表" @keydown.enter="createDatabase" />
|
||||
<template #footer>
|
||||
<div class="dialog-buttons">
|
||||
<NButton type="primary" :disabled="!creatingDatabase?.trim()" @click="createDatabase">确定</NButton>
|
||||
<NButton @click="creatingDatabase = null">取消</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<NModal :show="restoring !== null" preset="card" title="恢复实验初始数据" style="width: 460px" @update:show="restoring = null">
|
||||
<NSelect v-model:value="restoring" :options="experiments.map((e) => ({ label: e.title, value: e.id }))" />
|
||||
<p class="warning">会用实验的初始数据替换实验用到的数据库(如"环境数据表"),你在这些库里做的改动会丢失;其他数据库不受影响。</p>
|
||||
<template #footer>
|
||||
<div class="dialog-buttons">
|
||||
<NButton type="warning" :loading="restoreBusy" :disabled="!restoring" @click="restore">恢复</NButton>
|
||||
<NButton @click="restoring = null">取消</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.explorer {
|
||||
width: 280px;
|
||||
flex: none;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.documents {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabs :deep(.n-tabs-pane-wrapper) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tabs :deep(.n-tab-pane) {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: auto;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #b45309;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.explorer {
|
||||
width: auto;
|
||||
max-height: 40vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -409,6 +409,7 @@ function run() {
|
||||
</span>
|
||||
<span v-else class="spacer" />
|
||||
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
|
||||
<RouterLink v-if="!viewing" to="/database" target="_blank" class="nav-link" title="在新标签页打开数据库(代替 SSMS)">数据库</RouterLink>
|
||||
<span class="user">
|
||||
{{ currentUser?.name }}<template v-if="currentUser?.className">({{ currentUser.className }})</template>
|
||||
</span>
|
||||
|
||||
+2
-1
@@ -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 } },
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
<img v-if="imageUrl" :src="imageUrl" :style="imageStyle" alt="" draggable="false" />
|
||||
</div>
|
||||
|
||||
<DataGridViewControl v-else-if="node.type === 'DataGridView'" :node="node" :style="style" :enabled="enabled" :act="act" />
|
||||
|
||||
<DateTimePickerControl v-else-if="node.type === 'DateTimePicker'" :node="node" :style="style" :enabled="enabled" :act="act" />
|
||||
|
||||
<!-- CheckBox / RadioButton:自绘勾选框,状态完全由运行器决定(点击只上报,不在浏览器里自行切换) -->
|
||||
<div
|
||||
v-else-if="node.type === 'CheckBox' || node.type === 'RadioButton'"
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
// 数据表格:显示运行器发来的列和行;单元格的值、当前单元格、选中的行都以运行器为准。
|
||||
// 编辑:双击、按 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<HTMLInputElement[]>([])
|
||||
|
||||
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` }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="wf-control wf-dgv"
|
||||
:class="{ 'wf-disabled': !enabled }"
|
||||
:style="{ ...style, backgroundColor: (p.BackgroundColor as string | null) ?? '#ababab' }"
|
||||
tabindex="0"
|
||||
@keydown="keydown"
|
||||
>
|
||||
<table v-if="columns.length" class="wf-dgv-table" :class="{ 'wf-dgv-fill': hasFill }">
|
||||
<thead v-if="columnHeaders">
|
||||
<tr>
|
||||
<th v-if="rowHeaders" class="wf-dgv-corner" />
|
||||
<th v-for="column in columns" :key="column.index" :style="columnStyle(column)">{{ column.header }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, r) in rows.items"
|
||||
:key="r"
|
||||
:class="{ 'wf-dgv-selected': selectedRows.has(r) }"
|
||||
:style="{ height: `${rows.height}px` }"
|
||||
>
|
||||
<th v-if="rowHeaders" class="wf-dgv-rowheader" @click="click(r, -1)">
|
||||
{{ current?.[0] === r ? '▶' : row.isNew ? '*' : '' }}
|
||||
</th>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.index"
|
||||
:class="{ 'wf-dgv-current': isCurrent(r, column.index) }"
|
||||
:style="columnStyle(column)"
|
||||
@click="click(r, column.index)"
|
||||
@dblclick="act({ action: 'cellDoubleClick', row: r, column: column.index }); startEdit(r, column.index)"
|
||||
>
|
||||
<input
|
||||
v-if="editing && editing.row === r && editing.column === column.index"
|
||||
ref="editor"
|
||||
v-model="editing.value"
|
||||
class="wf-dgv-editor"
|
||||
@keydown.enter.stop.prevent="commit"
|
||||
@keydown.esc.stop.prevent="cancel"
|
||||
@keydown.stop
|
||||
@blur="commit"
|
||||
/>
|
||||
<span v-else-if="column.check" class="wf-checkbox-box wf-dgv-check" :data-checked="row.cells[column.index] === true" />
|
||||
<template v-else>{{ row.cells[column.index] }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="rows.total > rows.items.length" class="wf-dgv-more">(共 {{ rows.total }} 行,只显示前 {{ rows.items.length }} 行)</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// 日期时间选择器:文字由运行器按 Format 生成(如 2024年1月16日),点下拉按钮弹出浏览器的日期 / 时间选择框,
|
||||
// 选好后把完整的日期时间发给运行器(只选日期时保留原来的时间,和 WinForms 一样)。
|
||||
import { computed, ref, type CSSProperties } from 'vue'
|
||||
import type { ControlNode, UserAction } from '@iot/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
node: ControlNode
|
||||
style: CSSProperties
|
||||
enabled: boolean
|
||||
act: (action: UserAction) => void
|
||||
}>()
|
||||
|
||||
const p = computed(() => props.node.props)
|
||||
const value = computed(() => String(p.value.Value ?? '2000-01-01T00:00:00'))
|
||||
const hasDate = computed(() => p.value.HasDate !== false)
|
||||
const hasTime = computed(() => p.value.HasTime === true)
|
||||
const inputType = computed(() => (hasDate.value && hasTime.value ? 'datetime-local' : hasTime.value ? 'time' : 'date'))
|
||||
const nativeValue = computed(() => {
|
||||
const [date, time] = value.value.split('T')
|
||||
return inputType.value === 'date' ? date : inputType.value === 'time' ? time : value.value
|
||||
})
|
||||
const picker = ref<HTMLInputElement>()
|
||||
|
||||
function open() {
|
||||
if (!props.enabled) return
|
||||
props.act({ action: 'dropDown' })
|
||||
picker.value?.showPicker?.()
|
||||
}
|
||||
|
||||
function pick(event: Event) {
|
||||
const picked = (event.target as HTMLInputElement).value
|
||||
if (!picked) return
|
||||
const [date, time] = value.value.split('T')
|
||||
const withSeconds = (t: string) => (t.length === 5 ? `${t}:00` : t)
|
||||
const next =
|
||||
inputType.value === 'date' ? `${picked}T${time}` : inputType.value === 'time' ? `${date}T${withSeconds(picked)}` : withSeconds(picked)
|
||||
props.act({ action: 'pick', value: next })
|
||||
}
|
||||
|
||||
/** ShowUpDown 的上下箭头:日期格式按天、时间格式按分钟调整 */
|
||||
function step(delta: number) {
|
||||
if (!props.enabled) return
|
||||
const d = new Date(value.value)
|
||||
if (hasTime.value && !hasDate.value) d.setMinutes(d.getMinutes() + delta)
|
||||
else d.setDate(d.getDate() + delta)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
props.act({
|
||||
action: 'pick',
|
||||
value: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wf-control wf-dtp" :class="{ 'wf-disabled': !enabled }" :style="style">
|
||||
<span class="wf-dtp-text">{{ p.Text }}</span>
|
||||
<span v-if="p.ShowUpDown" class="wf-dtp-spin">
|
||||
<span @mousedown.prevent="step(1)">▴</span>
|
||||
<span @mousedown.prevent="step(-1)">▾</span>
|
||||
</span>
|
||||
<span v-else class="wf-dtp-button" title="选择日期" @mousedown.prevent="open">▾</span>
|
||||
<input ref="picker" class="wf-dtp-native" :type="inputType" step="1" :value="nativeValue" tabindex="-1" @change="pick" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user