using System; using System.IO; using System.Linq; using Xunit; using ZB.MOM.WW.CBDD.Bson; using ZB.MOM.WW.CBDD.Bson.Schema; using ZB.MOM.WW.CBDD.Core.Storage; using ZB.MOM.WW.CBDD.Core.Collections; using ZB.MOM.WW.CBDD.Core.Indexing; using ZB.MOM.WW.CBDD.Shared.TestDbContext_TestDbContext_Mappers; using ZB.MOM.WW.CBDD.Shared; namespace ZB.MOM.WW.CBDD.Tests; public class SchemaPersistenceTests : IDisposable { private readonly string _dbPath; private readonly Shared.TestDbContext _db; public SchemaPersistenceTests() { _dbPath = Path.Combine(Path.GetTempPath(), $"schema_test_{Guid.NewGuid()}.db"); _db = new Shared.TestDbContext(_dbPath); } public void Dispose() { _db.Dispose(); if (File.Exists(_dbPath)) File.Delete(_dbPath); } [Fact] public void BsonSchema_Serialization_RoundTrip() { var schema = new BsonSchema { Title = "Person", Fields = { new BsonField { Name = "id", Type = BsonType.ObjectId }, new BsonField { Name = "name", Type = BsonType.String, IsNullable = true }, new BsonField { Name = "age", Type = BsonType.Int32 }, new BsonField { Name = "address", Type = BsonType.Document, NestedSchema = new BsonSchema { Fields = { new BsonField { Name = "city", Type = BsonType.String } } } } } }; var buffer = new byte[1024]; var keyMap = new System.Collections.Concurrent.ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); var keys = new System.Collections.Concurrent.ConcurrentDictionary(); // Manual registration for schema keys ushort id = 1; foreach (var k in new[] { "person", "id", "name", "age", "address", "city", "fields", "title", "type", "isnullable", "nestedschema", "t", "v", "f", "n", "b", "s", "a", "_v", "0", "1", "2", "3", "4", "5" }) { keyMap[k] = id; keys[id] = k; id++; } var writer = new BsonSpanWriter(buffer, keyMap); schema.ToBson(ref writer); var reader = new BsonSpanReader(buffer.AsSpan(0, writer.Position), keys); var roundTrip = BsonSchema.FromBson(ref reader); roundTrip.Title.ShouldBe(schema.Title); roundTrip.Fields.Count.ShouldBe(schema.Fields.Count); roundTrip.Fields[0].Name.ShouldBe(schema.Fields[0].Name); roundTrip.Fields[3].NestedSchema!.Fields[0].Name.ShouldBe(schema.Fields[3].NestedSchema!.Fields[0].Name); schema.Equals(roundTrip).ShouldBeTrue(); } [Fact] public void StorageEngine_Collections_Metadata_Persistence() { var meta = new CollectionMetadata { Name = "users", PrimaryRootPageId = 10, SchemaRootPageId = 20 }; meta.Indexes.Add(new IndexMetadata { Name = "age", IsUnique = false, Type = IndexType.BTree, PropertyPaths = ["Age"] }); _db.Storage.SaveCollectionMetadata(meta); var loaded = _db.Storage.GetCollectionMetadata("users"); loaded.ShouldNotBeNull(); loaded.Name.ShouldBe(meta.Name); loaded.PrimaryRootPageId.ShouldBe(meta.PrimaryRootPageId); loaded.SchemaRootPageId.ShouldBe(meta.SchemaRootPageId); loaded.Indexes.Count().ShouldBe(1); loaded.Indexes[0].Name.ShouldBe("age"); } [Fact] public void StorageEngine_Schema_Versioning() { var schema1 = new BsonSchema { Title = "V1", Fields = { new BsonField { Name = "f1", Type = BsonType.String } } }; var schema2 = new BsonSchema { Title = "V2", Fields = { new BsonField { Name = "f1", Type = BsonType.String }, new BsonField { Name = "f2", Type = BsonType.Int32 } } }; var rootId = _db.Storage.AppendSchema(0, schema1); rootId.ShouldNotBe(0u); var schemas = _db.Storage.GetSchemas(rootId); schemas.Count().ShouldBe(1); schemas[0].Title.ShouldBe("V1"); var updatedRoot = _db.Storage.AppendSchema(rootId, schema2); updatedRoot.ShouldBe(rootId); schemas = _db.Storage.GetSchemas(rootId); schemas.Count.ShouldBe(2, $"Expected 2 schemas but found {schemas.Count}. Titles: {(schemas.Count > 0 ? string.Join(", ", schemas.Select(s => s.Title)) : "None")}"); schemas[0].Title.ShouldBe("V1"); schemas[1].Title.ShouldBe("V2"); } [Fact] public void DocumentCollection_Integrates_Schema_Versioning_On_Startup() { // Use a dedicated database for this test to avoid schema pollution from _db var testDbPath = Path.Combine(Path.GetTempPath(), $"schema_versioning_test_{Guid.NewGuid()}.db"); try { var mapper1 = new ZB_MOM_WW_CBDD_Shared_PersonMapper(); var schema1 = mapper1.GetSchema(); // 1. First startup - create DB and initialize Person collection using (var db1 = new Shared.TestDbContext(testDbPath)) { // Access only People collection to avoid initializing others var coll = db1.People; var meta = db1.Storage.GetCollectionMetadata("people_collection"); meta.ShouldNotBeNull(); var schemas = db1.Storage.GetSchemas(meta.SchemaRootPageId); schemas.Count().ShouldBe(1); schema1.Equals(schemas[0]).ShouldBeTrue("Persisted schema 1 should equal current schema 1"); coll.CurrentSchemaVersion.ShouldNotBeNull(); coll.CurrentSchemaVersion!.Value.Version.ShouldBe(1); coll.CurrentSchemaVersion!.Value.Hash.ShouldBe(schema1.GetHash()); } // 2. Restart with SAME schema (should NOT append) using (var db2 = new Shared.TestDbContext(testDbPath)) { var coll = db2.People; var meta = db2.Storage.GetCollectionMetadata("people_collection"); var schemas = db2.Storage.GetSchemas(meta!.SchemaRootPageId); schemas.Count().ShouldBe(1); // Still 1 coll.CurrentSchemaVersion!.Value.Version.ShouldBe(1); coll.CurrentSchemaVersion!.Value.Hash.ShouldBe(schema1.GetHash()); } // 3. Simulate schema evolution: Person with an additional field // Since we can't change the actual Person class at runtime, this test verifies // that the same schema doesn't get re-appended. // A real-world scenario would involve deploying a new mapper version. using (var db3 = new Shared.TestDbContext(testDbPath)) { var coll = db3.People; var meta = db3.Storage.GetCollectionMetadata("people_collection"); var schemas = db3.Storage.GetSchemas(meta!.SchemaRootPageId); // Schema should still be 1 since we're using the same Person type schemas.Count().ShouldBe(1); schemas[0].Title.ShouldBe("Person"); coll.CurrentSchemaVersion!.Value.Version.ShouldBe(1); } } finally { if (File.Exists(testDbPath)) File.Delete(testDbPath); } } [Fact] public void Document_Contains_Schema_Version_Field() { var mapper = new ZB_MOM_WW_CBDD_Shared_PersonMapper(); using (var coll = _db.People) { var person = new Person { Name = "John" }; var id = coll.Insert(person); _db.SaveChanges(); coll.Count().ShouldBe(1); coll.CurrentSchemaVersion.ShouldNotBeNull(); coll.CurrentSchemaVersion!.Value.Version.ShouldBe(1); // Verify that the document in storage contains _v var meta = _db.Storage.GetCollectionMetadata("persons"); // person lowercase // meta.ShouldNotBeNull(); // Get location from primary index (internal access enabled by InternalsVisibleTo) var key = mapper.ToIndexKey(id); coll._primaryIndex.TryFind(key, out var location, 0).ShouldBeTrue(); // Read raw bytes from page var pageBuffer = new byte[_db.Storage.PageSize]; _db.Storage.ReadPage(location.PageId, 0, pageBuffer); var slotOffset = SlottedPageHeader.Size + (location.SlotIndex * SlotEntry.Size); var slot = SlotEntry.ReadFrom(pageBuffer.AsSpan(slotOffset)); var docData = pageBuffer.AsSpan(slot.Offset, slot.Length); // Print some info if it fails (using Assert messages) string hex = BitConverter.ToString(docData.ToArray()).Replace("-", ""); // Look for _v (BsonType.Int32 + 2-byte ID) ushort vId = _db.Storage.GetKeyMap()["_v"]; string vIdHex = vId.ToString("X4"); // Reverse endian for hex string check (ushort is LE) string vIdHexLE = vIdHex.Substring(2, 2) + vIdHex.Substring(0, 2); string pattern = "10" + vIdHexLE; bool found = hex.Contains(pattern); found.ShouldBeTrue($"Document should contain _v field ({pattern}). Raw BSON: {hex}"); // Verify the value (1) follows the key int index = hex.IndexOf(pattern); string valueHex = hex.Substring(index + 6, 8); // 4 bytes = 8 hex chars (pattern is 6 hex chars: 10 + ID_LE) valueHex.ShouldBe("01000000"); } } }