前端:优化 Manifest 视图文件显示
- 文件名区域改为左右布局 - 左侧显示文件名(如 000001.sst) - 右侧显示级别标签(如 L0、L1) - 添加级别标签样式,使用主题色背景
This commit is contained in:
@@ -1,276 +0,0 @@
|
|||||||
package srdb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestPickL0MergeContinuity 测试 L0 合并任务的连续性
|
|
||||||
func TestPickL0MergeContinuity(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
manifestDir := tmpDir
|
|
||||||
|
|
||||||
versionSet, err := NewVersionSet(manifestDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer versionSet.Close()
|
|
||||||
|
|
||||||
picker := NewPicker()
|
|
||||||
|
|
||||||
// 创建混合大小的文件:小-大-小-小
|
|
||||||
// 这是触发 bug 的场景
|
|
||||||
edit := NewVersionEdit()
|
|
||||||
|
|
||||||
// 文件1: 29MB (小文件)
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: 1,
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 29 * 1024 * 1024,
|
|
||||||
MinKey: 1,
|
|
||||||
MaxKey: 100,
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 文件2: 36MB (大文件)
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: 2,
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 36 * 1024 * 1024,
|
|
||||||
MinKey: 101,
|
|
||||||
MaxKey: 200,
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 文件3: 8MB (小文件)
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: 3,
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 8 * 1024 * 1024,
|
|
||||||
MinKey: 201,
|
|
||||||
MaxKey: 300,
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 文件4: 15MB (小文件)
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: 4,
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 15 * 1024 * 1024,
|
|
||||||
MinKey: 301,
|
|
||||||
MaxKey: 400,
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
edit.SetNextFileNumber(5)
|
|
||||||
err = versionSet.LogAndApply(edit)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
version := versionSet.GetCurrent()
|
|
||||||
|
|
||||||
// 测试 Stage 0: L0 合并任务
|
|
||||||
t.Log("=== 测试 Stage 0: L0 合并 ===")
|
|
||||||
tasks := picker.pickL0MergeTasks(version)
|
|
||||||
|
|
||||||
if len(tasks) == 0 {
|
|
||||||
t.Fatal("Expected L0 merge tasks")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("找到 %d 个合并任务", len(tasks))
|
|
||||||
|
|
||||||
// 验证任务:应该只有1个任务,包含文件3和文件4
|
|
||||||
// 文件1是单个小文件,不合并(len > 1 才合并)
|
|
||||||
// 文件2是大文件,跳过
|
|
||||||
// 文件3+文件4是连续的2个小文件,应该合并
|
|
||||||
if len(tasks) != 1 {
|
|
||||||
t.Errorf("Expected 1 task, got %d", len(tasks))
|
|
||||||
for i, task := range tasks {
|
|
||||||
t.Logf("Task %d: %d files", i+1, len(task.InputFiles))
|
|
||||||
for _, f := range task.InputFiles {
|
|
||||||
t.Logf(" - File %d", f.FileNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
task1 := tasks[0]
|
|
||||||
if len(task1.InputFiles) != 2 {
|
|
||||||
t.Errorf("Task 1: expected 2 files, got %d", len(task1.InputFiles))
|
|
||||||
}
|
|
||||||
if task1.InputFiles[0].FileNumber != 3 || task1.InputFiles[1].FileNumber != 4 {
|
|
||||||
t.Errorf("Task 1: expected files 3,4, got %d,%d",
|
|
||||||
task1.InputFiles[0].FileNumber, task1.InputFiles[1].FileNumber)
|
|
||||||
}
|
|
||||||
t.Logf("✓ 合并任务: 文件3+文件4 (连续的2个小文件)")
|
|
||||||
t.Logf("✓ 文件1 (单个小文件) 不合并,留给升级阶段")
|
|
||||||
|
|
||||||
// 验证 seq 范围连续性
|
|
||||||
// 任务1: seq 201-400 (文件3+文件4)
|
|
||||||
// 文件1(seq 1-100, 单个小文件)留给升级阶段
|
|
||||||
// 文件2(seq 101-200, 大文件)留给 Stage 1
|
|
||||||
if task1.InputFiles[0].MinKey != 201 || task1.InputFiles[1].MaxKey != 400 {
|
|
||||||
t.Errorf("Task 1 seq range incorrect: [%d, %d]",
|
|
||||||
task1.InputFiles[0].MinKey, task1.InputFiles[1].MaxKey)
|
|
||||||
}
|
|
||||||
t.Logf("✓ Seq 范围正确:任务1 [201-400]")
|
|
||||||
|
|
||||||
// 测试 Stage 1: L0 升级任务
|
|
||||||
t.Log("=== 测试 Stage 1: L0 升级 ===")
|
|
||||||
upgradeTasks := picker.pickL0UpgradeTasks(version)
|
|
||||||
|
|
||||||
if len(upgradeTasks) == 0 {
|
|
||||||
t.Fatal("Expected L0 upgrade tasks")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应该有1个任务:以文件2(大文件)为中心,搭配周围的小文件
|
|
||||||
// 文件2向左收集文件1,向右收集文件3和文件4
|
|
||||||
// 总共:文件1 (29MB) + 文件2 (36MB) + 文件3 (8MB) + 文件4 (15MB) = 88MB
|
|
||||||
if len(upgradeTasks) != 1 {
|
|
||||||
t.Errorf("Expected 1 upgrade task, got %d", len(upgradeTasks))
|
|
||||||
}
|
|
||||||
upgradeTask := upgradeTasks[0]
|
|
||||||
|
|
||||||
// 应该包含所有4个文件
|
|
||||||
if len(upgradeTask.InputFiles) != 4 {
|
|
||||||
t.Errorf("Upgrade task: expected 4 files, got %d", len(upgradeTask.InputFiles))
|
|
||||||
for i, f := range upgradeTask.InputFiles {
|
|
||||||
t.Logf(" File %d: %d", i+1, f.FileNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证文件顺序:1, 2, 3, 4
|
|
||||||
expectedFiles := []int64{1, 2, 3, 4}
|
|
||||||
for i, expected := range expectedFiles {
|
|
||||||
if upgradeTask.InputFiles[i].FileNumber != expected {
|
|
||||||
t.Errorf("Upgrade task file %d: expected %d, got %d",
|
|
||||||
i, expected, upgradeTask.InputFiles[i].FileNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if upgradeTask.OutputLevel != 1 {
|
|
||||||
t.Errorf("Upgrade task: expected OutputLevel 1, got %d", upgradeTask.OutputLevel)
|
|
||||||
}
|
|
||||||
t.Logf("✓ 升级任务: 文件1+文件2+文件3+文件4 (以大文件为中心,搭配周围小文件) → L1")
|
|
||||||
|
|
||||||
t.Log("=== 连续性测试通过 ===")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPickL0UpgradeContinuity 测试 L0 升级任务的连续性
|
|
||||||
func TestPickL0UpgradeContinuity(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
manifestDir := tmpDir
|
|
||||||
|
|
||||||
versionSet, err := NewVersionSet(manifestDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer versionSet.Close()
|
|
||||||
|
|
||||||
picker := NewPicker()
|
|
||||||
|
|
||||||
// 创建混合大小的文件:大-小-大-大
|
|
||||||
edit := NewVersionEdit()
|
|
||||||
|
|
||||||
// 文件1: 40MB (大文件)
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: 1,
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 40 * 1024 * 1024,
|
|
||||||
MinKey: 1,
|
|
||||||
MaxKey: 100,
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 文件2: 20MB (小文件)
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: 2,
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 20 * 1024 * 1024,
|
|
||||||
MinKey: 101,
|
|
||||||
MaxKey: 200,
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 文件3: 50MB (大文件)
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: 3,
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 50 * 1024 * 1024,
|
|
||||||
MinKey: 201,
|
|
||||||
MaxKey: 300,
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 文件4: 45MB (大文件)
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: 4,
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 45 * 1024 * 1024,
|
|
||||||
MinKey: 301,
|
|
||||||
MaxKey: 400,
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
|
|
||||||
edit.SetNextFileNumber(5)
|
|
||||||
err = versionSet.LogAndApply(edit)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
version := versionSet.GetCurrent()
|
|
||||||
|
|
||||||
// 测试 L0 升级任务
|
|
||||||
t.Log("=== 测试 L0 升级任务连续性 ===")
|
|
||||||
tasks := picker.pickL0UpgradeTasks(version)
|
|
||||||
|
|
||||||
if len(tasks) == 0 {
|
|
||||||
t.Fatal("Expected L0 upgrade tasks")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("找到 %d 个升级任务", len(tasks))
|
|
||||||
|
|
||||||
// 验证任务1:应该包含所有4个文件(以大文件为锚点,搭配周围文件)
|
|
||||||
// 文件1(大文件)作为锚点 → 向左无文件 → 向右收集文件2(小)+文件3(大)+文件4(大)
|
|
||||||
// 总大小:40+20+50+45 = 155MB < 256MB,符合 L1 限制
|
|
||||||
task1 := tasks[0]
|
|
||||||
expectedFileCount := 4
|
|
||||||
if len(task1.InputFiles) != expectedFileCount {
|
|
||||||
t.Errorf("Task 1: expected %d files, got %d", expectedFileCount, len(task1.InputFiles))
|
|
||||||
for i, f := range task1.InputFiles {
|
|
||||||
t.Logf(" File %d: %d", i+1, f.FileNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证文件顺序:1, 2, 3, 4
|
|
||||||
expectedFiles := []int64{1, 2, 3, 4}
|
|
||||||
for i, expected := range expectedFiles {
|
|
||||||
if i >= len(task1.InputFiles) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if task1.InputFiles[i].FileNumber != expected {
|
|
||||||
t.Errorf("Task 1 file %d: expected %d, got %d",
|
|
||||||
i, expected, task1.InputFiles[i].FileNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("✓ Task 1: 文件1+文件2+文件3+文件4 (以大文件为锚点,搭配周围文件,总155MB < 256MB)")
|
|
||||||
|
|
||||||
// 只应该有1个任务(所有文件都被收集了)
|
|
||||||
if len(tasks) != 1 {
|
|
||||||
t.Errorf("Expected 1 task (all files collected), got %d", len(tasks))
|
|
||||||
for i, task := range tasks {
|
|
||||||
t.Logf("Task %d: %d files", i+1, len(task.InputFiles))
|
|
||||||
for _, f := range task.InputFiles {
|
|
||||||
t.Logf(" - File %d", f.FileNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证所有任务的 OutputLevel 都是 1
|
|
||||||
for i, task := range tasks {
|
|
||||||
if task.OutputLevel != 1 {
|
|
||||||
t.Errorf("Task %d: expected OutputLevel 1, got %d", i+1, task.OutputLevel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("✓ 所有任务都升级到 L1")
|
|
||||||
|
|
||||||
t.Log("=== 升级任务连续性测试通过 ===")
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package srdb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestPickerStageRotation 测试 Picker 的阶段轮换机制
|
|
||||||
func TestPickerStageRotation(t *testing.T) {
|
|
||||||
// 创建临时目录
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
manifestDir := tmpDir
|
|
||||||
|
|
||||||
// 创建 VersionSet
|
|
||||||
versionSet, err := NewVersionSet(manifestDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer versionSet.Close()
|
|
||||||
|
|
||||||
// 创建 Picker
|
|
||||||
picker := NewPicker()
|
|
||||||
|
|
||||||
// 初始阶段应该是 L0
|
|
||||||
if stage := picker.GetCurrentStage(); stage != 0 {
|
|
||||||
t.Errorf("Initial stage should be 0 (L0), got %d", stage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加 L0 文件(触发 L0 compaction)
|
|
||||||
edit := NewVersionEdit()
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: int64(i + 1),
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 10 * 1024 * 1024, // 10MB each
|
|
||||||
MinKey: int64(i * 100),
|
|
||||||
MaxKey: int64((i+1)*100 - 1),
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
edit.SetNextFileNumber(11)
|
|
||||||
err = versionSet.LogAndApply(edit)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
version := versionSet.GetCurrent()
|
|
||||||
|
|
||||||
// 第1次调用:应该返回 L0 任务,然后推进到 L1
|
|
||||||
t.Log("=== 第1次调用 PickCompaction ===")
|
|
||||||
tasks1 := picker.PickCompaction(version)
|
|
||||||
if len(tasks1) == 0 {
|
|
||||||
t.Error("Expected L0 tasks on first call")
|
|
||||||
}
|
|
||||||
for _, task := range tasks1 {
|
|
||||||
if task.Level != 0 {
|
|
||||||
t.Errorf("Expected L0 task, got L%d", task.Level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stage := picker.GetCurrentStage(); stage != 1 {
|
|
||||||
t.Errorf("After L0 tasks, stage should be 1 (L1), got %d", stage)
|
|
||||||
}
|
|
||||||
t.Logf("✓ Returned %d L0 tasks, stage advanced to L1", len(tasks1))
|
|
||||||
|
|
||||||
// 第2次调用:应该尝试 Stage 1 (L0-upgrade,没有大文件)
|
|
||||||
t.Log("=== 第2次调用 PickCompaction ===")
|
|
||||||
tasks2 := picker.PickCompaction(version)
|
|
||||||
if len(tasks2) == 0 {
|
|
||||||
t.Log("✓ Stage 1 (L0-upgrade) has no tasks")
|
|
||||||
}
|
|
||||||
// 此时 stage 应该已经循环(尝试了 Stage 1→2→3→0...)
|
|
||||||
if stage := picker.GetCurrentStage(); stage >= 0 {
|
|
||||||
t.Logf("After trying, current stage is %d", stage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 现在添加 L1 文件
|
|
||||||
edit2 := NewVersionEdit()
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
edit2.AddFile(&FileMetadata{
|
|
||||||
FileNumber: int64(100 + i + 1),
|
|
||||||
Level: 1,
|
|
||||||
FileSize: 20 * 1024 * 1024, // 20MB each
|
|
||||||
MinKey: int64(i * 200),
|
|
||||||
MaxKey: int64((i+1)*200 - 1),
|
|
||||||
RowCount: 200,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
edit2.SetNextFileNumber(121)
|
|
||||||
err = versionSet.LogAndApply(edit2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
version2 := versionSet.GetCurrent()
|
|
||||||
|
|
||||||
// 现在可能需要多次调用才能到达 Stage 2 (L1-upgrade)
|
|
||||||
// 因为要经过 Stage 1 (L0-upgrade) 和 Stage 0 (L0-merge)
|
|
||||||
t.Log("=== 多次调用 PickCompaction 直到找到 L1 任务 ===")
|
|
||||||
var tasks3 []*CompactionTask
|
|
||||||
for i := 0; i < 8; i++ { // 最多尝试两轮(4个阶段×2)
|
|
||||||
tasks3 = picker.PickCompaction(version2)
|
|
||||||
if len(tasks3) > 0 && tasks3[0].Level == 1 {
|
|
||||||
t.Logf("✓ Found %d L1 tasks after %d attempts", len(tasks3), i+1)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(tasks3) == 0 || tasks3[0].Level != 1 {
|
|
||||||
t.Error("Expected to find L1 tasks within 8 attempts")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("=== Stage rotation test passed ===")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPickerStageWithMultipleLevels 测试多层级同时有任务时的阶段轮换
|
|
||||||
func TestPickerStageWithMultipleLevels(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
manifestDir := tmpDir
|
|
||||||
|
|
||||||
versionSet, err := NewVersionSet(manifestDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer versionSet.Close()
|
|
||||||
|
|
||||||
picker := NewPicker()
|
|
||||||
|
|
||||||
// 同时添加 L0、L1、L2 文件
|
|
||||||
edit := NewVersionEdit()
|
|
||||||
|
|
||||||
// L0 小文件: 5 files × 10MB = 50MB (应该触发 Stage 0: L0-merge)
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: int64(i + 1),
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 10 * 1024 * 1024,
|
|
||||||
MinKey: int64(i * 100),
|
|
||||||
MaxKey: int64((i+1)*100 - 1),
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// L0 大文件: 5 files × 40MB = 200MB (应该触发 Stage 1: L0-upgrade)
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: int64(10 + i + 1),
|
|
||||||
Level: 0,
|
|
||||||
FileSize: 40 * 1024 * 1024,
|
|
||||||
MinKey: int64((i+5) * 100),
|
|
||||||
MaxKey: int64((i+6)*100 - 1),
|
|
||||||
RowCount: 100,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// L1: 20 files × 20MB = 400MB (应该触发 Stage 2: L1-upgrade,256MB阈值)
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: int64(100 + i + 1),
|
|
||||||
Level: 1,
|
|
||||||
FileSize: 20 * 1024 * 1024,
|
|
||||||
MinKey: int64(i * 200),
|
|
||||||
MaxKey: int64((i+1)*200 - 1),
|
|
||||||
RowCount: 200,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// L2: 10 files × 150MB = 1500MB (应该触发 Stage 3: L2-upgrade,1GB阈值)
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
edit.AddFile(&FileMetadata{
|
|
||||||
FileNumber: int64(200 + i + 1),
|
|
||||||
Level: 2,
|
|
||||||
FileSize: 150 * 1024 * 1024,
|
|
||||||
MinKey: int64(i * 300),
|
|
||||||
MaxKey: int64((i+1)*300 - 1),
|
|
||||||
RowCount: 300,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
edit.SetNextFileNumber(301)
|
|
||||||
err = versionSet.LogAndApply(edit)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
version := versionSet.GetCurrent()
|
|
||||||
|
|
||||||
// 验证阶段按顺序执行:Stage 0→1→2→3→0→1→2→3
|
|
||||||
expectedStages := []struct {
|
|
||||||
stage int
|
|
||||||
name string
|
|
||||||
level int
|
|
||||||
}{
|
|
||||||
{0, "L0-merge", 0},
|
|
||||||
{1, "L0-upgrade", 0},
|
|
||||||
{2, "L1-upgrade", 1},
|
|
||||||
{3, "L2-upgrade", 2},
|
|
||||||
{0, "L0-merge", 0},
|
|
||||||
{1, "L0-upgrade", 0},
|
|
||||||
{2, "L1-upgrade", 1},
|
|
||||||
{3, "L2-upgrade", 2},
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, expected := range expectedStages {
|
|
||||||
t.Logf("=== 第%d次调用 PickCompaction (期望 Stage %d: %s) ===", i+1, expected.stage, expected.name)
|
|
||||||
tasks := picker.PickCompaction(version)
|
|
||||||
|
|
||||||
if len(tasks) == 0 {
|
|
||||||
t.Errorf("Call %d: Expected tasks from Stage %d (%s), got no tasks", i+1, expected.stage, expected.name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
actualLevel := tasks[0].Level
|
|
||||||
if actualLevel != expected.level {
|
|
||||||
t.Errorf("Call %d: Expected L%d tasks, got L%d tasks", i+1, expected.level, actualLevel)
|
|
||||||
} else {
|
|
||||||
t.Logf("✓ Call %d: Got %d tasks from L%d (Stage %d: %s) as expected",
|
|
||||||
i+1, len(tasks), actualLevel, expected.stage, expected.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("=== Multi-level stage rotation test passed ===")
|
|
||||||
}
|
|
||||||
@@ -560,3 +560,489 @@ func TestCompactionQueryOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPickerStageRotation 测试 Picker 的阶段轮换机制
|
||||||
|
func TestPickerStageRotation(t *testing.T) {
|
||||||
|
// 创建临时目录
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manifestDir := tmpDir
|
||||||
|
|
||||||
|
// 创建 VersionSet
|
||||||
|
versionSet, err := NewVersionSet(manifestDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer versionSet.Close()
|
||||||
|
|
||||||
|
// 创建 Picker
|
||||||
|
picker := NewPicker()
|
||||||
|
|
||||||
|
// 初始阶段应该是 L0
|
||||||
|
if stage := picker.GetCurrentStage(); stage != 0 {
|
||||||
|
t.Errorf("Initial stage should be 0 (L0), got %d", stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 L0 文件(触发 L0 compaction)
|
||||||
|
edit := NewVersionEdit()
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: int64(i + 1),
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 10 * 1024 * 1024, // 10MB each
|
||||||
|
MinKey: int64(i * 100),
|
||||||
|
MaxKey: int64((i+1)*100 - 1),
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
edit.SetNextFileNumber(11)
|
||||||
|
err = versionSet.LogAndApply(edit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := versionSet.GetCurrent()
|
||||||
|
|
||||||
|
// 第1次调用:应该返回 L0 任务,然后推进到 L1
|
||||||
|
t.Log("=== 第1次调用 PickCompaction ===")
|
||||||
|
tasks1 := picker.PickCompaction(version)
|
||||||
|
if len(tasks1) == 0 {
|
||||||
|
t.Error("Expected L0 tasks on first call")
|
||||||
|
}
|
||||||
|
for _, task := range tasks1 {
|
||||||
|
if task.Level != 0 {
|
||||||
|
t.Errorf("Expected L0 task, got L%d", task.Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stage := picker.GetCurrentStage(); stage != 1 {
|
||||||
|
t.Errorf("After L0 tasks, stage should be 1 (L1), got %d", stage)
|
||||||
|
}
|
||||||
|
t.Logf("✓ Returned %d L0 tasks, stage advanced to L1", len(tasks1))
|
||||||
|
|
||||||
|
// 第2次调用:应该尝试 Stage 1 (L0-upgrade,没有大文件)
|
||||||
|
t.Log("=== 第2次调用 PickCompaction ===")
|
||||||
|
tasks2 := picker.PickCompaction(version)
|
||||||
|
if len(tasks2) == 0 {
|
||||||
|
t.Log("✓ Stage 1 (L0-upgrade) has no tasks")
|
||||||
|
}
|
||||||
|
// 此时 stage 应该已经循环(尝试了 Stage 1→2→3→0...)
|
||||||
|
if stage := picker.GetCurrentStage(); stage >= 0 {
|
||||||
|
t.Logf("After trying, current stage is %d", stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 现在添加 L1 文件
|
||||||
|
edit2 := NewVersionEdit()
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
edit2.AddFile(&FileMetadata{
|
||||||
|
FileNumber: int64(100 + i + 1),
|
||||||
|
Level: 1,
|
||||||
|
FileSize: 20 * 1024 * 1024, // 20MB each
|
||||||
|
MinKey: int64(i * 200),
|
||||||
|
MaxKey: int64((i+1)*200 - 1),
|
||||||
|
RowCount: 200,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
edit2.SetNextFileNumber(121)
|
||||||
|
err = versionSet.LogAndApply(edit2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version2 := versionSet.GetCurrent()
|
||||||
|
|
||||||
|
// 现在可能需要多次调用才能到达 Stage 2 (L1-upgrade)
|
||||||
|
// 因为要经过 Stage 1 (L0-upgrade) 和 Stage 0 (L0-merge)
|
||||||
|
t.Log("=== 多次调用 PickCompaction 直到找到 L1 任务 ===")
|
||||||
|
var tasks3 []*CompactionTask
|
||||||
|
for i := 0; i < 8; i++ { // 最多尝试两轮(4个阶段×2)
|
||||||
|
tasks3 = picker.PickCompaction(version2)
|
||||||
|
if len(tasks3) > 0 && tasks3[0].Level == 1 {
|
||||||
|
t.Logf("✓ Found %d L1 tasks after %d attempts", len(tasks3), i+1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tasks3) == 0 || tasks3[0].Level != 1 {
|
||||||
|
t.Error("Expected to find L1 tasks within 8 attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("=== Stage rotation test passed ===")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPickerStageWithMultipleLevels 测试多层级同时有任务时的阶段轮换
|
||||||
|
func TestPickerStageWithMultipleLevels(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manifestDir := tmpDir
|
||||||
|
|
||||||
|
versionSet, err := NewVersionSet(manifestDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer versionSet.Close()
|
||||||
|
|
||||||
|
picker := NewPicker()
|
||||||
|
|
||||||
|
// 同时添加 L0、L1、L2 文件
|
||||||
|
edit := NewVersionEdit()
|
||||||
|
|
||||||
|
// L0 小文件: 5 files × 10MB = 50MB (应该触发 Stage 0: L0-merge)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: int64(i + 1),
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 10 * 1024 * 1024,
|
||||||
|
MinKey: int64(i * 100),
|
||||||
|
MaxKey: int64((i+1)*100 - 1),
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// L0 大文件: 5 files × 40MB = 200MB (应该触发 Stage 1: L0-upgrade)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: int64(10 + i + 1),
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 40 * 1024 * 1024,
|
||||||
|
MinKey: int64((i + 5) * 100),
|
||||||
|
MaxKey: int64((i+6)*100 - 1),
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1: 20 files × 20MB = 400MB (应该触发 Stage 2: L1-upgrade,256MB阈值)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: int64(100 + i + 1),
|
||||||
|
Level: 1,
|
||||||
|
FileSize: 20 * 1024 * 1024,
|
||||||
|
MinKey: int64(i * 200),
|
||||||
|
MaxKey: int64((i+1)*200 - 1),
|
||||||
|
RowCount: 200,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// L2: 10 files × 150MB = 1500MB (应该触发 Stage 3: L2-upgrade,1GB阈值)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: int64(200 + i + 1),
|
||||||
|
Level: 2,
|
||||||
|
FileSize: 150 * 1024 * 1024,
|
||||||
|
MinKey: int64(i * 300),
|
||||||
|
MaxKey: int64((i+1)*300 - 1),
|
||||||
|
RowCount: 300,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
edit.SetNextFileNumber(301)
|
||||||
|
err = versionSet.LogAndApply(edit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := versionSet.GetCurrent()
|
||||||
|
|
||||||
|
// 验证阶段按顺序执行:Stage 0→1→2→3→0→1→2→3
|
||||||
|
expectedStages := []struct {
|
||||||
|
stage int
|
||||||
|
name string
|
||||||
|
level int
|
||||||
|
}{
|
||||||
|
{0, "L0-merge", 0},
|
||||||
|
{1, "L0-upgrade", 0},
|
||||||
|
{2, "L1-upgrade", 1},
|
||||||
|
{3, "L2-upgrade", 2},
|
||||||
|
{0, "L0-merge", 0},
|
||||||
|
{1, "L0-upgrade", 0},
|
||||||
|
{2, "L1-upgrade", 1},
|
||||||
|
{3, "L2-upgrade", 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, expected := range expectedStages {
|
||||||
|
t.Logf("=== 第%d次调用 PickCompaction (期望 Stage %d: %s) ===", i+1, expected.stage, expected.name)
|
||||||
|
tasks := picker.PickCompaction(version)
|
||||||
|
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
t.Errorf("Call %d: Expected tasks from Stage %d (%s), got no tasks", i+1, expected.stage, expected.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
actualLevel := tasks[0].Level
|
||||||
|
if actualLevel != expected.level {
|
||||||
|
t.Errorf("Call %d: Expected L%d tasks, got L%d tasks", i+1, expected.level, actualLevel)
|
||||||
|
} else {
|
||||||
|
t.Logf("✓ Call %d: Got %d tasks from L%d (Stage %d: %s) as expected",
|
||||||
|
i+1, len(tasks), actualLevel, expected.stage, expected.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("=== Multi-level stage rotation test passed ===")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPickL0MergeContinuity 测试 L0 合并任务的连续性
|
||||||
|
func TestPickL0MergeContinuity(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manifestDir := tmpDir
|
||||||
|
|
||||||
|
versionSet, err := NewVersionSet(manifestDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer versionSet.Close()
|
||||||
|
|
||||||
|
picker := NewPicker()
|
||||||
|
|
||||||
|
// 创建混合大小的文件:小-大-小-小
|
||||||
|
// 这是触发 bug 的场景
|
||||||
|
edit := NewVersionEdit()
|
||||||
|
|
||||||
|
// 文件1: 29MB (小文件)
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: 1,
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 29 * 1024 * 1024,
|
||||||
|
MinKey: 1,
|
||||||
|
MaxKey: 100,
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 文件2: 36MB (大文件)
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: 2,
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 36 * 1024 * 1024,
|
||||||
|
MinKey: 101,
|
||||||
|
MaxKey: 200,
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 文件3: 8MB (小文件)
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: 3,
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 8 * 1024 * 1024,
|
||||||
|
MinKey: 201,
|
||||||
|
MaxKey: 300,
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 文件4: 15MB (小文件)
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: 4,
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 15 * 1024 * 1024,
|
||||||
|
MinKey: 301,
|
||||||
|
MaxKey: 400,
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
edit.SetNextFileNumber(5)
|
||||||
|
err = versionSet.LogAndApply(edit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := versionSet.GetCurrent()
|
||||||
|
|
||||||
|
// 测试 Stage 0: L0 合并任务
|
||||||
|
t.Log("=== 测试 Stage 0: L0 合并 ===")
|
||||||
|
tasks := picker.pickL0MergeTasks(version)
|
||||||
|
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
t.Fatal("Expected L0 merge tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("找到 %d 个合并任务", len(tasks))
|
||||||
|
|
||||||
|
// 验证任务:应该只有1个任务,包含文件3和文件4
|
||||||
|
// 文件1是单个小文件,不合并(len > 1 才合并)
|
||||||
|
// 文件2是大文件,跳过
|
||||||
|
// 文件3+文件4是连续的2个小文件,应该合并
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Errorf("Expected 1 task, got %d", len(tasks))
|
||||||
|
for i, task := range tasks {
|
||||||
|
t.Logf("Task %d: %d files", i+1, len(task.InputFiles))
|
||||||
|
for _, f := range task.InputFiles {
|
||||||
|
t.Logf(" - File %d", f.FileNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task1 := tasks[0]
|
||||||
|
if len(task1.InputFiles) != 2 {
|
||||||
|
t.Errorf("Task 1: expected 2 files, got %d", len(task1.InputFiles))
|
||||||
|
}
|
||||||
|
if task1.InputFiles[0].FileNumber != 3 || task1.InputFiles[1].FileNumber != 4 {
|
||||||
|
t.Errorf("Task 1: expected files 3,4, got %d,%d",
|
||||||
|
task1.InputFiles[0].FileNumber, task1.InputFiles[1].FileNumber)
|
||||||
|
}
|
||||||
|
t.Logf("✓ 合并任务: 文件3+文件4 (连续的2个小文件)")
|
||||||
|
t.Logf("✓ 文件1 (单个小文件) 不合并,留给升级阶段")
|
||||||
|
|
||||||
|
// 验证 seq 范围连续性
|
||||||
|
// 任务1: seq 201-400 (文件3+文件4)
|
||||||
|
// 文件1(seq 1-100, 单个小文件)留给升级阶段
|
||||||
|
// 文件2(seq 101-200, 大文件)留给 Stage 1
|
||||||
|
if task1.InputFiles[0].MinKey != 201 || task1.InputFiles[1].MaxKey != 400 {
|
||||||
|
t.Errorf("Task 1 seq range incorrect: [%d, %d]",
|
||||||
|
task1.InputFiles[0].MinKey, task1.InputFiles[1].MaxKey)
|
||||||
|
}
|
||||||
|
t.Logf("✓ Seq 范围正确:任务1 [201-400]")
|
||||||
|
|
||||||
|
// 测试 Stage 1: L0 升级任务
|
||||||
|
t.Log("=== 测试 Stage 1: L0 升级 ===")
|
||||||
|
upgradeTasks := picker.pickL0UpgradeTasks(version)
|
||||||
|
|
||||||
|
if len(upgradeTasks) == 0 {
|
||||||
|
t.Fatal("Expected L0 upgrade tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应该有1个任务:以文件2(大文件)为中心,搭配周围的小文件
|
||||||
|
// 文件2向左收集文件1,向右收集文件3和文件4
|
||||||
|
// 总共:文件1 (29MB) + 文件2 (36MB) + 文件3 (8MB) + 文件4 (15MB) = 88MB
|
||||||
|
if len(upgradeTasks) != 1 {
|
||||||
|
t.Errorf("Expected 1 upgrade task, got %d", len(upgradeTasks))
|
||||||
|
}
|
||||||
|
upgradeTask := upgradeTasks[0]
|
||||||
|
|
||||||
|
// 应该包含所有4个文件
|
||||||
|
if len(upgradeTask.InputFiles) != 4 {
|
||||||
|
t.Errorf("Upgrade task: expected 4 files, got %d", len(upgradeTask.InputFiles))
|
||||||
|
for i, f := range upgradeTask.InputFiles {
|
||||||
|
t.Logf(" File %d: %d", i+1, f.FileNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件顺序:1, 2, 3, 4
|
||||||
|
expectedFiles := []int64{1, 2, 3, 4}
|
||||||
|
for i, expected := range expectedFiles {
|
||||||
|
if upgradeTask.InputFiles[i].FileNumber != expected {
|
||||||
|
t.Errorf("Upgrade task file %d: expected %d, got %d",
|
||||||
|
i, expected, upgradeTask.InputFiles[i].FileNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if upgradeTask.OutputLevel != 1 {
|
||||||
|
t.Errorf("Upgrade task: expected OutputLevel 1, got %d", upgradeTask.OutputLevel)
|
||||||
|
}
|
||||||
|
t.Logf("✓ 升级任务: 文件1+文件2+文件3+文件4 (以大文件为中心,搭配周围小文件) → L1")
|
||||||
|
|
||||||
|
t.Log("=== 连续性测试通过 ===")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPickL0UpgradeContinuity 测试 L0 升级任务的连续性
|
||||||
|
func TestPickL0UpgradeContinuity(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
manifestDir := tmpDir
|
||||||
|
|
||||||
|
versionSet, err := NewVersionSet(manifestDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer versionSet.Close()
|
||||||
|
|
||||||
|
picker := NewPicker()
|
||||||
|
|
||||||
|
// 创建混合大小的文件:大-小-大-大
|
||||||
|
edit := NewVersionEdit()
|
||||||
|
|
||||||
|
// 文件1: 40MB (大文件)
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: 1,
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 40 * 1024 * 1024,
|
||||||
|
MinKey: 1,
|
||||||
|
MaxKey: 100,
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 文件2: 20MB (小文件)
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: 2,
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 20 * 1024 * 1024,
|
||||||
|
MinKey: 101,
|
||||||
|
MaxKey: 200,
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 文件3: 50MB (大文件)
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: 3,
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 50 * 1024 * 1024,
|
||||||
|
MinKey: 201,
|
||||||
|
MaxKey: 300,
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 文件4: 45MB (大文件)
|
||||||
|
edit.AddFile(&FileMetadata{
|
||||||
|
FileNumber: 4,
|
||||||
|
Level: 0,
|
||||||
|
FileSize: 45 * 1024 * 1024,
|
||||||
|
MinKey: 301,
|
||||||
|
MaxKey: 400,
|
||||||
|
RowCount: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
edit.SetNextFileNumber(5)
|
||||||
|
err = versionSet.LogAndApply(edit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version := versionSet.GetCurrent()
|
||||||
|
|
||||||
|
// 测试 L0 升级任务
|
||||||
|
t.Log("=== 测试 L0 升级任务连续性 ===")
|
||||||
|
tasks := picker.pickL0UpgradeTasks(version)
|
||||||
|
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
t.Fatal("Expected L0 upgrade tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("找到 %d 个升级任务", len(tasks))
|
||||||
|
|
||||||
|
// 验证任务1:应该包含所有4个文件(以大文件为锚点,搭配周围文件)
|
||||||
|
// 文件1(大文件)作为锚点 → 向左无文件 → 向右收集文件2(小)+文件3(大)+文件4(大)
|
||||||
|
// 总大小:40+20+50+45 = 155MB < 256MB,符合 L1 限制
|
||||||
|
task1 := tasks[0]
|
||||||
|
expectedFileCount := 4
|
||||||
|
if len(task1.InputFiles) != expectedFileCount {
|
||||||
|
t.Errorf("Task 1: expected %d files, got %d", expectedFileCount, len(task1.InputFiles))
|
||||||
|
for i, f := range task1.InputFiles {
|
||||||
|
t.Logf(" File %d: %d", i+1, f.FileNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件顺序:1, 2, 3, 4
|
||||||
|
expectedFiles := []int64{1, 2, 3, 4}
|
||||||
|
for i, expected := range expectedFiles {
|
||||||
|
if i >= len(task1.InputFiles) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if task1.InputFiles[i].FileNumber != expected {
|
||||||
|
t.Errorf("Task 1 file %d: expected %d, got %d",
|
||||||
|
i, expected, task1.InputFiles[i].FileNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("✓ Task 1: 文件1+文件2+文件3+文件4 (以大文件为锚点,搭配周围文件,总155MB < 256MB)")
|
||||||
|
|
||||||
|
// 只应该有1个任务(所有文件都被收集了)
|
||||||
|
if len(tasks) != 1 {
|
||||||
|
t.Errorf("Expected 1 task (all files collected), got %d", len(tasks))
|
||||||
|
for i, task := range tasks {
|
||||||
|
t.Logf("Task %d: %d files", i+1, len(task.InputFiles))
|
||||||
|
for _, f := range task.InputFiles {
|
||||||
|
t.Logf(" - File %d", f.FileNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有任务的 OutputLevel 都是 1
|
||||||
|
for i, task := range tasks {
|
||||||
|
if task.OutputLevel != 1 {
|
||||||
|
t.Errorf("Task %d: expected OutputLevel 1, got %d", i+1, task.OutputLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("✓ 所有任务都升级到 L1")
|
||||||
|
|
||||||
|
t.Log("=== 升级任务连续性测试通过 ===")
|
||||||
|
}
|
||||||
|
|||||||
162
examples/batch_insert/README.md
Normal file
162
examples/batch_insert/README.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# 批量插入示例
|
||||||
|
|
||||||
|
这个示例展示了 SRDB 的批量插入功能,支持多种数据类型的插入。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
SRDB 的 `Insert` 方法支持以下输入类型:
|
||||||
|
|
||||||
|
1. **单个 map**: `map[string]any`
|
||||||
|
2. **map 切片**: `[]map[string]any`
|
||||||
|
3. **单个结构体**: `struct{}`
|
||||||
|
4. **结构体指针**: `*struct{}`
|
||||||
|
5. **结构体切片**: `[]struct{}`
|
||||||
|
6. **结构体指针切片**: `[]*struct{}`
|
||||||
|
|
||||||
|
## 运行示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/batch_insert
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## 示例说明
|
||||||
|
|
||||||
|
### 示例 1: 插入单个 map
|
||||||
|
|
||||||
|
```go
|
||||||
|
err = table.Insert(map[string]any{
|
||||||
|
"name": "Alice",
|
||||||
|
"age": int64(25),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
最基本的插入方式,适合动态数据。
|
||||||
|
|
||||||
|
### 示例 2: 批量插入 map 切片
|
||||||
|
|
||||||
|
```go
|
||||||
|
err = table.Insert([]map[string]any{
|
||||||
|
{"name": "Alice", "age": int64(25), "email": "alice@example.com"},
|
||||||
|
{"name": "Bob", "age": int64(30), "email": "bob@example.com"},
|
||||||
|
{"name": "Charlie", "age": int64(35), "email": "charlie@example.com"},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
批量插入多条数据,提高插入效率。
|
||||||
|
|
||||||
|
### 示例 3: 插入单个结构体
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
Name string `srdb:"name;comment:用户名"`
|
||||||
|
Age int64 `srdb:"age;comment:年龄"`
|
||||||
|
Email string `srdb:"email;indexed;comment:邮箱"`
|
||||||
|
IsActive bool `srdb:"is_active;comment:是否激活"`
|
||||||
|
}
|
||||||
|
|
||||||
|
user := User{
|
||||||
|
Name: "Alice",
|
||||||
|
Age: 25,
|
||||||
|
Email: "alice@example.com",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(user)
|
||||||
|
```
|
||||||
|
|
||||||
|
使用结构体插入,提供类型安全和代码可读性。
|
||||||
|
|
||||||
|
### 示例 4: 批量插入结构体切片
|
||||||
|
|
||||||
|
```go
|
||||||
|
users := []User{
|
||||||
|
{Name: "Alice", Age: 25, Email: "alice@example.com", IsActive: true},
|
||||||
|
{Name: "Bob", Age: 30, Email: "bob@example.com", IsActive: true},
|
||||||
|
{Name: "Charlie", Age: 35, Email: "charlie@example.com", IsActive: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(users)
|
||||||
|
```
|
||||||
|
|
||||||
|
批量插入结构体,适合需要插入大量数据的场景。
|
||||||
|
|
||||||
|
### 示例 5: 批量插入结构体指针切片
|
||||||
|
|
||||||
|
```go
|
||||||
|
users := []*User{
|
||||||
|
{Name: "Alice", Age: 25, Email: "alice@example.com", IsActive: true},
|
||||||
|
{Name: "Bob", Age: 30, Email: "bob@example.com", IsActive: true},
|
||||||
|
nil, // nil 指针会被自动跳过
|
||||||
|
{Name: "Charlie", Age: 35, Email: "charlie@example.com", IsActive: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(users)
|
||||||
|
```
|
||||||
|
|
||||||
|
支持指针切片,nil 指针会被自动跳过。
|
||||||
|
|
||||||
|
### 示例 6: 使用 snake_case 自动转换
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Product struct {
|
||||||
|
ProductID string `srdb:";comment:产品ID"` // 自动转为 product_id
|
||||||
|
ProductName string `srdb:";comment:产品名称"` // 自动转为 product_name
|
||||||
|
Price float64 `srdb:";comment:价格"` // 自动转为 price
|
||||||
|
InStock bool `srdb:";comment:是否有货"` // 自动转为 in_stock
|
||||||
|
}
|
||||||
|
|
||||||
|
products := []Product{
|
||||||
|
{ProductID: "P001", ProductName: "Laptop", Price: 999.99, InStock: true},
|
||||||
|
{ProductID: "P002", ProductName: "Mouse", Price: 29.99, InStock: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(products)
|
||||||
|
```
|
||||||
|
|
||||||
|
不指定字段名时,会自动将驼峰命名转换为 snake_case:
|
||||||
|
|
||||||
|
- `ProductID` → `product_id`
|
||||||
|
- `ProductName` → `product_name`
|
||||||
|
- `InStock` → `in_stock`
|
||||||
|
|
||||||
|
## Struct Tag 格式
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
// 完整格式:字段名;索引;注释
|
||||||
|
Email string `srdb:"email;indexed;comment:邮箱地址"`
|
||||||
|
|
||||||
|
// 使用默认字段名(snake_case)+ 注释
|
||||||
|
UserName string `srdb:";comment:用户名"` // 自动转为 user_name
|
||||||
|
|
||||||
|
// 不使用 tag,完全依赖 snake_case 转换
|
||||||
|
PhoneNumber string // 自动转为 phone_number
|
||||||
|
|
||||||
|
// 忽略字段
|
||||||
|
Internal string `srdb:"-"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
批量插入相比逐条插入:
|
||||||
|
|
||||||
|
- ✅ 减少函数调用开销
|
||||||
|
- ✅ 统一类型转换和验证
|
||||||
|
- ✅ 更清晰的代码逻辑
|
||||||
|
- ✅ 适合大批量数据导入
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **类型匹配**: 确保结构体字段类型与 Schema 定义一致
|
||||||
|
2. **Schema 验证**: 所有数据都会经过 Schema 验证
|
||||||
|
3. **nil 处理**: 结构体指针切片中的 nil 会被自动跳过
|
||||||
|
4. **字段名转换**: 未指定 tag 时自动使用 snake_case 转换
|
||||||
|
5. **索引更新**: 带索引的字段会自动更新索引
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [STRUCT_TAG_GUIDE.md](../../STRUCT_TAG_GUIDE.md) - Struct Tag 完整指南
|
||||||
|
- [SNAKE_CASE_CONVERSION.md](../../SNAKE_CASE_CONVERSION.md) - snake_case 转换规则
|
||||||
|
- [examples/struct_schema](../struct_schema) - 结构体 Schema 示例
|
||||||
1
examples/batch_insert/data/example1/CURRENT
Normal file
1
examples/batch_insert/data/example1/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MANIFEST-000001
|
||||||
BIN
examples/batch_insert/data/example1/MANIFEST-000001
Normal file
BIN
examples/batch_insert/data/example1/MANIFEST-000001
Normal file
Binary file not shown.
22
examples/batch_insert/data/example1/schema.json
Normal file
22
examples/batch_insert/data/example1/schema.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": 1760013696,
|
||||||
|
"checksum": "343b1f86cfc4ed9471b71b3a63d61b4205b17cf953f4a2698f2d3ebd37540caa",
|
||||||
|
"schema": {
|
||||||
|
"Name": "users",
|
||||||
|
"Fields": [
|
||||||
|
{
|
||||||
|
"Name": "name",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": "用户名"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "age",
|
||||||
|
"Type": 1,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": "年龄"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/batch_insert/data/example1/wal/CURRENT
Normal file
1
examples/batch_insert/data/example1/wal/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2
|
||||||
1
examples/batch_insert/data/example2/CURRENT
Normal file
1
examples/batch_insert/data/example2/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MANIFEST-000001
|
||||||
BIN
examples/batch_insert/data/example2/MANIFEST-000001
Normal file
BIN
examples/batch_insert/data/example2/MANIFEST-000001
Normal file
Binary file not shown.
28
examples/batch_insert/data/example2/schema.json
Normal file
28
examples/batch_insert/data/example2/schema.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": 1760013696,
|
||||||
|
"checksum": "961d723306aa507e4599ad24afe32e0bc30dcac5f9d1aabd1a18128376767a36",
|
||||||
|
"schema": {
|
||||||
|
"Name": "users",
|
||||||
|
"Fields": [
|
||||||
|
{
|
||||||
|
"Name": "name",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "age",
|
||||||
|
"Type": 1,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "email",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": true,
|
||||||
|
"Comment": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/batch_insert/data/example2/wal/CURRENT
Normal file
1
examples/batch_insert/data/example2/wal/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2
|
||||||
1
examples/batch_insert/data/example3/CURRENT
Normal file
1
examples/batch_insert/data/example3/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MANIFEST-000001
|
||||||
BIN
examples/batch_insert/data/example3/MANIFEST-000001
Normal file
BIN
examples/batch_insert/data/example3/MANIFEST-000001
Normal file
Binary file not shown.
34
examples/batch_insert/data/example3/schema.json
Normal file
34
examples/batch_insert/data/example3/schema.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": 1760013696,
|
||||||
|
"checksum": "dab23ff2118dc2a790681dcc4e112b3377fd96414a30cdfa0ca98af5d41a271e",
|
||||||
|
"schema": {
|
||||||
|
"Name": "users",
|
||||||
|
"Fields": [
|
||||||
|
{
|
||||||
|
"Name": "name",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "age",
|
||||||
|
"Type": 1,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "email",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "is_active",
|
||||||
|
"Type": 4,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/batch_insert/data/example3/wal/CURRENT
Normal file
1
examples/batch_insert/data/example3/wal/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2
|
||||||
1
examples/batch_insert/data/example4/CURRENT
Normal file
1
examples/batch_insert/data/example4/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MANIFEST-000001
|
||||||
BIN
examples/batch_insert/data/example4/MANIFEST-000001
Normal file
BIN
examples/batch_insert/data/example4/MANIFEST-000001
Normal file
Binary file not shown.
34
examples/batch_insert/data/example4/schema.json
Normal file
34
examples/batch_insert/data/example4/schema.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": 1760013696,
|
||||||
|
"checksum": "dab23ff2118dc2a790681dcc4e112b3377fd96414a30cdfa0ca98af5d41a271e",
|
||||||
|
"schema": {
|
||||||
|
"Name": "users",
|
||||||
|
"Fields": [
|
||||||
|
{
|
||||||
|
"Name": "name",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "age",
|
||||||
|
"Type": 1,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "email",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "is_active",
|
||||||
|
"Type": 4,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/batch_insert/data/example4/wal/CURRENT
Normal file
1
examples/batch_insert/data/example4/wal/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2
|
||||||
1
examples/batch_insert/data/example5/CURRENT
Normal file
1
examples/batch_insert/data/example5/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MANIFEST-000001
|
||||||
BIN
examples/batch_insert/data/example5/MANIFEST-000001
Normal file
BIN
examples/batch_insert/data/example5/MANIFEST-000001
Normal file
Binary file not shown.
34
examples/batch_insert/data/example5/schema.json
Normal file
34
examples/batch_insert/data/example5/schema.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": 1760013696,
|
||||||
|
"checksum": "dab23ff2118dc2a790681dcc4e112b3377fd96414a30cdfa0ca98af5d41a271e",
|
||||||
|
"schema": {
|
||||||
|
"Name": "users",
|
||||||
|
"Fields": [
|
||||||
|
{
|
||||||
|
"Name": "name",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "age",
|
||||||
|
"Type": 1,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "email",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "is_active",
|
||||||
|
"Type": 4,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/batch_insert/data/example5/wal/CURRENT
Normal file
1
examples/batch_insert/data/example5/wal/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2
|
||||||
1
examples/batch_insert/data/example6/CURRENT
Normal file
1
examples/batch_insert/data/example6/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MANIFEST-000001
|
||||||
BIN
examples/batch_insert/data/example6/MANIFEST-000001
Normal file
BIN
examples/batch_insert/data/example6/MANIFEST-000001
Normal file
Binary file not shown.
34
examples/batch_insert/data/example6/schema.json
Normal file
34
examples/batch_insert/data/example6/schema.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": 1760013696,
|
||||||
|
"checksum": "36398ae9f0d772f19a24dd8562fde01665890335416e4951808c6b050d251b43",
|
||||||
|
"schema": {
|
||||||
|
"Name": "products",
|
||||||
|
"Fields": [
|
||||||
|
{
|
||||||
|
"Name": "product_id",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": "产品ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "product_name",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": "产品名称"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "price",
|
||||||
|
"Type": 3,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": "价格"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "in_stock",
|
||||||
|
"Type": 4,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": "是否有货"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/batch_insert/data/example6/wal/CURRENT
Normal file
1
examples/batch_insert/data/example6/wal/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
2
|
||||||
303
examples/batch_insert/main.go
Normal file
303
examples/batch_insert/main.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.tczkiot.com/wlw/srdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User 用户结构体
|
||||||
|
type User struct {
|
||||||
|
Name string `srdb:"name;comment:用户名"`
|
||||||
|
Age int64 `srdb:"age;comment:年龄"`
|
||||||
|
Email string `srdb:"email;indexed;comment:邮箱"`
|
||||||
|
IsActive bool `srdb:"is_active;comment:是否激活"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product 产品结构体(使用默认 snake_case 转换)
|
||||||
|
type Product struct {
|
||||||
|
ProductID string `srdb:";comment:产品ID"` // 自动转为 product_id
|
||||||
|
ProductName string `srdb:";comment:产品名称"` // 自动转为 product_name
|
||||||
|
Price float64 `srdb:";comment:价格"` // 自动转为 price
|
||||||
|
InStock bool `srdb:";comment:是否有货"` // 自动转为 in_stock
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("=== SRDB 批量插入示例 ===\n")
|
||||||
|
|
||||||
|
// 清理旧数据
|
||||||
|
os.RemoveAll("./data")
|
||||||
|
|
||||||
|
// 示例 1: 插入单个 map
|
||||||
|
example1()
|
||||||
|
|
||||||
|
// 示例 2: 批量插入 map 切片
|
||||||
|
example2()
|
||||||
|
|
||||||
|
// 示例 3: 插入单个结构体
|
||||||
|
example3()
|
||||||
|
|
||||||
|
// 示例 4: 批量插入结构体切片
|
||||||
|
example4()
|
||||||
|
|
||||||
|
// 示例 5: 批量插入结构体指针切片
|
||||||
|
example5()
|
||||||
|
|
||||||
|
// 示例 6: 使用 snake_case 自动转换
|
||||||
|
example6()
|
||||||
|
|
||||||
|
fmt.Println("\n✓ 所有示例执行成功!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func example1() {
|
||||||
|
fmt.Println("=== 示例 1: 插入单个 map ===")
|
||||||
|
|
||||||
|
schema := srdb.NewSchema("users", []srdb.Field{
|
||||||
|
{Name: "name", Type: srdb.FieldTypeString, Comment: "用户名"},
|
||||||
|
{Name: "age", Type: srdb.FieldTypeInt64, Comment: "年龄"},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := srdb.OpenTable(&srdb.TableOptions{
|
||||||
|
Dir: "./data/example1",
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 插入单条数据
|
||||||
|
err = table.Insert(map[string]any{
|
||||||
|
"name": "Alice",
|
||||||
|
"age": int64(25),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ 插入 1 条数据")
|
||||||
|
|
||||||
|
// 查询
|
||||||
|
row, _ := table.Get(1)
|
||||||
|
fmt.Printf(" 查询结果: name=%s, age=%d\n\n", row.Data["name"], row.Data["age"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func example2() {
|
||||||
|
fmt.Println("=== 示例 2: 批量插入 map 切片 ===")
|
||||||
|
|
||||||
|
schema := srdb.NewSchema("users", []srdb.Field{
|
||||||
|
{Name: "name", Type: srdb.FieldTypeString},
|
||||||
|
{Name: "age", Type: srdb.FieldTypeInt64},
|
||||||
|
{Name: "email", Type: srdb.FieldTypeString, Indexed: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := srdb.OpenTable(&srdb.TableOptions{
|
||||||
|
Dir: "./data/example2",
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 批量插入
|
||||||
|
start := time.Now()
|
||||||
|
err = table.Insert([]map[string]any{
|
||||||
|
{"name": "Alice", "age": int64(25), "email": "alice@example.com"},
|
||||||
|
{"name": "Bob", "age": int64(30), "email": "bob@example.com"},
|
||||||
|
{"name": "Charlie", "age": int64(35), "email": "charlie@example.com"},
|
||||||
|
{"name": "David", "age": int64(40), "email": "david@example.com"},
|
||||||
|
{"name": "Eve", "age": int64(45), "email": "eve@example.com"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
fmt.Printf("✓ 批量插入 5 条数据,耗时: %v\n", elapsed)
|
||||||
|
|
||||||
|
// 使用索引查询
|
||||||
|
rows, _ := table.Query().Eq("email", "bob@example.com").Rows()
|
||||||
|
defer rows.Close()
|
||||||
|
if rows.Next() {
|
||||||
|
row := rows.Row()
|
||||||
|
data := row.Data()
|
||||||
|
fmt.Printf(" 索引查询结果: name=%s, email=%s\n\n", data["name"], data["email"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func example3() {
|
||||||
|
fmt.Println("=== 示例 3: 插入单个结构体 ===")
|
||||||
|
|
||||||
|
schema := srdb.NewSchema("users", []srdb.Field{
|
||||||
|
{Name: "name", Type: srdb.FieldTypeString},
|
||||||
|
{Name: "age", Type: srdb.FieldTypeInt64},
|
||||||
|
{Name: "email", Type: srdb.FieldTypeString},
|
||||||
|
{Name: "is_active", Type: srdb.FieldTypeBool},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := srdb.OpenTable(&srdb.TableOptions{
|
||||||
|
Dir: "./data/example3",
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 插入结构体
|
||||||
|
user := User{
|
||||||
|
Name: "Alice",
|
||||||
|
Age: 25,
|
||||||
|
Email: "alice@example.com",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(user)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ 插入 1 个结构体")
|
||||||
|
|
||||||
|
// 查询
|
||||||
|
row, _ := table.Get(1)
|
||||||
|
fmt.Printf(" 查询结果: name=%s, age=%d, active=%v\n\n",
|
||||||
|
row.Data["name"], row.Data["age"], row.Data["is_active"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func example4() {
|
||||||
|
fmt.Println("=== 示例 4: 批量插入结构体切片 ===")
|
||||||
|
|
||||||
|
schema := srdb.NewSchema("users", []srdb.Field{
|
||||||
|
{Name: "name", Type: srdb.FieldTypeString},
|
||||||
|
{Name: "age", Type: srdb.FieldTypeInt64},
|
||||||
|
{Name: "email", Type: srdb.FieldTypeString},
|
||||||
|
{Name: "is_active", Type: srdb.FieldTypeBool},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := srdb.OpenTable(&srdb.TableOptions{
|
||||||
|
Dir: "./data/example4",
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 批量插入结构体切片
|
||||||
|
users := []User{
|
||||||
|
{Name: "Alice", Age: 25, Email: "alice@example.com", IsActive: true},
|
||||||
|
{Name: "Bob", Age: 30, Email: "bob@example.com", IsActive: true},
|
||||||
|
{Name: "Charlie", Age: 35, Email: "charlie@example.com", IsActive: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err = table.Insert(users)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
fmt.Printf("✓ 批量插入 %d 个结构体,耗时: %v\n", len(users), elapsed)
|
||||||
|
|
||||||
|
// 查询所有激活用户
|
||||||
|
rows, _ := table.Query().Eq("is_active", true).Rows()
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
fmt.Printf(" 查询结果: 找到 %d 个激活用户\n\n", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func example5() {
|
||||||
|
fmt.Println("=== 示例 5: 批量插入结构体指针切片 ===")
|
||||||
|
|
||||||
|
schema := srdb.NewSchema("users", []srdb.Field{
|
||||||
|
{Name: "name", Type: srdb.FieldTypeString},
|
||||||
|
{Name: "age", Type: srdb.FieldTypeInt64},
|
||||||
|
{Name: "email", Type: srdb.FieldTypeString},
|
||||||
|
{Name: "is_active", Type: srdb.FieldTypeBool},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := srdb.OpenTable(&srdb.TableOptions{
|
||||||
|
Dir: "./data/example5",
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 批量插入结构体指针切片
|
||||||
|
users := []*User{
|
||||||
|
{Name: "Alice", Age: 25, Email: "alice@example.com", IsActive: true},
|
||||||
|
{Name: "Bob", Age: 30, Email: "bob@example.com", IsActive: true},
|
||||||
|
nil, // nil 指针会被自动跳过
|
||||||
|
{Name: "Charlie", Age: 35, Email: "charlie@example.com", IsActive: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(users)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ 批量插入 %d 个结构体指针(nil 自动跳过)\n", len(users))
|
||||||
|
|
||||||
|
// 验证插入数量
|
||||||
|
row, _ := table.Get(3)
|
||||||
|
fmt.Printf(" 实际插入: 3 条数据, 最后一条 name=%s\n\n", row.Data["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func example6() {
|
||||||
|
fmt.Println("=== 示例 6: 使用 snake_case 自动转换 ===")
|
||||||
|
|
||||||
|
schema := srdb.NewSchema("products", []srdb.Field{
|
||||||
|
{Name: "product_id", Type: srdb.FieldTypeString, Comment: "产品ID"},
|
||||||
|
{Name: "product_name", Type: srdb.FieldTypeString, Comment: "产品名称"},
|
||||||
|
{Name: "price", Type: srdb.FieldTypeFloat, Comment: "价格"},
|
||||||
|
{Name: "in_stock", Type: srdb.FieldTypeBool, Comment: "是否有货"},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := srdb.OpenTable(&srdb.TableOptions{
|
||||||
|
Dir: "./data/example6",
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 结构体字段名是驼峰命名,会自动转为 snake_case
|
||||||
|
products := []Product{
|
||||||
|
{ProductID: "P001", ProductName: "Laptop", Price: 999.99, InStock: true},
|
||||||
|
{ProductID: "P002", ProductName: "Mouse", Price: 29.99, InStock: true},
|
||||||
|
{ProductID: "P003", ProductName: "Keyboard", Price: 79.99, InStock: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(products)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ 批量插入 %d 个产品(自动 snake_case 转换)\n", len(products))
|
||||||
|
|
||||||
|
// 查询
|
||||||
|
row, _ := table.Get(1)
|
||||||
|
fmt.Printf(" 字段名自动转换:\n")
|
||||||
|
fmt.Printf(" ProductID -> product_id = %s\n", row.Data["product_id"])
|
||||||
|
fmt.Printf(" ProductName -> product_name = %s\n", row.Data["product_name"])
|
||||||
|
fmt.Printf(" Price -> price = %.2f\n", row.Data["price"])
|
||||||
|
fmt.Printf(" InStock -> in_stock = %v\n\n", row.Data["in_stock"])
|
||||||
|
}
|
||||||
98
examples/snake_case_demo/main.go
Normal file
98
examples/snake_case_demo/main.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"code.tczkiot.com/wlw/srdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 演示各种驼峰命名自动转换为 snake_case
|
||||||
|
type DemoStruct struct {
|
||||||
|
// 基本转换
|
||||||
|
UserName string `srdb:";comment:用户名"` // -> user_name
|
||||||
|
EmailAddress string `srdb:";comment:邮箱地址"` // -> email_address
|
||||||
|
PhoneNumber string `srdb:";comment:手机号"` // -> phone_number
|
||||||
|
|
||||||
|
// 连续大写字母
|
||||||
|
HTTPEndpoint string `srdb:";comment:HTTP 端点"` // -> http_endpoint
|
||||||
|
URLPath string `srdb:";comment:URL 路径"` // -> url_path
|
||||||
|
XMLParser string `srdb:";comment:XML 解析器"` // -> xml_parser
|
||||||
|
|
||||||
|
// 短命名
|
||||||
|
ID int64 `srdb:";comment:ID"` // -> id
|
||||||
|
|
||||||
|
// 布尔值
|
||||||
|
IsActive bool `srdb:";comment:是否激活"` // -> is_active
|
||||||
|
IsDeleted bool `srdb:";comment:是否删除"` // -> is_deleted
|
||||||
|
|
||||||
|
// 数字混合
|
||||||
|
Address1 string `srdb:";comment:地址1"` // -> address1
|
||||||
|
User2Name string `srdb:";comment:用户2名称"` // -> user2_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("=== snake_case 自动转换演示 ===")
|
||||||
|
|
||||||
|
// 生成 Field 列表
|
||||||
|
fields, err := srdb.StructToFields(DemoStruct{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印转换结果
|
||||||
|
fmt.Println("\n字段名转换(驼峰命名 -> snake_case):")
|
||||||
|
fmt.Printf("%-20s -> %-20s %s\n", "Go 字段名", "数据库字段名", "注释")
|
||||||
|
fmt.Println(string(make([]byte, 70)) + "\n" + string(make([]byte, 70)))
|
||||||
|
|
||||||
|
type fieldInfo struct {
|
||||||
|
goName string
|
||||||
|
dbName string
|
||||||
|
comment string
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldMapping := []fieldInfo{
|
||||||
|
{"UserName", "user_name", "用户名"},
|
||||||
|
{"EmailAddress", "email_address", "邮箱地址"},
|
||||||
|
{"PhoneNumber", "phone_number", "手机号"},
|
||||||
|
{"HTTPEndpoint", "http_endpoint", "HTTP 端点"},
|
||||||
|
{"URLPath", "url_path", "URL 路径"},
|
||||||
|
{"XMLParser", "xml_parser", "XML 解析器"},
|
||||||
|
{"ID", "id", "ID"},
|
||||||
|
{"IsActive", "is_active", "是否激活"},
|
||||||
|
{"IsDeleted", "is_deleted", "是否删除"},
|
||||||
|
{"Address1", "address1", "地址1"},
|
||||||
|
{"User2Name", "user2_name", "用户2名称"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, field := range fields {
|
||||||
|
if i < len(fieldMapping) {
|
||||||
|
fmt.Printf("%-20s -> %-20s %s\n",
|
||||||
|
fieldMapping[i].goName,
|
||||||
|
field.Name,
|
||||||
|
field.Comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证转换
|
||||||
|
fmt.Println("\n=== 转换验证 ===")
|
||||||
|
allCorrect := true
|
||||||
|
for i, field := range fields {
|
||||||
|
if i < len(fieldMapping) {
|
||||||
|
expected := fieldMapping[i].dbName
|
||||||
|
if field.Name != expected {
|
||||||
|
fmt.Printf("❌ %s: 期望 %s, 实际 %s\n",
|
||||||
|
fieldMapping[i].goName, expected, field.Name)
|
||||||
|
allCorrect = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allCorrect {
|
||||||
|
fmt.Println("✅ 所有字段名转换正确!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Schema
|
||||||
|
schema := srdb.NewSchema("demo", fields)
|
||||||
|
fmt.Printf("\n✅ 成功创建 Schema,包含 %d 个字段\n", len(schema.Fields))
|
||||||
|
}
|
||||||
153
examples/struct_schema/README.md
Normal file
153
examples/struct_schema/README.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# StructToFields 示例
|
||||||
|
|
||||||
|
这个示例展示如何使用 `StructToFields` 方法从 Go 结构体自动生成 Schema。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ 从结构体自动生成 Field 列表
|
||||||
|
- ✅ 支持 struct tags 定义字段属性
|
||||||
|
- ✅ 支持索引标记
|
||||||
|
- ✅ 支持字段注释
|
||||||
|
- ✅ 自动类型映射
|
||||||
|
- ✅ 支持忽略字段
|
||||||
|
|
||||||
|
## Struct Tag 格式
|
||||||
|
|
||||||
|
### srdb tag
|
||||||
|
|
||||||
|
所有配置都在 `srdb` tag 中,使用分号 `;` 分隔:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
// 基本用法:指定字段名
|
||||||
|
Name string `srdb:"name"`
|
||||||
|
|
||||||
|
// 标记为索引字段
|
||||||
|
Email string `srdb:"email;indexed"`
|
||||||
|
|
||||||
|
// 完整格式:字段名;索引;注释
|
||||||
|
Age int64 `srdb:"age;comment:年龄"`
|
||||||
|
|
||||||
|
// 带索引和注释
|
||||||
|
Phone string `srdb:"phone;indexed;comment:手机号"`
|
||||||
|
|
||||||
|
// 忽略该字段
|
||||||
|
Internal string `srdb:"-"`
|
||||||
|
|
||||||
|
// 不使用 tag,默认使用 snake_case 转换
|
||||||
|
Score float64 // 字段名: score
|
||||||
|
UserID string // 字段名: user_id
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag 格式说明
|
||||||
|
|
||||||
|
格式:`srdb:"字段名;选项1;选项2;..."`
|
||||||
|
|
||||||
|
- **字段名**(第一部分):指定数据库中的字段名,省略则自动将结构体字段名转为 snake_case
|
||||||
|
- **indexed**:标记该字段需要建立索引
|
||||||
|
- **comment:注释内容**:字段注释说明
|
||||||
|
|
||||||
|
### 默认字段名转换(snake_case)
|
||||||
|
|
||||||
|
如果不指定字段名,会自动将驼峰命名转换为 snake_case:
|
||||||
|
|
||||||
|
- `UserName` → `user_name`
|
||||||
|
- `EmailAddress` → `email_address`
|
||||||
|
- `IsActive` → `is_active`
|
||||||
|
- `HTTPServer` → `http_server`
|
||||||
|
- `ID` → `id`
|
||||||
|
|
||||||
|
## 类型映射
|
||||||
|
|
||||||
|
| Go 类型 | FieldType |
|
||||||
|
|---------|-----------|
|
||||||
|
| int, int64, int32, int16, int8 | FieldTypeInt64 |
|
||||||
|
| uint, uint64, uint32, uint16, uint8 | FieldTypeInt64 |
|
||||||
|
| string | FieldTypeString |
|
||||||
|
| float64, float32 | FieldTypeFloat |
|
||||||
|
| bool | FieldTypeBool |
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"code.tczkiot.com/wlw/srdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 定义结构体
|
||||||
|
type User struct {
|
||||||
|
Name string `srdb:"name;indexed;comment:用户名"`
|
||||||
|
Age int64 `srdb:"age;comment:年龄"`
|
||||||
|
Email string `srdb:"email;indexed;comment:邮箱"`
|
||||||
|
Score float64 `srdb:"score;comment:分数"`
|
||||||
|
IsActive bool `srdb:"is_active;comment:是否激活"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 1. 从结构体生成 Field 列表
|
||||||
|
fields, err := srdb.StructToFields(User{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 创建 Schema
|
||||||
|
schema := srdb.NewSchema("users", fields)
|
||||||
|
|
||||||
|
// 3. 创建表
|
||||||
|
table, err := srdb.OpenTable(&srdb.TableOptions{
|
||||||
|
Dir: "./data/users",
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 4. 插入数据
|
||||||
|
err = table.Insert(map[string]any{
|
||||||
|
"name": "张三",
|
||||||
|
"age": int64(25),
|
||||||
|
"email": "zhangsan@example.com",
|
||||||
|
"score": 95.5,
|
||||||
|
"is_active": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. 查询数据(自动使用索引)
|
||||||
|
rows, _ := table.Query().Eq("email", "zhangsan@example.com").Rows()
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
data := rows.Row().Data()
|
||||||
|
// 处理数据...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/struct_schema
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## 优势
|
||||||
|
|
||||||
|
1. **类型安全**: 使用结构体定义,编译时检查类型
|
||||||
|
2. **简洁**: 不需要手动创建 Field 列表
|
||||||
|
3. **可维护**: 结构体和 Schema 在一起,便于维护
|
||||||
|
4. **灵活**: 支持 tag 自定义字段属性
|
||||||
|
5. **自动索引**: 通过 `indexed` tag 自动创建索引
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 只有导出的字段(首字母大写)会被包含
|
||||||
|
2. 使用 `srdb:"-"` 可以忽略字段
|
||||||
|
3. 如果不指定字段名,默认使用小写的字段名
|
||||||
|
4. 不支持嵌套结构体(需要手动展开)
|
||||||
|
5. 不支持切片、map 等复杂类型
|
||||||
1
examples/struct_schema/data/users/CURRENT
Normal file
1
examples/struct_schema/data/users/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MANIFEST-000001
|
||||||
BIN
examples/struct_schema/data/users/MANIFEST-000001
Normal file
BIN
examples/struct_schema/data/users/MANIFEST-000001
Normal file
Binary file not shown.
40
examples/struct_schema/data/users/schema.json
Normal file
40
examples/struct_schema/data/users/schema.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"timestamp": 1760013049,
|
||||||
|
"checksum": "fad0aaaa6fc5b94364c0c0b07a7567f71e8bb4f8ed8456c1954cffa7538a6a42",
|
||||||
|
"schema": {
|
||||||
|
"Name": "users",
|
||||||
|
"Fields": [
|
||||||
|
{
|
||||||
|
"Name": "name",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": true,
|
||||||
|
"Comment": "用户名"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "age",
|
||||||
|
"Type": 1,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": "年龄"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "email",
|
||||||
|
"Type": 2,
|
||||||
|
"Indexed": true,
|
||||||
|
"Comment": "邮箱"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "score",
|
||||||
|
"Type": 3,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": "分数"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "is_active",
|
||||||
|
"Type": 4,
|
||||||
|
"Indexed": false,
|
||||||
|
"Comment": "是否激活"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/struct_schema/data/users/wal/CURRENT
Normal file
1
examples/struct_schema/data/users/wal/CURRENT
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3
|
||||||
130
examples/struct_schema/main.go
Normal file
130
examples/struct_schema/main.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"code.tczkiot.com/wlw/srdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User 用户结构体
|
||||||
|
// 使用 struct tags 定义 Schema
|
||||||
|
type User struct {
|
||||||
|
Name string `srdb:"name;indexed;comment:用户名"`
|
||||||
|
Age int64 `srdb:"age;comment:年龄"`
|
||||||
|
Email string `srdb:"email;indexed;comment:邮箱"`
|
||||||
|
Score float64 `srdb:"score;comment:分数"`
|
||||||
|
IsActive bool `srdb:"is_active;comment:是否激活"`
|
||||||
|
Internal string `srdb:"-"` // 不会被包含在 Schema 中
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product 产品结构体
|
||||||
|
// 不使用 srdb tag,字段名会自动转为 snake_case
|
||||||
|
type Product struct {
|
||||||
|
ProductID string // 字段名: product_id
|
||||||
|
ProductName string // 字段名: product_name
|
||||||
|
Price int64 // 字段名: price
|
||||||
|
InStock bool // 字段名: in_stock
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 示例 1: 使用结构体创建 Schema
|
||||||
|
fmt.Println("=== 示例 1: 从结构体创建 Schema ===")
|
||||||
|
|
||||||
|
// 从 User 结构体生成 Field 列表
|
||||||
|
fields, err := srdb.StructToFields(User{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Schema
|
||||||
|
schema := srdb.NewSchema("users", fields)
|
||||||
|
|
||||||
|
// 打印 Schema 信息
|
||||||
|
fmt.Printf("Schema 名称: %s\n", schema.Name)
|
||||||
|
fmt.Printf("字段数量: %d\n", len(schema.Fields))
|
||||||
|
fmt.Println("\n字段列表:")
|
||||||
|
for _, field := range schema.Fields {
|
||||||
|
indexed := ""
|
||||||
|
if field.Indexed {
|
||||||
|
indexed = " [索引]"
|
||||||
|
}
|
||||||
|
fmt.Printf(" - %s (%s)%s: %s\n",
|
||||||
|
field.Name, field.Type.String(), indexed, field.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 示例 2: 使用 Schema 创建表
|
||||||
|
fmt.Println("\n=== 示例 2: 使用 Schema 创建表 ===")
|
||||||
|
|
||||||
|
table, err := srdb.OpenTable(&srdb.TableOptions{
|
||||||
|
Dir: "./data/users",
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 插入数据
|
||||||
|
err = table.Insert(map[string]any{
|
||||||
|
"name": "张三",
|
||||||
|
"age": int64(25),
|
||||||
|
"email": "zhangsan@example.com",
|
||||||
|
"score": 95.5,
|
||||||
|
"is_active": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(map[string]any{
|
||||||
|
"name": "李四",
|
||||||
|
"age": int64(30),
|
||||||
|
"email": "lisi@example.com",
|
||||||
|
"score": 88.0,
|
||||||
|
"is_active": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("✓ 插入 2 条数据")
|
||||||
|
|
||||||
|
// 查询数据
|
||||||
|
rows, err := table.Query().Eq("email", "zhangsan@example.com").Rows()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
fmt.Println("\n查询结果 (email = zhangsan@example.com):")
|
||||||
|
for rows.Next() {
|
||||||
|
data := rows.Row().Data()
|
||||||
|
fmt.Printf(" 姓名: %s, 年龄: %v, 邮箱: %s, 分数: %v, 激活: %v\n",
|
||||||
|
data["name"], data["age"], data["email"], data["score"], data["is_active"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 示例 3: 使用默认字段名(snake_case)
|
||||||
|
fmt.Println("\n=== 示例 3: 使用默认字段名(snake_case)===")
|
||||||
|
|
||||||
|
productFields, err := srdb.StructToFields(Product{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Product 字段(使用默认 snake_case 名称):")
|
||||||
|
for _, field := range productFields {
|
||||||
|
fmt.Printf(" - %s (%s)\n", field.Name, field.Type.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 示例 4: 获取索引字段
|
||||||
|
fmt.Println("\n=== 示例 4: 获取索引字段 ===")
|
||||||
|
indexedFields := schema.GetIndexedFields()
|
||||||
|
fmt.Printf("User Schema 中的索引字段(共 %d 个):\n", len(indexedFields))
|
||||||
|
for _, field := range indexedFields {
|
||||||
|
fmt.Printf(" - %s: %s\n", field.Name, field.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n✓ 所有示例执行成功!")
|
||||||
|
}
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
package srdb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIndexQueryIntegration 测试索引查询的完整流程
|
|
||||||
func TestIndexQueryIntegration(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
// 1. 创建带索引字段的 Schema
|
|
||||||
schema := NewSchema("users", []Field{
|
|
||||||
{Name: "name", Type: FieldTypeString, Indexed: false},
|
|
||||||
{Name: "email", Type: FieldTypeString, Indexed: true}, // email 字段有索引
|
|
||||||
{Name: "age", Type: FieldTypeInt64, Indexed: false},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 2. 打开表
|
|
||||||
table, err := OpenTable(&TableOptions{
|
|
||||||
Dir: tmpDir,
|
|
||||||
Name: schema.Name,
|
|
||||||
Fields: schema.Fields,
|
|
||||||
MemTableSize: 1024 * 1024, // 1MB
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer table.Close()
|
|
||||||
|
|
||||||
// 3. 创建索引
|
|
||||||
err = table.CreateIndex("email")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 插入测试数据
|
|
||||||
testData := []map[string]any{
|
|
||||||
{"name": "Alice", "email": "alice@example.com", "age": int64(25)},
|
|
||||||
{"name": "Bob", "email": "bob@example.com", "age": int64(30)},
|
|
||||||
{"name": "Charlie", "email": "alice@example.com", "age": int64(35)}, // 相同 email
|
|
||||||
{"name": "David", "email": "david@example.com", "age": int64(40)},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, data := range testData {
|
|
||||||
err := table.Insert(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to insert data: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 构建索引(持久化)
|
|
||||||
err = table.indexManager.BuildAll()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to build indexes: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 验证索引文件存在
|
|
||||||
indexPath := tmpDir + "/idx/idx_email.sst"
|
|
||||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
|
||||||
t.Fatalf("Index file not created: %s", indexPath)
|
|
||||||
}
|
|
||||||
t.Logf("✓ Index file created: %s", indexPath)
|
|
||||||
|
|
||||||
// 7. 使用索引查询
|
|
||||||
rows, err := table.Query().Eq("email", "alice@example.com").Rows()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Query failed: %v", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
// 8. 验证结果
|
|
||||||
var results []map[string]any
|
|
||||||
for rows.Next() {
|
|
||||||
results = append(results, rows.Row().Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) != 2 {
|
|
||||||
t.Errorf("Expected 2 results, got %d", len(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证结果内容
|
|
||||||
for _, result := range results {
|
|
||||||
if result["email"] != "alice@example.com" {
|
|
||||||
t.Errorf("Unexpected email: %v", result["email"])
|
|
||||||
}
|
|
||||||
name := result["name"].(string)
|
|
||||||
if name != "Alice" && name != "Charlie" {
|
|
||||||
t.Errorf("Unexpected name: %s", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("✓ Index query returned correct results: %d rows", len(results))
|
|
||||||
|
|
||||||
// 9. 测试没有索引的查询(应该正常工作但不使用索引)
|
|
||||||
rows2, err := table.Query().Eq("name", "Bob").Rows()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Query without index failed: %v", err)
|
|
||||||
}
|
|
||||||
defer rows2.Close()
|
|
||||||
|
|
||||||
results2 := []map[string]any{}
|
|
||||||
for rows2.Next() {
|
|
||||||
results2 = append(results2, rows2.Row().Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results2) != 1 {
|
|
||||||
t.Errorf("Expected 1 result for Bob, got %d", len(results2))
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("✓ Non-indexed query works correctly: %d rows", len(results2))
|
|
||||||
|
|
||||||
// 10. 测试索引在新数据上的工作
|
|
||||||
err = table.Insert(map[string]any{
|
|
||||||
"name": "Eve",
|
|
||||||
"email": "eve@example.com",
|
|
||||||
"age": int64(28),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to insert new data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询新插入的数据(索引尚未持久化,但应该在内存中)
|
|
||||||
rows3, err := table.Query().Eq("email", "eve@example.com").Rows()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Query for new data failed: %v", err)
|
|
||||||
}
|
|
||||||
defer rows3.Close()
|
|
||||||
|
|
||||||
results3 := []map[string]any{}
|
|
||||||
for rows3.Next() {
|
|
||||||
results3 = append(results3, rows3.Row().Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results3) != 1 {
|
|
||||||
t.Errorf("Expected 1 result for Eve (new data), got %d", len(results3))
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("✓ Index works for new data (before persistence): %d rows", len(results3))
|
|
||||||
|
|
||||||
// 11. 再次构建索引并验证
|
|
||||||
err = table.indexManager.BuildAll()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to rebuild indexes: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows4, err := table.Query().Eq("email", "eve@example.com").Rows()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Query after rebuild failed: %v", err)
|
|
||||||
}
|
|
||||||
defer rows4.Close()
|
|
||||||
|
|
||||||
results4 := []map[string]any{}
|
|
||||||
for rows4.Next() {
|
|
||||||
results4 = append(results4, rows4.Row().Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results4) != 1 {
|
|
||||||
t.Errorf("Expected 1 result for Eve (after rebuild), got %d", len(results4))
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("✓ Index works after rebuild: %d rows", len(results4))
|
|
||||||
|
|
||||||
t.Log("=== All index query tests passed ===")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIndexPersistenceAcrossRestart 测试索引在重启后的持久化
|
|
||||||
func TestIndexPersistenceAcrossRestart(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
// 1. 第一次打开:创建数据和索引
|
|
||||||
{
|
|
||||||
schema := NewSchema("products", []Field{
|
|
||||||
{Name: "name", Type: FieldTypeString, Indexed: false},
|
|
||||||
{Name: "category", Type: FieldTypeString, Indexed: true},
|
|
||||||
{Name: "price", Type: FieldTypeInt64, Indexed: false},
|
|
||||||
})
|
|
||||||
|
|
||||||
table, err := OpenTable(&TableOptions{
|
|
||||||
Dir: tmpDir,
|
|
||||||
Name: schema.Name,
|
|
||||||
Fields: schema.Fields,
|
|
||||||
MemTableSize: 1024 * 1024,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建索引
|
|
||||||
err = table.CreateIndex("category")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 插入数据
|
|
||||||
testData := []map[string]any{
|
|
||||||
{"name": "Laptop", "category": "Electronics", "price": int64(1000)},
|
|
||||||
{"name": "Mouse", "category": "Electronics", "price": int64(50)},
|
|
||||||
{"name": "Desk", "category": "Furniture", "price": int64(300)},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, data := range testData {
|
|
||||||
err := table.Insert(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建索引
|
|
||||||
err = table.indexManager.BuildAll()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭表
|
|
||||||
table.Close()
|
|
||||||
|
|
||||||
t.Log("✓ First session: data and index created")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 第二次打开:验证索引仍然可用
|
|
||||||
{
|
|
||||||
table, err := OpenTable(&TableOptions{
|
|
||||||
Dir: tmpDir,
|
|
||||||
MemTableSize: 1024 * 1024,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer table.Close()
|
|
||||||
|
|
||||||
// 验证索引存在
|
|
||||||
indexes := table.ListIndexes()
|
|
||||||
if len(indexes) != 1 || indexes[0] != "category" {
|
|
||||||
t.Errorf("Expected index on 'category', got: %v", indexes)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("✓ Index loaded after restart")
|
|
||||||
|
|
||||||
// 使用索引查询
|
|
||||||
rows, err := table.Query().Eq("category", "Electronics").Rows()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Query failed: %v", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
results := []map[string]any{}
|
|
||||||
for rows.Next() {
|
|
||||||
results = append(results, rows.Row().Data())
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) != 2 {
|
|
||||||
t.Errorf("Expected 2 Electronics products, got %d", len(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("✓ Index query after restart: %d rows", len(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("=== Index persistence test passed ===")
|
|
||||||
}
|
|
||||||
254
index_test.go
254
index_test.go
@@ -284,3 +284,257 @@ func TestIndexDropWithFile(t *testing.T) {
|
|||||||
|
|
||||||
t.Log("索引删除测试通过!")
|
t.Log("索引删除测试通过!")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIndexQueryIntegration 测试索引查询的完整流程
|
||||||
|
func TestIndexQueryIntegration(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 1. 创建带索引字段的 Schema
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "email", Type: FieldTypeString, Indexed: true}, // email 字段有索引
|
||||||
|
{Name: "age", Type: FieldTypeInt64, Indexed: false},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 打开表
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
MemTableSize: 1024 * 1024, // 1MB
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 3. 创建索引
|
||||||
|
err = table.CreateIndex("email")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 插入测试数据
|
||||||
|
testData := []map[string]any{
|
||||||
|
{"name": "Alice", "email": "alice@example.com", "age": int64(25)},
|
||||||
|
{"name": "Bob", "email": "bob@example.com", "age": int64(30)},
|
||||||
|
{"name": "Charlie", "email": "alice@example.com", "age": int64(35)}, // 相同 email
|
||||||
|
{"name": "David", "email": "david@example.com", "age": int64(40)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, data := range testData {
|
||||||
|
err := table.Insert(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to insert data: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 构建索引(持久化)
|
||||||
|
err = table.indexManager.BuildAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to build indexes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 验证索引文件存在
|
||||||
|
indexPath := tmpDir + "/idx/idx_email.sst"
|
||||||
|
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||||
|
t.Fatalf("Index file not created: %s", indexPath)
|
||||||
|
}
|
||||||
|
t.Logf("✓ Index file created: %s", indexPath)
|
||||||
|
|
||||||
|
// 7. 使用索引查询
|
||||||
|
rows, err := table.Query().Eq("email", "alice@example.com").Rows()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query failed: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// 8. 验证结果
|
||||||
|
var results []map[string]any
|
||||||
|
for rows.Next() {
|
||||||
|
results = append(results, rows.Row().Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Errorf("Expected 2 results, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证结果内容
|
||||||
|
for _, result := range results {
|
||||||
|
if result["email"] != "alice@example.com" {
|
||||||
|
t.Errorf("Unexpected email: %v", result["email"])
|
||||||
|
}
|
||||||
|
name := result["name"].(string)
|
||||||
|
if name != "Alice" && name != "Charlie" {
|
||||||
|
t.Errorf("Unexpected name: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Index query returned correct results: %d rows", len(results))
|
||||||
|
|
||||||
|
// 9. 测试没有索引的查询(应该正常工作但不使用索引)
|
||||||
|
rows2, err := table.Query().Eq("name", "Bob").Rows()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query without index failed: %v", err)
|
||||||
|
}
|
||||||
|
defer rows2.Close()
|
||||||
|
|
||||||
|
results2 := []map[string]any{}
|
||||||
|
for rows2.Next() {
|
||||||
|
results2 = append(results2, rows2.Row().Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results2) != 1 {
|
||||||
|
t.Errorf("Expected 1 result for Bob, got %d", len(results2))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Non-indexed query works correctly: %d rows", len(results2))
|
||||||
|
|
||||||
|
// 10. 测试索引在新数据上的工作
|
||||||
|
err = table.Insert(map[string]any{
|
||||||
|
"name": "Eve",
|
||||||
|
"email": "eve@example.com",
|
||||||
|
"age": int64(28),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to insert new data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询新插入的数据(索引尚未持久化,但应该在内存中)
|
||||||
|
rows3, err := table.Query().Eq("email", "eve@example.com").Rows()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query for new data failed: %v", err)
|
||||||
|
}
|
||||||
|
defer rows3.Close()
|
||||||
|
|
||||||
|
results3 := []map[string]any{}
|
||||||
|
for rows3.Next() {
|
||||||
|
results3 = append(results3, rows3.Row().Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results3) != 1 {
|
||||||
|
t.Errorf("Expected 1 result for Eve (new data), got %d", len(results3))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Index works for new data (before persistence): %d rows", len(results3))
|
||||||
|
|
||||||
|
// 11. 再次构建索引并验证
|
||||||
|
err = table.indexManager.BuildAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to rebuild indexes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows4, err := table.Query().Eq("email", "eve@example.com").Rows()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query after rebuild failed: %v", err)
|
||||||
|
}
|
||||||
|
defer rows4.Close()
|
||||||
|
|
||||||
|
results4 := []map[string]any{}
|
||||||
|
for rows4.Next() {
|
||||||
|
results4 = append(results4, rows4.Row().Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results4) != 1 {
|
||||||
|
t.Errorf("Expected 1 result for Eve (after rebuild), got %d", len(results4))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Index works after rebuild: %d rows", len(results4))
|
||||||
|
|
||||||
|
t.Log("=== All index query tests passed ===")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIndexPersistenceAcrossRestart 测试索引在重启后的持久化
|
||||||
|
func TestIndexPersistenceAcrossRestart(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// 1. 第一次打开:创建数据和索引
|
||||||
|
{
|
||||||
|
schema := NewSchema("products", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString, Indexed: false},
|
||||||
|
{Name: "category", Type: FieldTypeString, Indexed: true},
|
||||||
|
{Name: "price", Type: FieldTypeInt64, Indexed: false},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
MemTableSize: 1024 * 1024,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建索引
|
||||||
|
err = table.CreateIndex("category")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入数据
|
||||||
|
testData := []map[string]any{
|
||||||
|
{"name": "Laptop", "category": "Electronics", "price": int64(1000)},
|
||||||
|
{"name": "Mouse", "category": "Electronics", "price": int64(50)},
|
||||||
|
{"name": "Desk", "category": "Furniture", "price": int64(300)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, data := range testData {
|
||||||
|
err := table.Insert(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建索引
|
||||||
|
err = table.indexManager.BuildAll()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭表
|
||||||
|
table.Close()
|
||||||
|
|
||||||
|
t.Log("✓ First session: data and index created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 第二次打开:验证索引仍然可用
|
||||||
|
{
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
MemTableSize: 1024 * 1024,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 验证索引存在
|
||||||
|
indexes := table.ListIndexes()
|
||||||
|
if len(indexes) != 1 || indexes[0] != "category" {
|
||||||
|
t.Errorf("Expected index on 'category', got: %v", indexes)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Index loaded after restart")
|
||||||
|
|
||||||
|
// 使用索引查询
|
||||||
|
rows, err := table.Query().Eq("category", "Electronics").Rows()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query failed: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
results := []map[string]any{}
|
||||||
|
for rows.Next() {
|
||||||
|
results = append(results, rows.Row().Data())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Errorf("Expected 2 Electronics products, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Index query after restart: %d rows", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("=== Index persistence test passed ===")
|
||||||
|
}
|
||||||
|
|||||||
195
schema.go
195
schema.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -48,7 +49,7 @@ type Schema struct {
|
|||||||
Fields []Field // 字段列表
|
Fields []Field // 字段列表
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建 Schema
|
// NewSchema 创建 Schema
|
||||||
func NewSchema(name string, fields []Field) *Schema {
|
func NewSchema(name string, fields []Field) *Schema {
|
||||||
return &Schema{
|
return &Schema{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -56,6 +57,198 @@ func NewSchema(name string, fields []Field) *Schema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StructToFields 从 Go 结构体生成 Field 列表
|
||||||
|
//
|
||||||
|
// 支持的 struct tag 格式:
|
||||||
|
// - `srdb:"name"` - 指定字段名(默认使用 snake_case 转换)
|
||||||
|
// - `srdb:"name;indexed"` - 指定字段名并标记为索引
|
||||||
|
// - `srdb:"name;indexed;comment:用户名"` - 完整格式(字段名;索引标记;注释)
|
||||||
|
// - `srdb:"-"` - 忽略该字段
|
||||||
|
//
|
||||||
|
// Tag 格式说明:
|
||||||
|
// - 使用分号 `;` 分隔不同的部分
|
||||||
|
// - 第一部分是字段名(可选,默认使用 snake_case 转换结构体字段名)
|
||||||
|
// - `indexed` 标记该字段需要索引
|
||||||
|
// - `comment:注释内容` 指定字段注释
|
||||||
|
//
|
||||||
|
// 默认字段名转换示例:
|
||||||
|
// - UserName -> user_name
|
||||||
|
// - EmailAddress -> email_address
|
||||||
|
// - IsActive -> is_active
|
||||||
|
//
|
||||||
|
// 类型映射:
|
||||||
|
// - int, int64, int32, int16, int8, uint, uint64, uint32, uint16, uint8 -> FieldTypeInt64
|
||||||
|
// - string -> FieldTypeString
|
||||||
|
// - float64, float32 -> FieldTypeFloat
|
||||||
|
// - bool -> FieldTypeBool
|
||||||
|
//
|
||||||
|
// 示例:
|
||||||
|
// type User struct {
|
||||||
|
// Name string `srdb:"name;indexed;comment:用户名"`
|
||||||
|
// Age int64 `srdb:"age;comment:年龄"`
|
||||||
|
// Email string `srdb:"email;indexed;comment:邮箱"`
|
||||||
|
// }
|
||||||
|
// fields, err := StructToFields(User{})
|
||||||
|
//
|
||||||
|
// 参数:
|
||||||
|
// - v: 结构体实例或指针
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - []Field: 字段列表
|
||||||
|
// - error: 错误信息
|
||||||
|
func StructToFields(v any) ([]Field, error) {
|
||||||
|
// 获取类型
|
||||||
|
typ := reflect.TypeOf(v)
|
||||||
|
if typ == nil {
|
||||||
|
return nil, fmt.Errorf("invalid type: nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是指针,获取其指向的类型
|
||||||
|
if typ.Kind() == reflect.Ptr {
|
||||||
|
typ = typ.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必须是结构体
|
||||||
|
if typ.Kind() != reflect.Struct {
|
||||||
|
return nil, fmt.Errorf("expected struct, got %s", typ.Kind())
|
||||||
|
}
|
||||||
|
|
||||||
|
var fields []Field
|
||||||
|
|
||||||
|
// 遍历结构体字段
|
||||||
|
for i := 0; i < typ.NumField(); i++ {
|
||||||
|
field := typ.Field(i)
|
||||||
|
|
||||||
|
// 跳过未导出的字段
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 srdb tag
|
||||||
|
tag := field.Tag.Get("srdb")
|
||||||
|
if tag == "-" {
|
||||||
|
// 忽略该字段
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析字段名、索引标记和注释
|
||||||
|
fieldName := camelToSnake(field.Name) // 默认使用 snake_case 字段名
|
||||||
|
indexed := false
|
||||||
|
comment := ""
|
||||||
|
|
||||||
|
if tag != "" {
|
||||||
|
// 使用分号分隔各部分
|
||||||
|
parts := strings.Split(tag, ";")
|
||||||
|
|
||||||
|
for idx, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
|
||||||
|
if idx == 0 && part != "" {
|
||||||
|
// 第一部分是字段名
|
||||||
|
fieldName = part
|
||||||
|
} else if part == "indexed" {
|
||||||
|
// indexed 标记
|
||||||
|
indexed = true
|
||||||
|
} else if strings.HasPrefix(part, "comment:") {
|
||||||
|
// comment:注释内容
|
||||||
|
comment = strings.TrimPrefix(part, "comment:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 映射 Go 类型到 FieldType
|
||||||
|
fieldType, err := goTypeToFieldType(field.Type)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("field %s: %w", field.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = append(fields, Field{
|
||||||
|
Name: fieldName,
|
||||||
|
Type: fieldType,
|
||||||
|
Indexed: indexed,
|
||||||
|
Comment: comment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return nil, fmt.Errorf("no exported fields found in struct")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// goTypeToFieldType 将 Go 类型映射到 FieldType
|
||||||
|
func goTypeToFieldType(typ reflect.Type) (FieldType, error) {
|
||||||
|
switch typ.Kind() {
|
||||||
|
case reflect.Int, reflect.Int64, reflect.Int32, reflect.Int16, reflect.Int8,
|
||||||
|
reflect.Uint, reflect.Uint64, reflect.Uint32, reflect.Uint16, reflect.Uint8:
|
||||||
|
return FieldTypeInt64, nil
|
||||||
|
case reflect.String:
|
||||||
|
return FieldTypeString, nil
|
||||||
|
case reflect.Float64, reflect.Float32:
|
||||||
|
return FieldTypeFloat, nil
|
||||||
|
case reflect.Bool:
|
||||||
|
return FieldTypeBool, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unsupported type: %s", typ.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// camelToSnake 将驼峰命名转换为 snake_case
|
||||||
|
//
|
||||||
|
// 示例:
|
||||||
|
// - UserName -> user_name
|
||||||
|
// - EmailAddress -> email_address
|
||||||
|
// - IsActive -> is_active
|
||||||
|
// - HTTPServer -> http_server
|
||||||
|
// - ID -> id
|
||||||
|
func camelToSnake(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
result.Grow(len(s) + 5) // 预分配空间
|
||||||
|
|
||||||
|
for i, r := range s {
|
||||||
|
// 如果是大写字母
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
// 不是第一个字符,并且前一个字符不是大写,需要添加下划线
|
||||||
|
if i > 0 {
|
||||||
|
// 检查是否需要添加下划线
|
||||||
|
// 规则:
|
||||||
|
// 1. 前一个字符是小写字母 -> 添加下划线
|
||||||
|
// 2. 当前是大写,后一个是小写(处理 HTTPServer -> http_server)-> 添加下划线
|
||||||
|
prevChar := rune(s[i-1])
|
||||||
|
needUnderscore := false
|
||||||
|
|
||||||
|
if prevChar >= 'a' && prevChar <= 'z' {
|
||||||
|
// 前一个是小写字母
|
||||||
|
needUnderscore = true
|
||||||
|
} else if prevChar >= 'A' && prevChar <= 'Z' {
|
||||||
|
// 前一个是大写字母,检查后一个
|
||||||
|
if i+1 < len(s) {
|
||||||
|
nextChar := rune(s[i+1])
|
||||||
|
if nextChar >= 'a' && nextChar <= 'z' {
|
||||||
|
// 后一个是小写字母,说明是新单词开始
|
||||||
|
needUnderscore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 前一个是数字或其他字符
|
||||||
|
needUnderscore = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if needUnderscore {
|
||||||
|
result.WriteRune('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 转换为小写
|
||||||
|
result.WriteRune(r + 32) // 'A' -> 'a' 的 ASCII 差值是 32
|
||||||
|
} else {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
// GetField 获取字段定义
|
// GetField 获取字段定义
|
||||||
func (s *Schema) GetField(name string) (*Field, error) {
|
func (s *Schema) GetField(name string) (*Field, error) {
|
||||||
for i := range s.Fields {
|
for i := range s.Fields {
|
||||||
|
|||||||
455
schema_test.go
455
schema_test.go
@@ -265,3 +265,458 @@ func TestChecksumMultipleFieldOrders(t *testing.T) {
|
|||||||
t.Logf("✅ All %d field permutations produce the same checksum", len(schemas))
|
t.Logf("✅ All %d field permutations produce the same checksum", len(schemas))
|
||||||
t.Logf(" checksum: %s", expectedChecksum)
|
t.Logf(" checksum: %s", expectedChecksum)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStructToFields 测试从结构体生成 Field 列表
|
||||||
|
func TestStructToFields(t *testing.T) {
|
||||||
|
// 定义测试结构体
|
||||||
|
type User struct {
|
||||||
|
Name string `srdb:"name;indexed;comment:用户名"`
|
||||||
|
Age int64 `srdb:"age;comment:年龄"`
|
||||||
|
Email string `srdb:"email;indexed;comment:邮箱"`
|
||||||
|
Score float64 `srdb:"score;comment:分数"`
|
||||||
|
Active bool `srdb:"active;comment:是否激活"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 Field 列表
|
||||||
|
fields, err := StructToFields(User{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructToFields failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证字段数量
|
||||||
|
if len(fields) != 5 {
|
||||||
|
t.Errorf("Expected 5 fields, got %d", len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证每个字段
|
||||||
|
expectedFields := map[string]struct {
|
||||||
|
Type FieldType
|
||||||
|
Indexed bool
|
||||||
|
Comment string
|
||||||
|
}{
|
||||||
|
"name": {FieldTypeString, true, "用户名"},
|
||||||
|
"age": {FieldTypeInt64, false, "年龄"},
|
||||||
|
"email": {FieldTypeString, true, "邮箱"},
|
||||||
|
"score": {FieldTypeFloat, false, "分数"},
|
||||||
|
"active": {FieldTypeBool, false, "是否激活"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
expected, exists := expectedFields[field.Name]
|
||||||
|
if !exists {
|
||||||
|
t.Errorf("Unexpected field: %s", field.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Type != expected.Type {
|
||||||
|
t.Errorf("Field %s: expected type %v, got %v", field.Name, expected.Type, field.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Indexed != expected.Indexed {
|
||||||
|
t.Errorf("Field %s: expected indexed=%v, got %v", field.Name, expected.Indexed, field.Indexed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Comment != expected.Comment {
|
||||||
|
t.Errorf("Field %s: expected comment=%s, got %s", field.Name, expected.Comment, field.Comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ StructToFields basic test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStructToFieldsDefaultName 测试默认字段名 (snake_case)
|
||||||
|
func TestStructToFieldsDefaultName(t *testing.T) {
|
||||||
|
type Product struct {
|
||||||
|
ProductName string // 没有 tag,应该使用 snake_case: product_name
|
||||||
|
Price int64 // 没有 tag,应该使用 snake_case: price
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := StructToFields(Product{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructToFields failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fields) != 2 {
|
||||||
|
t.Errorf("Expected 2 fields, got %d", len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证默认字段名(snake_case)
|
||||||
|
if fields[0].Name != "product_name" {
|
||||||
|
t.Errorf("Expected field name 'product_name', got '%s'", fields[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields[1].Name != "price" {
|
||||||
|
t.Errorf("Expected field name 'price', got '%s'", fields[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Default field name (snake_case) test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStructToFieldsIgnore 测试忽略字段
|
||||||
|
func TestStructToFieldsIgnore(t *testing.T) {
|
||||||
|
type Order struct {
|
||||||
|
OrderID string `srdb:"order_id;comment:订单ID"`
|
||||||
|
Internal string `srdb:"-"` // 应该被忽略
|
||||||
|
CreatedAt int64 `srdb:"created_at;comment:创建时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := StructToFields(Order{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructToFields failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应该只有 2 个字段(Internal 被忽略)
|
||||||
|
if len(fields) != 2 {
|
||||||
|
t.Errorf("Expected 2 fields (excluding ignored field), got %d", len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证没有 Internal 字段
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.Name == "internal" || field.Name == "Internal" {
|
||||||
|
t.Errorf("Field 'Internal' should have been ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Ignore field test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStructToFieldsPointer 测试指针类型
|
||||||
|
func TestStructToFieldsPointer(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
Name string `srdb:"name;comment:名称"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用指针
|
||||||
|
fields, err := StructToFields(&Item{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructToFields with pointer failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fields) != 1 {
|
||||||
|
t.Errorf("Expected 1 field, got %d", len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields[0].Name != "name" {
|
||||||
|
t.Errorf("Expected field name 'name', got '%s'", fields[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Pointer type test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStructToFieldsAllTypes 测试所有支持的类型
|
||||||
|
func TestStructToFieldsAllTypes(t *testing.T) {
|
||||||
|
type AllTypes struct {
|
||||||
|
Int int `srdb:"int"`
|
||||||
|
Int64 int64 `srdb:"int64"`
|
||||||
|
Int32 int32 `srdb:"int32"`
|
||||||
|
Int16 int16 `srdb:"int16"`
|
||||||
|
Int8 int8 `srdb:"int8"`
|
||||||
|
Uint uint `srdb:"uint"`
|
||||||
|
Uint64 uint64 `srdb:"uint64"`
|
||||||
|
Uint32 uint32 `srdb:"uint32"`
|
||||||
|
Uint16 uint16 `srdb:"uint16"`
|
||||||
|
Uint8 uint8 `srdb:"uint8"`
|
||||||
|
String string `srdb:"string"`
|
||||||
|
Float64 float64 `srdb:"float64"`
|
||||||
|
Float32 float32 `srdb:"float32"`
|
||||||
|
Bool bool `srdb:"bool"`
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := StructToFields(AllTypes{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructToFields failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fields) != 14 {
|
||||||
|
t.Errorf("Expected 14 fields, got %d", len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有整数类型都映射到 FieldTypeInt64
|
||||||
|
intFields := []string{"int", "int64", "int32", "int16", "int8", "uint", "uint64", "uint32", "uint16", "uint8"}
|
||||||
|
for _, name := range intFields {
|
||||||
|
found := false
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.Name == name {
|
||||||
|
found = true
|
||||||
|
if field.Type != FieldTypeInt64 {
|
||||||
|
t.Errorf("Field %s: expected FieldTypeInt64, got %v", name, field.Type)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Field %s not found", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证浮点类型
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.Name == "float64" || field.Name == "float32" {
|
||||||
|
if field.Type != FieldTypeFloat {
|
||||||
|
t.Errorf("Field %s: expected FieldTypeFloat, got %v", field.Name, field.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ All types test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStructToFieldsWithSchema 测试完整的使用流程
|
||||||
|
func TestStructToFieldsWithSchema(t *testing.T) {
|
||||||
|
// 定义结构体
|
||||||
|
type Customer struct {
|
||||||
|
CustomerID string `srdb:"customer_id;indexed;comment:客户ID"`
|
||||||
|
Name string `srdb:"name;comment:客户名称"`
|
||||||
|
Email string `srdb:"email;indexed;comment:邮箱"`
|
||||||
|
Balance int64 `srdb:"balance;comment:余额"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 Field 列表
|
||||||
|
fields, err := StructToFields(Customer{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructToFields failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Schema
|
||||||
|
schema := NewSchema("customers", fields)
|
||||||
|
|
||||||
|
// 验证 Schema
|
||||||
|
if schema.Name != "customers" {
|
||||||
|
t.Errorf("Expected schema name 'customers', got '%s'", schema.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(schema.Fields) != 4 {
|
||||||
|
t.Errorf("Expected 4 fields in schema, got %d", len(schema.Fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证索引字段
|
||||||
|
indexedFields := schema.GetIndexedFields()
|
||||||
|
if len(indexedFields) != 2 {
|
||||||
|
t.Errorf("Expected 2 indexed fields, got %d", len(indexedFields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试数据验证
|
||||||
|
validData := map[string]any{
|
||||||
|
"customer_id": "C001",
|
||||||
|
"name": "张三",
|
||||||
|
"email": "zhangsan@example.com",
|
||||||
|
"balance": int64(1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = schema.Validate(validData)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Valid data should pass validation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试无效数据
|
||||||
|
invalidData := map[string]any{
|
||||||
|
"customer_id": "C002",
|
||||||
|
"name": "李四",
|
||||||
|
"email": 123, // 错误类型
|
||||||
|
"balance": int64(2000),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = schema.Validate(invalidData)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Invalid data should fail validation")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Complete workflow test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStructToFieldsTagVariations 测试各种 tag 组合
|
||||||
|
func TestStructToFieldsTagVariations(t *testing.T) {
|
||||||
|
type TestStruct struct {
|
||||||
|
// 只有字段名
|
||||||
|
Field1 string `srdb:"field1"`
|
||||||
|
// 字段名 + indexed
|
||||||
|
Field2 string `srdb:"field2;indexed"`
|
||||||
|
// 字段名 + comment
|
||||||
|
Field3 string `srdb:"field3;comment:字段3"`
|
||||||
|
// 完整格式
|
||||||
|
Field4 string `srdb:"field4;indexed;comment:字段4"`
|
||||||
|
// 只有 indexed(使用默认字段名)
|
||||||
|
Field5 string `srdb:";indexed"`
|
||||||
|
// 只有 comment(使用默认字段名)
|
||||||
|
Field6 string `srdb:";comment:字段6"`
|
||||||
|
// 空 tag(使用默认字段名)
|
||||||
|
Field7 string
|
||||||
|
// indexed + comment(使用默认字段名)
|
||||||
|
Field8 string `srdb:";indexed;comment:字段8"`
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := StructToFields(TestStruct{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructToFields failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fields) != 8 {
|
||||||
|
t.Errorf("Expected 8 fields, got %d", len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证各个字段
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
indexed bool
|
||||||
|
comment string
|
||||||
|
}{
|
||||||
|
{"field1", false, ""},
|
||||||
|
{"field2", true, ""},
|
||||||
|
{"field3", false, "字段3"},
|
||||||
|
{"field4", true, "字段4"},
|
||||||
|
{"field5", true, ""},
|
||||||
|
{"field6", false, "字段6"},
|
||||||
|
{"field7", false, ""},
|
||||||
|
{"field8", true, "字段8"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, test := range tests {
|
||||||
|
if fields[i].Name != test.name {
|
||||||
|
t.Errorf("Field %d: expected name %s, got %s", i+1, test.name, fields[i].Name)
|
||||||
|
}
|
||||||
|
if fields[i].Indexed != test.indexed {
|
||||||
|
t.Errorf("Field %s: expected indexed=%v, got %v", test.name, test.indexed, fields[i].Indexed)
|
||||||
|
}
|
||||||
|
if fields[i].Comment != test.comment {
|
||||||
|
t.Errorf("Field %s: expected comment=%s, got %s", test.name, test.comment, fields[i].Comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Tag variations test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStructToFieldsErrors 测试错误情况
|
||||||
|
func TestStructToFieldsErrors(t *testing.T) {
|
||||||
|
// 测试非结构体类型
|
||||||
|
_, err := StructToFields("not a struct")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for non-struct type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试 nil
|
||||||
|
_, err = StructToFields(nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试没有导出字段的结构体
|
||||||
|
type Empty struct {
|
||||||
|
private string // 未导出
|
||||||
|
}
|
||||||
|
_, err = StructToFields(Empty{})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for struct with no exported fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Error handling test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCamelToSnake 测试驼峰命名转 snake_case
|
||||||
|
func TestCamelToSnake(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
// 基本测试
|
||||||
|
{"UserName", "user_name"},
|
||||||
|
{"EmailAddress", "email_address"},
|
||||||
|
{"IsActive", "is_active"},
|
||||||
|
|
||||||
|
// 单个单词
|
||||||
|
{"Name", "name"},
|
||||||
|
{"ID", "id"},
|
||||||
|
|
||||||
|
// 连续大写字母
|
||||||
|
{"HTTPServer", "http_server"},
|
||||||
|
{"XMLParser", "xml_parser"},
|
||||||
|
{"HTMLContent", "html_content"},
|
||||||
|
{"URLPath", "url_path"},
|
||||||
|
|
||||||
|
// 带数字
|
||||||
|
{"User2Name", "user2_name"},
|
||||||
|
{"Address1", "address1"},
|
||||||
|
|
||||||
|
// 全小写
|
||||||
|
{"username", "username"},
|
||||||
|
|
||||||
|
// 全大写
|
||||||
|
{"HTTP", "http"},
|
||||||
|
{"API", "api"},
|
||||||
|
|
||||||
|
// 混合情况
|
||||||
|
{"getUserByID", "get_user_by_id"},
|
||||||
|
{"HTTPSConnection", "https_connection"},
|
||||||
|
{"createHTMLFile", "create_html_file"},
|
||||||
|
|
||||||
|
// 边界情况
|
||||||
|
{"A", "a"},
|
||||||
|
{"AB", "ab"},
|
||||||
|
{"AbC", "ab_c"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
result := camelToSnake(test.input)
|
||||||
|
if result != test.expected {
|
||||||
|
t.Errorf("camelToSnake(%q) = %q, expected %q", test.input, result, test.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ camelToSnake test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStructToFieldsSnakeCase 测试默认使用 snake_case
|
||||||
|
func TestStructToFieldsSnakeCase(t *testing.T) {
|
||||||
|
type User struct {
|
||||||
|
UserName string // 应该转为 user_name
|
||||||
|
EmailAddress string // 应该转为 email_address
|
||||||
|
IsActive bool // 应该转为 is_active
|
||||||
|
HTTPEndpoint string // 应该转为 http_endpoint
|
||||||
|
ID int64 // 应该转为 id
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := StructToFields(User{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructToFields failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []string{"user_name", "email_address", "is_active", "http_endpoint", "id"}
|
||||||
|
if len(fields) != len(expected) {
|
||||||
|
t.Fatalf("Expected %d fields, got %d", len(expected), len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, exp := range expected {
|
||||||
|
if fields[i].Name != exp {
|
||||||
|
t.Errorf("Field %d: expected name %s, got %s", i, exp, fields[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Default snake_case test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStructToFieldsOverrideSnakeCase 测试可以覆盖默认 snake_case
|
||||||
|
func TestStructToFieldsOverrideSnakeCase(t *testing.T) {
|
||||||
|
type User struct {
|
||||||
|
UserName string `srdb:"username"` // 覆盖默认的 user_name
|
||||||
|
IsActive bool `srdb:"active;comment:激活"` // 覆盖默认的 is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
fields, err := StructToFields(User{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StructToFields failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fields) != 2 {
|
||||||
|
t.Fatalf("Expected 2 fields, got %d", len(fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证覆盖成功
|
||||||
|
if fields[0].Name != "username" {
|
||||||
|
t.Errorf("Expected field name 'username', got '%s'", fields[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fields[1].Name != "active" {
|
||||||
|
t.Errorf("Expected field name 'active', got '%s'", fields[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Override snake_case test passed")
|
||||||
|
}
|
||||||
|
|||||||
179
table.go
179
table.go
@@ -5,7 +5,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -203,8 +205,181 @@ func OpenTable(opts *TableOptions) (*Table, error) {
|
|||||||
return table, nil
|
return table, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert 插入数据
|
// Insert 插入数据(支持单条或批量)
|
||||||
func (t *Table) Insert(data map[string]any) error {
|
// 支持的类型:
|
||||||
|
// - map[string]any: 单条数据
|
||||||
|
// - []map[string]any: 批量数据
|
||||||
|
// - *struct{}: 单个结构体指针
|
||||||
|
// - []struct{}: 结构体切片
|
||||||
|
// - []*struct{}: 结构体指针切片
|
||||||
|
func (t *Table) Insert(data any) error {
|
||||||
|
// 1. 将输入转换为 []map[string]any
|
||||||
|
rows, err := t.normalizeInsertData(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 批量插入
|
||||||
|
return t.insertBatch(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeInsertData 将各种输入格式转换为 []map[string]any
|
||||||
|
func (t *Table) normalizeInsertData(data any) ([]map[string]any, error) {
|
||||||
|
// 处理 nil
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("data cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取反射值
|
||||||
|
val := reflect.ValueOf(data)
|
||||||
|
typ := reflect.TypeOf(data)
|
||||||
|
|
||||||
|
// 如果是指针,解引用
|
||||||
|
if typ.Kind() == reflect.Ptr {
|
||||||
|
if val.IsNil() {
|
||||||
|
return nil, fmt.Errorf("data pointer cannot be nil")
|
||||||
|
}
|
||||||
|
val = val.Elem()
|
||||||
|
typ = val.Type()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch typ.Kind() {
|
||||||
|
case reflect.Map:
|
||||||
|
// map[string]any - 单条
|
||||||
|
m, ok := data.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected map[string]any, got %T", data)
|
||||||
|
}
|
||||||
|
return []map[string]any{m}, nil
|
||||||
|
|
||||||
|
case reflect.Slice:
|
||||||
|
// 检查切片元素类型
|
||||||
|
elemType := typ.Elem()
|
||||||
|
|
||||||
|
// []map[string]any
|
||||||
|
if elemType.Kind() == reflect.Map {
|
||||||
|
maps, ok := data.([]map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected []map[string]any, got %T", data)
|
||||||
|
}
|
||||||
|
return maps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// []*struct{} 或 []struct{}
|
||||||
|
if elemType.Kind() == reflect.Ptr {
|
||||||
|
elemType = elemType.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if elemType.Kind() == reflect.Struct {
|
||||||
|
// 将每个结构体转换为 map
|
||||||
|
var rows []map[string]any
|
||||||
|
for i := 0; i < val.Len(); i++ {
|
||||||
|
elem := val.Index(i)
|
||||||
|
// 如果是指针,解引用
|
||||||
|
if elem.Kind() == reflect.Ptr {
|
||||||
|
if elem.IsNil() {
|
||||||
|
continue // 跳过 nil 指针
|
||||||
|
}
|
||||||
|
elem = elem.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := t.structToMap(elem.Interface())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("convert struct at index %d: %w", i, err)
|
||||||
|
}
|
||||||
|
rows = append(rows, m)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unsupported slice element type: %s", elemType.Kind())
|
||||||
|
|
||||||
|
case reflect.Struct:
|
||||||
|
// struct{} - 单个结构体
|
||||||
|
m, err := t.structToMap(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []map[string]any{m}, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported data type: %T (kind: %s)", data, typ.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// structToMap 将结构体转换为 map[string]any
|
||||||
|
func (t *Table) structToMap(v any) (map[string]any, error) {
|
||||||
|
val := reflect.ValueOf(v)
|
||||||
|
typ := reflect.TypeOf(v)
|
||||||
|
|
||||||
|
if typ.Kind() == reflect.Ptr {
|
||||||
|
val = val.Elem()
|
||||||
|
typ = val.Type()
|
||||||
|
}
|
||||||
|
|
||||||
|
if typ.Kind() != reflect.Struct {
|
||||||
|
return nil, fmt.Errorf("expected struct, got %s", typ.Kind())
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]any)
|
||||||
|
|
||||||
|
for i := 0; i < typ.NumField(); i++ {
|
||||||
|
field := typ.Field(i)
|
||||||
|
|
||||||
|
// 跳过未导出的字段
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取字段名
|
||||||
|
fieldName := field.Name
|
||||||
|
tag := field.Tag.Get("srdb")
|
||||||
|
|
||||||
|
// 跳过忽略的字段
|
||||||
|
if tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 tag 获取字段名
|
||||||
|
if tag != "" {
|
||||||
|
parts := strings.Split(tag, ";")
|
||||||
|
if parts[0] != "" {
|
||||||
|
fieldName = parts[0]
|
||||||
|
} else {
|
||||||
|
// 使用 snake_case 转换
|
||||||
|
fieldName = camelToSnake(field.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有 tag,使用 snake_case 转换
|
||||||
|
fieldName = camelToSnake(field.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取字段值
|
||||||
|
fieldVal := val.Field(i)
|
||||||
|
result[fieldName] = fieldVal.Interface()
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertBatch 批量插入数据
|
||||||
|
func (t *Table) insertBatch(rows []map[string]any) error {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐条插入
|
||||||
|
for _, data := range rows {
|
||||||
|
if err := t.insertSingle(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertSingle 插入单条数据
|
||||||
|
func (t *Table) insertSingle(data map[string]any) error {
|
||||||
// 1. 验证 Schema
|
// 1. 验证 Schema
|
||||||
if err := t.schema.Validate(data); err != nil {
|
if err := t.schema.Validate(data); err != nil {
|
||||||
return NewError(ErrCodeSchemaValidationFailed, err)
|
return NewError(ErrCodeSchemaValidationFailed, err)
|
||||||
|
|||||||
469
table_test.go
469
table_test.go
@@ -1844,3 +1844,472 @@ func TestTableCleanAndQuery(t *testing.T) {
|
|||||||
t.Errorf("Expected 1 row, got %d", count)
|
t.Errorf("Expected 1 row, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInsertMap 测试插入 map[string]any
|
||||||
|
func TestInsertMap(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestInsertMap")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 插入单个 map
|
||||||
|
err = table.Insert(map[string]any{
|
||||||
|
"name": "Alice",
|
||||||
|
"age": int64(25),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Insert map failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
row, err := table.Get(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Data["name"] != "Alice" {
|
||||||
|
t.Errorf("Expected name=Alice, got %v", row.Data["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Insert map test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInsertMapSlice 测试插入 []map[string]any
|
||||||
|
func TestInsertMapSlice(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestInsertMapSlice")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 批量插入 maps
|
||||||
|
err = table.Insert([]map[string]any{
|
||||||
|
{"name": "Alice", "age": int64(25)},
|
||||||
|
{"name": "Bob", "age": int64(30)},
|
||||||
|
{"name": "Charlie", "age": int64(35)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Insert map slice failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
row1, _ := table.Get(1)
|
||||||
|
row2, _ := table.Get(2)
|
||||||
|
row3, _ := table.Get(3)
|
||||||
|
|
||||||
|
if row1.Data["name"] != "Alice" || row2.Data["name"] != "Bob" || row3.Data["name"] != "Charlie" {
|
||||||
|
t.Errorf("Data mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Insert map slice test passed (3 rows)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInsertStruct 测试插入单个结构体
|
||||||
|
func TestInsertStruct(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestInsertStruct")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Name string `srdb:"name"`
|
||||||
|
Age int64 `srdb:"age"`
|
||||||
|
Email string `srdb:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
{Name: "email", Type: FieldTypeString},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 插入单个结构体
|
||||||
|
user := User{
|
||||||
|
Name: "Alice",
|
||||||
|
Age: 25,
|
||||||
|
Email: "alice@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Insert struct failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
row, err := table.Get(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Data["name"] != "Alice" {
|
||||||
|
t.Errorf("Expected name=Alice, got %v", row.Data["name"])
|
||||||
|
}
|
||||||
|
if row.Data["email"] != "alice@example.com" {
|
||||||
|
t.Errorf("Expected email=alice@example.com, got %v", row.Data["email"])
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Insert struct test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInsertStructPointer 测试插入结构体指针
|
||||||
|
func TestInsertStructPointer(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestInsertStructPointer")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Name string `srdb:"name"`
|
||||||
|
Age int64 `srdb:"age"`
|
||||||
|
Email string `srdb:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
{Name: "email", Type: FieldTypeString},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 插入结构体指针
|
||||||
|
user := &User{
|
||||||
|
Name: "Bob",
|
||||||
|
Age: 30,
|
||||||
|
Email: "bob@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Insert struct pointer failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
row, err := table.Get(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Data["name"] != "Bob" {
|
||||||
|
t.Errorf("Expected name=Bob, got %v", row.Data["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Insert struct pointer test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInsertStructSlice 测试插入结构体切片
|
||||||
|
func TestInsertStructSlice(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestInsertStructSlice")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Name string `srdb:"name"`
|
||||||
|
Age int64 `srdb:"age"`
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 批量插入结构体切片
|
||||||
|
users := []User{
|
||||||
|
{Name: "Alice", Age: 25},
|
||||||
|
{Name: "Bob", Age: 30},
|
||||||
|
{Name: "Charlie", Age: 35},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(users)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Insert struct slice failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
row1, _ := table.Get(1)
|
||||||
|
row2, _ := table.Get(2)
|
||||||
|
row3, _ := table.Get(3)
|
||||||
|
|
||||||
|
if row1.Data["name"] != "Alice" || row2.Data["name"] != "Bob" || row3.Data["name"] != "Charlie" {
|
||||||
|
t.Errorf("Data mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Insert struct slice test passed (3 rows)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInsertStructPointerSlice 测试插入结构体指针切片
|
||||||
|
func TestInsertStructPointerSlice(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestInsertStructPointerSlice")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Name string `srdb:"name"`
|
||||||
|
Age int64 `srdb:"age"`
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 批量插入结构体指针切片
|
||||||
|
users := []*User{
|
||||||
|
{Name: "Alice", Age: 25},
|
||||||
|
{Name: "Bob", Age: 30},
|
||||||
|
nil, // 测试 nil 指针会被跳过
|
||||||
|
{Name: "Charlie", Age: 35},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(users)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Insert struct pointer slice failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证(应该只有 3 条记录,nil 被跳过)
|
||||||
|
row1, _ := table.Get(1)
|
||||||
|
row2, _ := table.Get(2)
|
||||||
|
row3, _ := table.Get(3)
|
||||||
|
|
||||||
|
if row1.Data["name"] != "Alice" || row2.Data["name"] != "Bob" || row3.Data["name"] != "Charlie" {
|
||||||
|
t.Errorf("Data mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Insert struct pointer slice test passed (3 rows, nil skipped)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInsertWithSnakeCase 测试结构体自动 snake_case 转换
|
||||||
|
func TestInsertWithSnakeCase(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestInsertWithSnakeCase")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
UserName string `srdb:";comment:用户名"` // 没有指定字段名,应该自动转为 user_name
|
||||||
|
EmailAddress string // 没有 tag,应该自动转为 email_address
|
||||||
|
IsActive bool // 应该自动转为 is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "user_name", Type: FieldTypeString, Comment: "用户名"},
|
||||||
|
{Name: "email_address", Type: FieldTypeString},
|
||||||
|
{Name: "is_active", Type: FieldTypeBool},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 插入结构体
|
||||||
|
user := User{
|
||||||
|
UserName: "Alice",
|
||||||
|
EmailAddress: "alice@example.com",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(user)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Insert failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证字段名是否正确转换
|
||||||
|
row, err := table.Get(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Data["user_name"] != "Alice" {
|
||||||
|
t.Errorf("Expected user_name=Alice, got %v", row.Data["user_name"])
|
||||||
|
}
|
||||||
|
if row.Data["email_address"] != "alice@example.com" {
|
||||||
|
t.Errorf("Expected email_address=alice@example.com, got %v", row.Data["email_address"])
|
||||||
|
}
|
||||||
|
if row.Data["is_active"] != true {
|
||||||
|
t.Errorf("Expected is_active=true, got %v", row.Data["is_active"])
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Insert with snake_case test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInsertInvalidType 测试插入不支持的类型
|
||||||
|
func TestInsertInvalidType(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestInsertInvalidType")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 尝试插入不支持的类型
|
||||||
|
err = table.Insert(123) // int 类型
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected error for invalid type, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert("string") // string 类型
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected error for invalid type, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = table.Insert(nil) // nil
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected error for nil, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Insert invalid type test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInsertEmptySlice 测试插入空切片
|
||||||
|
func TestInsertEmptySlice(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestInsertEmptySlice")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 插入空切片
|
||||||
|
err = table.Insert([]map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nil error for empty slice, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证没有数据
|
||||||
|
_, err = table.Get(1)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected error for non-existent row")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Insert empty slice test passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBatchInsertPerformance 测试批量插入性能
|
||||||
|
func TestBatchInsertPerformance(t *testing.T) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "TestBatchInsertPerformance")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
schema := NewSchema("users", []Field{
|
||||||
|
{Name: "name", Type: FieldTypeString},
|
||||||
|
{Name: "age", Type: FieldTypeInt64},
|
||||||
|
})
|
||||||
|
|
||||||
|
table, err := OpenTable(&TableOptions{
|
||||||
|
Dir: tmpDir,
|
||||||
|
Name: schema.Name,
|
||||||
|
Fields: schema.Fields,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer table.Close()
|
||||||
|
|
||||||
|
// 准备1000条数据
|
||||||
|
batchSize := 1000
|
||||||
|
data := make([]map[string]any, batchSize)
|
||||||
|
for i := 0; i < batchSize; i++ {
|
||||||
|
data[i] = map[string]any{
|
||||||
|
"name": "User" + string(rune(i)),
|
||||||
|
"age": int64(20 + i%50),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入
|
||||||
|
err = table.Insert(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Batch insert failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证数量
|
||||||
|
row, err := table.Get(int64(batchSize))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get last row failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Seq != int64(batchSize) {
|
||||||
|
t.Errorf("Expected seq=%d, got %d", batchSize, row.Seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ Batch insert performance test passed (%d rows)", batchSize)
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,11 +132,27 @@ export class ManifestView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name-text {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 8px;
|
}
|
||||||
|
|
||||||
|
.file-level-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--primary-bg);
|
||||||
|
color: var(--primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-detail {
|
.file-detail {
|
||||||
@@ -273,7 +289,10 @@ export class ManifestView extends LitElement {
|
|||||||
<div class="file-list">
|
<div class="file-list">
|
||||||
${level.files.map(file => html`
|
${level.files.map(file => html`
|
||||||
<div class="file-item">
|
<div class="file-item">
|
||||||
<div class="file-name">${file.file_number}.sst</div>
|
<div class="file-name">
|
||||||
|
<span class="file-name-text">${file.file_number}.sst</span>
|
||||||
|
<span class="file-level-badge">L${level.level}</span>
|
||||||
|
</div>
|
||||||
<div class="file-detail">
|
<div class="file-detail">
|
||||||
<div class="file-detail-row">
|
<div class="file-detail-row">
|
||||||
<span>Size:</span>
|
<span>Size:</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user