项目 6 数据存储:仿冒 SqlClient(T-SQL 转写成 SQLite)、数据库页面、DataGridView、DateTimePicker
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- 每个学生一套数据库,长期保存(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:
xuyueandClaude Opus 5.5 committed 2026-10-23 09:18:56 +08:00
1 parent 6b088240da
commit 670abe5921
72 files changed
+7253 -51

No files matched your search

+5 -4
View File
@@ -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
View File
@@ -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 小时 | 提供可调、可加速的虚拟时钟 |
+2
View File
@@ -8,5 +8,7 @@
<!-- 需要 zh-CN 区域设置:日期格式要和中文 Windows 一致 -->
<InvariantGlobalization>false</InvariantGlobalization>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<!-- 异常堆栈里的源文件路径不暴露服务器上的目录(StudentStackTrace 按 /runtime/Sim. 识别平台自己的代码) -->
<PathMap>$(MSBuildThisFileDirectory)=/runtime/</PathMap>
</PropertyGroup>
</Project>
+1
View File
@@ -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>
+168
View File
@@ -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);
}
+254
View File
@@ -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),
};
}
+243
View File
@@ -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();
}
}
+83
View File
@@ -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";
}
+916
View File
@@ -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);
}
}
+102
View File
@@ -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);
}
+17
View File
@@ -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>
+302
View File
@@ -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;
}
+169
View File
@@ -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);
}
+211
View File
@@ -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";
}
+6 -1
View File
@@ -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)
+3 -1
View File
@@ -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;
}
+1
View File
@@ -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>
+174
View File
@@ -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 };
}
+1 -1
View File
@@ -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);
+6 -2
View File
@@ -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);
}
}
+1
View File
@@ -98,3 +98,4 @@ public interface IButtonControl
void NotifyDefault(bool value);
void PerformClick();
}
public enum LeftRightAlignment { Left, Right }
+2
View File
@@ -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()),
+5
View File
@@ -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,
};
}
+282
View File
@@ -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;
}
}
+129
View File
@@ -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 写明列名(书上不写列名时,值的顺序和建表的列顺序不一致,温度和光照会存反)"
}
+138
View File
@@ -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;
}
}
+41
View File
@@ -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();//关闭数据库
}
}
}
+22
View File
@@ -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());
}
}
}
+16
View File
@@ -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')
+4
View File
@@ -0,0 +1,4 @@
{
"title": "项目6 任务3 查询校园安防数据",
"source": "教材书 p127–128,界面取自配套参考工程;补上书上漏写的 ds = new DataSet();(不写时 Fill 报空引用)。数据库和任务 2 相同(环境数据表)"
}
+31
View File
@@ -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'))
+6 -2
View File
@@ -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) {
+71
View File
@@ -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
}
+31
View File
@@ -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':
+2 -2
View File
@@ -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) => {
+67 -5
View File
@@ -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 ? ' ……' : ''}` : '没有数据'}`
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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# 编译服务已就绪')
+6 -2
View File
@@ -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++
+9 -3
View File
@@ -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 }
+108
View File
@@ -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()
+51
View File
@@ -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
View File
@@ -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)
})
+91 -5
View File
@@ -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(
+6
View File
@@ -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' }
+290
View File
@@ -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>
+288
View File
@@ -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>
+209
View File
@@ -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>
+159
View File
@@ -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>
+76
View File
@@ -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>
+68
View File
@@ -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(['命令已成功完成。'])
})
+123
View File
@@ -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
}
+1 -1
View File
@@ -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 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
+54 -1
View File
@@ -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: '复选框',
+9
View File
@@ -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)
+2 -1
View File
@@ -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 = {
+333
View File
@@ -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>
+1
View File
@@ -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
View File
@@ -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 } },
{
+6
View File
@@ -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'"
+175
View File
@@ -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>
+130
View File
@@ -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;