diff --git a/compaction_continuity_test.go b/compaction_continuity_test.go deleted file mode 100644 index 1ef7c3d..0000000 --- a/compaction_continuity_test.go +++ /dev/null @@ -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("=== 升级任务连续性测试通过 ===") -} diff --git a/compaction_stage_test.go b/compaction_stage_test.go deleted file mode 100644 index f85b945..0000000 --- a/compaction_stage_test.go +++ /dev/null @@ -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 ===") -} diff --git a/compaction_test.go b/compaction_test.go index 7a8e14d..42b35c5 100644 --- a/compaction_test.go +++ b/compaction_test.go @@ -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("=== 升级任务连续性测试通过 ===") +} diff --git a/examples/batch_insert/README.md b/examples/batch_insert/README.md new file mode 100644 index 0000000..ae41129 --- /dev/null +++ b/examples/batch_insert/README.md @@ -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 示例 diff --git a/examples/batch_insert/data/example1/CURRENT b/examples/batch_insert/data/example1/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/examples/batch_insert/data/example1/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/examples/batch_insert/data/example1/MANIFEST-000001 b/examples/batch_insert/data/example1/MANIFEST-000001 new file mode 100644 index 0000000..da26eca Binary files /dev/null and b/examples/batch_insert/data/example1/MANIFEST-000001 differ diff --git a/examples/batch_insert/data/example1/schema.json b/examples/batch_insert/data/example1/schema.json new file mode 100644 index 0000000..3da11d1 --- /dev/null +++ b/examples/batch_insert/data/example1/schema.json @@ -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": "年龄" + } + ] + } +} \ No newline at end of file diff --git a/examples/batch_insert/data/example1/wal/CURRENT b/examples/batch_insert/data/example1/wal/CURRENT new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/examples/batch_insert/data/example1/wal/CURRENT @@ -0,0 +1 @@ +2 diff --git a/examples/batch_insert/data/example2/CURRENT b/examples/batch_insert/data/example2/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/examples/batch_insert/data/example2/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/examples/batch_insert/data/example2/MANIFEST-000001 b/examples/batch_insert/data/example2/MANIFEST-000001 new file mode 100644 index 0000000..7551134 Binary files /dev/null and b/examples/batch_insert/data/example2/MANIFEST-000001 differ diff --git a/examples/batch_insert/data/example2/schema.json b/examples/batch_insert/data/example2/schema.json new file mode 100644 index 0000000..d81fb40 --- /dev/null +++ b/examples/batch_insert/data/example2/schema.json @@ -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": "" + } + ] + } +} \ No newline at end of file diff --git a/examples/batch_insert/data/example2/wal/CURRENT b/examples/batch_insert/data/example2/wal/CURRENT new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/examples/batch_insert/data/example2/wal/CURRENT @@ -0,0 +1 @@ +2 diff --git a/examples/batch_insert/data/example3/CURRENT b/examples/batch_insert/data/example3/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/examples/batch_insert/data/example3/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/examples/batch_insert/data/example3/MANIFEST-000001 b/examples/batch_insert/data/example3/MANIFEST-000001 new file mode 100644 index 0000000..d26da34 Binary files /dev/null and b/examples/batch_insert/data/example3/MANIFEST-000001 differ diff --git a/examples/batch_insert/data/example3/schema.json b/examples/batch_insert/data/example3/schema.json new file mode 100644 index 0000000..a2cf2c8 --- /dev/null +++ b/examples/batch_insert/data/example3/schema.json @@ -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": "" + } + ] + } +} \ No newline at end of file diff --git a/examples/batch_insert/data/example3/wal/CURRENT b/examples/batch_insert/data/example3/wal/CURRENT new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/examples/batch_insert/data/example3/wal/CURRENT @@ -0,0 +1 @@ +2 diff --git a/examples/batch_insert/data/example4/CURRENT b/examples/batch_insert/data/example4/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/examples/batch_insert/data/example4/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/examples/batch_insert/data/example4/MANIFEST-000001 b/examples/batch_insert/data/example4/MANIFEST-000001 new file mode 100644 index 0000000..cd20666 Binary files /dev/null and b/examples/batch_insert/data/example4/MANIFEST-000001 differ diff --git a/examples/batch_insert/data/example4/schema.json b/examples/batch_insert/data/example4/schema.json new file mode 100644 index 0000000..a2cf2c8 --- /dev/null +++ b/examples/batch_insert/data/example4/schema.json @@ -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": "" + } + ] + } +} \ No newline at end of file diff --git a/examples/batch_insert/data/example4/wal/CURRENT b/examples/batch_insert/data/example4/wal/CURRENT new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/examples/batch_insert/data/example4/wal/CURRENT @@ -0,0 +1 @@ +2 diff --git a/examples/batch_insert/data/example5/CURRENT b/examples/batch_insert/data/example5/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/examples/batch_insert/data/example5/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/examples/batch_insert/data/example5/MANIFEST-000001 b/examples/batch_insert/data/example5/MANIFEST-000001 new file mode 100644 index 0000000..cd20666 Binary files /dev/null and b/examples/batch_insert/data/example5/MANIFEST-000001 differ diff --git a/examples/batch_insert/data/example5/schema.json b/examples/batch_insert/data/example5/schema.json new file mode 100644 index 0000000..a2cf2c8 --- /dev/null +++ b/examples/batch_insert/data/example5/schema.json @@ -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": "" + } + ] + } +} \ No newline at end of file diff --git a/examples/batch_insert/data/example5/wal/CURRENT b/examples/batch_insert/data/example5/wal/CURRENT new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/examples/batch_insert/data/example5/wal/CURRENT @@ -0,0 +1 @@ +2 diff --git a/examples/batch_insert/data/example6/CURRENT b/examples/batch_insert/data/example6/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/examples/batch_insert/data/example6/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/examples/batch_insert/data/example6/MANIFEST-000001 b/examples/batch_insert/data/example6/MANIFEST-000001 new file mode 100644 index 0000000..8ce6e11 Binary files /dev/null and b/examples/batch_insert/data/example6/MANIFEST-000001 differ diff --git a/examples/batch_insert/data/example6/schema.json b/examples/batch_insert/data/example6/schema.json new file mode 100644 index 0000000..a086de4 --- /dev/null +++ b/examples/batch_insert/data/example6/schema.json @@ -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": "是否有货" + } + ] + } +} \ No newline at end of file diff --git a/examples/batch_insert/data/example6/wal/CURRENT b/examples/batch_insert/data/example6/wal/CURRENT new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/examples/batch_insert/data/example6/wal/CURRENT @@ -0,0 +1 @@ +2 diff --git a/examples/batch_insert/main.go b/examples/batch_insert/main.go new file mode 100644 index 0000000..4af9dfd --- /dev/null +++ b/examples/batch_insert/main.go @@ -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"]) +} diff --git a/examples/snake_case_demo/main.go b/examples/snake_case_demo/main.go new file mode 100644 index 0000000..8313f01 --- /dev/null +++ b/examples/snake_case_demo/main.go @@ -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)) +} diff --git a/examples/struct_schema/README.md b/examples/struct_schema/README.md new file mode 100644 index 0000000..a9ac6eb --- /dev/null +++ b/examples/struct_schema/README.md @@ -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 等复杂类型 diff --git a/examples/struct_schema/data/users/CURRENT b/examples/struct_schema/data/users/CURRENT new file mode 100644 index 0000000..7ed683d --- /dev/null +++ b/examples/struct_schema/data/users/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/examples/struct_schema/data/users/MANIFEST-000001 b/examples/struct_schema/data/users/MANIFEST-000001 new file mode 100644 index 0000000..7fefdd9 Binary files /dev/null and b/examples/struct_schema/data/users/MANIFEST-000001 differ diff --git a/examples/struct_schema/data/users/schema.json b/examples/struct_schema/data/users/schema.json new file mode 100644 index 0000000..a2e9058 --- /dev/null +++ b/examples/struct_schema/data/users/schema.json @@ -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": "是否激活" + } + ] + } +} \ No newline at end of file diff --git a/examples/struct_schema/data/users/wal/CURRENT b/examples/struct_schema/data/users/wal/CURRENT new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/examples/struct_schema/data/users/wal/CURRENT @@ -0,0 +1 @@ +3 diff --git a/examples/struct_schema/main.go b/examples/struct_schema/main.go new file mode 100644 index 0000000..e9290fa --- /dev/null +++ b/examples/struct_schema/main.go @@ -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✓ 所有示例执行成功!") +} diff --git a/index_query_test.go b/index_query_test.go deleted file mode 100644 index b515135..0000000 --- a/index_query_test.go +++ /dev/null @@ -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 ===") -} diff --git a/index_test.go b/index_test.go index 20cfa83..7257ac8 100644 --- a/index_test.go +++ b/index_test.go @@ -284,3 +284,257 @@ func TestIndexDropWithFile(t *testing.T) { 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 ===") +} diff --git a/schema.go b/schema.go index 6ae5f7a..b31a107 100644 --- a/schema.go +++ b/schema.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "reflect" "sort" "strings" "time" @@ -48,7 +49,7 @@ type Schema struct { Fields []Field // 字段列表 } -// New 创建 Schema +// NewSchema 创建 Schema func NewSchema(name string, fields []Field) *Schema { return &Schema{ 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 获取字段定义 func (s *Schema) GetField(name string) (*Field, error) { for i := range s.Fields { diff --git a/schema_test.go b/schema_test.go index c281d2e..cf7d13b 100644 --- a/schema_test.go +++ b/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(" 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") +} diff --git a/table.go b/table.go index 0653a6f..5dc0a2f 100644 --- a/table.go +++ b/table.go @@ -5,7 +5,9 @@ import ( "fmt" "os" "path/filepath" + "reflect" "sort" + "strings" "sync" "sync/atomic" "time" @@ -203,8 +205,181 @@ func OpenTable(opts *TableOptions) (*Table, error) { return table, nil } -// Insert 插入数据 -func (t *Table) Insert(data map[string]any) error { +// Insert 插入数据(支持单条或批量) +// 支持的类型: +// - 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 if err := t.schema.Validate(data); err != nil { return NewError(ErrCodeSchemaValidationFailed, err) diff --git a/table_test.go b/table_test.go index a078dd4..8ba5ff9 100644 --- a/table_test.go +++ b/table_test.go @@ -1844,3 +1844,472 @@ func TestTableCleanAndQuery(t *testing.T) { 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) +} diff --git a/webui/static/js/components/manifest-view.js b/webui/static/js/components/manifest-view.js index f3c88fa..3953f0f 100644 --- a/webui/static/js/components/manifest-view.js +++ b/webui/static/js/components/manifest-view.js @@ -132,11 +132,27 @@ export class ManifestView extends LitElement { } .file-name { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + + .file-name-text { font-family: 'Courier New', monospace; font-size: 13px; color: var(--text-primary); 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 { @@ -273,7 +289,10 @@ export class ManifestView extends LitElement {
${level.files.map(file => html`
-
${file.file_number}.sst
+
+ ${file.file_number}.sst + L${level.level} +
Size: