Files
xuyueandClaude Opus 5.5 670abe5921
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
项目 6 数据存储:仿冒 SqlClient(T-SQL 转写成 SQLite)、数据库页面、DataGridView、DateTimePicker
- 每个学生一套数据库,长期保存(data/databases/<用户编号>/库名.db);学生程序运行时挂进沙箱可读写
- Sim.Data:SqlConnection / SqlCommand / SqlDataReader / SqlDataAdapter / SqlCommandBuilder / SqlParameter;
  连接字符串只按库名打开学生自己的库;ScriptDom 解析 T-SQL 后按白名单转写成 SQLite 语句,
  STRICT 表 + 元数据表还原 SQL Server 的列类型,报错编号和中文信息与 SQL Server 一致;
  INSERT 不写列名时跳过自增列,日期统一格式(书上 DateTime.ToString() 拼接的日期能正确比较)
- 常驻 SQL 服务(Sim.Runner sql-server):数据库页面的查询、对象资源管理器,评分前按实验 database.sql 建库
- 数据库页面(代替 SSMS):对象资源管理器、查询窗口、编辑前 200 行、表设计器、恢复实验初始数据
- DataGridView(绑定 DataView/DataTable/DataSet,单元格修改写回 DataTable)、DateTimePicker;设计器工具箱加入
- 评分:每个检查项一份新建的实验库;新增点 / 修改表格单元格、选日期、表格里有 / 没有某行的检查
- 示例:项目 6 任务 2、3;时间格式改成和中文 Windows 一致(9:05:03);异常堆栈不暴露服务器路径
- 测试:数据库端到端、接口(每人只看到自己的库、恢复实验数据)、评分出错情况、T-SQL 生成

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:18:56 +08:00

169 lines
8.6 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}