项目 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

+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();
}
}