- 每个学生一套数据库,长期保存(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>
244 lines
11 KiB
C#
244 lines
11 KiB
C#
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();
|
||
}
|
||
}
|