重构存储文件布局与目录管理,增强健壮性与测试 - 移除 TableDirectory,新增 TableFileManager,统一表文件命名与分片/索引管理,所有表文件平铺于数据库目录 - DatabaseDirectory 适配新布局,ListTables 自动识别表名,支持分片归并 - FileHeader/PageHeader 增强校验与异常处理,严格序列化/反序列化 - MmfPager/PageCache 增强参数校验、线程安全、命中统计与 LRU 逻辑 - DataTypeExtensions/DefaultDataCodec 编解码更健壮,支持可空类型,异常信息更丰富 - 大量补充和完善单元测试,覆盖边界、异常、并发等场景 - 文档同步更新,确保实现与设计一致 - 为后续表引擎、事务、WAL 等模块开发打下坚实基础智能大石头 authored at 2026-02-19 00:37:11
diff --git "a/Doc/\345\256\236\347\216\260\346\211\247\350\241\214\350\256\241\345\210\222.md" "b/Doc/\345\256\236\347\216\260\346\211\247\350\241\214\350\256\241\345\210\222.md"
index 4aadefb..771c1d7 100644
--- "a/Doc/\345\256\236\347\216\260\346\211\247\350\241\214\350\256\241\345\210\222.md"
+++ "b/Doc/\345\256\236\347\216\260\346\211\247\350\241\214\350\256\241\345\210\222.md"
@@ -38,7 +38,7 @@
| 模块 | 目录 | 职责 |
|------|------|------|
| **Core** | `Core/` | 配置(`DbOptions`)、错误码(`ErrorCode`)、数据类型映射(`DataType`)、编解码(`IDataCodec`)、异常(`NovaException`) |
-| **Storage** | `Storage/` | 文件布局(`DatabaseDirectory`/`TableDirectory`)、MMF(`MmfPager`)、页/块(`FileHeader`/`PageHeader`)、页缓存(`PageCache`) |
+| **Storage** | `Storage/` | 文件布局(`DatabaseDirectory`/`TableFileManager`)、MMF(`MmfPager`)、页/块(`FileHeader`/`PageHeader`)、页缓存(`PageCache`) |
| **WAL** | `WAL/` | 日志写入(`WalWriter`)、记录格式(`WalRecord`)、崩溃恢复(`WalRecovery`) |
| **Tx** | `Tx/` | 事务管理(`TransactionManager`)、MVCC 行版本(`RowVersion`)、事务(`Transaction`) |
| **Engine.Nova** | `Engine/` | 表模型(`NovaTable`/`TableSchema`)、SkipList 索引、热/冷索引(`HotIndexManager`/`ColdIndexDirectory`)、分片(`ShardManager`/`ShardInfo`) |
@@ -90,16 +90,17 @@
#### 3) 文件夹即数据库 + 文件布局(M1)✅
-**目标**:把"表文件组"落地到文件命名与目录结构。
+**目标**:把"表文件组"落地到文件命名规则。
- `DatabaseDirectory`:创建/打开/列举库 ✅
-- `TableDirectory`:创建/打开/列举表 ✅
+- `TableFileManager`:表文件路径管理(替代原 TableDirectory) ✅
- 文件组命名约定 ✅:
- - 数据分片:`{TableName}/{ShardId}.data`
- - 索引文件:`{TableName}/{IndexName}.idx`
- - WAL:`{TableName}/{ShardId}.wal`
+ - 数据文件:`{TableName}.data`(无分片)/ `{TableName}_{ShardId}.data`(有分片)
+ - 索引文件:`{TableName}.idx`(主键)/ `{TableName}_{IndexName}.idx`(二级索引)
+ - WAL 文件:`{TableName}.wal`(无分片)/ `{TableName}_{ShardId}.wal`(有分片)
+ - 所有文件平铺在数据库目录下,无需为每表创建独立目录
-**完成状态**:✅ 可创建空库/空表,目录结构符合约定(`DatabaseDirectoryTests`)。
+**完成状态**:✅ 可创建空库/表文件,文件命名符合约定(`DatabaseDirectoryTests`、`TableFileManagerTests`)。
---
diff --git "a/Doc/\346\236\266\346\236\204\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/Doc/\346\236\266\346\236\204\350\256\276\350\256\241\346\226\207\346\241\243.md"
index 3b160e9..4c70614 100644
--- "a/Doc/\346\236\266\346\236\204\350\256\276\350\256\241\346\226\207\346\241\243.md"
+++ "b/Doc/\346\236\266\346\236\204\350\256\276\350\256\241\346\226\207\346\241\243.md"
@@ -28,7 +28,7 @@ NewLife.NovaDb 是一个纯 C# 实现的混合数据库引擎,运行于 .NET 8
│ WalWriter │ WalRecord │ WalRecovery │
├──────────────────────────────────────────────────────────────────────┤
│ 存储层 (Storage) │
-│ MmfPager │ PageCache │ FileHeader │ PageHeader │ DatabaseDirectory │
+│ MmfPager │ PageCache │ FileHeader │ PageHeader │ TableFileManager │
├──────────────────────────────────────────────────────────────────────┤
│ 核心层 (Core) │
│ DbOptions │ DataType │ IDataCodec │ NovaException │ ErrorCode │
@@ -98,16 +98,27 @@ Cluster → WAL → Storage → Core
```
{DatabasePath}/
-├── nova.db # 数据库元数据文件 (FileHeader)
-├── _sys/ # 系统表目录
-├── {TableName}/ # 表目录
-│ ├── 0.data # 数据分片文件
-│ ├── 0.wal # WAL 日志文件
-│ ├── PK.idx # 主键索引文件
-│ └── {IndexName}.idx # 二级索引文件
+├── nova.db # 数据库元数据文件 (FileHeader)
+├── _sys_tables.data # 系统表数据文件
+├── _sys_tables.wal # 系统表 WAL 文件
+├── _sys_tables.idx # 系统表主键索引
+├── {TableName}.data # 表数据文件
+├── {TableName}.wal # 表 WAL 日志文件
+├── {TableName}.idx # 表主键索引文件
+├── {TableName}_{IndexName}.idx # 表二级索引文件
+├── {TableName}_0.data # 分片 0 数据文件(启用分片时)
+├── {TableName}_0.wal # 分片 0 WAL 文件
+├── {TableName}_0.idx # 分片 0 主键索引
+├── {TableName}_1.data # 分片 1 数据文件
└── ...
```
+**说明**:
+- 每表文件组使用相同的表名前缀,通过后缀区分类型(`.data`/`.wal`/`.idx`)
+- 二级索引文件命名为 `{TableName}_{IndexName}.idx`
+- 启用分片时,文件名格式为 `{TableName}_{ShardId}.{ext}`
+- 无需为每个表单独创建目录,所有文件平铺在数据库目录下
+
### 3.2 文件头 (`FileHeader`)
32 字节固定大小,包含:
@@ -127,7 +138,7 @@ Cluster → WAL → Storage → Core
### 3.4 目录管理
- **DatabaseDirectory**: 数据库级别管理(创建/打开/列举/删除)
-- **TableDirectory**: 表级别管理(文件组创建/文件列举)
+- **TableFileManager**: 表文件路径管理(文件命名规则/路径生成/分片列举)
## 4. WAL 日志层 (WAL)
diff --git "a/Doc/\351\234\200\346\261\202\350\247\204\346\240\274\350\257\264\346\230\216\344\271\246.md" "b/Doc/\351\234\200\346\261\202\350\247\204\346\240\274\350\257\264\346\230\216\344\271\246.md"
index beca016..0ae75e2 100644
--- "a/Doc/\351\234\200\346\261\202\350\247\204\346\240\274\350\257\264\346\230\216\344\271\246.md"
+++ "b/Doc/\351\234\200\346\261\202\350\247\204\346\240\274\350\257\264\346\230\216\344\271\246.md"
@@ -35,8 +35,9 @@
* **自动文件切分**:当单个物理文件达到阈值(按大小/时间/行数等策略)后,自动切分为多个数据分片(Shard),以支持无上限存储。
* **冷热加载原则**:表数据极少读取或不读取时,不应占用显著内存;只有频繁读取的热点范围,才提升为内存热索引。
* **索引文件 (核心)**:
- * **多索引多文件**:一张表每个索引(主键/二级索引/唯一索引等)对应独立索引文件(例如 `PK.idx`, `{IndexName}.idx`),便于独立加载、淘汰与重建。
+ * **多索引多文件**:一张表每个索引(主键/二级索引/唯一索引等)对应独立索引文件(主键索引为 `{TableName}.idx`,二级索引为 `{TableName}_{IndexName}.idx`),便于独立加载、淘汰与重建。
* **idx 不是内存跳表节点镜像**:`.idx` 存储可持久化的“目录 + 块/页”结构(稀疏目录锚点 -> 块偏移),用于按需 MMF 映射与快速定位;内存 SkipList 用作热缓存结构,可从 `.idx` 按需重建热点区域。
+ * **文件组命名**:每表文件组使用相同表名前缀,通过后缀区分(`.data`/`.wal`/`.idx`),分片时使用 `{TableName}_{ShardId}.{ext}` 格式,无需独立目录。
* **适用场景**:通用 CRUD,配置表,业务订单,用户数据。
### 3.2 时序引擎:Flux Engine (时序 + MQ)
diff --git a/NewLife.NovaDb/Core/DataType.cs b/NewLife.NovaDb/Core/DataType.cs
index 3876ed4..42a7753 100644
--- a/NewLife.NovaDb/Core/DataType.cs
+++ b/NewLife.NovaDb/Core/DataType.cs
@@ -64,6 +64,13 @@ public static class DataTypeExtensions
/// <returns>数据类型</returns>
public static DataType FromClrType(Type type)
{
+ if (type == null)
+ throw new ArgumentNullException(nameof(type));
+
+ // 处理可空类型
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
+ type = Nullable.GetUnderlyingType(type)!;
+
if (type == typeof(Boolean)) return DataType.Boolean;
if (type == typeof(Int32)) return DataType.Int32;
if (type == typeof(Int64)) return DataType.Int64;
diff --git a/NewLife.NovaDb/Core/IDataCodec.cs b/NewLife.NovaDb/Core/IDataCodec.cs
index bb1dc33..c073502 100644
--- a/NewLife.NovaDb/Core/IDataCodec.cs
+++ b/NewLife.NovaDb/Core/IDataCodec.cs
@@ -33,23 +33,31 @@ public class DefaultDataCodec : IDataCodec
public Byte[] Encode(Object? value, DataType dataType)
{
if (value == null)
- {
- // NULL 值标记为长度 -1
return BitConverter.GetBytes(-1);
- }
- return dataType switch
+ try
{
- DataType.Boolean => BitConverter.GetBytes((Boolean)value),
- DataType.Int32 => BitConverter.GetBytes((Int32)value),
- DataType.Int64 => BitConverter.GetBytes((Int64)value),
- DataType.Double => BitConverter.GetBytes((Double)value),
- DataType.Decimal => EncodeDecimal((Decimal)value),
- DataType.String => EncodeString((String)value),
- DataType.Binary => EncodeByteArray((Byte[])value),
- DataType.DateTime => BitConverter.GetBytes(((DateTime)value).Ticks),
- _ => throw new NotSupportedException($"Unsupported data type: {dataType}")
- };
+ return dataType switch
+ {
+ DataType.Boolean => BitConverter.GetBytes((Boolean)value),
+ DataType.Int32 => BitConverter.GetBytes((Int32)value),
+ DataType.Int64 => BitConverter.GetBytes((Int64)value),
+ DataType.Double => BitConverter.GetBytes((Double)value),
+ DataType.Decimal => EncodeDecimal((Decimal)value),
+ DataType.String => EncodeString((String)value),
+ DataType.Binary => EncodeByteArray((Byte[])value),
+ DataType.DateTime => BitConverter.GetBytes(((DateTime)value).Ticks),
+ _ => throw new NotSupportedException($"Unsupported data type: {dataType}")
+ };
+ }
+ catch (InvalidCastException ex)
+ {
+ throw new NovaException(
+ ErrorCode.InvalidArgument,
+ $"Cannot encode value of type {value.GetType().Name} as {dataType}",
+ ex
+ );
+ }
}
/// <summary>从二进制解码值</summary>
@@ -59,28 +67,51 @@ public class DefaultDataCodec : IDataCodec
/// <returns>解码后的值</returns>
public Object? Decode(Byte[] buffer, Int32 offset, DataType dataType)
{
- // 检查是否为 NULL
- if (buffer.Length >= offset + 4)
+ if (buffer == null)
+ throw new ArgumentNullException(nameof(buffer));
+ if (offset < 0)
+ throw new ArgumentOutOfRangeException(nameof(offset), "Offset cannot be negative");
+ if (offset >= buffer.Length)
+ throw new ArgumentOutOfRangeException(nameof(offset), "Offset exceeds buffer length");
+
+ // 检查 NULL 标记(String/Binary 类型使用 -1 长度表示 NULL)
+ if (dataType is DataType.String or DataType.Binary)
{
+ if (buffer.Length < offset + 4)
+ throw new ArgumentException("Buffer too short to read length prefix");
+
var length = BitConverter.ToInt32(buffer, offset);
if (length == -1)
- {
return null;
- }
}
- return dataType switch
+ try
{
- DataType.Boolean => BitConverter.ToBoolean(buffer, offset),
- DataType.Int32 => BitConverter.ToInt32(buffer, offset),
- DataType.Int64 => BitConverter.ToInt64(buffer, offset),
- DataType.Double => BitConverter.ToDouble(buffer, offset),
- DataType.Decimal => DecodeDecimal(buffer, offset),
- DataType.String => DecodeString(buffer, offset),
- DataType.Binary => DecodeByteArray(buffer, offset),
- DataType.DateTime => new DateTime(BitConverter.ToInt64(buffer, offset)),
- _ => throw new NotSupportedException($"Unsupported data type: {dataType}")
- };
+ return dataType switch
+ {
+ DataType.Boolean => DecodeBoolean(buffer, offset),
+ DataType.Int32 => DecodeInt32(buffer, offset),
+ DataType.Int64 => DecodeInt64(buffer, offset),
+ DataType.Double => DecodeDouble(buffer, offset),
+ DataType.Decimal => DecodeDecimal(buffer, offset),
+ DataType.String => DecodeString(buffer, offset),
+ DataType.Binary => DecodeByteArray(buffer, offset),
+ DataType.DateTime => DecodeDateTime(buffer, offset),
+ _ => throw new NotSupportedException($"Unsupported data type: {dataType}")
+ };
+ }
+ catch (ArgumentException)
+ {
+ throw;
+ }
+ catch (Exception ex) when (ex is not NotSupportedException)
+ {
+ throw new NovaException(
+ ErrorCode.ParseFailed,
+ $"Failed to decode {dataType} at offset {offset}",
+ ex
+ );
+ }
}
/// <summary>获取编码后的长度</summary>
@@ -116,8 +147,45 @@ public class DefaultDataCodec : IDataCodec
return buffer;
}
+ private Boolean DecodeBoolean(Byte[] buffer, Int32 offset)
+ {
+ if (buffer.Length < offset + 1)
+ throw new ArgumentException($"Buffer too short to read Boolean (need {offset + 1} bytes, got {buffer.Length})");
+ return BitConverter.ToBoolean(buffer, offset);
+ }
+
+ private Int32 DecodeInt32(Byte[] buffer, Int32 offset)
+ {
+ if (buffer.Length < offset + 4)
+ throw new ArgumentException($"Buffer too short to read Int32 (need {offset + 4} bytes, got {buffer.Length})");
+ return BitConverter.ToInt32(buffer, offset);
+ }
+
+ private Int64 DecodeInt64(Byte[] buffer, Int32 offset)
+ {
+ if (buffer.Length < offset + 8)
+ throw new ArgumentException($"Buffer too short to read Int64 (need {offset + 8} bytes, got {buffer.Length})");
+ return BitConverter.ToInt64(buffer, offset);
+ }
+
+ private Double DecodeDouble(Byte[] buffer, Int32 offset)
+ {
+ if (buffer.Length < offset + 8)
+ throw new ArgumentException($"Buffer too short to read Double (need {offset + 8} bytes, got {buffer.Length})");
+ return BitConverter.ToDouble(buffer, offset);
+ }
+
+ private DateTime DecodeDateTime(Byte[] buffer, Int32 offset)
+ {
+ if (buffer.Length < offset + 8)
+ throw new ArgumentException($"Buffer too short to read DateTime (need {offset + 8} bytes, got {buffer.Length})");
+ return new DateTime(BitConverter.ToInt64(buffer, offset));
+ }
+
private Decimal DecodeDecimal(Byte[] buffer, Int32 offset)
{
+ if (buffer.Length < offset + 16)
+ throw new ArgumentException($"Buffer too short to read Decimal (need {offset + 16} bytes, got {buffer.Length})");
var bits = new Int32[4];
Buffer.BlockCopy(buffer, offset, bits, 0, 16);
return new Decimal(bits);
@@ -148,7 +216,15 @@ public class DefaultDataCodec : IDataCodec
private Byte[] DecodeByteArray(Byte[] buffer, Int32 offset)
{
+ if (buffer.Length < offset + 4)
+ throw new ArgumentException($"Buffer too short to read ByteArray length (need {offset + 4} bytes, got {buffer.Length})");
+
var length = BitConverter.ToInt32(buffer, offset);
+ if (length < 0)
+ throw new ArgumentException($"Invalid byte array length: {length}");
+ if (buffer.Length < offset + 4 + length)
+ throw new ArgumentException($"Buffer too short to read ByteArray (need {offset + 4 + length} bytes, got {buffer.Length})");
+
var result = new Byte[length];
Buffer.BlockCopy(buffer, offset + 4, result, 0, length);
return result;
diff --git a/NewLife.NovaDb/Storage/DatabaseDirectory.cs b/NewLife.NovaDb/Storage/DatabaseDirectory.cs
index 25351f5..54e6778 100644
--- a/NewLife.NovaDb/Storage/DatabaseDirectory.cs
+++ b/NewLife.NovaDb/Storage/DatabaseDirectory.cs
@@ -1,46 +1,44 @@
-using NewLife.NovaDb.Core;
+using NewLife.NovaDb.Core;
namespace NewLife.NovaDb.Storage;
-/// <summary>
-/// 数据库目录管理(文件夹即数据库)
-/// </summary>
+/// <summary>数据库目录管理(文件夹即数据库)</summary>
+/// <remarks>
+/// 数据库目录结构:
+/// - nova.db: 元数据文件(32 字节 FileHeader)
+/// - {TableName}.data/.wal/.idx: 表文件组(平铺在目录下)
+/// - _sys_*.data: 系统表文件
+/// </remarks>
public class DatabaseDirectory
{
private readonly String _basePath;
private readonly DbOptions _options;
- /// <summary>
- /// 数据库路径
- /// </summary>
+ /// <summary>数据库路径</summary>
public String Path => _basePath;
- /// <summary>
- /// 系统表路径
- /// </summary>
- public String SystemPath => System.IO.Path.Combine(_basePath, "_sys");
-
+ /// <summary>实例化数据库目录管理器</summary>
+ /// <param name="path">数据库目录路径</param>
+ /// <param name="options">数据库配置</param>
public DatabaseDirectory(String path, DbOptions options)
{
_basePath = path ?? throw new ArgumentNullException(nameof(path));
_options = options ?? throw new ArgumentNullException(nameof(options));
+
+ if (String.IsNullOrWhiteSpace(path))
+ throw new ArgumentException("Database path cannot be empty", nameof(path));
}
- /// <summary>
- /// 创建数据库(创建目录结构)
- /// </summary>
+ /// <summary>创建数据库(创建目录并写入元数据文件)</summary>
+ /// <exception cref="NovaException">目录已存在时抛出</exception>
public void Create()
{
if (Directory.Exists(_basePath))
- {
- throw new NovaException(ErrorCode.InvalidArgument,
- $"Database directory already exists: {_basePath}");
- }
+ throw new NovaException(ErrorCode.InvalidArgument, $"Database directory already exists: {_basePath}");
Directory.CreateDirectory(_basePath);
- Directory.CreateDirectory(SystemPath);
- // 创建数据库元数据文件
+ // 写入元数据文件
var metaPath = System.IO.Path.Combine(_basePath, "nova.db");
var header = new FileHeader
{
@@ -54,35 +52,25 @@ public class DatabaseDirectory
File.WriteAllBytes(metaPath, header.ToBytes());
}
- /// <summary>
- /// 打开数据库(验证目录存在且格式正确)
- /// </summary>
+ /// <summary>打开数据库(验证目录存在且格式正确)</summary>
+ /// <exception cref="NovaException">目录不存在、元数据文件缺失或版本不兼容时抛出</exception>
public void Open()
{
if (!Directory.Exists(_basePath))
- {
- throw new NovaException(ErrorCode.InvalidArgument,
- $"Database directory does not exist: {_basePath}");
- }
+ throw new NovaException(ErrorCode.InvalidArgument, $"Database directory does not exist: {_basePath}");
var metaPath = System.IO.Path.Combine(_basePath, "nova.db");
if (!File.Exists(metaPath))
- {
- throw new NovaException(ErrorCode.FileCorrupted,
- $"Database metadata file not found: {metaPath}");
- }
+ throw new NovaException(ErrorCode.FileCorrupted, $"Database metadata file not found: {metaPath}");
// 验证文件格式
var metaBytes = File.ReadAllBytes(metaPath);
var header = FileHeader.FromBytes(metaBytes);
if (header.Version > 1)
- {
- throw new NovaException(ErrorCode.IncompatibleFileFormat,
- $"Unsupported database version: {header.Version}");
- }
+ throw new NovaException(ErrorCode.IncompatibleFileFormat, $"Unsupported database version: {header.Version}");
- // 验证配置一致性
+ // 配置一致性警告
var currentHash = ComputeOptionsHash(_options);
if (header.OptionsHash != currentHash && header.PageSize != _options.PageSize)
{
@@ -91,57 +79,60 @@ public class DatabaseDirectory
}
}
- /// <summary>
- /// 列举所有表
- /// </summary>
+ /// <summary>列举所有用户表</summary>
+ /// <remarks>通过扫描数据库目录下的 .data 文件提取表名,自动排除系统表</remarks>
+ /// <returns>按字母排序的表名集合</returns>
public IEnumerable<String> ListTables()
{
if (!Directory.Exists(_basePath))
- {
yield break;
+
+ var tableNames = new HashSet<String>();
+ var dataFiles = Directory.GetFiles(_basePath, "*.data");
+
+ foreach (var file in dataFiles)
+ {
+ var fileName = System.IO.Path.GetFileNameWithoutExtension(file);
+
+ // 跳过系统表
+ if (fileName.StartsWith("_sys"))
+ continue;
+
+ // 提取表名:{TableName}.data 或 {TableName}_{ShardId}.data
+ var parts = fileName.Split('_');
+ var tableName = parts[0];
+
+ if (!String.IsNullOrEmpty(tableName))
+ tableNames.Add(tableName);
}
- foreach (var dir in Directory.GetDirectories(_basePath))
+ foreach (var name in tableNames.OrderBy(x => x))
{
- var tableName = System.IO.Path.GetFileName(dir);
- if (!tableName.StartsWith("_"))
- {
- yield return tableName;
- }
+ yield return name;
}
}
- /// <summary>
- /// 获取表目录
- /// </summary>
- public TableDirectory GetTableDirectory(String tableName)
+ /// <summary>获取表文件管理器</summary>
+ /// <param name="tableName">表名</param>
+ /// <returns>表文件管理器</returns>
+ /// <exception cref="ArgumentException">表名为空时抛出</exception>
+ public TableFileManager GetTableFileManager(String tableName)
{
if (String.IsNullOrWhiteSpace(tableName))
- {
throw new ArgumentException("Table name cannot be empty", nameof(tableName));
- }
- var tablePath = System.IO.Path.Combine(_basePath, tableName);
- return new TableDirectory(tablePath, tableName, _options);
+ return new TableFileManager(_basePath, tableName, _options);
}
- /// <summary>
- /// 删除数据库
- /// </summary>
+ /// <summary>删除数据库(删除整个目录)</summary>
public void Drop()
{
if (Directory.Exists(_basePath))
- {
Directory.Delete(_basePath, recursive: true);
- }
}
- /// <summary>
- /// 计算配置哈希
- /// </summary>
- private UInt32 ComputeOptionsHash(DbOptions options)
- {
- // 简单的哈希计算(使用页大小作为主要标识)
- return (UInt32)options.PageSize;
- }
+ /// <summary>计算配置哈希</summary>
+ /// <param name="options">数据库配置</param>
+ /// <returns>配置哈希值</returns>
+ private UInt32 ComputeOptionsHash(DbOptions options) => (UInt32)options.PageSize;
}
diff --git a/NewLife.NovaDb/Storage/FileHeader.cs b/NewLife.NovaDb/Storage/FileHeader.cs
index 6670bce..e27c17d 100644
--- a/NewLife.NovaDb/Storage/FileHeader.cs
+++ b/NewLife.NovaDb/Storage/FileHeader.cs
@@ -1,46 +1,45 @@
namespace NewLife.NovaDb.Storage;
-/// <summary>
-/// 文件头结构(每个 .data/.idx/.wal 文件的开头)
-/// </summary>
+/// <summary>文件头结构(每个 .data/.idx/.wal 文件的开头,固定 32 字节)</summary>
+/// <remarks>
+/// 文件头布局(32 字节):
+/// - 0-3: Magic Number (0x4E4F5641 "NOVA")
+/// - 4-5: Version (当前版本 1)
+/// - 6: FileType (1=Data, 2=Index, 3=Wal)
+/// - 7: Reserved
+/// - 8-11: PageSize (页大小,字节)
+/// - 12-19: CreatedAt (创建时间,UTC Ticks)
+/// - 20-23: OptionsHash (配置哈希)
+/// - 24-31: Reserved (预留扩展)
+/// </remarks>
public class FileHeader
{
- /// <summary>
- /// 魔数标识(固定 "NOVA" 0x4E4F5641)
- /// </summary>
+ /// <summary>魔数标识(固定 "NOVA" 0x4E4F5641)</summary>
public const UInt32 MagicNumber = 0x4E4F5641;
- /// <summary>
- /// 文件格式版本号
- /// </summary>
+ /// <summary>文件头固定大小(32 字节)</summary>
+ public const Int32 HeaderSize = 32;
+
+ /// <summary>文件格式版本号</summary>
public UInt16 Version { get; set; } = 1;
- /// <summary>
- /// 文件类型(Data/Index/Wal)
- /// </summary>
+ /// <summary>文件类型(Data/Index/Wal)</summary>
public FileType FileType { get; set; }
- /// <summary>
- /// 页大小(字节)
- /// </summary>
+ /// <summary>页大小(字节)</summary>
public UInt32 PageSize { get; set; }
- /// <summary>
- /// 创建时间(UTC Ticks)
- /// </summary>
+ /// <summary>创建时间(UTC Ticks)</summary>
public Int64 CreatedAt { get; set; }
- /// <summary>
- /// 配置哈希(用于验证配置一致性)
- /// </summary>
+ /// <summary>配置哈希(用于验证配置一致性)</summary>
public UInt32 OptionsHash { get; set; }
- /// <summary>
- /// 序列化为字节数组(固定 32 字节)
- /// </summary>
+ /// <summary>序列化为字节数组(固定 32 字节)</summary>
+ /// <returns>32 字节的文件头数据</returns>
public Byte[] ToBytes()
{
- var buffer = new Byte[32];
+ var buffer = new Byte[HeaderSize];
var offset = 0;
// Magic Number (4 bytes)
@@ -69,46 +68,52 @@ public class FileHeader
Buffer.BlockCopy(BitConverter.GetBytes(OptionsHash), 0, buffer, offset, 4);
offset += 4;
- // Reserved (8 bytes)
- // offset += 8;
+ // Reserved (8 bytes) - 自动为 0
return buffer;
}
- /// <summary>
- /// 从字节数组反序列化
- /// </summary>
+ /// <summary>从字节数组反序列化</summary>
+ /// <param name="buffer">包含文件头数据的字节数组(至少 32 字节)</param>
+ /// <returns>反序列化的文件头对象</returns>
+ /// <exception cref="ArgumentNullException">buffer 为 null</exception>
+ /// <exception cref="ArgumentException">buffer 长度不足 32 字节</exception>
+ /// <exception cref="Core.NovaException">魔数验证失败或文件类型无效</exception>
public static FileHeader FromBytes(Byte[] buffer)
{
- if (buffer.Length < 32)
- {
- throw new ArgumentException("Buffer too short for FileHeader");
- }
+ if (buffer == null)
+ throw new ArgumentNullException(nameof(buffer));
+
+ if (buffer.Length < HeaderSize)
+ throw new ArgumentException($"Buffer too short for FileHeader, expected {HeaderSize} bytes, got {buffer.Length}", nameof(buffer));
var offset = 0;
- // Magic Number
+ // Magic Number 验证
var magic = BitConverter.ToUInt32(buffer, offset);
offset += 4;
if (magic != MagicNumber)
- {
- throw new Core.NovaException(Core.ErrorCode.FileCorrupted,
- $"Invalid magic number: 0x{magic:X8}");
- }
+ throw new Core.NovaException(Core.ErrorCode.FileCorrupted, $"Invalid magic number: 0x{magic:X8}, expected 0x{MagicNumber:X8}");
// Version
var version = BitConverter.ToUInt16(buffer, offset);
offset += 2;
// FileType
- var fileType = (FileType)buffer[offset++];
+ var fileTypeByte = buffer[offset++];
+ if (!Enum.IsDefined(typeof(FileType), fileTypeByte))
+ throw new Core.NovaException(Core.ErrorCode.FileCorrupted, $"Invalid file type: {fileTypeByte}");
+
+ var fileType = (FileType)fileTypeByte;
// Reserved
offset += 1;
- // PageSize
+ // PageSize 验证
var pageSize = BitConverter.ToUInt32(buffer, offset);
offset += 4;
+ if (pageSize == 0 || pageSize > 64 * 1024)
+ throw new Core.NovaException(Core.ErrorCode.FileCorrupted, $"Invalid page size: {pageSize}, must be between 1 and 65536");
// CreatedAt
var createdAt = BitConverter.ToInt64(buffer, offset);
diff --git a/NewLife.NovaDb/Storage/MmfPager.cs b/NewLife.NovaDb/Storage/MmfPager.cs
index 5bb5c6d..167b256 100644
--- a/NewLife.NovaDb/Storage/MmfPager.cs
+++ b/NewLife.NovaDb/Storage/MmfPager.cs
@@ -3,9 +3,15 @@ using NewLife.NovaDb.Core;
namespace NewLife.NovaDb.Storage;
-/// <summary>
-/// 基于内存映射文件的分页访问器
-/// </summary>
+/// <summary>基于内存映射文件的分页访问器</summary>
+/// <remarks>
+/// 提供文件级别的分页读写能力,支持:
+/// - 自动写入/验证 FileHeader
+/// - 按 PageId 随机读写页面
+/// - 可选的 CRC32 校验和验证
+/// - 文件按需扩展
+/// 页面偏移 = FileHeader(32B) + PageId * PageSize
+/// </remarks>
public class MmfPager : IDisposable
{
private readonly String _filePath;
@@ -16,19 +22,13 @@ public class MmfPager : IDisposable
private readonly Object _lock = new();
private Boolean _disposed;
- /// <summary>
- /// 文件路径
- /// </summary>
+ /// <summary>文件路径</summary>
public String FilePath => _filePath;
- /// <summary>
- /// 页大小
- /// </summary>
+ /// <summary>页大小(字节)</summary>
public Int32 PageSize => _pageSize;
- /// <summary>
- /// 页数量
- /// </summary>
+ /// <summary>当前页数量</summary>
public UInt64 PageCount
{
get
@@ -41,16 +41,25 @@ public class MmfPager : IDisposable
}
}
+ /// <summary>实例化分页访问器</summary>
+ /// <param name="filePath">数据文件路径</param>
+ /// <param name="pageSize">页大小(字节)</param>
+ /// <param name="enableChecksum">是否启用页校验和</param>
public MmfPager(String filePath, Int32 pageSize, Boolean enableChecksum = true)
{
_filePath = filePath ?? throw new ArgumentNullException(nameof(filePath));
+
+ if (pageSize <= 0)
+ throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be positive");
+
_pageSize = pageSize;
_enableChecksum = enableChecksum;
}
- /// <summary>
- /// 打开或创建文件
- /// </summary>
+ /// <summary>打开或创建数据文件</summary>
+ /// <param name="header">新文件时写入的文件头(已有文件则忽略)</param>
+ /// <exception cref="ObjectDisposedException">已释放时抛出</exception>
+ /// <exception cref="NovaException">PageSize 与文件不匹配时抛出</exception>
public void Open(FileHeader? header = null)
{
lock (_lock)
@@ -64,23 +73,23 @@ public class MmfPager : IDisposable
if (isNewFile && header != null)
{
- // 写入文件头
+ // 新文件写入文件头
var headerBytes = header.ToBytes();
_fileStream.Write(headerBytes, 0, headerBytes.Length);
_fileStream.Flush();
}
else if (!isNewFile)
{
- // 验证文件头
- var headerBytes = new Byte[32];
- _fileStream.Read(headerBytes, 0, 32);
+ // 已有文件验证文件头
+ var headerBytes = new Byte[FileHeader.HeaderSize];
+ var bytesRead = _fileStream.Read(headerBytes, 0, FileHeader.HeaderSize);
+ if (bytesRead < FileHeader.HeaderSize)
+ throw new NovaException(ErrorCode.FileCorrupted, $"File too short, cannot read header: {_filePath}");
+
var fileHeader = FileHeader.FromBytes(headerBytes);
if (fileHeader.PageSize != _pageSize)
- {
- throw new NovaException(ErrorCode.IncompatibleFileFormat,
- $"Page size mismatch: file={fileHeader.PageSize}, expected={_pageSize}");
- }
+ throw new NovaException(ErrorCode.IncompatibleFileFormat, $"Page size mismatch: file={fileHeader.PageSize}, expected={_pageSize}");
}
// 创建内存映射文件
@@ -97,9 +106,13 @@ public class MmfPager : IDisposable
}
}
- /// <summary>
- /// 读取页
- /// </summary>
+ /// <summary>读取指定页面</summary>
+ /// <param name="pageId">页 ID(从 0 开始)</param>
+ /// <returns>页面数据(长度等于 PageSize)</returns>
+ /// <exception cref="ObjectDisposedException">已释放时抛出</exception>
+ /// <exception cref="InvalidOperationException">未打开时抛出</exception>
+ /// <exception cref="ArgumentOutOfRangeException">页 ID 超出范围时抛出</exception>
+ /// <exception cref="NovaException">校验和不匹配时抛出</exception>
public Byte[] ReadPage(UInt64 pageId)
{
lock (_lock)
@@ -110,48 +123,44 @@ public class MmfPager : IDisposable
if (_fileStream == null)
throw new InvalidOperationException("Pager not opened");
- var offset = 32 + (Int64)(pageId * (UInt64)_pageSize); // 跳过文件头
+ // 偏移 = FileHeader(32B) + PageId * PageSize
+ var offset = FileHeader.HeaderSize + (Int64)(pageId * (UInt64)_pageSize);
if (offset + _pageSize > _fileStream.Length)
- {
- throw new ArgumentOutOfRangeException(nameof(pageId),
- $"Page {pageId} is out of range");
- }
+ throw new ArgumentOutOfRangeException(nameof(pageId), $"Page {pageId} is out of range");
- // 使用 FileStream 直接读取
_fileStream.Seek(offset, SeekOrigin.Begin);
var buffer = new Byte[_pageSize];
var bytesRead = _fileStream.Read(buffer, 0, _pageSize);
if (bytesRead != _pageSize)
- {
throw new IOException($"Failed to read complete page: expected {_pageSize}, got {bytesRead}");
- }
- // 验证校验和
+ // 校验和验证
if (_enableChecksum)
{
var pageHeader = PageHeader.FromBytes(buffer);
- var computedChecksum = ComputeChecksum(buffer, 32, pageHeader.DataLength);
+ var computedChecksum = ComputeChecksum(buffer, PageHeader.HeaderSize, pageHeader.DataLength);
if (pageHeader.Checksum != computedChecksum)
- {
- throw new NovaException(ErrorCode.ChecksumFailed,
- $"Checksum mismatch for page {pageId}");
- }
+ throw new NovaException(ErrorCode.ChecksumFailed, $"Checksum mismatch for page {pageId}");
}
return buffer;
}
}
- /// <summary>
- /// 写入页
- /// </summary>
+ /// <summary>写入指定页面</summary>
+ /// <param name="pageId">页 ID(从 0 开始)</param>
+ /// <param name="data">页面数据(长度必须等于 PageSize)</param>
+ /// <exception cref="ArgumentException">数据长度不匹配时抛出</exception>
+ /// <exception cref="ObjectDisposedException">已释放时抛出</exception>
+ /// <exception cref="InvalidOperationException">未打开时抛出</exception>
public void WritePage(UInt64 pageId, Byte[] data)
{
- if (data == null || data.Length != _pageSize)
- {
- throw new ArgumentException($"Page data must be exactly {_pageSize} bytes", nameof(data));
- }
+ if (data == null)
+ throw new ArgumentNullException(nameof(data));
+
+ if (data.Length != _pageSize)
+ throw new ArgumentException($"Page data must be exactly {_pageSize} bytes, got {data.Length}", nameof(data));
lock (_lock)
{
@@ -161,35 +170,30 @@ public class MmfPager : IDisposable
if (_fileStream == null)
throw new InvalidOperationException("Pager not opened");
- var offset = 32 + (Int64)(pageId * (UInt64)_pageSize);
+ var offset = FileHeader.HeaderSize + (Int64)(pageId * (UInt64)_pageSize);
// 计算并设置校验和
if (_enableChecksum)
{
var pageHeader = PageHeader.FromBytes(data);
- var checksum = ComputeChecksum(data, 32, pageHeader.DataLength);
+ var checksum = ComputeChecksum(data, PageHeader.HeaderSize, pageHeader.DataLength);
pageHeader.Checksum = checksum;
var headerBytes = pageHeader.ToBytes();
- Buffer.BlockCopy(headerBytes, 0, data, 0, 32);
+ Buffer.BlockCopy(headerBytes, 0, data, 0, PageHeader.HeaderSize);
}
- // 扩展文件如果需要
+ // 按需扩展文件
if (offset + _pageSize > _fileStream.Length)
- {
_fileStream.SetLength(offset + _pageSize);
- }
- // 直接写入文件流(不使用MMF写入以避免重新映射问题)
_fileStream.Seek(offset, SeekOrigin.Begin);
_fileStream.Write(data, 0, _pageSize);
_fileStream.Flush();
}
}
- /// <summary>
- /// 刷新到磁盘
- /// </summary>
+ /// <summary>刷新缓冲区到磁盘</summary>
public void Flush()
{
lock (_lock)
@@ -198,9 +202,11 @@ public class MmfPager : IDisposable
}
}
- /// <summary>
- /// 计算校验和(CRC32 简化版)
- /// </summary>
+ /// <summary>计算校验和(CRC32 简化版)</summary>
+ /// <param name="buffer">数据缓冲区</param>
+ /// <param name="offset">起始偏移</param>
+ /// <param name="length">数据长度</param>
+ /// <returns>校验和值</returns>
private UInt32 ComputeChecksum(Byte[] buffer, Int32 offset, UInt32 length)
{
UInt32 checksum = 0;
@@ -211,6 +217,7 @@ public class MmfPager : IDisposable
return checksum;
}
+ /// <summary>释放资源</summary>
public void Dispose()
{
lock (_lock)
diff --git a/NewLife.NovaDb/Storage/PageCache.cs b/NewLife.NovaDb/Storage/PageCache.cs
index 040b572..9ce29ad 100644
--- a/NewLife.NovaDb/Storage/PageCache.cs
+++ b/NewLife.NovaDb/Storage/PageCache.cs
@@ -1,8 +1,12 @@
namespace NewLife.NovaDb.Storage;
-/// <summary>
-/// 页缓存(LRU 策略)
-/// </summary>
+/// <summary>页缓存(LRU 策略)</summary>
+/// <remarks>
+/// 基于 LinkedList + Dictionary 实现 O(1) 的 LRU 缓存:
+/// - TryGet: 命中时将页移至链表头部(最近访问)
+/// - Put: 容量满时淘汰链表尾部(最久未访问)
+/// - 线程安全:所有操作均在锁保护下执行
+/// </remarks>
public class PageCache
{
private readonly Int32 _capacity;
@@ -10,14 +14,10 @@ public class PageCache
private readonly LinkedList<UInt64> _lruList;
private readonly Object _lock = new();
- /// <summary>
- /// 缓存大小(页数)
- /// </summary>
+ /// <summary>缓存容量(最大页数)</summary>
public Int32 Capacity => _capacity;
- /// <summary>
- /// 当前缓存页数
- /// </summary>
+ /// <summary>当前缓存页数</summary>
public Int32 Count
{
get
@@ -29,6 +29,15 @@ public class PageCache
}
}
+ /// <summary>命中次数</summary>
+ public Int64 HitCount { get; private set; }
+
+ /// <summary>未命中次数</summary>
+ public Int64 MissCount { get; private set; }
+
+ /// <summary>实例化页缓存</summary>
+ /// <param name="capacity">最大缓存页数(必须为正整数)</param>
+ /// <exception cref="ArgumentOutOfRangeException">capacity 不为正整数时抛出</exception>
public PageCache(Int32 capacity)
{
if (capacity <= 0)
@@ -39,31 +48,35 @@ public class PageCache
_lruList = new LinkedList<UInt64>();
}
- /// <summary>
- /// 尝试获取缓存的页
- /// </summary>
+ /// <summary>尝试获取缓存的页</summary>
+ /// <param name="pageId">页 ID</param>
+ /// <param name="data">命中时返回页数据,未命中返回 null</param>
+ /// <returns>是否命中缓存</returns>
public Boolean TryGet(UInt64 pageId, out Byte[]? data)
{
lock (_lock)
{
if (_cache.TryGetValue(pageId, out var entry))
{
- // 移动到 LRU 列表头部
+ // 移动到 LRU 链表头部(最近访问)
_lruList.Remove(entry.LruNode);
entry.LruNode = _lruList.AddFirst(pageId);
data = entry.Data;
+ HitCount++;
return true;
}
data = null;
+ MissCount++;
return false;
}
}
- /// <summary>
- /// 添加页到缓存
- /// </summary>
+ /// <summary>添加或更新页到缓存</summary>
+ /// <param name="pageId">页 ID</param>
+ /// <param name="data">页数据</param>
+ /// <exception cref="ArgumentNullException">data 为 null 时抛出</exception>
public void Put(UInt64 pageId, Byte[] data)
{
if (data == null)
@@ -71,7 +84,7 @@ public class PageCache
lock (_lock)
{
- // 如果已存在,更新数据并移到头部
+ // 已存在则更新数据并移到头部
if (_cache.TryGetValue(pageId, out var entry))
{
entry.Data = data;
@@ -80,7 +93,7 @@ public class PageCache
return;
}
- // 如果达到容量,淘汰最久未使用的页
+ // 容量满时淘汰最久未使用的页
if (_cache.Count >= _capacity)
{
var lruPageId = _lruList.Last!.Value;
@@ -88,7 +101,7 @@ public class PageCache
_cache.Remove(lruPageId);
}
- // 添加新页
+ // 添加新页到头部
var node = _lruList.AddFirst(pageId);
_cache[pageId] = new CacheEntry
{
@@ -98,10 +111,10 @@ public class PageCache
}
}
- /// <summary>
- /// 移除页从缓存
- /// </summary>
- public void Remove(UInt64 pageId)
+ /// <summary>从缓存中移除指定页</summary>
+ /// <param name="pageId">页 ID</param>
+ /// <returns>是否成功移除(页不存在时返回 false)</returns>
+ public Boolean Remove(UInt64 pageId)
{
lock (_lock)
{
@@ -109,19 +122,21 @@ public class PageCache
{
_lruList.Remove(entry.LruNode);
_cache.Remove(pageId);
+ return true;
}
+ return false;
}
}
- /// <summary>
- /// 清空缓存
- /// </summary>
+ /// <summary>清空全部缓存</summary>
public void Clear()
{
lock (_lock)
{
_cache.Clear();
_lruList.Clear();
+ HitCount = 0;
+ MissCount = 0;
}
}
diff --git a/NewLife.NovaDb/Storage/PageHeader.cs b/NewLife.NovaDb/Storage/PageHeader.cs
index b616a82..dd34d40 100644
--- a/NewLife.NovaDb/Storage/PageHeader.cs
+++ b/NewLife.NovaDb/Storage/PageHeader.cs
@@ -1,41 +1,41 @@
namespace NewLife.NovaDb.Storage;
-/// <summary>
-/// 页头结构(每个页的开头)
-/// </summary>
+/// <summary>页头结构(每个页的开头,固定 32 字节)</summary>
+/// <remarks>
+/// 页头布局(32 字节):
+/// - 0-7: PageId (页 ID)
+/// - 8: PageType (0=Empty, 1=Data, 2=Index, 3=Directory, 4=Metadata)
+/// - 9-11: Reserved (预留)
+/// - 12-19: LSN (日志序列号)
+/// - 20-23: Checksum (CRC32 校验和)
+/// - 24-27: DataLength (页内有效数据长度)
+/// - 28-31: Reserved (预留扩展)
+/// </remarks>
public class PageHeader
{
- /// <summary>
- /// 页 ID
- /// </summary>
+ /// <summary>页头固定大小(32 字节)</summary>
+ public const Int32 HeaderSize = 32;
+
+ /// <summary>页 ID</summary>
public UInt64 PageId { get; set; }
- /// <summary>
- /// 页类型
- /// </summary>
+ /// <summary>页类型</summary>
public PageType PageType { get; set; }
- /// <summary>
- /// 日志序列号(LSN)
- /// </summary>
+ /// <summary>日志序列号(LSN)</summary>
public UInt64 Lsn { get; set; }
- /// <summary>
- /// 校验和(CRC32)
- /// </summary>
+ /// <summary>校验和(CRC32)</summary>
public UInt32 Checksum { get; set; }
- /// <summary>
- /// 页内有效数据长度
- /// </summary>
+ /// <summary>页内有效数据长度</summary>
public UInt32 DataLength { get; set; }
- /// <summary>
- /// 序列化为字节数组(固定 32 字节)
- /// </summary>
+ /// <summary>序列化为字节数组(固定 32 字节)</summary>
+ /// <returns>32 字节的页头数据</returns>
public Byte[] ToBytes()
{
- var buffer = new Byte[32];
+ var buffer = new Byte[HeaderSize];
var offset = 0;
// PageId (8 bytes)
@@ -60,21 +60,24 @@ public class PageHeader
Buffer.BlockCopy(BitConverter.GetBytes(DataLength), 0, buffer, offset, 4);
offset += 4;
- // Reserved (4 bytes)
- // offset += 4;
+ // Reserved (4 bytes) - 自动为 0
return buffer;
}
- /// <summary>
- /// 从字节数组反序列化
- /// </summary>
+ /// <summary>从字节数组反序列化</summary>
+ /// <param name="buffer">包含页头数据的字节数组(至少 32 字节)</param>
+ /// <returns>反序列化的页头对象</returns>
+ /// <exception cref="ArgumentNullException">buffer 为 null</exception>
+ /// <exception cref="ArgumentException">buffer 长度不足 32 字节</exception>
+ /// <exception cref="Core.NovaException">页类型无效</exception>
public static PageHeader FromBytes(Byte[] buffer)
{
- if (buffer.Length < 32)
- {
- throw new ArgumentException("Buffer too short for PageHeader");
- }
+ if (buffer == null)
+ throw new ArgumentNullException(nameof(buffer));
+
+ if (buffer.Length < HeaderSize)
+ throw new ArgumentException($"Buffer too short for PageHeader, expected {HeaderSize} bytes, got {buffer.Length}", nameof(buffer));
var offset = 0;
@@ -82,8 +85,12 @@ public class PageHeader
var pageId = BitConverter.ToUInt64(buffer, offset);
offset += 8;
- // PageType
- var pageType = (PageType)buffer[offset++];
+ // PageType 验证
+ var pageTypeByte = buffer[offset++];
+ if (!Enum.IsDefined(typeof(PageType), pageTypeByte))
+ throw new Core.NovaException(Core.ErrorCode.FileCorrupted, $"Invalid page type: {pageTypeByte}");
+
+ var pageType = (PageType)pageTypeByte;
// Reserved
offset += 3;
diff --git a/NewLife.NovaDb/Storage/TableFileManager.cs b/NewLife.NovaDb/Storage/TableFileManager.cs
new file mode 100644
index 0000000..c47746d
--- /dev/null
+++ b/NewLife.NovaDb/Storage/TableFileManager.cs
@@ -0,0 +1,201 @@
+using NewLife.NovaDb.Core;
+
+namespace NewLife.NovaDb.Storage;
+
+/// <summary>表文件管理器(文件路径生成与命名规则)</summary>
+/// <remarks>
+/// 负责生成表相关文件的路径,基于新的平铺文件布局:
+/// - 数据文件:{TableName}.data 或 {TableName}_{ShardId}.data
+/// - 索引文件:{TableName}.idx(主键)或 {TableName}_{IndexName}.idx(二级索引)
+/// - WAL 文件:{TableName}.wal 或 {TableName}_{ShardId}.wal
+/// 所有文件平铺在数据库目录下,无需为每表创建独立目录
+/// </remarks>
+public class TableFileManager
+{
+ private readonly String _databasePath;
+ private readonly String _tableName;
+ private readonly DbOptions _options;
+
+ /// <summary>数据库路径</summary>
+ public String DatabasePath => _databasePath;
+
+ /// <summary>表名</summary>
+ public String TableName => _tableName;
+
+ /// <summary>构造表文件管理器</summary>
+ /// <param name="databasePath">数据库目录路径</param>
+ /// <param name="tableName">表名</param>
+ /// <param name="options">数据库配置</param>
+ public TableFileManager(String databasePath, String tableName, DbOptions options)
+ {
+ _databasePath = databasePath ?? throw new ArgumentNullException(nameof(databasePath));
+ _tableName = tableName ?? throw new ArgumentNullException(nameof(tableName));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+
+ if (String.IsNullOrWhiteSpace(_tableName))
+ throw new ArgumentException("Table name cannot be empty", nameof(tableName));
+ }
+
+ /// <summary>获取数据文件路径</summary>
+ /// <param name="shardId">分片 ID(可选,默认 null 表示无分片)</param>
+ /// <returns>数据文件完整路径</returns>
+ public String GetDataFilePath(Int32? shardId = null)
+ {
+ var fileName = shardId.HasValue
+ ? $"{_tableName}_{shardId.Value}.data"
+ : $"{_tableName}.data";
+
+ return System.IO.Path.Combine(_databasePath, fileName);
+ }
+
+ /// <summary>获取主键索引文件路径</summary>
+ /// <param name="shardId">分片 ID(可选,默认 null 表示无分片)</param>
+ /// <returns>主键索引文件完整路径</returns>
+ public String GetPrimaryIndexFilePath(Int32? shardId = null)
+ {
+ var fileName = shardId.HasValue
+ ? $"{_tableName}_{shardId.Value}.idx"
+ : $"{_tableName}.idx";
+
+ return System.IO.Path.Combine(_databasePath, fileName);
+ }
+
+ /// <summary>获取二级索引文件路径</summary>
+ /// <param name="indexName">索引名称</param>
+ /// <param name="shardId">分片 ID(可选,默认 null 表示无分片)</param>
+ /// <returns>二级索引文件完整路径</returns>
+ public String GetSecondaryIndexFilePath(String indexName, Int32? shardId = null)
+ {
+ if (String.IsNullOrWhiteSpace(indexName))
+ throw new ArgumentException("Index name cannot be empty", nameof(indexName));
+
+ var fileName = shardId.HasValue
+ ? $"{_tableName}_{shardId.Value}_{indexName}.idx"
+ : $"{_tableName}_{indexName}.idx";
+
+ return System.IO.Path.Combine(_databasePath, fileName);
+ }
+
+ /// <summary>获取 WAL 文件路径</summary>
+ /// <param name="shardId">分片 ID(可选,默认 null 表示无分片)</param>
+ /// <returns>WAL 文件完整路径</returns>
+ public String GetWalFilePath(Int32? shardId = null)
+ {
+ var fileName = shardId.HasValue
+ ? $"{_tableName}_{shardId.Value}.wal"
+ : $"{_tableName}.wal";
+
+ return System.IO.Path.Combine(_databasePath, fileName);
+ }
+
+ /// <summary>列举所有数据分片 ID</summary>
+ /// <returns>分片 ID 列表,按从小到大排序</returns>
+ public IEnumerable<Int32> ListDataShards()
+ {
+ if (!Directory.Exists(_databasePath))
+ return Enumerable.Empty<Int32>();
+
+ var pattern = $"{_tableName}_*.data";
+ var files = Directory.GetFiles(_databasePath, pattern);
+
+ var shardIds = new List<Int32>();
+ foreach (var file in files)
+ {
+ var fileName = System.IO.Path.GetFileNameWithoutExtension(file);
+ // fileName 格式:{TableName}_{ShardId}
+ var parts = fileName.Split('_');
+ if (parts.Length >= 2 && Int32.TryParse(parts[^1], out var shardId))
+ {
+ shardIds.Add(shardId);
+ }
+ }
+
+ return shardIds.OrderBy(x => x);
+ }
+
+ /// <summary>列举所有二级索引名称</summary>
+ /// <returns>索引名称列表</returns>
+ public IEnumerable<String> ListSecondaryIndexes()
+ {
+ if (!Directory.Exists(_databasePath))
+ return Enumerable.Empty<String>();
+
+ var pattern = $"{_tableName}_*.idx";
+ var files = Directory.GetFiles(_databasePath, pattern);
+
+ var indexes = new HashSet<String>();
+ foreach (var file in files)
+ {
+ var fileName = System.IO.Path.GetFileNameWithoutExtension(file);
+ // fileName 格式:{TableName}_{IndexName} 或 {TableName}_{ShardId}_{IndexName}
+
+ // 移除表名前缀
+ var suffix = fileName.Substring(_tableName.Length + 1); // +1 for '_'
+ var parts = suffix.Split('_');
+
+ if (parts.Length >= 1)
+ {
+ // 检查第一部分是否为数字(分片 ID)
+ if (Int32.TryParse(parts[0], out _))
+ {
+ // {TableName}_{ShardId}_{IndexName} - 提取索引名(剩余部分)
+ if (parts.Length >= 2)
+ {
+ var indexName = String.Join("_", parts.Skip(1));
+ if (!String.IsNullOrEmpty(indexName))
+ {
+ indexes.Add(indexName);
+ }
+ }
+ }
+ else
+ {
+ // {TableName}_{IndexName} - 整个 suffix 就是索引名
+ indexes.Add(suffix);
+ }
+ }
+ }
+
+ return indexes.OrderBy(x => x);
+ }
+
+ /// <summary>删除表的所有文件</summary>
+ /// <remarks>删除数据文件、索引文件、WAL 文件</remarks>
+ public void DeleteAllFiles()
+ {
+ if (!Directory.Exists(_databasePath))
+ return;
+
+ // 删除数据文件(包括分片)
+ var dataPattern = $"{_tableName}*.data";
+ foreach (var file in Directory.GetFiles(_databasePath, dataPattern))
+ {
+ File.Delete(file);
+ }
+
+ // 删除索引文件(主键和二级索引)
+ var indexPattern = $"{_tableName}*.idx";
+ foreach (var file in Directory.GetFiles(_databasePath, indexPattern))
+ {
+ File.Delete(file);
+ }
+
+ // 删除 WAL 文件
+ var walPattern = $"{_tableName}*.wal";
+ foreach (var file in Directory.GetFiles(_databasePath, walPattern))
+ {
+ File.Delete(file);
+ }
+ }
+
+ /// <summary>检查表文件是否存在</summary>
+ /// <returns>如果至少存在一个表相关文件则返回 true</returns>
+ public Boolean Exists()
+ {
+ if (!Directory.Exists(_databasePath))
+ return false;
+
+ var dataFile = GetDataFilePath();
+ return File.Exists(dataFile);
+ }
+}
diff --git a/temp.txt b/temp.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/temp.txt
diff --git a/XUnitTest/Core/DataCodecTests.cs b/XUnitTest/Core/DataCodecTests.cs
index 7ab7f5a..5a3d6c2 100644
--- a/XUnitTest/Core/DataCodecTests.cs
+++ b/XUnitTest/Core/DataCodecTests.cs
@@ -110,4 +110,153 @@ public class DataCodecTests
Assert.Equal(8, _codec.GetEncodedLength(DateTime.Now, DataType.DateTime));
Assert.Equal(4, _codec.GetEncodedLength(null, DataType.String));
}
+
+ [Fact]
+ public void TestEncodeDecodeEmptyString()
+ {
+ var value = "";
+ var encoded = _codec.Encode(value, DataType.String);
+ var decoded = _codec.Decode(encoded, 0, DataType.String);
+
+ Assert.Equal(value, decoded);
+ Assert.Equal(4, encoded.Length); // 长度前缀 4 字节 + 0 字节数据
+ }
+
+ [Fact]
+ public void TestEncodeDecodeEmptyByteArray()
+ {
+ var value = Array.Empty<Byte>();
+ var encoded = _codec.Encode(value, DataType.Binary);
+ var decoded = (Byte[])_codec.Decode(encoded, 0, DataType.Binary)!;
+
+ Assert.Equal(value, decoded);
+ Assert.Equal(4, encoded.Length); // 长度前缀 4 字节 + 0 字节数据
+ }
+
+ [Fact]
+ public void TestEncodeDecodeUnicodeString()
+ {
+ var value = "你好,NovaDb!🚀";
+ var encoded = _codec.Encode(value, DataType.String);
+ var decoded = _codec.Decode(encoded, 0, DataType.String);
+
+ Assert.Equal(value, decoded);
+ }
+
+ [Fact]
+ public void TestEncodeDecodeMinMaxInt32()
+ {
+ var minEncoded = _codec.Encode(Int32.MinValue, DataType.Int32);
+ var minDecoded = _codec.Decode(minEncoded, 0, DataType.Int32);
+ Assert.Equal(Int32.MinValue, minDecoded);
+
+ var maxEncoded = _codec.Encode(Int32.MaxValue, DataType.Int32);
+ var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.Int32);
+ Assert.Equal(Int32.MaxValue, maxDecoded);
+ }
+
+ [Fact]
+ public void TestEncodeDecodeMinMaxInt64()
+ {
+ var minEncoded = _codec.Encode(Int64.MinValue, DataType.Int64);
+ var minDecoded = _codec.Decode(minEncoded, 0, DataType.Int64);
+ Assert.Equal(Int64.MinValue, minDecoded);
+
+ var maxEncoded = _codec.Encode(Int64.MaxValue, DataType.Int64);
+ var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.Int64);
+ Assert.Equal(Int64.MaxValue, maxDecoded);
+ }
+
+ [Fact]
+ public void TestEncodeDecodeMinMaxDouble()
+ {
+ var minEncoded = _codec.Encode(Double.MinValue, DataType.Double);
+ var minDecoded = _codec.Decode(minEncoded, 0, DataType.Double);
+ Assert.Equal(Double.MinValue, minDecoded);
+
+ var maxEncoded = _codec.Encode(Double.MaxValue, DataType.Double);
+ var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.Double);
+ Assert.Equal(Double.MaxValue, maxDecoded);
+ }
+
+ [Fact]
+ public void TestEncodeDecodeMinMaxDecimal()
+ {
+ var minEncoded = _codec.Encode(Decimal.MinValue, DataType.Decimal);
+ var minDecoded = _codec.Decode(minEncoded, 0, DataType.Decimal);
+ Assert.Equal(Decimal.MinValue, minDecoded);
+
+ var maxEncoded = _codec.Encode(Decimal.MaxValue, DataType.Decimal);
+ var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.Decimal);
+ Assert.Equal(Decimal.MaxValue, maxDecoded);
+ }
+
+ [Fact]
+ public void TestEncodeDecodeMinMaxDateTime()
+ {
+ var minEncoded = _codec.Encode(DateTime.MinValue, DataType.DateTime);
+ var minDecoded = _codec.Decode(minEncoded, 0, DataType.DateTime);
+ Assert.Equal(DateTime.MinValue, minDecoded);
+
+ var maxEncoded = _codec.Encode(DateTime.MaxValue, DataType.DateTime);
+ var maxDecoded = _codec.Decode(maxEncoded, 0, DataType.DateTime);
+ Assert.Equal(DateTime.MaxValue, maxDecoded);
+ }
+
+ [Fact]
+ public void TestDecodeWithOffset()
+ {
+ // 构造一个包含多个值的缓冲区
+ var value1 = 12345;
+ var value2 = 67890;
+ var encoded1 = _codec.Encode(value1, DataType.Int32);
+ var encoded2 = _codec.Encode(value2, DataType.Int32);
+
+ var buffer = new Byte[encoded1.Length + encoded2.Length];
+ Buffer.BlockCopy(encoded1, 0, buffer, 0, encoded1.Length);
+ Buffer.BlockCopy(encoded2, 0, buffer, encoded1.Length, encoded2.Length);
+
+ // 从不同偏移解码
+ var decoded1 = _codec.Decode(buffer, 0, DataType.Int32);
+ var decoded2 = _codec.Decode(buffer, encoded1.Length, DataType.Int32);
+
+ Assert.Equal(value1, decoded1);
+ Assert.Equal(value2, decoded2);
+ }
+
+ [Fact]
+ public void TestEncodeDecodeNullForAllTypes()
+ {
+ // 测试所有支持 NULL 的类型
+ var types = new[] { DataType.String, DataType.Binary };
+ foreach (var type in types)
+ {
+ var encoded = _codec.Encode(null, type);
+ var decoded = _codec.Decode(encoded, 0, type);
+ Assert.Null(decoded);
+ }
+ }
+
+ [Fact]
+ public void TestEncodeUnsupportedType()
+ {
+ // 测试不支持的类型
+ var ex = Assert.Throws<NotSupportedException>(() => _codec.Encode(123, (DataType)255));
+ Assert.Contains("Unsupported data type", ex.Message);
+ }
+
+ [Fact]
+ public void TestDecodeUnsupportedType()
+ {
+ var buffer = new Byte[4];
+ var ex = Assert.Throws<NotSupportedException>(() => _codec.Decode(buffer, 0, (DataType)255));
+ Assert.Contains("Unsupported data type", ex.Message);
+ }
+
+ [Fact]
+ public void TestGetEncodedLengthUnsupportedType()
+ {
+ var ex = Assert.Throws<NotSupportedException>(() => _codec.GetEncodedLength(123, (DataType)255));
+ Assert.Contains("Unsupported data type", ex.Message);
+ }
}
diff --git a/XUnitTest/Core/DataTypeExtensionsTests.cs b/XUnitTest/Core/DataTypeExtensionsTests.cs
new file mode 100644
index 0000000..1bd5262
--- /dev/null
+++ b/XUnitTest/Core/DataTypeExtensionsTests.cs
@@ -0,0 +1,147 @@
+using System;
+using NewLife.NovaDb.Core;
+using Xunit;
+
+namespace XUnitTest.Core;
+
+/// <summary>DataType 扩展方法测试</summary>
+public class DataTypeExtensionsTests
+{
+ [Theory]
+ [InlineData(DataType.Boolean, typeof(Boolean))]
+ [InlineData(DataType.Int32, typeof(Int32))]
+ [InlineData(DataType.Int64, typeof(Int64))]
+ [InlineData(DataType.Double, typeof(Double))]
+ [InlineData(DataType.Decimal, typeof(Decimal))]
+ [InlineData(DataType.DateTime, typeof(DateTime))]
+ [InlineData(DataType.String, typeof(String))]
+ [InlineData(DataType.Binary, typeof(Byte[]))]
+ [InlineData(DataType.GeoPoint, typeof(GeoPoint))]
+ [InlineData(DataType.Vector, typeof(Single[]))]
+ public void TestGetClrType(DataType dataType, Type expectedType)
+ {
+ var clrType = dataType.GetClrType();
+ Assert.Equal(expectedType, clrType);
+ }
+
+ [Theory]
+ [InlineData(typeof(Boolean), DataType.Boolean)]
+ [InlineData(typeof(Int32), DataType.Int32)]
+ [InlineData(typeof(Int64), DataType.Int64)]
+ [InlineData(typeof(Double), DataType.Double)]
+ [InlineData(typeof(Decimal), DataType.Decimal)]
+ [InlineData(typeof(DateTime), DataType.DateTime)]
+ [InlineData(typeof(String), DataType.String)]
+ [InlineData(typeof(Byte[]), DataType.Binary)]
+ [InlineData(typeof(GeoPoint), DataType.GeoPoint)]
+ [InlineData(typeof(Single[]), DataType.Vector)]
+ public void TestFromClrType(Type clrType, DataType expectedDataType)
+ {
+ var dataType = DataTypeExtensions.FromClrType(clrType);
+ Assert.Equal(expectedDataType, dataType);
+ }
+
+ [Fact]
+ public void TestRoundTripConversion()
+ {
+ // 测试所有支持的类型往返转换
+ var allDataTypes = new[]
+ {
+ DataType.Boolean, DataType.Int32, DataType.Int64, DataType.Double,
+ DataType.Decimal, DataType.DateTime, DataType.String, DataType.Binary,
+ DataType.GeoPoint, DataType.Vector
+ };
+
+ foreach (var dataType in allDataTypes)
+ {
+ var clrType = dataType.GetClrType();
+ var backToDataType = DataTypeExtensions.FromClrType(clrType);
+ Assert.Equal(dataType, backToDataType);
+ }
+ }
+
+ [Fact]
+ public void TestGetClrTypeUnsupported()
+ {
+ var ex = Assert.Throws<NotSupportedException>(() => ((DataType)255).GetClrType());
+ Assert.Contains("Unsupported data type", ex.Message);
+ }
+
+ [Fact]
+ public void TestFromClrTypeUnsupported()
+ {
+ var ex = Assert.Throws<NotSupportedException>(() => DataTypeExtensions.FromClrType(typeof(Object)));
+ Assert.Contains("Unsupported CLR type", ex.Message);
+ }
+
+ [Fact]
+ public void TestDataTypeEnumValues()
+ {
+ // 验证基础类型的枚举值与 TypeCode 保持一致
+ Assert.Equal(3, (Byte)DataType.Boolean);
+ Assert.Equal(9, (Byte)DataType.Int32);
+ Assert.Equal(11, (Byte)DataType.Int64);
+ Assert.Equal(14, (Byte)DataType.Double);
+ Assert.Equal(15, (Byte)DataType.Decimal);
+ Assert.Equal(16, (Byte)DataType.DateTime);
+ Assert.Equal(18, (Byte)DataType.String);
+
+ // 自定义类型
+ Assert.Equal(101, (Byte)DataType.Binary);
+ Assert.Equal(102, (Byte)DataType.GeoPoint);
+ Assert.Equal(103, (Byte)DataType.Vector);
+ }
+}
+
+/// <summary>GeoPoint 结构体测试</summary>
+public class GeoPointTests
+{
+ [Fact]
+ public void TestConstructor()
+ {
+ var point = new GeoPoint(39.9042, 116.4074);
+
+ Assert.Equal(39.9042, point.Latitude);
+ Assert.Equal(116.4074, point.Longitude);
+ }
+
+ [Fact]
+ public void TestToString()
+ {
+ var point = new GeoPoint(39.9042, 116.4074);
+ var str = point.ToString();
+
+ Assert.Contains("39.9042", str);
+ Assert.Contains("116.4074", str);
+ }
+
+ [Fact]
+ public void TestPropertySetter()
+ {
+ var point = new GeoPoint(0, 0)
+ {
+ Latitude = 31.2304,
+ Longitude = 121.4737
+ };
+
+ Assert.Equal(31.2304, point.Latitude);
+ Assert.Equal(121.4737, point.Longitude);
+ }
+
+ [Fact]
+ public void TestBoundaryValues()
+ {
+ // 测试边界值
+ var northPole = new GeoPoint(90, 0);
+ Assert.Equal(90, northPole.Latitude);
+
+ var southPole = new GeoPoint(-90, 0);
+ Assert.Equal(-90, southPole.Latitude);
+
+ var dateLine = new GeoPoint(0, 180);
+ Assert.Equal(180, dateLine.Longitude);
+
+ var antiMeridian = new GeoPoint(0, -180);
+ Assert.Equal(-180, antiMeridian.Longitude);
+ }
+}
diff --git a/XUnitTest/Core/DbOptionsTests.cs b/XUnitTest/Core/DbOptionsTests.cs
new file mode 100644
index 0000000..ca898ed
--- /dev/null
+++ b/XUnitTest/Core/DbOptionsTests.cs
@@ -0,0 +1,150 @@
+using System;
+using NewLife.NovaDb.Core;
+using Xunit;
+
+namespace XUnitTest.Core;
+
+/// <summary>DbOptions 配置选项测试</summary>
+public class DbOptionsTests
+{
+ [Fact]
+ public void TestDefaultValues()
+ {
+ var options = new DbOptions();
+
+ Assert.Equal(String.Empty, options.Path);
+ Assert.Equal(WalMode.Normal, options.WalMode);
+ Assert.Equal(4096, options.PageSize);
+ Assert.Equal(600, options.HotWindowSeconds);
+ Assert.Equal(1800, options.ColdEvictionSeconds);
+ Assert.Equal(1024L * 1024 * 1024, options.ShardSizeThreshold);
+ Assert.Equal(10_000_000, options.ShardRowThreshold);
+ Assert.True(options.EnableChecksum);
+ Assert.Equal(1024, options.PageCacheSize);
+ Assert.Equal(1, options.FluxPartitionHours);
+ Assert.Equal(0, options.FluxDefaultTtlSeconds);
+ }
+
+ [Fact]
+ public void TestSetPath()
+ {
+ var options = new DbOptions
+ {
+ Path = "/data/nova"
+ };
+
+ Assert.Equal("/data/nova", options.Path);
+ }
+
+ [Fact]
+ public void TestSetWalMode()
+ {
+ var options = new DbOptions
+ {
+ WalMode = WalMode.Full
+ };
+
+ Assert.Equal(WalMode.Full, options.WalMode);
+ }
+
+ [Fact]
+ public void TestSetPageSize()
+ {
+ var options = new DbOptions
+ {
+ PageSize = 8192
+ };
+
+ Assert.Equal(8192, options.PageSize);
+ }
+
+ [Fact]
+ public void TestSetHotWindowSeconds()
+ {
+ var options = new DbOptions
+ {
+ HotWindowSeconds = 1200
+ };
+
+ Assert.Equal(1200, options.HotWindowSeconds);
+ }
+
+ [Fact]
+ public void TestSetColdEvictionSeconds()
+ {
+ var options = new DbOptions
+ {
+ ColdEvictionSeconds = 3600
+ };
+
+ Assert.Equal(3600, options.ColdEvictionSeconds);
+ }
+
+ [Fact]
+ public void TestSetShardThresholds()
+ {
+ var options = new DbOptions
+ {
+ ShardSizeThreshold = 2L * 1024 * 1024 * 1024,
+ ShardRowThreshold = 20_000_000
+ };
+
+ Assert.Equal(2L * 1024 * 1024 * 1024, options.ShardSizeThreshold);
+ Assert.Equal(20_000_000, options.ShardRowThreshold);
+ }
+
+ [Fact]
+ public void TestSetEnableChecksum()
+ {
+ var options = new DbOptions
+ {
+ EnableChecksum = false
+ };
+
+ Assert.False(options.EnableChecksum);
+ }
+
+ [Fact]
+ public void TestSetPageCacheSize()
+ {
+ var options = new DbOptions
+ {
+ PageCacheSize = 2048
+ };
+
+ Assert.Equal(2048, options.PageCacheSize);
+ }
+
+ [Fact]
+ public void TestSetFluxOptions()
+ {
+ var options = new DbOptions
+ {
+ FluxPartitionHours = 24,
+ FluxDefaultTtlSeconds = 86400
+ };
+
+ Assert.Equal(24, options.FluxPartitionHours);
+ Assert.Equal(86400, options.FluxDefaultTtlSeconds);
+ }
+}
+
+/// <summary>WalMode 枚举测试</summary>
+public class WalModeTests
+{
+ [Fact]
+ public void TestWalModeValues()
+ {
+ Assert.Equal(0, (Int32)WalMode.None);
+ Assert.Equal(1, (Int32)WalMode.Normal);
+ Assert.Equal(2, (Int32)WalMode.Full);
+ }
+
+ [Fact]
+ public void TestWalModeName()
+ {
+ Assert.Equal("None", WalMode.None.ToString());
+ Assert.Equal("Normal", WalMode.Normal.ToString());
+ Assert.Equal("Full", WalMode.Full.ToString());
+ }
+}
diff --git a/XUnitTest/Core/NovaExceptionTests.cs b/XUnitTest/Core/NovaExceptionTests.cs
new file mode 100644
index 0000000..229e549
--- /dev/null
+++ b/XUnitTest/Core/NovaExceptionTests.cs
@@ -0,0 +1,150 @@
+using System;
+using NewLife.NovaDb.Core;
+using Xunit;
+
+namespace XUnitTest.Core;
+
+/// <summary>NovaException 异常测试</summary>
+public class NovaExceptionTests
+{
+ [Fact]
+ public void TestConstructorWithMessage()
+ {
+ var exception = new NovaException(ErrorCode.TableNotFound, "Table 'users' not found");
+
+ Assert.Equal(ErrorCode.TableNotFound, exception.Code);
+ Assert.Equal("Table 'users' not found", exception.Message);
+ Assert.Null(exception.InnerException);
+ }
+
+ [Fact]
+ public void TestConstructorWithInnerException()
+ {
+ var innerException = new InvalidOperationException("Inner error");
+ var exception = new NovaException(
+ ErrorCode.IoError,
+ "Failed to read file",
+ innerException
+ );
+
+ Assert.Equal(ErrorCode.IoError, exception.Code);
+ Assert.Equal("Failed to read file", exception.Message);
+ Assert.Same(innerException, exception.InnerException);
+ }
+
+ [Fact]
+ public void TestErrorCodePreservedThroughCatch()
+ {
+ try
+ {
+ throw new NovaException(ErrorCode.ChecksumFailed, "Checksum mismatch");
+ }
+ catch (NovaException ex)
+ {
+ Assert.Equal(ErrorCode.ChecksumFailed, ex.Code);
+ }
+ }
+
+ [Theory]
+ [InlineData(ErrorCode.Unknown, 0)]
+ [InlineData(ErrorCode.FileCorrupted, 1000)]
+ [InlineData(ErrorCode.ChecksumFailed, 1001)]
+ [InlineData(ErrorCode.IncompatibleFileFormat, 1002)]
+ [InlineData(ErrorCode.ParseFailed, 2000)]
+ [InlineData(ErrorCode.SyntaxError, 2001)]
+ [InlineData(ErrorCode.TransactionConflict, 3000)]
+ [InlineData(ErrorCode.Deadlock, 3001)]
+ [InlineData(ErrorCode.TransactionError, 3002)]
+ [InlineData(ErrorCode.TableExists, 4000)]
+ [InlineData(ErrorCode.TableNotFound, 4001)]
+ [InlineData(ErrorCode.PrimaryKeyConflict, 4002)]
+ [InlineData(ErrorCode.ConstraintViolation, 4003)]
+ [InlineData(ErrorCode.ShardNotFound, 4004)]
+ [InlineData(ErrorCode.ShardLimitExceeded, 4005)]
+ [InlineData(ErrorCode.StreamNotFound, 4006)]
+ [InlineData(ErrorCode.ConsumerGroupNotFound, 4007)]
+ [InlineData(ErrorCode.MessageExpired, 4008)]
+ [InlineData(ErrorCode.KeyNotFound, 4009)]
+ [InlineData(ErrorCode.KeyExpired, 4010)]
+ [InlineData(ErrorCode.NotSupported, 5000)]
+ [InlineData(ErrorCode.InvalidArgument, 5001)]
+ [InlineData(ErrorCode.IoError, 6000)]
+ [InlineData(ErrorCode.DiskFull, 6001)]
+ [InlineData(ErrorCode.ConnectionFailed, 7000)]
+ [InlineData(ErrorCode.AuthenticationFailed, 7001)]
+ [InlineData(ErrorCode.ProtocolError, 7002)]
+ [InlineData(ErrorCode.SessionExpired, 7003)]
+ [InlineData(ErrorCode.ReplicationError, 8000)]
+ [InlineData(ErrorCode.NodeNotFound, 8001)]
+ [InlineData(ErrorCode.NotMaster, 8002)]
+ [InlineData(ErrorCode.ReplicationLag, 8003)]
+ public void TestErrorCodeValues(ErrorCode code, Int32 expectedValue)
+ {
+ Assert.Equal(expectedValue, (Int32)code);
+ }
+
+ [Fact]
+ public void TestErrorCodeCategories()
+ {
+ // 文件错误 (1000-1999)
+ Assert.InRange((Int32)ErrorCode.FileCorrupted, 1000, 1999);
+ Assert.InRange((Int32)ErrorCode.ChecksumFailed, 1000, 1999);
+
+ // 解析错误 (2000-2999)
+ Assert.InRange((Int32)ErrorCode.ParseFailed, 2000, 2999);
+ Assert.InRange((Int32)ErrorCode.SyntaxError, 2000, 2999);
+
+ // 事务错误 (3000-3999)
+ Assert.InRange((Int32)ErrorCode.TransactionConflict, 3000, 3999);
+ Assert.InRange((Int32)ErrorCode.Deadlock, 3000, 3999);
+
+ // 数据错误 (4000-4999)
+ Assert.InRange((Int32)ErrorCode.TableNotFound, 4000, 4999);
+ Assert.InRange((Int32)ErrorCode.PrimaryKeyConflict, 4000, 4999);
+
+ // 通用错误 (5000-5999)
+ Assert.InRange((Int32)ErrorCode.NotSupported, 5000, 5999);
+ Assert.InRange((Int32)ErrorCode.InvalidArgument, 5000, 5999);
+
+ // I/O 错误 (6000-6999)
+ Assert.InRange((Int32)ErrorCode.IoError, 6000, 6999);
+ Assert.InRange((Int32)ErrorCode.DiskFull, 6000, 6999);
+
+ // 网络错误 (7000-7999)
+ Assert.InRange((Int32)ErrorCode.ConnectionFailed, 7000, 7999);
+ Assert.InRange((Int32)ErrorCode.ProtocolError, 7000, 7999);
+
+ // 集群错误 (8000-8999)
+ Assert.InRange((Int32)ErrorCode.ReplicationError, 8000, 8999);
+ Assert.InRange((Int32)ErrorCode.NotMaster, 8000, 8999);
+ }
+
+ [Fact]
+ public void TestInheritanceFromException()
+ {
+ var exception = new NovaException(ErrorCode.Unknown, "Test");
+ Assert.IsAssignableFrom<Exception>(exception);
+ }
+
+ [Fact]
+ public void TestMultipleCatchBlocks()
+ {
+ Exception? caughtException = null;
+
+ try
+ {
+ throw new NovaException(ErrorCode.TableNotFound, "Table missing");
+ }
+ catch (NovaException ex)
+ {
+ caughtException = ex;
+ }
+ catch (Exception)
+ {
+ Assert.Fail("Should have caught NovaException specifically");
+ }
+
+ Assert.NotNull(caughtException);
+ Assert.IsType<NovaException>(caughtException);
+ }
+}
diff --git a/XUnitTest/Storage/DatabaseDirectoryTests.cs b/XUnitTest/Storage/DatabaseDirectoryTests.cs
index ad3148a..bc68438 100644
--- a/XUnitTest/Storage/DatabaseDirectoryTests.cs
+++ b/XUnitTest/Storage/DatabaseDirectoryTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using System.Linq;
using NewLife.NovaDb.Core;
@@ -9,7 +9,7 @@ namespace XUnitTest.Storage;
public class DatabaseDirectoryTests : IDisposable
{
- private readonly string _testPath;
+ private readonly String _testPath;
private readonly DbOptions _options;
public DatabaseDirectoryTests()
@@ -30,6 +30,28 @@ public class DatabaseDirectoryTests : IDisposable
}
}
+ #region 构造函数
+ [Fact]
+ public void TestConstructorNullPath()
+ {
+ Assert.Throws<ArgumentNullException>(() => new DatabaseDirectory(null!, _options));
+ }
+
+ [Fact]
+ public void TestConstructorNullOptions()
+ {
+ Assert.Throws<ArgumentNullException>(() => new DatabaseDirectory(_testPath, null!));
+ }
+
+ [Fact]
+ public void TestConstructorEmptyPath()
+ {
+ Assert.Throws<ArgumentException>(() => new DatabaseDirectory("", _options));
+ Assert.Throws<ArgumentException>(() => new DatabaseDirectory(" ", _options));
+ }
+ #endregion
+
+ #region Create
[Fact]
public void TestCreateDatabase()
{
@@ -37,11 +59,40 @@ public class DatabaseDirectoryTests : IDisposable
db.Create();
Assert.True(Directory.Exists(_testPath));
- Assert.True(Directory.Exists(db.SystemPath));
Assert.True(File.Exists(Path.Combine(_testPath, "nova.db")));
}
[Fact]
+ public void TestCreateDatabaseVerifyMetadata()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ db.Create();
+
+ // 验证元数据文件内容
+ var metaBytes = File.ReadAllBytes(Path.Combine(_testPath, "nova.db"));
+ Assert.Equal(FileHeader.HeaderSize, metaBytes.Length);
+
+ var header = FileHeader.FromBytes(metaBytes);
+ Assert.Equal(1, header.Version);
+ Assert.Equal(FileType.Data, header.FileType);
+ Assert.Equal(4096u, header.PageSize);
+ Assert.True(header.CreatedAt > 0);
+ }
+
+ [Fact]
+ public void TestCreateDatabaseAlreadyExists()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ db.Create();
+
+ var ex = Assert.Throws<NovaException>(() => db.Create());
+ Assert.Equal(ErrorCode.InvalidArgument, ex.Code);
+ Assert.Contains("already exists", ex.Message);
+ }
+ #endregion
+
+ #region Open
+ [Fact]
public void TestOpenDatabase()
{
var db = new DatabaseDirectory(_testPath, _options);
@@ -52,29 +103,83 @@ public class DatabaseDirectoryTests : IDisposable
}
[Fact]
- public void TestDropDatabase()
+ public void TestOpenDatabaseNotExists()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+
+ var ex = Assert.Throws<NovaException>(() => db.Open());
+ Assert.Equal(ErrorCode.InvalidArgument, ex.Code);
+ Assert.Contains("does not exist", ex.Message);
+ }
+
+ [Fact]
+ public void TestOpenDatabaseMissingMetadata()
+ {
+ // 创建目录但不写入元数据
+ Directory.CreateDirectory(_testPath);
+
+ var db = new DatabaseDirectory(_testPath, _options);
+ var ex = Assert.Throws<NovaException>(() => db.Open());
+ Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+ Assert.Contains("metadata file not found", ex.Message);
+ }
+
+ [Fact]
+ public void TestOpenDatabaseCorruptedMetadata()
+ {
+ // 创建目录并写入损坏的元数据
+ Directory.CreateDirectory(_testPath);
+ var metaPath = Path.Combine(_testPath, "nova.db");
+ File.WriteAllBytes(metaPath, new Byte[32]); // 全零,魔数不对
+
+ var db = new DatabaseDirectory(_testPath, _options);
+ Assert.Throws<NovaException>(() => db.Open());
+ }
+
+ [Fact]
+ public void TestOpenDatabaseUnsupportedVersion()
{
var db = new DatabaseDirectory(_testPath, _options);
db.Create();
- Assert.True(Directory.Exists(_testPath));
+ // 篡改版本号为 99
+ var metaPath = Path.Combine(_testPath, "nova.db");
+ var metaBytes = File.ReadAllBytes(metaPath);
+ BitConverter.GetBytes((UInt16)99).CopyTo(metaBytes, 4);
+ File.WriteAllBytes(metaPath, metaBytes);
- db.Drop();
+ var db2 = new DatabaseDirectory(_testPath, _options);
+ var ex = Assert.Throws<NovaException>(() => db2.Open());
+ Assert.Equal(ErrorCode.IncompatibleFileFormat, ex.Code);
+ Assert.Contains("Unsupported database version", ex.Message);
+ }
- Assert.False(Directory.Exists(_testPath));
+ [Fact]
+ public void TestOpenDatabaseTruncatedMetadata()
+ {
+ // 创建目录并写入过短的元数据
+ Directory.CreateDirectory(_testPath);
+ var metaPath = Path.Combine(_testPath, "nova.db");
+ File.WriteAllBytes(metaPath, new Byte[10]); // 只有 10 字节
+
+ var db = new DatabaseDirectory(_testPath, _options);
+ Assert.ThrowsAny<Exception>(() => db.Open()); // FileHeader.FromBytes 拒绝短 buffer
}
+ #endregion
+ #region ListTables
[Fact]
public void TestListTables()
{
var db = new DatabaseDirectory(_testPath, _options);
db.Create();
- var table1 = db.GetTableDirectory("Users");
- table1.Create();
+ // 创建表文件(模拟表的存在)
+ var table1 = db.GetTableFileManager("Users");
+ File.WriteAllText(table1.GetDataFilePath(), "");
- var table2 = db.GetTableDirectory("Orders");
- table2.Create();
+ var table2 = db.GetTableFileManager("Orders");
+ File.WriteAllText(table2.GetDataFilePath(), "");
var tables = db.ListTables().ToList();
@@ -82,4 +187,137 @@ public class DatabaseDirectoryTests : IDisposable
Assert.Contains("Users", tables);
Assert.Contains("Orders", tables);
}
+
+ [Fact]
+ public void TestListTablesEmpty()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ db.Create();
+
+ var tables = db.ListTables().ToList();
+ Assert.Empty(tables);
+ }
+
+ [Fact]
+ public void TestListTablesExcludesSystemTables()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ db.Create();
+
+ // 创建系统表文件
+ File.WriteAllText(Path.Combine(_testPath, "_sys_tables.data"), "");
+ File.WriteAllText(Path.Combine(_testPath, "_sys_columns.data"), "");
+
+ // 创建用户表
+ File.WriteAllText(Path.Combine(_testPath, "Users.data"), "");
+
+ var tables = db.ListTables().ToList();
+ Assert.Single(tables);
+ Assert.Contains("Users", tables);
+ }
+
+ [Fact]
+ public void TestListTablesWithShardFiles()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ db.Create();
+
+ // 同一张表有多个分片文件
+ File.WriteAllText(Path.Combine(_testPath, "BigTable.data"), "");
+ File.WriteAllText(Path.Combine(_testPath, "BigTable_0.data"), "");
+ File.WriteAllText(Path.Combine(_testPath, "BigTable_1.data"), "");
+
+ var tables = db.ListTables().ToList();
+ Assert.Single(tables); // 仍然只显示一张表
+ Assert.Contains("BigTable", tables);
+ }
+
+ [Fact]
+ public void TestListTablesDirectoryNotExists()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+
+ // 目录不存在时返回空
+ var tables = db.ListTables().ToList();
+ Assert.Empty(tables);
+ }
+
+ [Fact]
+ public void TestListTablesReturnsSorted()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ db.Create();
+
+ File.WriteAllText(Path.Combine(_testPath, "Zebra.data"), "");
+ File.WriteAllText(Path.Combine(_testPath, "Alpha.data"), "");
+ File.WriteAllText(Path.Combine(_testPath, "Middle.data"), "");
+
+ var tables = db.ListTables().ToList();
+ Assert.Equal(3, tables.Count);
+ Assert.Equal("Alpha", tables[0]);
+ Assert.Equal("Middle", tables[1]);
+ Assert.Equal("Zebra", tables[2]);
+ }
+ #endregion
+
+ #region GetTableFileManager
+ [Fact]
+ public void TestGetTableFileManager()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ db.Create();
+
+ var manager = db.GetTableFileManager("Products");
+ Assert.Equal("Products", manager.TableName);
+ Assert.Equal(_testPath, manager.DatabasePath);
+ }
+
+ [Fact]
+ public void TestGetTableFileManagerEmptyName()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ Assert.Throws<ArgumentException>(() => db.GetTableFileManager(""));
+ Assert.Throws<ArgumentException>(() => db.GetTableFileManager(" "));
+ }
+ #endregion
+
+ #region Drop
+ [Fact]
+ public void TestDropDatabase()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ db.Create();
+
+ Assert.True(Directory.Exists(_testPath));
+
+ db.Drop();
+
+ Assert.False(Directory.Exists(_testPath));
+ }
+
+ [Fact]
+ public void TestDropDatabaseNotExists()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+
+ // 删除不存在的库不应抛异常
+ db.Drop();
+ }
+
+ [Fact]
+ public void TestDropDatabaseWithFiles()
+ {
+ var db = new DatabaseDirectory(_testPath, _options);
+ db.Create();
+
+ // 添加一些文件
+ File.WriteAllText(Path.Combine(_testPath, "Users.data"), "test");
+ File.WriteAllText(Path.Combine(_testPath, "Users.idx"), "test");
+ File.WriteAllText(Path.Combine(_testPath, "Users.wal"), "test");
+
+ db.Drop();
+
+ Assert.False(Directory.Exists(_testPath));
+ }
+ #endregion
}
diff --git a/XUnitTest/Storage/FileHeaderTests.cs b/XUnitTest/Storage/FileHeaderTests.cs
index 302300d..6163d11 100644
--- a/XUnitTest/Storage/FileHeaderTests.cs
+++ b/XUnitTest/Storage/FileHeaderTests.cs
@@ -1,4 +1,5 @@
-using System;
+using System;
+using NewLife.NovaDb.Core;
using NewLife.NovaDb.Storage;
using Xunit;
@@ -19,6 +20,7 @@ public class FileHeaderTests
};
var bytes = header.ToBytes();
+ Assert.Equal(FileHeader.HeaderSize, bytes.Length);
Assert.Equal(32, bytes.Length);
var deserialized = FileHeader.FromBytes(bytes);
@@ -49,9 +51,183 @@ public class FileHeaderTests
[Fact]
public void TestInvalidMagicNumber()
{
- var bytes = new byte[32];
+ var bytes = new Byte[32];
BitConverter.GetBytes(0xDEADBEEFu).CopyTo(bytes, 0);
- Assert.Throws<NewLife.NovaDb.Core.NovaException>(() => FileHeader.FromBytes(bytes));
+ var ex = Assert.Throws<NovaException>(() => FileHeader.FromBytes(bytes));
+ Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+ Assert.Contains("Invalid magic number", ex.Message);
+ }
+
+ [Fact]
+ public void TestNullBuffer()
+ {
+ Assert.Throws<ArgumentNullException>(() => FileHeader.FromBytes(null!));
+ }
+
+ [Fact]
+ public void TestBufferTooShort()
+ {
+ var bytes = new Byte[31]; // 少于 32 字节
+ var ex = Assert.Throws<ArgumentException>(() => FileHeader.FromBytes(bytes));
+ Assert.Contains("too short", ex.Message);
+ }
+
+ [Fact]
+ public void TestInvalidFileType()
+ {
+ var header = new FileHeader
+ {
+ FileType = FileType.Data,
+ PageSize = 4096
+ };
+ var bytes = header.ToBytes();
+
+ // 设置无效的文件类型(0 或 > 3)
+ bytes[6] = 99;
+
+ var ex = Assert.Throws<NovaException>(() => FileHeader.FromBytes(bytes));
+ Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+ Assert.Contains("Invalid file type", ex.Message);
+ }
+
+ [Fact]
+ public void TestInvalidPageSizeZero()
+ {
+ var header = new FileHeader
+ {
+ FileType = FileType.Data,
+ PageSize = 4096
+ };
+ var bytes = header.ToBytes();
+
+ // 设置 PageSize 为 0
+ BitConverter.GetBytes(0u).CopyTo(bytes, 8);
+
+ var ex = Assert.Throws<NovaException>(() => FileHeader.FromBytes(bytes));
+ Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+ Assert.Contains("Invalid page size", ex.Message);
+ }
+
+ [Fact]
+ public void TestInvalidPageSizeTooLarge()
+ {
+ var header = new FileHeader
+ {
+ FileType = FileType.Data,
+ PageSize = 4096
+ };
+ var bytes = header.ToBytes();
+
+ // 设置 PageSize > 64KB
+ BitConverter.GetBytes(128u * 1024).CopyTo(bytes, 8);
+
+ var ex = Assert.Throws<NovaException>(() => FileHeader.FromBytes(bytes));
+ Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+ Assert.Contains("Invalid page size", ex.Message);
+ }
+
+ [Fact]
+ public void TestDifferentFileTypes()
+ {
+ foreach (FileType type in Enum.GetValues(typeof(FileType)))
+ {
+ var header = new FileHeader
+ {
+ FileType = type,
+ PageSize = 4096
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = FileHeader.FromBytes(bytes);
+
+ Assert.Equal(type, deserialized.FileType);
+ }
+ }
+
+ [Fact]
+ public void TestBoundaryPageSizes()
+ {
+ // 最小有效 PageSize
+ var header1 = new FileHeader { FileType = FileType.Data, PageSize = 1 };
+ var bytes1 = header1.ToBytes();
+ var deserialized1 = FileHeader.FromBytes(bytes1);
+ Assert.Equal(1u, deserialized1.PageSize);
+
+ // 最大有效 PageSize (64KB)
+ var header2 = new FileHeader { FileType = FileType.Data, PageSize = 64 * 1024 };
+ var bytes2 = header2.ToBytes();
+ var deserialized2 = FileHeader.FromBytes(bytes2);
+ Assert.Equal(64u * 1024, deserialized2.PageSize);
+ }
+
+ [Fact]
+ public void TestVersionPersistence()
+ {
+ var header = new FileHeader
+ {
+ Version = 42,
+ FileType = FileType.Index,
+ PageSize = 8192
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = FileHeader.FromBytes(bytes);
+
+ Assert.Equal(42, deserialized.Version);
+ }
+
+ [Fact]
+ public void TestCreatedAtPersistence()
+ {
+ var now = DateTime.UtcNow.Ticks;
+ var header = new FileHeader
+ {
+ FileType = FileType.Wal,
+ PageSize = 4096,
+ CreatedAt = now
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = FileHeader.FromBytes(bytes);
+
+ Assert.Equal(now, deserialized.CreatedAt);
+ }
+
+ [Fact]
+ public void TestOptionsHashPersistence()
+ {
+ var header = new FileHeader
+ {
+ FileType = FileType.Data,
+ PageSize = 4096,
+ OptionsHash = 0xABCD1234
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = FileHeader.FromBytes(bytes);
+
+ Assert.Equal(0xABCD1234u, deserialized.OptionsHash);
+ }
+
+ [Fact]
+ public void TestReservedBytesAreZero()
+ {
+ var header = new FileHeader
+ {
+ FileType = FileType.Data,
+ PageSize = 4096
+ };
+
+ var bytes = header.ToBytes();
+
+ // Reserved byte at offset 7
+ Assert.Equal(0, bytes[7]);
+
+ // Reserved 8 bytes at offset 24-31
+ for (var i = 24; i < 32; i++)
+ {
+ Assert.Equal(0, bytes[i]);
+ }
}
}
diff --git a/XUnitTest/Storage/MmfPagerTests.cs b/XUnitTest/Storage/MmfPagerTests.cs
index f87b874..b77503b 100644
--- a/XUnitTest/Storage/MmfPagerTests.cs
+++ b/XUnitTest/Storage/MmfPagerTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using NewLife.NovaDb.Core;
using NewLife.NovaDb.Storage;
@@ -8,7 +8,7 @@ namespace XUnitTest.Storage;
public class MmfPagerTests : IDisposable
{
- private readonly string _testFile;
+ private readonly String _testFile;
public MmfPagerTests()
{
@@ -23,6 +23,22 @@ public class MmfPagerTests : IDisposable
}
}
+ #region 构造函数
+ [Fact]
+ public void TestConstructorNullPath()
+ {
+ Assert.Throws<ArgumentNullException>(() => new MmfPager(null!, 4096));
+ }
+
+ [Fact]
+ public void TestConstructorInvalidPageSize()
+ {
+ Assert.Throws<ArgumentOutOfRangeException>(() => new MmfPager(_testFile, 0));
+ Assert.Throws<ArgumentOutOfRangeException>(() => new MmfPager(_testFile, -1));
+ }
+ #endregion
+
+ #region Open
[Fact]
public void TestCreateAndOpenPager()
{
@@ -41,6 +57,34 @@ public class MmfPagerTests : IDisposable
}
[Fact]
+ public void TestOpenExistingFileWithMismatchedPageSize()
+ {
+ // 创建文件
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+ using (var pager1 = new MmfPager(_testFile, 4096))
+ {
+ pager1.Open(header);
+ }
+
+ // 用不同 PageSize 打开
+ using var pager2 = new MmfPager(_testFile, 8192);
+ var ex = Assert.Throws<NovaException>(() => pager2.Open());
+ Assert.Equal(ErrorCode.IncompatibleFileFormat, ex.Code);
+ Assert.Contains("Page size mismatch", ex.Message);
+ }
+
+ [Fact]
+ public void TestOpenAfterDispose()
+ {
+ var pager = new MmfPager(_testFile, 4096);
+ pager.Dispose();
+
+ Assert.Throws<ObjectDisposedException>(() => pager.Open());
+ }
+ #endregion
+
+ #region ReadPage / WritePage
+ [Fact]
public void TestWriteAndReadPage()
{
var header = new FileHeader
@@ -53,8 +97,8 @@ public class MmfPagerTests : IDisposable
using var pager = new MmfPager(_testFile, 4096, enableChecksum: false);
pager.Open(header);
- // 创建测试页
- var pageData = new byte[4096];
+ // 构建测试页
+ var pageData = new Byte[4096];
var pageHeader = new PageHeader
{
PageId = 0,
@@ -64,29 +108,195 @@ public class MmfPagerTests : IDisposable
};
var headerBytes = pageHeader.ToBytes();
- Buffer.BlockCopy(headerBytes, 0, pageData, 0, 32);
+ Buffer.BlockCopy(headerBytes, 0, pageData, 0, PageHeader.HeaderSize);
- // 写入一些测试数据
- for (int i = 32; i < 132; i++)
+ for (var i = 32; i < 132; i++)
{
- pageData[i] = (byte)(i % 256);
+ pageData[i] = (Byte)(i % 256);
}
- // 写入页
pager.WritePage(0, pageData);
- // 读取页
var readData = pager.ReadPage(0);
- // 验证数据
Assert.Equal(pageData.Length, readData.Length);
- for (int i = 32; i < 132; i++)
+ for (var i = 32; i < 132; i++)
{
Assert.Equal(pageData[i], readData[i]);
}
}
[Fact]
+ public void TestWriteMultiplePages()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+ using var pager = new MmfPager(_testFile, 4096, enableChecksum: false);
+ pager.Open(header);
+
+ // 写入 3 个页面
+ for (UInt64 i = 0; i < 3; i++)
+ {
+ var data = new Byte[4096];
+ data[32] = (Byte)(i + 100); // 每页标记不同数据
+ pager.WritePage(i, data);
+ }
+
+ // 验证每个页面数据独立
+ for (UInt64 i = 0; i < 3; i++)
+ {
+ var data = pager.ReadPage(i);
+ Assert.Equal((Byte)(i + 100), data[32]);
+ }
+ }
+
+ [Fact]
+ public void TestReadPageOutOfRange()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+ using var pager = new MmfPager(_testFile, 4096, enableChecksum: false);
+ pager.Open(header);
+
+ // 没有写任何页,读取 pageId=0 应越界
+ Assert.Throws<ArgumentOutOfRangeException>(() => pager.ReadPage(0));
+ }
+
+ [Fact]
+ public void TestReadPageNotOpened()
+ {
+ using var pager = new MmfPager(_testFile, 4096);
+ Assert.Throws<InvalidOperationException>(() => pager.ReadPage(0));
+ }
+
+ [Fact]
+ public void TestWritePageNotOpened()
+ {
+ using var pager = new MmfPager(_testFile, 4096);
+ var data = new Byte[4096];
+ Assert.Throws<InvalidOperationException>(() => pager.WritePage(0, data));
+ }
+
+ [Fact]
+ public void TestWritePageNullData()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+ using var pager = new MmfPager(_testFile, 4096);
+ pager.Open(header);
+
+ Assert.Throws<ArgumentNullException>(() => pager.WritePage(0, null!));
+ }
+
+ [Fact]
+ public void TestWritePageWrongSize()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+ using var pager = new MmfPager(_testFile, 4096);
+ pager.Open(header);
+
+ Assert.Throws<ArgumentException>(() => pager.WritePage(0, new Byte[1024])); // 太小
+ Assert.Throws<ArgumentException>(() => pager.WritePage(0, new Byte[8192])); // 太大
+ }
+
+ [Fact]
+ public void TestReadAfterDispose()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+ var pager = new MmfPager(_testFile, 4096);
+ pager.Open(header);
+ pager.WritePage(0, new Byte[4096]);
+ pager.Dispose();
+
+ Assert.Throws<ObjectDisposedException>(() => pager.ReadPage(0));
+ }
+
+ [Fact]
+ public void TestWriteAfterDispose()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+ var pager = new MmfPager(_testFile, 4096);
+ pager.Open(header);
+ pager.Dispose();
+
+ Assert.Throws<ObjectDisposedException>(() => pager.WritePage(0, new Byte[4096]));
+ }
+ #endregion
+
+ #region Checksum
+ [Fact]
+ public void TestWriteAndReadWithChecksum()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+ using var pager = new MmfPager(_testFile, 4096, enableChecksum: true);
+ pager.Open(header);
+
+ var pageData = new Byte[4096];
+ var pageHeader = new PageHeader
+ {
+ PageId = 0,
+ PageType = PageType.Data,
+ Lsn = 1,
+ DataLength = 50
+ };
+
+ Buffer.BlockCopy(pageHeader.ToBytes(), 0, pageData, 0, PageHeader.HeaderSize);
+ for (var i = 32; i < 82; i++)
+ {
+ pageData[i] = (Byte)(i * 3);
+ }
+
+ // WritePage 会自动计算校验和
+ pager.WritePage(0, pageData);
+
+ // ReadPage 会自动验证校验和
+ var readData = pager.ReadPage(0);
+ Assert.Equal(4096, readData.Length);
+ }
+
+ [Fact]
+ public void TestChecksumCorruption()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+ // 先写入数据
+ using (var pager = new MmfPager(_testFile, 4096, enableChecksum: true))
+ {
+ pager.Open(header);
+
+ var pageData = new Byte[4096];
+ var pageHeader = new PageHeader
+ {
+ PageId = 0,
+ PageType = PageType.Data,
+ Lsn = 1,
+ DataLength = 10
+ };
+
+ Buffer.BlockCopy(pageHeader.ToBytes(), 0, pageData, 0, PageHeader.HeaderSize);
+ pager.WritePage(0, pageData);
+ }
+
+ // 篡改文件中的页数据
+ using (var fs = File.Open(_testFile, FileMode.Open, FileAccess.ReadWrite))
+ {
+ // 在 FileHeader(32B) + PageHeader(32B) 后篡改一个字节
+ fs.Seek(FileHeader.HeaderSize + PageHeader.HeaderSize, SeekOrigin.Begin);
+ fs.WriteByte(0xFF);
+ }
+
+ // 重新打开并读取应触发校验和错误
+ using var pager2 = new MmfPager(_testFile, 4096, enableChecksum: true);
+ pager2.Open();
+
+ var ex = Assert.Throws<NovaException>(() => pager2.ReadPage(0));
+ Assert.Equal(ErrorCode.ChecksumFailed, ex.Code);
+ Assert.Contains("Checksum mismatch", ex.Message);
+ }
+ #endregion
+
+ #region PageCount / Flush
+ [Fact]
public void TestPageCount()
{
var header = new FileHeader
@@ -101,11 +311,76 @@ public class MmfPagerTests : IDisposable
Assert.Equal(0UL, pager.PageCount);
- var pageData = new byte[4096];
- pager.WritePage(0, pageData);
+ pager.WritePage(0, new Byte[4096]);
Assert.Equal(1UL, pager.PageCount);
- pager.WritePage(1, pageData);
+ pager.WritePage(1, new Byte[4096]);
Assert.Equal(2UL, pager.PageCount);
}
+
+ [Fact]
+ public void TestPageCountBeforeOpen()
+ {
+ using var pager = new MmfPager(_testFile, 4096);
+ Assert.Equal(0UL, pager.PageCount); // 未打开返回 0
+ }
+
+ [Fact]
+ public void TestFlush()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+ using var pager = new MmfPager(_testFile, 4096, enableChecksum: false);
+ pager.Open(header);
+
+ pager.WritePage(0, new Byte[4096]);
+ pager.Flush(); // 不应抛异常
+ }
+
+ [Fact]
+ public void TestFlushBeforeOpen()
+ {
+ using var pager = new MmfPager(_testFile, 4096);
+ pager.Flush(); // 未打开时不应抛异常
+ }
+ #endregion
+
+ #region DoubleDispose
+ [Fact]
+ public void TestDoubleDispose()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+ var pager = new MmfPager(_testFile, 4096);
+ pager.Open(header);
+
+ pager.Dispose();
+ pager.Dispose(); // 第二次释放不应抛异常
+ }
+ #endregion
+
+ #region 跨实例持久化
+ [Fact]
+ public void TestDataPersistsAcrossInstances()
+ {
+ var header = new FileHeader { FileType = FileType.Data, PageSize = 4096 };
+
+ // 第一个实例写数据
+ using (var pager1 = new MmfPager(_testFile, 4096, enableChecksum: false))
+ {
+ pager1.Open(header);
+ var data = new Byte[4096];
+ data[32] = 42;
+ data[100] = 99;
+ pager1.WritePage(0, data);
+ }
+
+ // 第二个实例读数据
+ using (var pager2 = new MmfPager(_testFile, 4096, enableChecksum: false))
+ {
+ pager2.Open();
+ var data = pager2.ReadPage(0);
+ Assert.Equal(42, data[32]);
+ Assert.Equal(99, data[100]);
+ }
+ }
+ #endregion
}
diff --git a/XUnitTest/Storage/PageCacheTests.cs b/XUnitTest/Storage/PageCacheTests.cs
index 1234909..b57eda2 100644
--- a/XUnitTest/Storage/PageCacheTests.cs
+++ b/XUnitTest/Storage/PageCacheTests.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Threading.Tasks;
using NewLife.NovaDb.Storage;
using Xunit;
@@ -5,13 +7,45 @@ namespace XUnitTest.Storage;
public class PageCacheTests
{
+ #region 构造函数
+ [Fact]
+ public void TestConstructorValidCapacity()
+ {
+ var cache = new PageCache(100);
+ Assert.Equal(100, cache.Capacity);
+ Assert.Equal(0, cache.Count);
+ }
+
+ [Fact]
+ public void TestConstructorInvalidCapacity()
+ {
+ Assert.Throws<ArgumentOutOfRangeException>(() => new PageCache(0));
+ Assert.Throws<ArgumentOutOfRangeException>(() => new PageCache(-1));
+ }
+
+ [Fact]
+ public void TestConstructorMinCapacity()
+ {
+ var cache = new PageCache(1);
+ Assert.Equal(1, cache.Capacity);
+
+ cache.Put(1, new Byte[] { 1 });
+ cache.Put(2, new Byte[] { 2 }); // 淘汰 1
+
+ Assert.Equal(1, cache.Count);
+ Assert.False(cache.TryGet(1, out _));
+ Assert.True(cache.TryGet(2, out _));
+ }
+ #endregion
+
+ #region Put / TryGet
[Fact]
public void TestPutAndGet()
{
var cache = new PageCache(3);
- var data1 = new byte[] { 1, 2, 3 };
- var data2 = new byte[] { 4, 5, 6 };
+ var data1 = new Byte[] { 1, 2, 3 };
+ var data2 = new Byte[] { 4, 5, 6 };
cache.Put(1, data1);
cache.Put(2, data2);
@@ -26,47 +60,164 @@ public class PageCacheTests
}
[Fact]
+ public void TestPutNullData()
+ {
+ var cache = new PageCache(3);
+ Assert.Throws<ArgumentNullException>(() => cache.Put(1, null!));
+ }
+
+ [Fact]
+ public void TestPutUpdateExisting()
+ {
+ var cache = new PageCache(3);
+
+ cache.Put(1, new Byte[] { 10 });
+ cache.Put(1, new Byte[] { 20 }); // 更新同一个 key
+
+ Assert.Equal(1, cache.Count);
+ Assert.True(cache.TryGet(1, out var data));
+ Assert.Equal(20, data![0]);
+ }
+
+ [Fact]
+ public void TestGetMissReturnsNull()
+ {
+ var cache = new PageCache(3);
+
+ Assert.False(cache.TryGet(999, out var data));
+ Assert.Null(data);
+ }
+ #endregion
+
+ #region LRU 淘汰
+ [Fact]
public void TestLruEviction()
{
var cache = new PageCache(2);
- var data1 = new byte[] { 1 };
- var data2 = new byte[] { 2 };
- var data3 = new byte[] { 3 };
+ cache.Put(1, new Byte[] { 1 });
+ cache.Put(2, new Byte[] { 2 });
- cache.Put(1, data1);
- cache.Put(2, data2);
+ // 缓存已满,添加第三个应淘汰第一个(最久未访问)
+ cache.Put(3, new Byte[] { 3 });
- // 缓存已满,添加第三个应淘汰第一个
- cache.Put(3, data3);
-
- Assert.False(cache.TryGet(1, out _)); // 应被淘汰
+ Assert.False(cache.TryGet(1, out _)); // 已淘汰
Assert.True(cache.TryGet(2, out _));
Assert.True(cache.TryGet(3, out _));
}
[Fact]
- public void TestRemove()
+ public void TestLruEvictionAfterAccess()
+ {
+ var cache = new PageCache(2);
+
+ cache.Put(1, new Byte[] { 1 });
+ cache.Put(2, new Byte[] { 2 });
+
+ // 访问 key=1,让它变成最近使用
+ cache.TryGet(1, out _);
+
+ // 添加第三个,应淘汰 key=2(最久未访问)
+ cache.Put(3, new Byte[] { 3 });
+
+ Assert.True(cache.TryGet(1, out _)); // 被访问过,不淘汰
+ Assert.False(cache.TryGet(2, out _)); // 被淘汰
+ Assert.True(cache.TryGet(3, out _));
+ }
+
+ [Fact]
+ public void TestLruEvictionAfterUpdate()
+ {
+ var cache = new PageCache(2);
+
+ cache.Put(1, new Byte[] { 1 });
+ cache.Put(2, new Byte[] { 2 });
+
+ // 更新 key=1,让它变成最近使用
+ cache.Put(1, new Byte[] { 10 });
+
+ // 添加第三个,应淘汰 key=2
+ cache.Put(3, new Byte[] { 3 });
+
+ Assert.True(cache.TryGet(1, out var data));
+ Assert.Equal(10, data![0]); // 确认是更新后的值
+ Assert.False(cache.TryGet(2, out _));
+ Assert.True(cache.TryGet(3, out _));
+ }
+
+ [Fact]
+ public void TestLruEvictionSequence()
{
var cache = new PageCache(3);
- var data = new byte[] { 1, 2, 3 };
- cache.Put(1, data);
+ cache.Put(1, new Byte[] { 1 });
+ cache.Put(2, new Byte[] { 2 });
+ cache.Put(3, new Byte[] { 3 });
+
+ // 此时 LRU 序:3(头) → 2 → 1(尾)
+ // 再加入 4,应淘汰 1
+ cache.Put(4, new Byte[] { 4 });
+ Assert.False(cache.TryGet(1, out _));
+ // 再加入 5,应淘汰 2
+ cache.Put(5, new Byte[] { 5 });
+ Assert.False(cache.TryGet(2, out _));
+
+ Assert.True(cache.TryGet(3, out _));
+ Assert.True(cache.TryGet(4, out _));
+ Assert.True(cache.TryGet(5, out _));
+ }
+ #endregion
+
+ #region Remove
+ [Fact]
+ public void TestRemove()
+ {
+ var cache = new PageCache(3);
+
+ cache.Put(1, new Byte[] { 1, 2, 3 });
Assert.True(cache.TryGet(1, out _));
+ var removed = cache.Remove(1);
+ Assert.True(removed);
+ Assert.False(cache.TryGet(1, out _));
+ }
+
+ [Fact]
+ public void TestRemoveNonExistent()
+ {
+ var cache = new PageCache(3);
+
+ var removed = cache.Remove(999);
+ Assert.False(removed);
+ }
+
+ [Fact]
+ public void TestRemoveFreesCapacity()
+ {
+ var cache = new PageCache(2);
+
+ cache.Put(1, new Byte[] { 1 });
+ cache.Put(2, new Byte[] { 2 });
+
+ // 移除一个后再添加不应触发淘汰
cache.Remove(1);
+ cache.Put(3, new Byte[] { 3 });
- Assert.False(cache.TryGet(1, out _));
+ Assert.True(cache.TryGet(2, out _));
+ Assert.True(cache.TryGet(3, out _));
+ Assert.Equal(2, cache.Count);
}
+ #endregion
+ #region Clear
[Fact]
public void TestClear()
{
var cache = new PageCache(3);
- cache.Put(1, new byte[] { 1 });
- cache.Put(2, new byte[] { 2 });
+ cache.Put(1, new Byte[] { 1 });
+ cache.Put(2, new Byte[] { 2 });
Assert.Equal(2, cache.Count);
@@ -76,4 +227,76 @@ public class PageCacheTests
Assert.False(cache.TryGet(1, out _));
Assert.False(cache.TryGet(2, out _));
}
+
+ [Fact]
+ public void TestClearResetsHitMissCounters()
+ {
+ var cache = new PageCache(3);
+
+ cache.Put(1, new Byte[] { 1 });
+ cache.TryGet(1, out _); // hit
+ cache.TryGet(2, out _); // miss
+
+ Assert.Equal(1, cache.HitCount);
+ Assert.Equal(1, cache.MissCount);
+
+ cache.Clear();
+
+ Assert.Equal(0, cache.HitCount);
+ Assert.Equal(0, cache.MissCount);
+ }
+
+ [Fact]
+ public void TestClearThenReuse()
+ {
+ var cache = new PageCache(2);
+
+ cache.Put(1, new Byte[] { 1 });
+ cache.Put(2, new Byte[] { 2 });
+ cache.Clear();
+
+ // 清空后可正常复用
+ cache.Put(3, new Byte[] { 3 });
+ Assert.Equal(1, cache.Count);
+ Assert.True(cache.TryGet(3, out _));
+ }
+ #endregion
+
+ #region 统计
+ [Fact]
+ public void TestHitMissCounters()
+ {
+ var cache = new PageCache(3);
+
+ cache.Put(1, new Byte[] { 1 });
+ cache.Put(2, new Byte[] { 2 });
+
+ cache.TryGet(1, out _); // hit
+ cache.TryGet(2, out _); // hit
+ cache.TryGet(3, out _); // miss
+ cache.TryGet(4, out _); // miss
+ cache.TryGet(1, out _); // hit
+
+ Assert.Equal(3, cache.HitCount);
+ Assert.Equal(2, cache.MissCount);
+ }
+ #endregion
+
+ #region 并发安全
+ [Fact]
+ public void TestConcurrentAccess()
+ {
+ var cache = new PageCache(100);
+
+ // 并发写入和读取不应抛异常
+ Parallel.For(0, 1000, i =>
+ {
+ cache.Put((UInt64)i, new Byte[] { (Byte)(i % 256) });
+ cache.TryGet((UInt64)(i / 2), out _);
+ });
+
+ Assert.True(cache.Count <= 100); // 不超过容量
+ Assert.True(cache.Count > 0);
+ }
+ #endregion
}
diff --git a/XUnitTest/Storage/PageHeaderTests.cs b/XUnitTest/Storage/PageHeaderTests.cs
index 4416500..ad59288 100644
--- a/XUnitTest/Storage/PageHeaderTests.cs
+++ b/XUnitTest/Storage/PageHeaderTests.cs
@@ -1,3 +1,5 @@
+using System;
+using NewLife.NovaDb.Core;
using NewLife.NovaDb.Storage;
using Xunit;
@@ -18,6 +20,7 @@ public class PageHeaderTests
};
var bytes = header.ToBytes();
+ Assert.Equal(PageHeader.HeaderSize, bytes.Length);
Assert.Equal(32, bytes.Length);
var deserialized = PageHeader.FromBytes(bytes);
@@ -28,4 +31,214 @@ public class PageHeaderTests
Assert.Equal(header.Checksum, deserialized.Checksum);
Assert.Equal(header.DataLength, deserialized.DataLength);
}
+
+ [Fact]
+ public void TestNullBuffer()
+ {
+ Assert.Throws<ArgumentNullException>(() => PageHeader.FromBytes(null!));
+ }
+
+ [Fact]
+ public void TestBufferTooShort()
+ {
+ var bytes = new Byte[31]; // 少于 32 字节
+ var ex = Assert.Throws<ArgumentException>(() => PageHeader.FromBytes(bytes));
+ Assert.Contains("too short", ex.Message);
+ }
+
+ [Fact]
+ public void TestInvalidPageType()
+ {
+ var header = new PageHeader
+ {
+ PageId = 1,
+ PageType = PageType.Data,
+ DataLength = 100
+ };
+ var bytes = header.ToBytes();
+
+ // 设置无效的页类型(> 4)
+ bytes[8] = 99;
+
+ var ex = Assert.Throws<NovaException>(() => PageHeader.FromBytes(bytes));
+ Assert.Equal(ErrorCode.FileCorrupted, ex.Code);
+ Assert.Contains("Invalid page type", ex.Message);
+ }
+
+ [Fact]
+ public void TestAllPageTypes()
+ {
+ foreach (PageType type in Enum.GetValues(typeof(PageType)))
+ {
+ var header = new PageHeader
+ {
+ PageId = 42,
+ PageType = type,
+ DataLength = 1024
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = PageHeader.FromBytes(bytes);
+
+ Assert.Equal(type, deserialized.PageType);
+ }
+ }
+
+ [Fact]
+ public void TestLargePageId()
+ {
+ var header = new PageHeader
+ {
+ PageId = UInt64.MaxValue,
+ PageType = PageType.Index,
+ DataLength = 512
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = PageHeader.FromBytes(bytes);
+
+ Assert.Equal(UInt64.MaxValue, deserialized.PageId);
+ }
+
+ [Fact]
+ public void TestLargeLsn()
+ {
+ var header = new PageHeader
+ {
+ PageId = 1,
+ PageType = PageType.Data,
+ Lsn = UInt64.MaxValue,
+ DataLength = 256
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = PageHeader.FromBytes(bytes);
+
+ Assert.Equal(UInt64.MaxValue, deserialized.Lsn);
+ }
+
+ [Fact]
+ public void TestLargeChecksum()
+ {
+ var header = new PageHeader
+ {
+ PageId = 1,
+ PageType = PageType.Data,
+ Checksum = UInt32.MaxValue,
+ DataLength = 128
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = PageHeader.FromBytes(bytes);
+
+ Assert.Equal(UInt32.MaxValue, deserialized.Checksum);
+ }
+
+ [Fact]
+ public void TestLargeDataLength()
+ {
+ var header = new PageHeader
+ {
+ PageId = 1,
+ PageType = PageType.Data,
+ DataLength = UInt32.MaxValue
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = PageHeader.FromBytes(bytes);
+
+ Assert.Equal(UInt32.MaxValue, deserialized.DataLength);
+ }
+
+ [Fact]
+ public void TestZeroValues()
+ {
+ var header = new PageHeader
+ {
+ PageId = 0,
+ PageType = PageType.Empty,
+ Lsn = 0,
+ Checksum = 0,
+ DataLength = 0
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = PageHeader.FromBytes(bytes);
+
+ Assert.Equal(0UL, deserialized.PageId);
+ Assert.Equal(PageType.Empty, deserialized.PageType);
+ Assert.Equal(0UL, deserialized.Lsn);
+ Assert.Equal(0u, deserialized.Checksum);
+ Assert.Equal(0u, deserialized.DataLength);
+ }
+
+ [Fact]
+ public void TestReservedBytesAreZero()
+ {
+ var header = new PageHeader
+ {
+ PageId = 123,
+ PageType = PageType.Directory,
+ Lsn = 456,
+ Checksum = 789,
+ DataLength = 1000
+ };
+
+ var bytes = header.ToBytes();
+
+ // Reserved 3 bytes at offset 9-11
+ Assert.Equal(0, bytes[9]);
+ Assert.Equal(0, bytes[10]);
+ Assert.Equal(0, bytes[11]);
+
+ // Reserved 4 bytes at offset 28-31
+ for (var i = 28; i < 32; i++)
+ {
+ Assert.Equal(0, bytes[i]);
+ }
+ }
+
+ [Fact]
+ public void TestRoundTripWithRandomData()
+ {
+ var random = new Random(42);
+
+ for (var i = 0; i < 100; i++)
+ {
+ var header = new PageHeader
+ {
+ PageId = (UInt64)random.Next() * (UInt64)random.Next(),
+ PageType = (PageType)(random.Next(0, 5)),
+ Lsn = (UInt64)random.Next() * (UInt64)random.Next(),
+ Checksum = (UInt32)random.Next(),
+ DataLength = (UInt32)random.Next()
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = PageHeader.FromBytes(bytes);
+
+ Assert.Equal(header.PageId, deserialized.PageId);
+ Assert.Equal(header.PageType, deserialized.PageType);
+ Assert.Equal(header.Lsn, deserialized.Lsn);
+ Assert.Equal(header.Checksum, deserialized.Checksum);
+ Assert.Equal(header.DataLength, deserialized.DataLength);
+ }
+ }
+
+ [Fact]
+ public void TestMetadataPageType()
+ {
+ var header = new PageHeader
+ {
+ PageId = 999,
+ PageType = PageType.Metadata,
+ Lsn = 777,
+ DataLength = 333
+ };
+
+ var bytes = header.ToBytes();
+ var deserialized = PageHeader.FromBytes(bytes);
+
+ Assert.Equal(PageType.Metadata, deserialized.PageType);
+ }
}
diff --git a/XUnitTest/Storage/TableFileManagerTests.cs b/XUnitTest/Storage/TableFileManagerTests.cs
new file mode 100644
index 0000000..7259e0a
--- /dev/null
+++ b/XUnitTest/Storage/TableFileManagerTests.cs
@@ -0,0 +1,316 @@
+using System;
+using System.IO;
+using System.Linq;
+using NewLife.NovaDb.Core;
+using NewLife.NovaDb.Storage;
+using Xunit;
+
+namespace XUnitTest.Storage;
+
+public class TableFileManagerTests : IDisposable
+{
+ private readonly String _testPath;
+ private readonly DbOptions _options;
+
+ public TableFileManagerTests()
+ {
+ _testPath = Path.Combine(Path.GetTempPath(), $"NovaTest_{Guid.NewGuid()}");
+ _options = new DbOptions
+ {
+ Path = _testPath,
+ PageSize = 4096
+ };
+
+ Directory.CreateDirectory(_testPath);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_testPath))
+ {
+ Directory.Delete(_testPath, true);
+ }
+ }
+
+ #region 构造函数
+ [Fact]
+ public void TestConstructorNullDatabasePath()
+ {
+ Assert.Throws<ArgumentNullException>(() => new TableFileManager(null!, "Users", _options));
+ }
+
+ [Fact]
+ public void TestConstructorNullTableName()
+ {
+ Assert.Throws<ArgumentNullException>(() => new TableFileManager(_testPath, null!, _options));
+ }
+
+ [Fact]
+ public void TestConstructorEmptyTableName()
+ {
+ Assert.Throws<ArgumentException>(() => new TableFileManager(_testPath, "", _options));
+ Assert.Throws<ArgumentException>(() => new TableFileManager(_testPath, " ", _options));
+ }
+
+ [Fact]
+ public void TestConstructorNullOptions()
+ {
+ Assert.Throws<ArgumentNullException>(() => new TableFileManager(_testPath, "Users", null!));
+ }
+
+ [Fact]
+ public void TestConstructorProperties()
+ {
+ var manager = new TableFileManager(_testPath, "Products", _options);
+ Assert.Equal(_testPath, manager.DatabasePath);
+ Assert.Equal("Products", manager.TableName);
+ }
+ #endregion
+
+ #region 路径生成
+ [Fact]
+ public void TestGetDataFilePath()
+ {
+ var manager = new TableFileManager(_testPath, "Users", _options);
+
+ var path1 = manager.GetDataFilePath();
+ Assert.Equal(Path.Combine(_testPath, "Users.data"), path1);
+
+ var path2 = manager.GetDataFilePath(0);
+ Assert.Equal(Path.Combine(_testPath, "Users_0.data"), path2);
+
+ var path3 = manager.GetDataFilePath(5);
+ Assert.Equal(Path.Combine(_testPath, "Users_5.data"), path3);
+ }
+
+ [Fact]
+ public void TestGetPrimaryIndexFilePath()
+ {
+ var manager = new TableFileManager(_testPath, "Orders", _options);
+
+ var path1 = manager.GetPrimaryIndexFilePath();
+ Assert.Equal(Path.Combine(_testPath, "Orders.idx"), path1);
+
+ var path2 = manager.GetPrimaryIndexFilePath(0);
+ Assert.Equal(Path.Combine(_testPath, "Orders_0.idx"), path2);
+ }
+
+ [Fact]
+ public void TestGetSecondaryIndexFilePath()
+ {
+ var manager = new TableFileManager(_testPath, "Products", _options);
+
+ var path1 = manager.GetSecondaryIndexFilePath("idx_name");
+ Assert.Equal(Path.Combine(_testPath, "Products_idx_name.idx"), path1);
+
+ var path2 = manager.GetSecondaryIndexFilePath("idx_category", 2);
+ Assert.Equal(Path.Combine(_testPath, "Products_2_idx_category.idx"), path2);
+ }
+
+ [Fact]
+ public void TestGetSecondaryIndexFilePathEmptyName()
+ {
+ var manager = new TableFileManager(_testPath, "Products", _options);
+ Assert.Throws<ArgumentException>(() => manager.GetSecondaryIndexFilePath(""));
+ Assert.Throws<ArgumentException>(() => manager.GetSecondaryIndexFilePath(" "));
+ }
+
+ [Fact]
+ public void TestGetWalFilePath()
+ {
+ var manager = new TableFileManager(_testPath, "Logs", _options);
+
+ var path1 = manager.GetWalFilePath();
+ Assert.Equal(Path.Combine(_testPath, "Logs.wal"), path1);
+
+ var path2 = manager.GetWalFilePath(1);
+ Assert.Equal(Path.Combine(_testPath, "Logs_1.wal"), path2);
+ }
+ #endregion
+
+ #region ListDataShards
+ [Fact]
+ public void TestListDataShards()
+ {
+ var manager = new TableFileManager(_testPath, "BigTable", _options);
+
+ File.WriteAllText(manager.GetDataFilePath(0), "");
+ File.WriteAllText(manager.GetDataFilePath(2), "");
+ File.WriteAllText(manager.GetDataFilePath(5), "");
+
+ var shards = manager.ListDataShards().ToList();
+
+ Assert.Equal(3, shards.Count);
+ Assert.Equal(0, shards[0]);
+ Assert.Equal(2, shards[1]);
+ Assert.Equal(5, shards[2]);
+ }
+
+ [Fact]
+ public void TestListDataShardsEmpty()
+ {
+ var manager = new TableFileManager(_testPath, "EmptyTable", _options);
+ var shards = manager.ListDataShards().ToList();
+ Assert.Empty(shards);
+ }
+
+ [Fact]
+ public void TestListDataShardsDirectoryNotExists()
+ {
+ var manager = new TableFileManager("/nonexistent/path", "Users", _options);
+ var shards = manager.ListDataShards().ToList();
+ Assert.Empty(shards);
+ }
+ #endregion
+
+ #region ListSecondaryIndexes
+ [Fact]
+ public void TestListSecondaryIndexes()
+ {
+ var manager = new TableFileManager(_testPath, "Users", _options);
+
+ // 主键索引(应被忽略,因为不含 _ 分隔的索引名)
+ File.WriteAllText(manager.GetPrimaryIndexFilePath(), "");
+
+ File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_email"), "");
+ File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_name"), "");
+ File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_age", 1), "");
+
+ var indexes = manager.ListSecondaryIndexes().ToList();
+
+ Assert.Equal(3, indexes.Count);
+ Assert.Contains("idx_age", indexes);
+ Assert.Contains("idx_email", indexes);
+ Assert.Contains("idx_name", indexes);
+ }
+
+ [Fact]
+ public void TestListSecondaryIndexesEmpty()
+ {
+ var manager = new TableFileManager(_testPath, "NoIndex", _options);
+ var indexes = manager.ListSecondaryIndexes().ToList();
+ Assert.Empty(indexes);
+ }
+
+ [Fact]
+ public void TestListSecondaryIndexesDirectoryNotExists()
+ {
+ var manager = new TableFileManager("/nonexistent/path", "Users", _options);
+ var indexes = manager.ListSecondaryIndexes().ToList();
+ Assert.Empty(indexes);
+ }
+
+ [Fact]
+ public void TestListSecondaryIndexesDeduplication()
+ {
+ var manager = new TableFileManager(_testPath, "Multi", _options);
+
+ // 同一个索引在不同分片存在
+ File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_status", 0), "");
+ File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_status", 1), "");
+ File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_status", 2), "");
+
+ var indexes = manager.ListSecondaryIndexes().ToList();
+ Assert.Single(indexes); // 去重后只有一个
+ Assert.Contains("idx_status", indexes);
+ }
+ #endregion
+
+ #region DeleteAllFiles
+ [Fact]
+ public void TestDeleteAllFiles()
+ {
+ var manager = new TableFileManager(_testPath, "TempTable", _options);
+
+ File.WriteAllText(manager.GetDataFilePath(), "");
+ File.WriteAllText(manager.GetDataFilePath(0), "");
+ File.WriteAllText(manager.GetPrimaryIndexFilePath(), "");
+ File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_test"), "");
+ File.WriteAllText(manager.GetWalFilePath(), "");
+ File.WriteAllText(manager.GetWalFilePath(0), "");
+
+ manager.DeleteAllFiles();
+
+ Assert.False(File.Exists(manager.GetDataFilePath()));
+ Assert.False(File.Exists(manager.GetDataFilePath(0)));
+ Assert.False(File.Exists(manager.GetPrimaryIndexFilePath()));
+ Assert.False(File.Exists(manager.GetSecondaryIndexFilePath("idx_test")));
+ Assert.False(File.Exists(manager.GetWalFilePath()));
+ Assert.False(File.Exists(manager.GetWalFilePath(0)));
+ }
+
+ [Fact]
+ public void TestDeleteAllFilesNoFiles()
+ {
+ var manager = new TableFileManager(_testPath, "Empty", _options);
+ manager.DeleteAllFiles(); // 不应抛异常
+ }
+
+ [Fact]
+ public void TestDeleteAllFilesDirectoryNotExists()
+ {
+ var manager = new TableFileManager("/nonexistent/path", "Users", _options);
+ manager.DeleteAllFiles(); // 不应抛异常
+ }
+
+ [Fact]
+ public void TestDeleteAllFilesDoesNotAffectOtherTables()
+ {
+ var manager1 = new TableFileManager(_testPath, "TableA", _options);
+ var manager2 = new TableFileManager(_testPath, "TableB", _options);
+
+ File.WriteAllText(manager1.GetDataFilePath(), "");
+ File.WriteAllText(manager2.GetDataFilePath(), "");
+
+ manager1.DeleteAllFiles();
+
+ Assert.False(File.Exists(manager1.GetDataFilePath()));
+ Assert.True(File.Exists(manager2.GetDataFilePath())); // 另一张表不受影响
+ }
+ #endregion
+
+ #region Exists
+ [Fact]
+ public void TestExists()
+ {
+ var manager = new TableFileManager(_testPath, "CheckTable", _options);
+
+ Assert.False(manager.Exists());
+
+ File.WriteAllText(manager.GetDataFilePath(), "");
+
+ Assert.True(manager.Exists());
+ }
+
+ [Fact]
+ public void TestExistsDirectoryNotExists()
+ {
+ var manager = new TableFileManager("/nonexistent/path", "Users", _options);
+ Assert.False(manager.Exists());
+ }
+ #endregion
+
+ #region 分片文件综合
+ [Fact]
+ public void TestShardedFileNaming()
+ {
+ var manager = new TableFileManager(_testPath, "ShardedTable", _options);
+
+ File.WriteAllText(manager.GetDataFilePath(0), "");
+ File.WriteAllText(manager.GetDataFilePath(1), "");
+ File.WriteAllText(manager.GetPrimaryIndexFilePath(0), "");
+ File.WriteAllText(manager.GetSecondaryIndexFilePath("idx_status", 0), "");
+ File.WriteAllText(manager.GetWalFilePath(0), "");
+
+ var shards = manager.ListDataShards().ToList();
+ var indexes = manager.ListSecondaryIndexes().ToList();
+
+ Assert.Equal(2, shards.Count);
+ Assert.Contains(0, shards);
+ Assert.Contains(1, shards);
+
+ Assert.Single(indexes);
+ Assert.Contains("idx_status", indexes);
+ }
+ #endregion
+}