Implement checkpoint modes with docs/tests and reorganize project file layout
All checks were successful
NuGet Publish / build-and-pack (push) Successful in 46s
NuGet Publish / publish-to-gitea (push) Successful in 53s

This commit is contained in:
Joseph Doherty
2026-02-21 07:56:36 -05:00
parent 3ffd468c79
commit 4c6aaa5a3f
96 changed files with 744 additions and 249 deletions

View File

@@ -0,0 +1,58 @@
using ZB.MOM.WW.CBDD.Core.Indexing;
using ZB.MOM.WW.CBDD.Core.Storage;
namespace ZB.MOM.WW.CBDD.Tests;
public class BTreeDeleteUnderflowTests
{
/// <summary>
/// Executes Delete_HeavyWorkload_Should_Remain_Queryable_After_Merges.
/// </summary>
[Fact]
public void Delete_HeavyWorkload_Should_Remain_Queryable_After_Merges()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"btree_underflow_{Guid.NewGuid():N}.db");
try
{
using var storage = new StorageEngine(dbPath, PageFileConfig.Default);
var index = new BTreeIndex(storage, IndexOptions.CreateBTree("k"));
var insertTxn = storage.BeginTransaction().TransactionId;
for (int i = 1; i <= 240; i++)
{
index.Insert(IndexKey.Create(i), new DocumentLocation((uint)(1000 + i), 0), insertTxn);
}
storage.CommitTransaction(insertTxn);
var deleteTxn = storage.BeginTransaction().TransactionId;
for (int i = 1; i <= 190; i++)
{
index.Delete(IndexKey.Create(i), new DocumentLocation((uint)(1000 + i), 0), deleteTxn).ShouldBeTrue();
}
storage.CommitTransaction(deleteTxn);
for (int i = 1; i <= 190; i++)
{
index.TryFind(IndexKey.Create(i), out _, 0).ShouldBeFalse();
}
for (int i = 191; i <= 240; i++)
{
index.TryFind(IndexKey.Create(i), out var location, 0).ShouldBeTrue();
location.PageId.ShouldBe((uint)(1000 + i));
}
var remaining = index.GreaterThan(IndexKey.Create(190), orEqual: false, 0).ToList();
remaining.Count.ShouldBe(50);
remaining.First().Key.ShouldBe(IndexKey.Create(191));
remaining.Last().Key.ShouldBe(IndexKey.Create(240));
}
finally
{
if (File.Exists(dbPath)) File.Delete(dbPath);
var walPath = Path.ChangeExtension(dbPath, ".wal");
if (File.Exists(walPath)) File.Delete(walPath);
}
}
}

View File

@@ -0,0 +1,165 @@
using ZB.MOM.WW.CBDD.Core.Indexing;
using ZB.MOM.WW.CBDD.Core.Storage;
using ZB.MOM.WW.CBDD.Shared;
using ZB.MOM.WW.CBDD.Shared.TestDbContext_TestDbContext_Mappers;
namespace ZB.MOM.WW.CBDD.Tests;
public class CollectionIndexManagerAndDefinitionTests
{
/// <summary>
/// Tests find best index should prefer unique index.
/// </summary>
[Fact]
public void FindBestIndex_Should_Prefer_Unique_Index()
{
var dbPath = NewDbPath();
try
{
using var storage = new StorageEngine(dbPath, PageFileConfig.Default);
var mapper = new ZB_MOM_WW_CBDD_Shared_PersonMapper();
using var manager = new CollectionIndexManager<int, Person>(storage, mapper, "people_idx_pref_unique");
manager.CreateIndex(p => p.Age, name: "idx_age", unique: false);
manager.CreateIndex(p => p.Age, name: "idx_age_unique", unique: true);
var best = manager.FindBestIndex("Age");
best.ShouldNotBeNull();
best.Definition.Name.ShouldBe("idx_age_unique");
best.Definition.IsUnique.ShouldBeTrue();
}
finally
{
CleanupFiles(dbPath);
}
}
/// <summary>
/// Tests find best compound index should choose longest prefix.
/// </summary>
[Fact]
public void FindBestCompoundIndex_Should_Choose_Longest_Prefix()
{
var dbPath = NewDbPath();
try
{
using var storage = new StorageEngine(dbPath, PageFileConfig.Default);
var mapper = new ZB_MOM_WW_CBDD_Shared_PersonMapper();
using var manager = new CollectionIndexManager<int, Person>(storage, mapper, "people_idx_compound");
manager.CreateIndex(new CollectionIndexDefinition<Person>(
"idx_name",
["Name"],
p => p.Name));
manager.CreateIndex(new CollectionIndexDefinition<Person>(
"idx_name_age",
["Name", "Age"],
p => new { p.Name, p.Age }));
manager.CreateIndex(new CollectionIndexDefinition<Person>(
"idx_name_age_id",
["Name", "Age", "Id"],
p => new { p.Name, p.Age, p.Id }));
var best = manager.FindBestCompoundIndex(["Name", "Age"]);
best.ShouldNotBeNull();
best.Definition.Name.ShouldBe("idx_name_age_id");
best.Definition.PropertyPaths.Length.ShouldBe(3);
}
finally
{
CleanupFiles(dbPath);
}
}
/// <summary>
/// Tests drop index should remove metadata and be idempotent.
/// </summary>
[Fact]
public void DropIndex_Should_Remove_Metadata_And_Be_Idempotent()
{
var dbPath = NewDbPath();
const string collectionName = "people_idx_drop";
try
{
using var storage = new StorageEngine(dbPath, PageFileConfig.Default);
var mapper = new ZB_MOM_WW_CBDD_Shared_PersonMapper();
using (var manager = new CollectionIndexManager<int, Person>(storage, mapper, collectionName))
{
manager.CreateIndex(p => p.Age, name: "idx_age", unique: false);
manager.DropIndex("idx_age").ShouldBeTrue();
manager.DropIndex("idx_age").ShouldBeFalse();
manager.GetIndexInfo().ShouldBeEmpty();
}
using var reloaded = new CollectionIndexManager<int, Person>(storage, mapper, collectionName);
reloaded.GetIndexInfo().ShouldBeEmpty();
}
finally
{
CleanupFiles(dbPath);
}
}
/// <summary>
/// Tests collection index definition should respect query support rules.
/// </summary>
[Fact]
public void CollectionIndexDefinition_Should_Respect_Query_Support_Rules()
{
var definition = new CollectionIndexDefinition<Person>(
"idx_name_age",
["Name", "Age"],
p => new { p.Name, p.Age });
definition.CanSupportQuery("Name").ShouldBeTrue();
definition.CanSupportQuery("Age").ShouldBeFalse();
definition.CanSupportCompoundQuery(["Name"]).ShouldBeTrue();
definition.CanSupportCompoundQuery(["Name", "Age"]).ShouldBeTrue();
definition.CanSupportCompoundQuery(["Name", "Age", "Id"]).ShouldBeFalse();
definition.ToString().ShouldContain("idx_name_age");
definition.ToString().ShouldContain("Name");
}
/// <summary>
/// Tests collection index info to string should include diagnostics.
/// </summary>
[Fact]
public void CollectionIndexInfo_ToString_Should_Include_Diagnostics()
{
var info = new CollectionIndexInfo
{
Name = "idx_age",
PropertyPaths = ["Age"],
EstimatedDocumentCount = 12,
EstimatedSizeBytes = 4096
};
var text = info.ToString();
text.ShouldContain("idx_age");
text.ShouldContain("Age");
text.ShouldContain("12 docs");
}
private static string NewDbPath()
=> Path.Combine(Path.GetTempPath(), $"idx_mgr_{Guid.NewGuid():N}.db");
private static void CleanupFiles(string dbPath)
{
if (File.Exists(dbPath)) File.Delete(dbPath);
var walPath = Path.ChangeExtension(dbPath, ".wal");
if (File.Exists(walPath)) File.Delete(walPath);
var altWalPath = dbPath + "-wal";
if (File.Exists(altWalPath)) File.Delete(altWalPath);
}
}

View File

@@ -0,0 +1,128 @@
using ZB.MOM.WW.CBDD.Core.Storage;
using ZB.MOM.WW.CBDD.Core.Indexing;
using ZB.MOM.WW.CBDD.Bson;
using Xunit;
namespace ZB.MOM.WW.CBDD.Tests;
public class CursorTests : IDisposable
{
private readonly string _testFile;
private readonly StorageEngine _storage;
private readonly BTreeIndex _index;
/// <summary>
/// Initializes a new instance of the <see cref="CursorTests"/> class.
/// </summary>
public CursorTests()
{
_testFile = Path.Combine(Path.GetTempPath(), $"docdb_cursor_test_{Guid.NewGuid()}.db");
_storage = new StorageEngine(_testFile, PageFileConfig.Default);
var options = IndexOptions.CreateBTree("test");
_index = new BTreeIndex(_storage, options);
SeedData();
}
private void SeedData()
{
var txnId = _storage.BeginTransaction().TransactionId;
// Insert 10, 20, 30
_index.Insert(IndexKey.Create(10), new DocumentLocation(1, 0), txnId);
_index.Insert(IndexKey.Create(20), new DocumentLocation(2, 0), txnId);
_index.Insert(IndexKey.Create(30), new DocumentLocation(3, 0), txnId);
_storage.CommitTransaction(txnId);
}
/// <summary>
/// Tests move to first should position at first.
/// </summary>
[Fact]
public void MoveToFirst_ShouldPositionAtFirst()
{
using var cursor = _index.CreateCursor(0);
cursor.MoveToFirst().ShouldBeTrue();
cursor.Current.Key.ShouldBe(IndexKey.Create(10));
}
/// <summary>
/// Tests move to last should position at last.
/// </summary>
[Fact]
public void MoveToLast_ShouldPositionAtLast()
{
using var cursor = _index.CreateCursor(0);
cursor.MoveToLast().ShouldBeTrue();
cursor.Current.Key.ShouldBe(IndexKey.Create(30));
}
/// <summary>
/// Tests move next should traverse forward.
/// </summary>
[Fact]
public void MoveNext_ShouldTraverseForward()
{
using var cursor = _index.CreateCursor(0);
cursor.MoveToFirst();
cursor.MoveNext().ShouldBeTrue();
cursor.Current.Key.ShouldBe(IndexKey.Create(20));
cursor.MoveNext().ShouldBeTrue();
cursor.Current.Key.ShouldBe(IndexKey.Create(30));
cursor.MoveNext().ShouldBeFalse(); // End
}
/// <summary>
/// Tests move prev should traverse backward.
/// </summary>
[Fact]
public void MovePrev_ShouldTraverseBackward()
{
using var cursor = _index.CreateCursor(0);
cursor.MoveToLast();
cursor.MovePrev().ShouldBeTrue();
cursor.Current.Key.ShouldBe(IndexKey.Create(20));
cursor.MovePrev().ShouldBeTrue();
cursor.Current.Key.ShouldBe(IndexKey.Create(10));
cursor.MovePrev().ShouldBeFalse(); // Start
}
/// <summary>
/// Tests seek should position exact or next.
/// </summary>
[Fact]
public void Seek_ShouldPositionExact_OrNext()
{
using var cursor = _index.CreateCursor(0);
// Exact
cursor.Seek(IndexKey.Create(20)).ShouldBeTrue();
cursor.Current.Key.ShouldBe(IndexKey.Create(20));
// Non-exact (15 -> should land on 20)
cursor.Seek(IndexKey.Create(15)).ShouldBeFalse();
cursor.Current.Key.ShouldBe(IndexKey.Create(20));
// Non-exact (35 -> should be invalid/end)
cursor.Seek(IndexKey.Create(35)).ShouldBeFalse();
// Current should throw invalid
Should.Throw<InvalidOperationException>(() => cursor.Current);
}
/// <summary>
/// Disposes the resources used by this instance.
/// </summary>
public void Dispose()
{
_storage.Dispose();
if (File.Exists(_testFile)) File.Delete(_testFile);
}
}

View File

@@ -0,0 +1,59 @@
using ZB.MOM.WW.CBDD.Shared;
namespace ZB.MOM.WW.CBDD.Tests;
public class GeospatialStressTests : IDisposable
{
private readonly string _dbPath;
private readonly Shared.TestDbContext _db;
/// <summary>
/// Initializes database state for geospatial stress tests.
/// </summary>
public GeospatialStressTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"geo_stress_{Guid.NewGuid():N}.db");
_db = new Shared.TestDbContext(_dbPath);
}
/// <summary>
/// Verifies spatial index handles node splits and query operations under load.
/// </summary>
[Fact]
public void SpatialIndex_Should_Handle_Node_Splits_And_Queries()
{
const int count = 350;
for (int i = 0; i < count; i++)
{
_db.GeoItems.Insert(new GeoEntity
{
Name = $"pt-{i}",
Location = (40.0 + (i * 0.001), -73.0 - (i * 0.001))
});
}
_db.SaveChanges();
var all = _db.GeoItems.Within("idx_spatial", (39.5, -74.5), (40.5, -72.5)).ToList();
all.Count.ShouldBe(count);
var subset = _db.GeoItems.Within("idx_spatial", (40.05, -73.30), (40.25, -73.05)).ToList();
subset.Count.ShouldBeGreaterThan(0);
subset.Count.ShouldBeLessThan(count);
var near = _db.GeoItems.Near("idx_spatial", (40.10, -73.10), 30.0).ToList();
near.Count.ShouldBeGreaterThan(0);
}
/// <summary>
/// Disposes test resources and removes generated files.
/// </summary>
public void Dispose()
{
_db.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
var wal = Path.ChangeExtension(_dbPath, ".wal");
if (File.Exists(wal)) File.Delete(wal);
}
}

View File

@@ -0,0 +1,120 @@
using Xunit;
using ZB.MOM.WW.CBDD.Core.Indexing;
using System.IO;
using System.Linq;
using ZB.MOM.WW.CBDD.Core;
using ZB.MOM.WW.CBDD.Shared;
namespace ZB.MOM.WW.CBDD.Tests;
public class GeospatialTests : IDisposable
{
private readonly string _dbPath;
private readonly Shared.TestDbContext _db;
/// <summary>
/// Initializes a new instance of the <see cref="GeospatialTests"/> class.
/// </summary>
public GeospatialTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"cbdd_geo_{Guid.NewGuid()}.db");
_db = new Shared.TestDbContext(_dbPath);
}
/// <summary>
/// Verifies spatial within queries return expected results.
/// </summary>
[Fact]
public void Can_Insert_And_Search_Within()
{
// Setup: Insert some points
var p1 = new GeoEntity { Name = "Point 1", Location = (45.0, 9.0) };
var p2 = new GeoEntity { Name = "Point 2", Location = (46.0, 10.0) };
var p3 = new GeoEntity { Name = "Point 3", Location = (50.0, 50.0) }; // Far away
_db.GeoItems.Insert(p1);
_db.GeoItems.Insert(p2);
_db.GeoItems.Insert(p3);
// Search: Within box [44, 8] to [47, 11]
var results = _db.GeoItems.Within("idx_spatial", (44.0, 8.0), (47.0, 11.0)).ToList();
results.Count.ShouldBe(2);
results.ShouldContain(r => r.Name == "Point 1");
results.ShouldContain(r => r.Name == "Point 2");
}
/// <summary>
/// Verifies near queries return expected proximity results.
/// </summary>
[Fact]
public void Can_Search_Near_Proximity()
{
// Setup: Milan (roughly 45.46, 9.18)
var milan = (45.4642, 9.1899);
var rome = (41.9028, 12.4964);
var ny = (40.7128, -74.0060);
_db.GeoItems.Insert(new GeoEntity { Name = "Milan Office", Location = milan });
_db.GeoItems.Insert(new GeoEntity { Name = "Rome Office", Location = rome });
_db.GeoItems.Insert(new GeoEntity { Name = "New York Office", Location = ny });
// Search near Milan (within 600km - should include Rome (~500km) but not NY)
var results = _db.GeoItems.Near("idx_spatial", milan, 600.0).ToList();
results.Count.ShouldBe(2);
results.ShouldContain(r => r.Name == "Milan Office");
results.ShouldContain(r => r.Name == "Rome Office");
results.ShouldNotContain(r => r.Name == "New York Office");
}
/// <summary>
/// Verifies LINQ near integration returns expected results.
/// </summary>
[Fact]
public void LINQ_Integration_Near_Works()
{
var milan = (45.4642, 9.1899);
_db.GeoItems.Insert(new GeoEntity { Name = "Milan Office", Location = milan });
// LINQ query using .Near() extension
var query = from p in _db.GeoItems.AsQueryable()
where p.Location.Near(milan, 10.0)
select p;
var results = query.ToList();
results.Count().ShouldBe(1);
results[0].Name.ShouldBe("Milan Office");
}
/// <summary>
/// Verifies LINQ within integration returns expected results.
/// </summary>
[Fact]
public void LINQ_Integration_Within_Works()
{
var milan = (45.4642, 9.1899);
_db.GeoItems.Insert(new GeoEntity { Name = "Milan Office", Location = milan });
var min = (45.0, 9.0);
var max = (46.0, 10.0);
// LINQ query using .Within() extension
var results = _db.GeoItems.AsQueryable()
.Where(p => p.Location.Within(min, max))
.ToList();
results.Count().ShouldBe(1);
results[0].Name.ShouldBe("Milan Office");
}
/// <summary>
/// Disposes test resources and removes temporary files.
/// </summary>
public void Dispose()
{
_db.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
}

View File

@@ -0,0 +1,91 @@
using ZB.MOM.WW.CBDD.Core.Indexing;
using ZB.MOM.WW.CBDD.Core.Storage;
namespace ZB.MOM.WW.CBDD.Tests;
public class HashIndexTests
{
/// <summary>
/// Executes Insert_And_TryFind_Should_Return_Location.
/// </summary>
[Fact]
public void Insert_And_TryFind_Should_Return_Location()
{
var index = new HashIndex(IndexOptions.CreateHash("age"));
var key = IndexKey.Create(42);
var location = new DocumentLocation(7, 3);
index.Insert(key, location);
index.TryFind(key, out var found).ShouldBeTrue();
found.PageId.ShouldBe(location.PageId);
found.SlotIndex.ShouldBe(location.SlotIndex);
}
/// <summary>
/// Executes Unique_HashIndex_Should_Throw_On_Duplicate_Key.
/// </summary>
[Fact]
public void Unique_HashIndex_Should_Throw_On_Duplicate_Key()
{
var options = new IndexOptions
{
Type = IndexType.Hash,
Unique = true,
Fields = ["id"]
};
var index = new HashIndex(options);
var key = IndexKey.Create("dup");
index.Insert(key, new DocumentLocation(1, 1));
Should.Throw<InvalidOperationException>(() =>
index.Insert(key, new DocumentLocation(2, 2)));
}
/// <summary>
/// Executes Remove_Should_Remove_Only_Matching_Entry.
/// </summary>
[Fact]
public void Remove_Should_Remove_Only_Matching_Entry()
{
var index = new HashIndex(IndexOptions.CreateHash("name"));
var key = IndexKey.Create("john");
var location1 = new DocumentLocation(10, 1);
var location2 = new DocumentLocation(11, 2);
index.Insert(key, location1);
index.Insert(key, location2);
index.Remove(key, location1).ShouldBeTrue();
index.Remove(key, location1).ShouldBeFalse();
var remaining = index.FindAll(key).ToList();
remaining.Count.ShouldBe(1);
remaining[0].Location.PageId.ShouldBe(location2.PageId);
remaining[0].Location.SlotIndex.ShouldBe(location2.SlotIndex);
index.Remove(key, location2).ShouldBeTrue();
index.FindAll(key).ShouldBeEmpty();
}
/// <summary>
/// Executes FindAll_Should_Return_All_Matching_Entries.
/// </summary>
[Fact]
public void FindAll_Should_Return_All_Matching_Entries()
{
var index = new HashIndex(IndexOptions.CreateHash("score"));
var key = IndexKey.Create(99);
index.Insert(key, new DocumentLocation(1, 0));
index.Insert(key, new DocumentLocation(2, 0));
index.Insert(IndexKey.Create(100), new DocumentLocation(3, 0));
var matches = index.FindAll(key).ToList();
matches.Count.ShouldBe(2);
matches.All(e => e.Key == key).ShouldBeTrue();
}
}

View File

@@ -0,0 +1,108 @@
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Core;
using ZB.MOM.WW.CBDD.Core.Collections;
using ZB.MOM.WW.CBDD.Core.Indexing;
using ZB.MOM.WW.CBDD.Shared;
using System;
using Xunit;
namespace ZB.MOM.WW.CBDD.Tests;
public class IndexDirectionTests : IDisposable
{
private readonly string _dbPath = "index_direction_tests.db";
private readonly Shared.TestDbContext _db;
/// <summary>
/// Initializes database state for index direction tests.
/// </summary>
public IndexDirectionTests()
{
if (File.Exists(_dbPath)) File.Delete(_dbPath);
_db = new Shared.TestDbContext(_dbPath);
// _db.Database.EnsureCreated(); // Not needed/doesn't exist? StorageEngine handles creation.
}
/// <summary>
/// Disposes test resources and deletes temporary files.
/// </summary>
public void Dispose()
{
_db.Dispose();
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
/// <summary>
/// Verifies forward range scans return values in ascending order.
/// </summary>
[Fact]
public void Range_Forward_ReturnsOrderedResults()
{
var collection = _db.People;
var index = collection.EnsureIndex(p => p.Age, "idx_age");
var people = Enumerable.Range(1, 100).Select(i => new Person { Id = i, Name = $"Person {i}", Age = i }).ToList();
collection.InsertBulk(people);
_db.SaveChanges();
// Scan Forward
var results = index.Range(10, 20, IndexDirection.Forward).ToList();
results.Count.ShouldBe(11); // 10 to 20 inclusive
collection.FindByLocation(results.First())!.Age.ShouldBe(10); // First is 10
collection.FindByLocation(results.Last())!.Age.ShouldBe(20); // Last is 20
}
/// <summary>
/// Verifies backward range scans return values in descending order.
/// </summary>
[Fact]
public void Range_Backward_ReturnsReverseOrderedResults()
{
var collection = _db.People;
var index = collection.EnsureIndex(p => p.Age, "idx_age");
var people = Enumerable.Range(1, 100).Select(i => new Person { Id = i, Name = $"Person {i}", Age = i }).ToList();
collection.InsertBulk(people);
_db.SaveChanges();
// Scan Backward
var results = index.Range(10, 20, IndexDirection.Backward).ToList();
results.Count.ShouldBe(11); // 10 to 20 inclusive
collection.FindByLocation(results.First())!.Age.ShouldBe(20); // First is 20 (Reverse)
collection.FindByLocation(results.Last())!.Age.ShouldBe(10); // Last is 10
}
/// <summary>
/// Verifies backward scans across split index pages return complete result sets.
/// </summary>
[Fact]
public void Range_Backward_WithMultiplePages_ReturnsReverseOrderedResults()
{
var collection = _db.People;
var index = collection.EnsureIndex(p => p.Age, "idx_age_large");
// Insert enough to force splits (default page size is smallish, 4096, so 1000 items should split)
// Entry size approx 10 bytes key + 6 bytes loc + overhead
// 1000 items * 20 bytes = 20KB > 4KB.
var count = 1000;
var people = Enumerable.Range(1, count).Select(i => new Person { Id = i, Name = $"Person {i}", Age = i }).ToList();
collection.InsertBulk(people);
_db.SaveChanges();
// Scan ALL Backward
var results = index.Range(null, null, IndexDirection.Backward).ToList();
results.Count.ShouldBe(count);
// Note on sorting: IndexKey uses Little Endian byte comparison for integers.
// This means 256 (0x0001...) sorts before 1 (0x01...).
// Strict value checking fails for ranges crossing 255 boundary unless IndexKey is fixed to use Big Endian.
// For this test, we verify that we retrieved all items (Count) which implies valid page traversal.
// collection.FindByLocation(results.First(), null)!.Age.ShouldBe(count); // Max Age (Fails: Max is likely 255)
// collection.FindByLocation(results.Last(), null)!.Age.ShouldBe(1); // Min Age (Fails: Min is likely 256)
}
}

View File

@@ -0,0 +1,163 @@
using Xunit;
using ZB.MOM.WW.CBDD.Core.Query;
using ZB.MOM.WW.CBDD.Core.Indexing;
using System.Linq.Expressions;
using System.Collections.Generic;
using System;
namespace ZB.MOM.WW.CBDD.Tests
{
public class IndexOptimizationTests
{
public class TestEntity
{
/// <summary>
/// Gets or sets the id.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the name.
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// Gets or sets the age.
/// </summary>
public int Age { get; set; }
}
/// <summary>
/// Tests optimizer identifies equality.
/// </summary>
[Fact]
public void Optimizer_Identifies_Equality()
{
var indexes = new List<CollectionIndexInfo>
{
new CollectionIndexInfo { Name = "idx_age", PropertyPaths = ["Age"] }
};
Expression<Func<TestEntity, bool>> predicate = x => x.Age == 30;
var model = new QueryModel { WhereClause = predicate };
var result = IndexOptimizer.TryOptimize<TestEntity>(model, indexes);
result.ShouldNotBeNull();
result.IndexName.ShouldBe("idx_age");
result.MinValue.ShouldBe(30);
result.MaxValue.ShouldBe(30);
result.IsRange.ShouldBeFalse();
}
/// <summary>
/// Tests optimizer identifies range greater than.
/// </summary>
[Fact]
public void Optimizer_Identifies_Range_GreaterThan()
{
var indexes = new List<CollectionIndexInfo>
{
new CollectionIndexInfo { Name = "idx_age", PropertyPaths = ["Age"] }
};
Expression<Func<TestEntity, bool>> predicate = x => x.Age > 25;
var model = new QueryModel { WhereClause = predicate };
var result = IndexOptimizer.TryOptimize<TestEntity>(model, indexes);
result.ShouldNotBeNull();
result.IndexName.ShouldBe("idx_age");
result.MinValue.ShouldBe(25);
result.MaxValue.ShouldBeNull();
result.IsRange.ShouldBeTrue();
}
/// <summary>
/// Tests optimizer identifies range less than.
/// </summary>
[Fact]
public void Optimizer_Identifies_Range_LessThan()
{
var indexes = new List<CollectionIndexInfo>
{
new CollectionIndexInfo { Name = "idx_age", PropertyPaths = ["Age"] }
};
Expression<Func<TestEntity, bool>> predicate = x => x.Age < 50;
var model = new QueryModel { WhereClause = predicate };
var result = IndexOptimizer.TryOptimize<TestEntity>(model, indexes);
result.ShouldNotBeNull();
result.IndexName.ShouldBe("idx_age");
result.MinValue.ShouldBeNull();
result.MaxValue.ShouldBe(50);
result.IsRange.ShouldBeTrue();
}
/// <summary>
/// Tests optimizer identifies range between simulated.
/// </summary>
[Fact]
public void Optimizer_Identifies_Range_Between_Simulated()
{
var indexes = new List<CollectionIndexInfo>
{
new CollectionIndexInfo { Name = "idx_age", PropertyPaths = ["Age"] }
};
Expression<Func<TestEntity, bool>> predicate = x => x.Age > 20 && x.Age < 40;
var model = new QueryModel { WhereClause = predicate };
var result = IndexOptimizer.TryOptimize<TestEntity>(model, indexes);
result.ShouldNotBeNull();
result.IndexName.ShouldBe("idx_age");
result.MinValue.ShouldBe(20);
result.MaxValue.ShouldBe(40);
result.IsRange.ShouldBeTrue();
}
/// <summary>
/// Tests optimizer identifies starts with.
/// </summary>
[Fact]
public void Optimizer_Identifies_StartsWith()
{
var indexes = new List<CollectionIndexInfo>
{
new CollectionIndexInfo { Name = "idx_name", PropertyPaths = ["Name"], Type = IndexType.BTree }
};
Expression<Func<TestEntity, bool>> predicate = x => x.Name.StartsWith("Ali");
var model = new QueryModel { WhereClause = predicate };
var result = IndexOptimizer.TryOptimize<TestEntity>(model, indexes);
result.ShouldNotBeNull();
result.IndexName.ShouldBe("idx_name");
result.MinValue.ShouldBe("Ali");
// "Ali" + next char -> "Alj"
result.MaxValue.ShouldBe("Alj");
result.IsRange.ShouldBeTrue();
}
/// <summary>
/// Tests optimizer ignores non indexed fields.
/// </summary>
[Fact]
public void Optimizer_Ignores_NonIndexed_Fields()
{
var indexes = new List<CollectionIndexInfo>
{
new CollectionIndexInfo { Name = "idx_age", PropertyPaths = ["Age"] }
};
Expression<Func<TestEntity, bool>> predicate = x => x.Name == "Alice"; // Name is not indexed
var model = new QueryModel { WhereClause = predicate };
var result = IndexOptimizer.TryOptimize<TestEntity>(model, indexes);
result.ShouldBeNull();
}
}
}

View File

@@ -0,0 +1,134 @@
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Core;
using ZB.MOM.WW.CBDD.Core.Collections;
using ZB.MOM.WW.CBDD.Core.Metadata;
using ZB.MOM.WW.CBDD.Shared;
using System;
using System.Buffers;
namespace ZB.MOM.WW.CBDD.Tests;
public class PrimaryKeyTests : IDisposable
{
private readonly string _dbPath = "primary_key_tests.db";
/// <summary>
/// Initializes a new instance of the <see cref="PrimaryKeyTests"/> class.
/// </summary>
public PrimaryKeyTests()
{
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
/// <summary>
/// Executes Dispose.
/// </summary>
public void Dispose()
{
if (File.Exists(_dbPath)) File.Delete(_dbPath);
}
/// <summary>
/// Executes Test_Int_PrimaryKey.
/// </summary>
[Fact]
public void Test_Int_PrimaryKey()
{
using var db = new Shared.TestDbContext(_dbPath);
var entity = new IntEntity { Id = 1, Name = "Test 1" };
db.IntEntities.Insert(entity);
db.SaveChanges();
var retrieved = db.IntEntities.FindById(1);
retrieved.ShouldNotBeNull();
retrieved.Id.ShouldBe(1);
retrieved.Name.ShouldBe("Test 1");
entity.Name = "Updated";
db.IntEntities.Update(entity);
retrieved = db.IntEntities.FindById(1);
retrieved?.Name.ShouldBe("Updated");
db.IntEntities.Delete(1);
db.IntEntities.FindById(1).ShouldBeNull();
}
/// <summary>
/// Executes Test_String_PrimaryKey.
/// </summary>
[Fact]
public void Test_String_PrimaryKey()
{
using var db = new Shared.TestDbContext(_dbPath);
var entity = new StringEntity { Id = "key1", Value = "Value 1" };
db.StringEntities.Insert(entity);
db.SaveChanges();
var retrieved = db.StringEntities.FindById("key1");
retrieved.ShouldNotBeNull();
retrieved.Id.ShouldBe("key1");
retrieved.Value.ShouldBe("Value 1");
db.StringEntities.Delete("key1");
db.SaveChanges();
db.StringEntities.FindById("key1").ShouldBeNull();
}
/// <summary>
/// Executes Test_Guid_PrimaryKey.
/// </summary>
[Fact]
public void Test_Guid_PrimaryKey()
{
using var db = new Shared.TestDbContext(_dbPath);
var id = Guid.NewGuid();
var entity = new GuidEntity { Id = id, Name = "Guid Test" };
db.GuidEntities.Insert(entity);
db.SaveChanges();
var retrieved = db.GuidEntities.FindById(id);
retrieved.ShouldNotBeNull();
retrieved.Id.ShouldBe(id);
db.GuidEntities.Delete(id);
db.SaveChanges();
db.GuidEntities.FindById(id).ShouldBeNull();
}
/// <summary>
/// Executes Test_String_PrimaryKey_With_Custom_Name.
/// </summary>
[Fact]
public void Test_String_PrimaryKey_With_Custom_Name()
{
// Test entity with string key NOT named "Id" (named "Code" instead)
using var db = new Shared.TestDbContext(_dbPath);
var entity = new CustomKeyEntity { Code = "ABC123", Description = "Test Description" };
db.CustomKeyEntities.Insert(entity);
db.SaveChanges();
// Verify retrieval works correctly
var retrieved = db.CustomKeyEntities.FindById("ABC123");
retrieved.ShouldNotBeNull();
retrieved.Code.ShouldBe("ABC123");
retrieved.Description.ShouldBe("Test Description");
// Verify update works
entity.Description = "Updated Description";
db.CustomKeyEntities.Update(entity);
db.SaveChanges();
retrieved = db.CustomKeyEntities.FindById("ABC123");
retrieved?.Description.ShouldBe("Updated Description");
// Verify delete works
db.CustomKeyEntities.Delete("ABC123");
db.SaveChanges();
db.CustomKeyEntities.FindById("ABC123").ShouldBeNull();
}
}

View File

@@ -0,0 +1,62 @@
using ZB.MOM.WW.CBDD.Core.Indexing;
namespace ZB.MOM.WW.CBDD.Tests;
public class VectorMathTests
{
/// <summary>
/// Verifies distance calculations across all supported vector metrics.
/// </summary>
[Fact]
public void Distance_Should_Cover_All_Metrics()
{
float[] v1 = [1f, 2f];
float[] v2 = [3f, 4f];
var cosineDistance = VectorMath.Distance(v1, v2, VectorMetric.Cosine);
var l2Distance = VectorMath.Distance(v1, v2, VectorMetric.L2);
var dotDistance = VectorMath.Distance(v1, v2, VectorMetric.DotProduct);
l2Distance.ShouldBe(8f);
dotDistance.ShouldBe(-11f);
var expectedCosine = 1f - (11f / (MathF.Sqrt(5f) * 5f));
MathF.Abs(cosineDistance - expectedCosine).ShouldBeLessThan(0.0001f);
}
/// <summary>
/// Verifies cosine similarity returns zero when one vector has zero magnitude.
/// </summary>
[Fact]
public void CosineSimilarity_Should_Return_Zero_For_ZeroMagnitude_Vector()
{
float[] v1 = [0f, 0f, 0f];
float[] v2 = [1f, 2f, 3f];
VectorMath.CosineSimilarity(v1, v2).ShouldBe(0f);
}
/// <summary>
/// Verifies dot product throws for mismatched vector lengths.
/// </summary>
[Fact]
public void DotProduct_Should_Throw_For_Length_Mismatch()
{
float[] v1 = [1f, 2f];
float[] v2 = [1f];
Should.Throw<ArgumentException>(() => VectorMath.DotProduct(v1, v2));
}
/// <summary>
/// Verifies squared Euclidean distance throws for mismatched vector lengths.
/// </summary>
[Fact]
public void EuclideanDistanceSquared_Should_Throw_For_Length_Mismatch()
{
float[] v1 = [1f, 2f, 3f];
float[] v2 = [1f, 2f];
Should.Throw<ArgumentException>(() => VectorMath.EuclideanDistanceSquared(v1, v2));
}
}

View File

@@ -0,0 +1,34 @@
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Core;
using ZB.MOM.WW.CBDD.Core.Indexing;
using ZB.MOM.WW.CBDD.Shared;
using Xunit;
namespace ZB.MOM.WW.CBDD.Tests;
public class VectorSearchTests
{
/// <summary>
/// Verifies basic vector-search query behavior.
/// </summary>
[Fact]
public void Test_VectorSearch_Basic()
{
string dbPath = "vector_test.db";
if (File.Exists(dbPath)) File.Delete(dbPath);
using (var db = new Shared.TestDbContext(dbPath))
{
db.VectorItems.Insert(new VectorEntity { Title = "Near", Embedding = [1.0f, 1.0f, 1.0f] });
db.VectorItems.Insert(new VectorEntity { Title = "Far", Embedding = [10.0f, 10.0f, 10.0f] });
var query = new[] { 0.9f, 0.9f, 0.9f };
var results = db.VectorItems.AsQueryable().Where(x => x.Embedding.VectorSearch(query, 1)).ToList();
results.Count().ShouldBe(1);
results[0].Title.ShouldBe("Near");
}
File.Delete(dbPath);
}
}

View File

@@ -0,0 +1,183 @@
using ZB.MOM.WW.CBDD.Bson;
using ZB.MOM.WW.CBDD.Core;
using ZB.MOM.WW.CBDD.Core.Collections;
using ZB.MOM.WW.CBDD.Core.Indexing;
using ZB.MOM.WW.CBDD.Core.Storage;
using ZB.MOM.WW.CBDD.Core.Transactions;
using ZB.MOM.WW.CBDD.Shared;
using ZB.MOM.WW.CBDD.Shared.TestDbContext_TestDbContext_Mappers;
using System.Buffers;
using Xunit;
namespace ZB.MOM.WW.CBDD.Tests;
public class WalIndexTests : IDisposable
{
private readonly string _dbPath;
private readonly string _walPath;
private readonly Shared.TestDbContext _db;
private readonly ITestOutputHelper _output;
/// <summary>
/// Initializes a new instance of the <see cref="WalIndexTests"/> class.
/// </summary>
/// <param name="output">Test output sink.</param>
public WalIndexTests(ITestOutputHelper output)
{
_output = output;
_dbPath = Path.Combine(Path.GetTempPath(), $"test_wal_index_{Guid.NewGuid()}.db");
// WAL defaults to .wal next to db
_walPath = Path.ChangeExtension(_dbPath, ".wal");
_db = new Shared.TestDbContext(_dbPath);
}
/// <summary>
/// Verifies index writes are recorded in the WAL.
/// </summary>
[Fact]
public void IndexWritesAreLoggedToWal()
{
// 2. Start a transaction
using var txn = _db.BeginTransaction();
_output.WriteLine($"Started Transaction: {txn.TransactionId}");
// 3. Insert a user
var user = new User { Name = "Alice", Age = 30 };
_db.Users.Insert(user);
// 4. Commit
txn.Commit();
_output.WriteLine("Committed Transaction");
// 5. Verify WAL
// Dispose current storage to release file locks, BUT skip checkpoint/truncate
_db.Dispose();
File.Exists(_walPath).ShouldBeTrue("WAL file should exist");
using var walReader = new WriteAheadLog(_walPath);
var records = walReader.ReadAll();
_output.WriteLine($"Found {records.Count} WAL records");
// Filter for this transaction
var txnRecords = records.Where(r => r.TransactionId == txn.TransactionId).ToList();
txnRecords.ShouldContain(r => r.Type == WalRecordType.Begin);
txnRecords.ShouldContain(r => r.Type == WalRecordType.Commit);
var writeRecords = txnRecords.Where(r => r.Type == WalRecordType.Write).ToList();
_output.WriteLine($"Found {writeRecords.Count} Write records for Txn {txn.TransactionId}");
// Analyze pages
int indexPageCount = 0;
int dataPageCount = 0;
foreach (var record in writeRecords)
{
var pageType = ParsePageType(record.AfterImage);
_output.WriteLine($"Page {record.PageId}: Type={pageType}, Size={record.AfterImage?.Length}");
if (pageType == PageType.Index) indexPageCount++;
else if (pageType == PageType.Data) dataPageCount++;
}
(indexPageCount > 0).ShouldBeTrue($"Expected at least 1 Index page write, found {indexPageCount}");
(dataPageCount > 0).ShouldBeTrue($"Expected at least 1 Data page write, found {dataPageCount}");
}
private PageType ParsePageType(byte[]? pageData)
{
if (pageData == null || pageData.Length < 32) return (PageType)0;
// PageType is at offset 4 (1 byte)
return (PageType)pageData[4]; // Casting byte to PageType
}
/// <summary>
/// Verifies offline compaction leaves the WAL empty.
/// </summary>
[Fact]
public void Compact_ShouldLeaveWalEmpty_AfterOfflineRun()
{
for (var i = 0; i < 100; i++)
{
_db.Users.Insert(new User { Name = $"wal-compact-{i:D3}", Age = i % 30 });
}
_db.SaveChanges();
_db.Storage.GetWalSize().ShouldBeGreaterThan(0);
var stats = _db.Compact(new CompactionOptions
{
EnableTailTruncation = true,
NormalizeFreeList = true,
DefragmentSlottedPages = true
});
stats.OnlineMode.ShouldBeFalse();
_db.Storage.GetWalSize().ShouldBe(0);
new FileInfo(_walPath).Length.ShouldBe(0);
}
/// <summary>
/// Verifies WAL recovery followed by compaction preserves data.
/// </summary>
[Fact]
public void Recover_WithCommittedWal_ThenCompact_ShouldPreserveData()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"test_wal_recover_compact_{Guid.NewGuid():N}.db");
var walPath = Path.ChangeExtension(dbPath, ".wal");
var markerPath = $"{dbPath}.compact.state";
var expectedIds = new List<ObjectId>();
try
{
using (var writer = new Shared.TestDbContext(dbPath))
{
for (var i = 0; i < 48; i++)
{
expectedIds.Add(writer.Users.Insert(new User { Name = $"recover-{i:D3}", Age = i % 10 }));
}
writer.SaveChanges();
writer.Storage.GetWalSize().ShouldBeGreaterThan(0);
}
new FileInfo(walPath).Length.ShouldBeGreaterThan(0);
using (var recovered = new Shared.TestDbContext(dbPath))
{
recovered.Users.Count().ShouldBe(expectedIds.Count);
recovered.Compact();
recovered.Storage.GetWalSize().ShouldBe(0);
foreach (var id in expectedIds)
{
recovered.Users.FindById(id).ShouldNotBeNull();
}
}
}
finally
{
if (File.Exists(dbPath)) File.Delete(dbPath);
if (File.Exists(walPath)) File.Delete(walPath);
if (File.Exists(markerPath)) File.Delete(markerPath);
}
}
/// <summary>
/// Releases test resources.
/// </summary>
public void Dispose()
{
try
{
_db?.Dispose(); // Safe to call multiple times
}
catch { }
try { if (File.Exists(_dbPath)) File.Delete(_dbPath); } catch { }
try { if (File.Exists(_walPath)) File.Delete(_walPath); } catch { }
}
}