Implement checkpoint modes with docs/tests and reorganize project file layout
This commit is contained in:
58
tests/CBDD.Tests/Indexing/BTreeDeleteUnderflowTests.cs
Normal file
58
tests/CBDD.Tests/Indexing/BTreeDeleteUnderflowTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
128
tests/CBDD.Tests/Indexing/CursorTests.cs
Executable file
128
tests/CBDD.Tests/Indexing/CursorTests.cs
Executable 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);
|
||||
}
|
||||
}
|
||||
59
tests/CBDD.Tests/Indexing/GeospatialStressTests.cs
Normal file
59
tests/CBDD.Tests/Indexing/GeospatialStressTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
120
tests/CBDD.Tests/Indexing/GeospatialTests.cs
Executable file
120
tests/CBDD.Tests/Indexing/GeospatialTests.cs
Executable 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);
|
||||
}
|
||||
}
|
||||
91
tests/CBDD.Tests/Indexing/HashIndexTests.cs
Normal file
91
tests/CBDD.Tests/Indexing/HashIndexTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
108
tests/CBDD.Tests/Indexing/IndexDirectionTests.cs
Executable file
108
tests/CBDD.Tests/Indexing/IndexDirectionTests.cs
Executable 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)
|
||||
}
|
||||
}
|
||||
163
tests/CBDD.Tests/Indexing/IndexOptimizationTests.cs
Executable file
163
tests/CBDD.Tests/Indexing/IndexOptimizationTests.cs
Executable 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
134
tests/CBDD.Tests/Indexing/PrimaryKeyTests.cs
Executable file
134
tests/CBDD.Tests/Indexing/PrimaryKeyTests.cs
Executable 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();
|
||||
}
|
||||
}
|
||||
62
tests/CBDD.Tests/Indexing/VectorMathTests.cs
Normal file
62
tests/CBDD.Tests/Indexing/VectorMathTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
34
tests/CBDD.Tests/Indexing/VectorSearchTests.cs
Executable file
34
tests/CBDD.Tests/Indexing/VectorSearchTests.cs
Executable 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);
|
||||
}
|
||||
}
|
||||
183
tests/CBDD.Tests/Indexing/WalIndexTests.cs
Executable file
183
tests/CBDD.Tests/Indexing/WalIndexTests.cs
Executable 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 { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user