diff --git a/.gitignore b/.gitignore index c0890d77..04d69149 100644 --- a/.gitignore +++ b/.gitignore @@ -199,4 +199,7 @@ ModelManifest.xml .vs # Custom -ConnectionStrings.config \ No newline at end of file +ConnectionStrings.config +docs/ +.idea/ +*.DS_Store \ No newline at end of file diff --git a/OnTopic.AspNetCore.Mvc.Host/Program.cs b/OnTopic.AspNetCore.Mvc.Host/Program.cs index 15bb5613..3b828e55 100644 --- a/OnTopic.AspNetCore.Mvc.Host/Program.cs +++ b/OnTopic.AspNetCore.Mvc.Host/Program.cs @@ -14,22 +14,30 @@ /*============================================================================================================================== | CONFIGURE SERVICES \-----------------------------------------------------------------------------------------------------------------------------*/ -var builder = WebApplication.CreateBuilder(args); +var builder = WebApplication.CreateBuilder(args); /*------------------------------------------------------------------------------------------------------------------------------ | Configure: Cookie Policy \-----------------------------------------------------------------------------------------------------------------------------*/ builder.Services.Configure(options => { // This lambda determines whether user consent for non-essential cookies is needed for a given request. - options.CheckConsentNeeded = context => true; + options.CheckConsentNeeded = context => true; options.MinimumSameSitePolicy = SameSiteMode.None; }); /*------------------------------------------------------------------------------------------------------------------------------ -| Configure: Output Caching +| Configure: Response Caching \-----------------------------------------------------------------------------------------------------------------------------*/ builder.Services.AddResponseCaching(); +/*------------------------------------------------------------------------------------------------------------------------------ +| Configure: Output Caching +>------------------------------------------------------------------------------------------------------------------------------- +| Required for MapTopicSitemap()'s default caching policy to take effect; without this, the sitemap still renders correctly on +| every request via the dedicated SqlSitemapTopicRepository, but it isn't cached. +\-----------------------------------------------------------------------------------------------------------------------------*/ +builder.Services.AddOutputCache(); + /*------------------------------------------------------------------------------------------------------------------------------ | Configure: MVC \-----------------------------------------------------------------------------------------------------------------------------*/ @@ -53,7 +61,7 @@ /*============================================================================================================================== | CONFIGURE APPLICATION \-----------------------------------------------------------------------------------------------------------------------------*/ -var app = builder.Build(); +var app = builder.Build(); /*------------------------------------------------------------------------------------------------------------------------------ | Configure: Error Pages @@ -74,6 +82,7 @@ app.UseRouting(); app.UseCors("default"); app.UseResponseCaching(); +app.UseOutputCache(); /*------------------------------------------------------------------------------------------------------------------------------ | Configure: MVC diff --git a/OnTopic.AspNetCore.Mvc.Host/SampleActivator.cs b/OnTopic.AspNetCore.Mvc.Host/SampleActivator.cs index 11ca0b89..c424377d 100644 --- a/OnTopic.AspNetCore.Mvc.Host/SampleActivator.cs +++ b/OnTopic.AspNetCore.Mvc.Host/SampleActivator.cs @@ -36,6 +36,7 @@ public class SampleActivator : IControllerActivator, IViewComponentActivator { private readonly ITypeLookupService _typeLookupService; private readonly ITopicMappingService _topicMappingService; private readonly ITopicRepository _topicRepository; + private readonly ISitemapTopicRepository _sitemapTopicRepository; private DateTime _cacheLastUpdated = DateTime.UtcNow; /*============================================================================================================================ @@ -64,17 +65,17 @@ public SampleActivator(string connectionString) { /*-------------------------------------------------------------------------------------------------------------------------- | Initialize Topic Repository \-------------------------------------------------------------------------------------------------------------------------*/ - var sqlTopicRepository = new SqlTopicRepository(connectionString); - var cachedTopicRepository = new CachedTopicRepository(sqlTopicRepository); - _ = new PageTopicViewModel(); + var sqlTopicRepository = new SqlTopicRepository(connectionString); + var cachedTopicRepository = new CachedTopicRepository(sqlTopicRepository); + _ = new PageTopicViewModel(); /*-------------------------------------------------------------------------------------------------------------------------- | Preload repository \-------------------------------------------------------------------------------------------------------------------------*/ - _topicRepository = cachedTopicRepository; - _typeLookupService = new DynamicTopicViewModelLookupService(); - _topicMappingService = new TopicMappingService(_topicRepository, _typeLookupService); - _ = _topicRepository.Load(); + _topicRepository = cachedTopicRepository; + _sitemapTopicRepository = new SqlSitemapTopicRepository(connectionString); + _typeLookupService = new DynamicTopicViewModelLookupService(); + _topicMappingService = new TopicMappingService(_topicRepository, _typeLookupService); /*-------------------------------------------------------------------------------------------------------------------------- | Establish hierarchical topic mapping service @@ -100,20 +101,20 @@ public object Create(ControllerContext context) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters \-------------------------------------------------------------------------------------------------------------------------*/ - Contract.Requires(context, nameof(context)); + Contract.Requires(context, nameof(context)); /*-------------------------------------------------------------------------------------------------------------------------- | Determine controller type \-------------------------------------------------------------------------------------------------------------------------*/ - var type = context.ActionDescriptor.ControllerTypeInfo.AsType(); + var type = context.ActionDescriptor.ControllerTypeInfo.AsType(); /*-------------------------------------------------------------------------------------------------------------------------- | Periodically update cache \-------------------------------------------------------------------------------------------------------------------------*/ if (DateTime.UtcNow > _cacheLastUpdated.AddMinutes(1)) { - var currentUpdate = DateTime.UtcNow; - _topicRepository.Refresh(_topicRepository.Load()!, _cacheLastUpdated); - _cacheLastUpdated = currentUpdate; + var currentUpdate = DateTime.UtcNow; + _topicRepository.Refresh(_topicRepository.Load().GetAwaiter().GetResult()!, _cacheLastUpdated).GetAwaiter().GetResult(); + _cacheLastUpdated = currentUpdate; } /*-------------------------------------------------------------------------------------------------------------------------- @@ -125,7 +126,7 @@ public object Create(ControllerContext context) { nameof(ErrorController) => new ErrorController(_topicRepository, _topicMappingService), nameof(SitemapController) => - new SitemapController(_topicRepository), + new SitemapController(_sitemapTopicRepository), nameof(RedirectController) => new RedirectController(_topicRepository), _ => throw new InvalidOperationException($"Unknown controller {type.Name}") @@ -142,12 +143,12 @@ public object Create(ViewComponentContext context) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters \-------------------------------------------------------------------------------------------------------------------------*/ - Contract.Requires(context, nameof(context)); + Contract.Requires(context, nameof(context)); /*-------------------------------------------------------------------------------------------------------------------------- | Determine view component type \-------------------------------------------------------------------------------------------------------------------------*/ - var type = context.ViewComponentDescriptor.TypeInfo.AsType(); + var type = context.ViewComponentDescriptor.TypeInfo.AsType(); /*-------------------------------------------------------------------------------------------------------------------------- | Configure and return appropriate view component diff --git a/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/OnTopic.AspNetCore.Mvc.IntegrationTests.Host.csproj b/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/OnTopic.AspNetCore.Mvc.IntegrationTests.Host.csproj index dce9490f..9a2a4121 100644 --- a/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/OnTopic.AspNetCore.Mvc.IntegrationTests.Host.csproj +++ b/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/OnTopic.AspNetCore.Mvc.IntegrationTests.Host.csproj @@ -8,6 +8,7 @@ + diff --git a/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Repositories/StubTopicRepository.cs b/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Repositories/StubTopicRepository.cs index b4fd5ab1..eb01b1a9 100644 --- a/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Repositories/StubTopicRepository.cs +++ b/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Repositories/StubTopicRepository.cs @@ -33,8 +33,8 @@ public class StubTopicRepository : TopicRepository, ITopicRepository { /// Instantiates a new instance of the StubTopicRepository. /// /// A new instance of the StubTopicRepository. - public StubTopicRepository() : base() { - _cache = CreateFakeData(); + public StubTopicRepository() { + _cache = CreateFakeData(); Contract.Assume(_cache); } @@ -42,90 +42,100 @@ public StubTopicRepository() : base() { | METHOD: LOAD \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override Topic? Load(int topicId, Topic? referenceTopic = null, bool isRecursive = true) { + public override Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Lookup by TopicId \-------------------------------------------------------------------------------------------------------------------------*/ - var topic = _cache; + var topic = (Topic?)_cache; if (topicId > 0) { - topic = _cache.FindFirst(t => t.Id.Equals(topicId)); + topic = _cache.FindFirst(t => t.Id.Equals(topicId)); } /*-------------------------------------------------------------------------------------------------------------------------- | Raise event \-------------------------------------------------------------------------------------------------------------------------*/ if (topic != null) { - OnTopicLoaded(new(topic, isRecursive)); + OnTopicLoaded(new(topic, depth)); } /*-------------------------------------------------------------------------------------------------------------------------- | Return value \-------------------------------------------------------------------------------------------------------------------------*/ - return topic; + return Task.FromResult(topic); } /// - public override Topic? Load(string uniqueKey, Topic? referenceTopic = null, bool isRecursive = true) { + public override Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters \-------------------------------------------------------------------------------------------------------------------------*/ if (String.IsNullOrEmpty(uniqueKey)) { - return null; + return Task.FromResult(null); } /*-------------------------------------------------------------------------------------------------------------------------- | Lookup by TopicKey \-------------------------------------------------------------------------------------------------------------------------*/ - var topic = _cache.GetByUniqueKey(uniqueKey); + var topic = _cache.GetByUniqueKey(uniqueKey); /*-------------------------------------------------------------------------------------------------------------------------- | Raise event \-------------------------------------------------------------------------------------------------------------------------*/ if (topic != null) { - OnTopicLoaded(new(topic, isRecursive)); + OnTopicLoaded(new(topic, depth)); } /*-------------------------------------------------------------------------------------------------------------------------- | Return topic \-------------------------------------------------------------------------------------------------------------------------*/ - return topic; + return Task.FromResult(topic); } /// - public override Topic? Load(int topicId, DateTime version, Topic? referenceTopic = null) => + public override Task Load(int topicId, DateTime version) => throw new NotImplementedException(); /*============================================================================================================================ | METHOD: REFRESH \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Refresh(Topic referenceTopic, DateTime since) => + public override Task Refresh(Topic referenceTopic, DateTime since) => throw new NotImplementedException(); /*============================================================================================================================ | METHOD: SAVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - protected override void SaveTopic([NotNull]Topic topic, DateTime version, bool persistRelationships) => + protected override Task SaveTopic([NotNull]Topic topic, DateTime version, bool persistRelationships) => throw new NotImplementedException(); /*============================================================================================================================ | METHOD: MOVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - protected override void MoveTopic(Topic topic, Topic target, Topic? sibling = null) => + protected override Task MoveTopic(Topic topic, Topic target, Topic? sibling = null) => throw new NotImplementedException(); /*============================================================================================================================ | METHOD: DELETE \---------------------------------------------------------------------------------------------------------------------------*/ /// - protected override void DeleteTopic(Topic topic) => + protected override Task DeleteTopic(Topic topic) => throw new NotImplementedException(); /*============================================================================================================================ diff --git a/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/SampleActivator.cs b/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/SampleActivator.cs index 93e19bff..08be4215 100644 --- a/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/SampleActivator.cs +++ b/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/SampleActivator.cs @@ -8,11 +8,11 @@ using Microsoft.AspNetCore.Mvc.ViewComponents; using OnTopic.AspNetCore.Mvc.Controllers; using OnTopic.AspNetCore.Mvc.IntegrationTests.Areas.Area.Controllers; -using OnTopic.AspNetCore.Mvc.IntegrationTests.Host.Repositories; using OnTopic.Data.Caching; using OnTopic.Lookup; using OnTopic.Mapping; using OnTopic.Mapping.Hierarchical; +using OnTopic.TestDoubles; using OnTopic.ViewModels; namespace OnTopic.AspNetCore.Mvc.IntegrationTests.Host; @@ -33,6 +33,7 @@ public class SampleActivator : IControllerActivator, IViewComponentActivator { private readonly ITypeLookupService _typeLookupService; private readonly ITopicMappingService _topicMappingService; private readonly ITopicRepository _topicRepository; + private readonly ISitemapTopicRepository _sitemapTopicRepository; /*============================================================================================================================ | HIERARCHICAL TOPIC MAPPING SERVICE @@ -55,22 +56,23 @@ public SampleActivator() { /*-------------------------------------------------------------------------------------------------------------------------- | Initialize Topic Repository \-------------------------------------------------------------------------------------------------------------------------*/ - var sqlTopicRepository = new StubTopicRepository(); + var sqlTopicRepository = new Repositories.StubTopicRepository(); var cachedTopicRepository = new CachedTopicRepository(sqlTopicRepository); - _ = new PageTopicViewModel(); + _ = new PageTopicViewModel(); /*-------------------------------------------------------------------------------------------------------------------------- | Preload repository \-------------------------------------------------------------------------------------------------------------------------*/ - _topicRepository = cachedTopicRepository; - _typeLookupService = new DynamicTopicViewModelLookupService(); - _topicMappingService = new TopicMappingService(_topicRepository, _typeLookupService); - _ = _topicRepository.Load(); + _topicRepository = cachedTopicRepository; + _sitemapTopicRepository = new StubSitemapTopicRepository(_topicRepository); + _typeLookupService = new DynamicTopicViewModelLookupService(); + _topicMappingService = new TopicMappingService(_topicRepository, _typeLookupService); + _ = _topicRepository.Load().GetAwaiter().GetResult(); /*-------------------------------------------------------------------------------------------------------------------------- | Establish hierarchical topic mapping service \-------------------------------------------------------------------------------------------------------------------------*/ - _hierarchicalMappingService = new CachedHierarchicalTopicMappingService( + _hierarchicalMappingService = new CachedHierarchicalTopicMappingService( new HierarchicalTopicMappingService( _topicRepository, _topicMappingService @@ -96,7 +98,7 @@ public object Create(ControllerContext context) { /*-------------------------------------------------------------------------------------------------------------------------- | Determine controller type \-------------------------------------------------------------------------------------------------------------------------*/ - var type = context.ActionDescriptor.ControllerTypeInfo.AsType(); + var type = context.ActionDescriptor.ControllerTypeInfo.AsType(); /*-------------------------------------------------------------------------------------------------------------------------- | Configure and return appropriate controller @@ -111,7 +113,7 @@ public object Create(ControllerContext context) { nameof(ControllerController) => new ControllerController(), nameof(SitemapController) => - new SitemapController(_topicRepository), + new SitemapController(_sitemapTopicRepository), nameof(RedirectController) => new RedirectController(_topicRepository), _ => throw new InvalidOperationException($"Unknown controller {type.Name}") @@ -133,13 +135,13 @@ public object Create(ViewComponentContext context) { /*-------------------------------------------------------------------------------------------------------------------------- | Determine view component type \-------------------------------------------------------------------------------------------------------------------------*/ - var type = context.ViewComponentDescriptor.TypeInfo.AsType(); + var type = context.ViewComponentDescriptor.TypeInfo.AsType(); /*-------------------------------------------------------------------------------------------------------------------------- | Configure and return appropriate view component \-------------------------------------------------------------------------------------------------------------------------*/ return type.Name switch { - _ => throw new InvalidOperationException($"Unknown view component {type.Name}") + _ => throw new InvalidOperationException($"Unknown view component {type.Name}") }; } diff --git a/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Startup.cs b/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Startup.cs index ecc309f8..50375a27 100644 --- a/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Startup.cs +++ b/OnTopic.AspNetCore.Mvc.IntegrationTests.Host/Startup.cs @@ -27,7 +27,7 @@ public class Startup { /// The shared dependency. /// public Startup(IConfiguration configuration) { - Configuration = configuration; + Configuration = configuration; } /*============================================================================================================================ @@ -62,7 +62,7 @@ public void ConfigureServices(IServiceCollection services) { /*-------------------------------------------------------------------------------------------------------------------------- | Register: Activators \-------------------------------------------------------------------------------------------------------------------------*/ - var activator = new SampleActivator(); + var activator = new SampleActivator(); services.AddSingleton(activator); services.AddSingleton(activator); diff --git a/OnTopic.AspNetCore.Mvc.IntegrationTests/ServiceCollectionExtensionsTests.cs b/OnTopic.AspNetCore.Mvc.IntegrationTests/ServiceCollectionExtensionsTests.cs index 04f3da61..57809f6a 100644 --- a/OnTopic.AspNetCore.Mvc.IntegrationTests/ServiceCollectionExtensionsTests.cs +++ b/OnTopic.AspNetCore.Mvc.IntegrationTests/ServiceCollectionExtensionsTests.cs @@ -3,7 +3,7 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ -using System; + using System.Net; using Microsoft.AspNetCore.Routing; @@ -31,7 +31,7 @@ public class ServiceCollectionExtensionsTests: IClassFixture. /// public ServiceCollectionExtensionsTests(WebApplicationFactory factory) { - _factory = factory; + _factory = factory; } /*============================================================================================================================ diff --git a/OnTopic.AspNetCore.Mvc.IntegrationTests/TopicViewLocationExpanderTest.cs b/OnTopic.AspNetCore.Mvc.IntegrationTests/TopicViewLocationExpanderTest.cs index 3328dabe..d1bfae24 100644 --- a/OnTopic.AspNetCore.Mvc.IntegrationTests/TopicViewLocationExpanderTest.cs +++ b/OnTopic.AspNetCore.Mvc.IntegrationTests/TopicViewLocationExpanderTest.cs @@ -30,15 +30,15 @@ public class TopicViewLocationExpanderTest: IClassFixture. /// public TopicViewLocationExpanderTest(WebApplicationFactory factory) { - _factory = factory; + _factory = factory; } /*============================================================================================================================ | TEST: EXPAND VIEW LOCATIONS: VIEWS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Evaluates multiple views to ensure they fallback to the appropriate locations as defined in and . + /// Evaluates multiple views to ensure they fallback to the appropriate locations as defined in and . /// [Theory] [InlineData( "AreaContentTypeView", "ContentType/AreaContentTypeView.cshtml")] @@ -56,10 +56,10 @@ public TopicViewLocationExpanderTest(WebApplicationFactory factory) { public async Task ExpandViewLocations_Views(string viewName, string viewLocation) { if (viewName is not null && viewName.StartsWith("Area", StringComparison.OrdinalIgnoreCase)) { - viewLocation = $"~/Areas/Area/Views/{viewLocation}"; + viewLocation = $"~/Areas/Area/Views/{viewLocation}"; } else { - viewLocation = $"~/Views/{viewLocation}"; + viewLocation = $"~/Views/{viewLocation}"; } var client = _factory.CreateClient(); @@ -94,10 +94,10 @@ public async Task ExpandViewLocations_Views(string viewName, string viewLocation public async Task ExpandViewLocations_Actions(string viewName, string viewLocation) { if (viewName is not null && viewName.StartsWith("Area", StringComparison.OrdinalIgnoreCase)) { - viewLocation = $"~/Areas/Area/Views/{viewLocation}"; + viewLocation = $"~/Areas/Area/Views/{viewLocation}"; } else { - viewLocation = $"~/Views/{viewLocation}"; + viewLocation = $"~/Views/{viewLocation}"; } var client = _factory.CreateClient(); diff --git a/OnTopic.AspNetCore.Mvc.IntegrationTests/TopicViewResultExecutorTest.cs b/OnTopic.AspNetCore.Mvc.IntegrationTests/TopicViewResultExecutorTest.cs index 439c4c1a..f2294b0b 100644 --- a/OnTopic.AspNetCore.Mvc.IntegrationTests/TopicViewResultExecutorTest.cs +++ b/OnTopic.AspNetCore.Mvc.IntegrationTests/TopicViewResultExecutorTest.cs @@ -30,7 +30,7 @@ public class TopicViewResultExecutorTest: IClassFixture. /// public TopicViewResultExecutorTest(WebApplicationFactory factory) { - _factory = factory; + _factory = factory; } /*============================================================================================================================ @@ -154,9 +154,9 @@ public async Task ContentType_ReturnsExpectedView() { [Fact] public async Task MissingView_ReturnsInternalServerError() { - var client = _factory.CreateClient(); - var uri = new Uri("/Web/MissingView/", UriKind.Relative); - var response = await client.GetAsync(uri, TestContext.Current.CancellationToken); + var client = _factory.CreateClient(); + var uri = new Uri("/Web/MissingView/", UriKind.Relative); + var response = await client.GetAsync(uri, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); diff --git a/OnTopic.AspNetCore.Mvc.Tests/SitemapControllerTest.cs b/OnTopic.AspNetCore.Mvc.Tests/SitemapControllerTest.cs index d4597e9e..181c7dc5 100644 --- a/OnTopic.AspNetCore.Mvc.Tests/SitemapControllerTest.cs +++ b/OnTopic.AspNetCore.Mvc.Tests/SitemapControllerTest.cs @@ -1,4 +1,4 @@ -/*============================================================================================================================== +/*============================================================================================================================== | Author Ignia, LLC | Client Ignia, LLC | Project Topics Library @@ -11,6 +11,7 @@ using OnTopic.AspNetCore.Mvc.Tests.TestDoubles; using OnTopic.Data.Caching; using OnTopic.Repositories; +using OnTopic.TestDoubles; namespace OnTopic.Tests; @@ -26,7 +27,7 @@ public class SitemapControllerTest: IClassFixture { /*============================================================================================================================ | PRIVATE VARIABLES \---------------------------------------------------------------------------------------------------------------------------*/ - readonly ITopicRepository _topicRepository; + readonly ISitemapTopicRepository _topicRepository; readonly ControllerContext _context; /*============================================================================================================================ @@ -37,7 +38,8 @@ public class SitemapControllerTest: IClassFixture { /// /// /// This uses the to provide data, and then to - /// manage the in-memory representation of the data. While this introduces some overhead to the tests, the latter is a + /// manage the in-memory representation of the data, wrapped in a to satisfy the + /// 's narrower dependency. While this introduces some overhead to the tests, the latter is a /// relatively lightweight façade to any , and prevents the need to duplicate logic for /// crawling the object graph. In addition, it initializes a shared reference to use for the various /// tests. @@ -47,7 +49,7 @@ public SitemapControllerTest(TestTopicRepository topicRepository) { /*-------------------------------------------------------------------------------------------------------------------------- | Establish dependencies \-------------------------------------------------------------------------------------------------------------------------*/ - _topicRepository = new CachedTopicRepository(topicRepository); + _topicRepository = new StubSitemapTopicRepository(new CachedTopicRepository(topicRepository)); /*-------------------------------------------------------------------------------------------------------------------------- | Establish view model context @@ -70,7 +72,7 @@ public SitemapControllerTest(TestTopicRepository topicRepository) { | TEST: SITEMAP CONTROLLER: INDEX: RETURNS SITEMAP XML \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Triggers the index action of the action. + /// Triggers the index action of the action. /// [Fact] public void SitemapController_Index_ReturnsSitemapXml() { @@ -94,7 +96,7 @@ public void SitemapController_Index_ReturnsSitemapXml() { | TEST: SITEMAP CONTROLLER: INDEX: EXCLUDES CONTENT TYPES \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Triggers the index action of the action and verifies that it + /// Triggers the index action of the action and verifies that it /// properly excludes List content types, and skips over Container and PageGroup. /// [Fact] @@ -103,7 +105,7 @@ public void SitemapController_Index_ExcludesContentTypes() { var controller = new SitemapController(_topicRepository) { ControllerContext = new(_context) }; - var result = controller.Extended(true) as ContentResult; + var result = controller.Index(true) as ContentResult; var model = result?.Content as string; controller.Dispose(); @@ -125,7 +127,7 @@ public void SitemapController_Index_ExcludesContentTypes() { | TEST: SITEMAP CONTROLLER: INDEX: EXCLUDES CONTAINER DESCENDANTS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Triggers the index action of the action and verifies that it + /// Triggers the index action of the action and verifies that it /// properly excludes the children of Container topics that are marked as NoIndex. /// [Fact] @@ -134,7 +136,7 @@ public void SitemapController_Index_ExcludesContainerDescendants() { var controller = new SitemapController(_topicRepository) { ControllerContext = new(_context) }; - var result = controller.Extended(true) as ContentResult; + var result = controller.Index(true) as ContentResult; var model = result?.Content as string; controller.Dispose(); @@ -149,7 +151,7 @@ public void SitemapController_Index_ExcludesContainerDescendants() { | TEST: SITEMAP CONTROLLER: INDEX: EXCLUDES PRIVATE BRANCHES \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Triggers the index action of the action and verifies that it + /// Triggers the index action of the action and verifies that it /// properly excludes the topics that are marked as IsPrivateBranch, including their descendants. /// [Fact] @@ -158,7 +160,7 @@ public void SitemapController_Index_ExcludesPrivateBranches() { var controller = new SitemapController(_topicRepository) { ControllerContext = new(_context) }; - var result = controller.Extended(true) as ContentResult; + var result = controller.Index(true) as ContentResult; var model = result?.Content as string; controller.Dispose(); @@ -169,63 +171,4 @@ public void SitemapController_Index_ExcludesPrivateBranches() { } - /*============================================================================================================================ - | TEST: SITEMAP CONTROLLER: EXTENDED: INCLUDES ATTRIBUTES - \---------------------------------------------------------------------------------------------------------------------------*/ - /// - /// Triggers the extended action of the action and ensures that the - /// results include the expected attributes. - /// - [Fact] - public void SitemapController_Extended_IncludesAttributes() { - - var controller = new SitemapController(_topicRepository) { - ControllerContext = new(_context) - }; - var result = controller.Extended(true) as ContentResult; - var model = result?.Content as string; - - controller.Dispose(); - - Assert.NotNull(model); - - Assert.Contains("", model, StringComparison.Ordinal); - Assert.Contains("/Web/Valid/Child/", model, StringComparison.Ordinal); - - Assert.Contains("Value", model, StringComparison.Ordinal); - Assert.Contains("Title", model, StringComparison.Ordinal); - Assert.Contains("", model, StringComparison.Ordinal); - Assert.Contains("Web:Redirect", model, StringComparison.Ordinal); - Assert.Contains("", model, StringComparison.Ordinal); - Assert.Contains("Web:Redirect", model, StringComparison.Ordinal); - - } - - /*============================================================================================================================ - | TEST: SITEMAP CONTROLLER: EXTENDED: EXCLUDES ATTRIBUTES - \---------------------------------------------------------------------------------------------------------------------------*/ - /// - /// Triggers the index action of the action and verifies that it - /// properly excludes e.g. the Body and IsHidden attributes. - /// - [Fact] - public void SitemapController_Index_ExcludesAttributes() { - - var controller = new SitemapController(_topicRepository) { - ControllerContext = new(_context) - }; - var result = controller.Extended(true) as ContentResult; - var model = result?.Content as string; - - controller.Dispose(); - - Assert.NotNull(model); - - Assert.False(model!.Contains("", StringComparison.Ordinal)); - Assert.False(model!.Contains("", StringComparison.Ordinal)); - Assert.False(model!.Contains("", StringComparison.Ordinal)); - Assert.False(model!.Contains("List", StringComparison.Ordinal)); - - } - } //Class \ No newline at end of file diff --git a/OnTopic.AspNetCore.Mvc.Tests/TestDoubles/FakeControllerContext.cs b/OnTopic.AspNetCore.Mvc.Tests/TestDoubles/FakeControllerContext.cs index 90344e4e..50469f03 100644 --- a/OnTopic.AspNetCore.Mvc.Tests/TestDoubles/FakeControllerContext.cs +++ b/OnTopic.AspNetCore.Mvc.Tests/TestDoubles/FakeControllerContext.cs @@ -57,4 +57,4 @@ public static ControllerContext GetControllerContext(string rootTopic, string? p } -} //Class +} //Class \ No newline at end of file diff --git a/OnTopic.AspNetCore.Mvc.Tests/TestDoubles/TestTopicRepository.cs b/OnTopic.AspNetCore.Mvc.Tests/TestDoubles/TestTopicRepository.cs index 4f4604ec..fda32721 100644 --- a/OnTopic.AspNetCore.Mvc.Tests/TestDoubles/TestTopicRepository.cs +++ b/OnTopic.AspNetCore.Mvc.Tests/TestDoubles/TestTopicRepository.cs @@ -6,6 +6,7 @@ using OnTopic.AspNetCore.Mvc.Controllers; using OnTopic.Attributes; using OnTopic.Internal.Diagnostics; +using OnTopic.Querying; using OnTopic.Repositories; namespace OnTopic.AspNetCore.Mvc.Tests.TestDoubles; @@ -33,8 +34,8 @@ public class TestTopicRepository: DummyTopicRepository { /// Instantiates a new instance of the StubTopicRepository. /// /// A new instance of the StubTopicRepository. - public TestTopicRepository() : base() { - _cache = CreateFakeData(); + public TestTopicRepository() { + _cache = CreateFakeData(); Contract.Assume(_cache); } @@ -42,7 +43,15 @@ public TestTopicRepository() : base() { | METHOD: LOAD \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override Topic? Load() => _cache; + public override Task Load() => Task.FromResult(_cache); + + /// + public override Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) => Task.FromResult(String.IsNullOrEmpty(uniqueKey)? null : _cache.FindFirst(t => t.GetUniqueKey() == uniqueKey)); /*============================================================================================================================ | METHOD: CREATE FAKE DATA @@ -115,4 +124,4 @@ private static Topic CreateFakeData() { } -} +} \ No newline at end of file diff --git a/OnTopic.AspNetCore.Mvc.Tests/TopicControllerTest.cs b/OnTopic.AspNetCore.Mvc.Tests/TopicControllerTest.cs index abf06f6c..d8a977c1 100644 --- a/OnTopic.AspNetCore.Mvc.Tests/TopicControllerTest.cs +++ b/OnTopic.AspNetCore.Mvc.Tests/TopicControllerTest.cs @@ -51,7 +51,7 @@ public TopicControllerTest(TestTopicRepository topicRepository) { | Establish dependencies \-------------------------------------------------------------------------------------------------------------------------*/ _topicRepository = new CachedTopicRepository(topicRepository); - _topic = _topicRepository.Load("Root:Web:Valid:Child")!; + _topic = _topicRepository.Load("Root:Web:Valid:Child").GetAwaiter().GetResult()!; _topicMappingService = new TopicMappingService(_topicRepository, new TopicViewModelLookupService()); _context = FakeControllerContext.GetControllerContext("Web", "Web/Valid/Child/"); diff --git a/OnTopic.AspNetCore.Mvc.Tests/TopicRepositoryExtensionsTest.cs b/OnTopic.AspNetCore.Mvc.Tests/TopicRepositoryExtensionsTest.cs index 0f302fcd..e819efba 100644 --- a/OnTopic.AspNetCore.Mvc.Tests/TopicRepositoryExtensionsTest.cs +++ b/OnTopic.AspNetCore.Mvc.Tests/TopicRepositoryExtensionsTest.cs @@ -38,7 +38,7 @@ public class TopicRepositoryExtensionsTest: IClassFixture { /// crawling the object graph. /// public TopicRepositoryExtensionsTest(StubTopicRepository topicRepository) { - _topicRepository = new CachedTopicRepository(topicRepository); + _topicRepository = new CachedTopicRepository(topicRepository); } /*============================================================================================================================ @@ -48,19 +48,19 @@ public TopicRepositoryExtensionsTest(StubTopicRepository topicRepository) { /// Establishes route data and ensures that a topic is correctly identified based on that route. /// [Fact] - public void Load_ByRoute_ReturnsTopic() { + public async Task Load_ByRoute_ReturnsTopic() { var routes = new RouteData(); - var topic = _topicRepository.Load("Root:Web:Web_0:Web_0_1:Web_0_1_1"); + var topic = await _topicRepository.Load("Root:Web:Web_1:Web_1_1:Web_1_1_1"); routes.Values.Add("rootTopic", "Web"); - routes.Values.Add("path", "Web_0/Web_0_1/Web_0_1_1"); + routes.Values.Add("path", "Web_1/Web_1_1/Web_1_1_1"); var currentTopic = _topicRepository.Load(routes); Assert.NotNull(currentTopic); Assert.Equal(topic, currentTopic); - Assert.Equal("Web_0_1_1", currentTopic?.Key); + Assert.Equal("Web_1_1_1", currentTopic?.Key); } @@ -71,10 +71,10 @@ public void Load_ByRoute_ReturnsTopic() { /// Establishes route data and ensures that the root topic is correctly identified based on that route. /// [Fact] - public void Load_ByRoute_ReturnsRootTopic() { + public async Task Load_ByRoute_ReturnsRootTopic() { var routes = new RouteData(); - var topic = _topicRepository.Load("Root"); + var topic = await _topicRepository.Load("Root"); routes.Values.Add("path", "Root/"); diff --git a/OnTopic.AspNetCore.Mvc.Tests/TopicViewComponentTest.cs b/OnTopic.AspNetCore.Mvc.Tests/TopicViewComponentTest.cs index d45e9c97..03fbf9ce 100644 --- a/OnTopic.AspNetCore.Mvc.Tests/TopicViewComponentTest.cs +++ b/OnTopic.AspNetCore.Mvc.Tests/TopicViewComponentTest.cs @@ -61,7 +61,7 @@ public TopicViewComponentTest(StubTopicRepository topicRepository) { | Establish dependencies \-------------------------------------------------------------------------------------------------------------------------*/ _topicRepository = new CachedTopicRepository(topicRepository); - _topic = _topicRepository.Load("Root:Web:Web_3:Web_3_0")!; + _topic = _topicRepository.Load("Root:Web:Web_3:Web_3_0").GetAwaiter().GetResult()!; _topicMappingService = new TopicMappingService(_topicRepository, new TopicViewModelLookupService()); /*-------------------------------------------------------------------------------------------------------------------------- @@ -159,8 +159,8 @@ public async Task Menu_Invoke_ReturnsConfiguredNavigationRoot() { | TEST: NAVIGATION TOPIC VIEW MODEL: IS SELECTED: RETURNS EXPECTED OUTPUT \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Constructs a with a child instance, and ensures that the method returns the expected results. + /// Constructs a with a child instance, and ensures that the method returns the expected results. /// [Fact] public void NavigationTopicViewModel_IsSelected_ReturnsExpectedOutput() { @@ -214,7 +214,7 @@ public async Task PageLevelNavigation_Invoke_ReturnsNavigationViewModel() { [Fact] public async Task PageLevelNavigation_Invoke_ReturnsNull() { - var webPath = "/Web/Web_1/Web_1_0/"; + var webPath = "/Web/Web_1/Web_1_1/"; var viewComponent = new PageLevelNavigationViewComponent(_topicRepository, _hierarchicalMappingService) { ViewComponentContext = GetViewComponentContext(webPath) @@ -241,16 +241,16 @@ public async Task PageLevelNavigation_Invoke_ReturnsNull() { public async Task PageLevelNavigation_InvokeWithNullTopic_ReturnsNull() { - var webPath = "/Invalid/Path/"; + var webPath = "/Invalid/Path/"; - var viewComponent = new PageLevelNavigationViewComponent(_topicRepository, _hierarchicalMappingService) + var viewComponent = new PageLevelNavigationViewComponent(_topicRepository, _hierarchicalMappingService) { - ViewComponentContext = GetViewComponentContext(webPath) + ViewComponentContext = GetViewComponentContext(webPath) }; - var result = await viewComponent.InvokeAsync(); - var concreteResult = result as ViewViewComponentResult; - var model = concreteResult?.ViewData?.Model as NavigationViewModel; + var result = await viewComponent.InvokeAsync(); + var concreteResult = result as ViewViewComponentResult; + var model = concreteResult?.ViewData?.Model as NavigationViewModel; Assert.NotNull(model); Assert.Equal(String.Empty, model?.CurrentWebPath); diff --git a/OnTopic.AspNetCore.Mvc.Tests/ValidateTopicAttributeTest.cs b/OnTopic.AspNetCore.Mvc.Tests/ValidateTopicAttributeTest.cs index 00068ccb..bcbfb372 100644 --- a/OnTopic.AspNetCore.Mvc.Tests/ValidateTopicAttributeTest.cs +++ b/OnTopic.AspNetCore.Mvc.Tests/ValidateTopicAttributeTest.cs @@ -34,7 +34,7 @@ public static ActionExecutingContext GetActionExecutingContext(Controller contro var modelState = new ModelStateDictionary(); - var actionContext = new ActionContext( + var actionContext = new ActionContext( new DefaultHttpContext(), new(), new ControllerActionDescriptor(), @@ -43,7 +43,7 @@ public static ActionExecutingContext GetActionExecutingContext(Controller contro var actionExecutingContext = new ActionExecutingContext( actionContext, - new List(), + [], new Dictionary(), controller ); @@ -62,9 +62,9 @@ public static ActionExecutingContext GetActionExecutingContext(Controller contro public static ControllerContext GetControllerContext() => new( new() { - HttpContext = new DefaultHttpContext(), - RouteData = new(), - ActionDescriptor = new ControllerActionDescriptor() + HttpContext = new DefaultHttpContext(), + RouteData = new(), + ActionDescriptor = new ControllerActionDescriptor() } ); @@ -291,16 +291,16 @@ public void PageGroupTopic_ReturnsRedirect() { [Fact] public void PageGroupTopic_Empty_ReturnsRedirect() { - var validateFilter = new ValidateTopicAttribute(); - var topic = new Topic("Key", "PageGroup"); - var controller = GetTopicController(topic); - var context = GetActionExecutingContext(controller); + var validateFilter = new ValidateTopicAttribute(); + var topic = new Topic("Key", "PageGroup"); + var controller = GetTopicController(topic); + var context = GetActionExecutingContext(controller); validateFilter.OnActionExecuting(context); controller.Dispose(); - var result = context.Result as StatusCodeResult; + var result = context.Result as StatusCodeResult; Assert.NotNull(result); Assert.Equal(403, result?.StatusCode); diff --git a/OnTopic.AspNetCore.Mvc/Components/MenuViewComponentBase{T}.cs b/OnTopic.AspNetCore.Mvc/Components/MenuViewComponentBase{T}.cs index 6e9cc422..6a7a4978 100644 --- a/OnTopic.AspNetCore.Mvc/Components/MenuViewComponentBase{T}.cs +++ b/OnTopic.AspNetCore.Mvc/Components/MenuViewComponentBase{T}.cs @@ -31,10 +31,10 @@ namespace OnTopic.AspNetCore.Mvc.Components; /// abstract and suffixed with Base. /// /// -/// While the only requires that the implement , views will require additional properties. These can be determined on a per-case -/// basis, as required by the implementation. Implementaters, however, should consider implementing the interface, which provides the standard properties that most views will likely need, as +/// While the only requires that the implement , views will require additional properties. These can be determined on a per-case +/// basis, as required by the implementation. Implementaters, however, should consider implementing the interface, which provides the standard properties that most views will likely need, as /// well as a method for determining if the navigation item /// is currently selected. /// @@ -82,9 +82,9 @@ IHierarchicalTopicMappingService hierarchicalTopicMappingService var configuredRoot = CurrentTopic.Attributes.GetValue("NavigationRoot", true); if (!String.IsNullOrEmpty(configuredRoot)) { - navigationRootTopic = TopicRepository.Load("Root:" + configuredRoot, CurrentTopic); + navigationRootTopic = TopicRepository.Load("Root:" + configuredRoot, CurrentTopic).GetAwaiter().GetResult(); } - navigationRootTopic ??= HierarchicalTopicMappingService.GetHierarchicalRoot(CurrentTopic, 2, "Web"); + navigationRootTopic ??= HierarchicalTopicMappingService.GetHierarchicalRoot(CurrentTopic, 2, "Root:Web"); /*-------------------------------------------------------------------------------------------------------------------------- | Return root @@ -117,14 +117,14 @@ public async Task InvokeAsync() { /*-------------------------------------------------------------------------------------------------------------------------- | Retrieve root topic \-------------------------------------------------------------------------------------------------------------------------*/ - var navigationRootTopic = GetNavigationRoot(); + var navigationRootTopic = GetNavigationRoot(); /*-------------------------------------------------------------------------------------------------------------------------- | Construct view model \-------------------------------------------------------------------------------------------------------------------------*/ - var navigationViewModel = new NavigationViewModel() { - NavigationRoot = await MapNavigationTopicViewModels(navigationRootTopic).ConfigureAwait(true), - CurrentWebPath = CurrentTopic?.GetWebPath()?? HttpContext.Request.Path + var navigationViewModel = new NavigationViewModel() { + NavigationRoot = await MapNavigationTopicViewModels(navigationRootTopic).ConfigureAwait(true), + CurrentWebPath = CurrentTopic?.GetWebPath()?? HttpContext.Request.Path }; /*-------------------------------------------------------------------------------------------------------------------------- diff --git a/OnTopic.AspNetCore.Mvc/Components/NavigationTopicViewComponentBase{T}.cs b/OnTopic.AspNetCore.Mvc/Components/NavigationTopicViewComponentBase{T}.cs index 34ccac4f..92bfba64 100644 --- a/OnTopic.AspNetCore.Mvc/Components/NavigationTopicViewComponentBase{T}.cs +++ b/OnTopic.AspNetCore.Mvc/Components/NavigationTopicViewComponentBase{T}.cs @@ -43,7 +43,7 @@ protected NavigationTopicViewComponentBase( ITopicRepository topicRepository, IHierarchicalTopicMappingService hierarchicalTopicMappingService ) { - TopicRepository = topicRepository; + TopicRepository = topicRepository; HierarchicalTopicMappingService = hierarchicalTopicMappingService; } @@ -67,8 +67,8 @@ IHierarchicalTopicMappingService hierarchicalTopicMappingService /// be mapped. /// /// - /// The associated with the . + /// The associated with the . /// protected IHierarchicalTopicMappingService HierarchicalTopicMappingService { get; } @@ -81,7 +81,7 @@ IHierarchicalTopicMappingService hierarchicalTopicMappingService /// The Topic associated with the current request. protected Topic? CurrentTopic { get { - field ??= TopicRepository.Load(RouteData); + field ??= TopicRepository.Load(RouteData); return field; } } diff --git a/OnTopic.AspNetCore.Mvc/Components/PageLevelNavigationViewComponentBase{T}.cs b/OnTopic.AspNetCore.Mvc/Components/PageLevelNavigationViewComponentBase{T}.cs index 3a7f11df..8fa89438 100644 --- a/OnTopic.AspNetCore.Mvc/Components/PageLevelNavigationViewComponentBase{T}.cs +++ b/OnTopic.AspNetCore.Mvc/Components/PageLevelNavigationViewComponentBase{T}.cs @@ -3,7 +3,7 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ -using System.Diagnostics.CodeAnalysis; + using OnTopic.AspNetCore.Mvc.Controllers; using OnTopic.AspNetCore.Mvc.Models; using OnTopic.Mapping.Hierarchical; @@ -85,7 +85,7 @@ IHierarchicalTopicMappingService hierarchicalTopicMappingService while ( navigationRootTopic is not null and not ({ Parent: null } or { ContentType: "PageGroup" }) ) { - navigationRootTopic = navigationRootTopic.Parent; + navigationRootTopic = navigationRootTopic.Parent; } } @@ -117,14 +117,14 @@ public async Task InvokeAsync() { /*-------------------------------------------------------------------------------------------------------------------------- | Retrieve root topic \-------------------------------------------------------------------------------------------------------------------------*/ - var navigationRootTopic = GetNavigationRoot(); + var navigationRootTopic = GetNavigationRoot(); /*-------------------------------------------------------------------------------------------------------------------------- | Construct view model \-------------------------------------------------------------------------------------------------------------------------*/ - var navigationViewModel = new NavigationViewModel() { - NavigationRoot = await MapNavigationTopicViewModels(navigationRootTopic).ConfigureAwait(true), - CurrentWebPath = CurrentTopic?.GetWebPath()?? HttpContext.Request.Path + var navigationViewModel = new NavigationViewModel() { + NavigationRoot = await MapNavigationTopicViewModels(navigationRootTopic).ConfigureAwait(true), + CurrentWebPath = CurrentTopic?.GetWebPath()?? HttpContext.Request.Path }; /*-------------------------------------------------------------------------------------------------------------------------- diff --git a/OnTopic.AspNetCore.Mvc/Controllers/ErrorController.cs b/OnTopic.AspNetCore.Mvc/Controllers/ErrorController.cs index 3cb9a557..a3fda5b8 100644 --- a/OnTopic.AspNetCore.Mvc/Controllers/ErrorController.cs +++ b/OnTopic.AspNetCore.Mvc/Controllers/ErrorController.cs @@ -20,8 +20,8 @@ namespace OnTopic.AspNetCore.Mvc.Controllers; /// The will redirect to a URL with the /// HTTP error code in the route. This is fine if there is one error page that, perhaps, injects the error code into the /// content. It's also fine if there is an error page for every HTTP error. In practice, however, many sites handle some -/// HTTP errors, but not others. Given this, the provides logic to deliver a associated with the HTTP error, if available, and otherwise to fallback first to the +/// HTTP errors, but not others. Given this, the provides logic to deliver a associated with the HTTP error, if available, and otherwise to fallback first to the /// HTTP category (e.g., 5xx), and otherwise to a generic error. /// public class ErrorController : TopicController { @@ -69,9 +69,9 @@ public async virtual Task HttpAsync([FromRoute(Name="id")] int st /*-------------------------------------------------------------------------------------------------------------------------- | Identify relevant topic \-------------------------------------------------------------------------------------------------------------------------*/ - CurrentTopic = TopicRepository.Load($"{rootTopic}:{statusCode}")?? - TopicRepository.Load($"{rootTopic}:{statusCode/100*100}")?? - TopicRepository.Load($"{rootTopic}"); + CurrentTopic = await TopicRepository.Load($"{rootTopic}:{statusCode}").ConfigureAwait(false) + ?? await TopicRepository.Load($"{rootTopic}:{statusCode/100*100}").ConfigureAwait(false) + ?? await TopicRepository.Load($"{rootTopic}").ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Return topic view diff --git a/OnTopic.AspNetCore.Mvc/Controllers/RedirectController.cs b/OnTopic.AspNetCore.Mvc/Controllers/RedirectController.cs index 593ac5e9..cae43430 100644 --- a/OnTopic.AspNetCore.Mvc/Controllers/RedirectController.cs +++ b/OnTopic.AspNetCore.Mvc/Controllers/RedirectController.cs @@ -41,7 +41,7 @@ public ActionResult Redirect(int topicId) { /*-------------------------------------------------------------------------------------------------------------------------- | Find the topic with the correct PageID. \-------------------------------------------------------------------------------------------------------------------------*/ - var topic = _topicRepository.Load(topicId); + var topic = _topicRepository.Load(topicId).GetAwaiter().GetResult(); /*-------------------------------------------------------------------------------------------------------------------------- | Provide error handling diff --git a/OnTopic.AspNetCore.Mvc/Controllers/SitemapController.cs b/OnTopic.AspNetCore.Mvc/Controllers/SitemapController.cs index f055fcfd..8249c912 100644 --- a/OnTopic.AspNetCore.Mvc/Controllers/SitemapController.cs +++ b/OnTopic.AspNetCore.Mvc/Controllers/SitemapController.cs @@ -5,7 +5,6 @@ \=============================================================================================================================*/ using System.Collections.ObjectModel; using System.Globalization; -using System.Xml; using System.Xml.Linq; using OnTopic.Attributes; @@ -19,35 +18,26 @@ namespace OnTopic.AspNetCore.Mvc.Controllers; /// child topics to generate the appropriate markup. /// /// -/// -/// By default, some s are excluded based on their content types—which includes not only the -/// , but also all of its descendents. Other s are skipped, also based on -/// their content types; in this case, the is excluded, but its descendents are not. What content -/// types are excluded or skipped can be configured, respectively, by modifying the static and collections. -/// -/// -/// The action enables an extended sitemap with Google's custom PageMap schema for -/// exposing , , and . By -/// default, some content attributes, such as Body, IsDisabled, and NoIndex, are hidden. This list -/// can be modified by updating the static collection. -/// +/// By default, some s are excluded based on their content types—which includes not only the +/// , but also all of its descendents. Other s are skipped, also based on +/// their content types; in this case, the is excluded, but its descendents are not. What content +/// types are excluded or skipped can be configured, respectively, by modifying the static and collections. /// /// -/// The used to retrieve instances for the sitemap. +/// The used to retrieve the minimal graph for the sitemap. /// -public class SitemapController(ITopicRepository topicRepository) : Controller { +public class SitemapController(ISitemapTopicRepository topicRepository) : Controller { /*============================================================================================================================ | PRIVATE VARIABLES \---------------------------------------------------------------------------------------------------------------------------*/ - private readonly ITopicRepository _topicRepository = Contract.Requires(topicRepository); + private readonly ISitemapTopicRepository _topicRepository = Contract.Requires(topicRepository); /*============================================================================================================================ | CONSTANTS \---------------------------------------------------------------------------------------------------------------------------*/ private static readonly XNamespace _sitemapNamespace = "http://www.sitemaps.org/schemas/sitemap/0.9"; - private static readonly XNamespace _pagemapNamespace = "http://www.google.com/schemas/sitemap-pagemap/1.0"; /*============================================================================================================================ | EXCLUDED CONTENT TYPES @@ -55,9 +45,7 @@ public class SitemapController(ITopicRepository topicRepository) : Controller { /// /// Specifies what content types should not be listed in the sitemap, including any descendents. /// - public static Collection ExcludedContentTypes { get; } = new() { - "List" - }; + public static Collection ExcludedContentTypes { get; } = ["List"]; /*============================================================================================================================ | SKIPPED CONTENT TYPES @@ -65,27 +53,10 @@ public class SitemapController(ITopicRepository topicRepository) : Controller { /// /// Specifies what content types should not be listed in the sitemap—but whose descendents should still be evaluated. /// - public static Collection SkippedContentTypes { get; } = new() { + public static Collection SkippedContentTypes { get; } = [ "PageGroup", "Container" - }; - - /*============================================================================================================================ - | EXCLUDED ATTRIBUTES - \---------------------------------------------------------------------------------------------------------------------------*/ - /// - /// Specifies what attributes should not be listed in the sitemap. - /// - public static Collection ExcludedAttributes { get; } = new() { - "Body", - "IsDisabled", - "ParentID", //Legacy, but exposed for avoid leacking legacy data - "TopicID", //Legacy, but exposed for avoid leacking legacy data - "ContentType", //Legacy, but exposed for avoid leacking legacy data - "IsHidden", - "NoIndex", - "SortOrder" - }; + ]; /*============================================================================================================================ | GET: /SITEMAP @@ -94,26 +65,19 @@ public class SitemapController(ITopicRepository topicRepository) : Controller { /// Provides the Sitemap.org sitemap for the site. /// /// Optionally enables indentation of XML elements in output for human readability. - /// Optionally enables extended metadata associated with each topic. /// A Sitemap.org sitemap. - public ActionResult Index(bool indent = false, bool includeMetadata = false) { + public ActionResult Index(bool indent = false) { /*-------------------------------------------------------------------------------------------------------------------------- | Ensure topics are loaded \-------------------------------------------------------------------------------------------------------------------------*/ - var rootTopic = _topicRepository.Load(); - - Contract.Assume( - rootTopic, - $"The topic graph could not be successfully loaded from the {nameof(ITopicRepository)} instance. The " + - $"{nameof(SitemapController)} is unable to establish a local copy to work off of." - ); + var rootTopic = _topicRepository.Load().GetAwaiter().GetResult(); /*-------------------------------------------------------------------------------------------------------------------------- | Establish sitemap \-------------------------------------------------------------------------------------------------------------------------*/ var declaration = new XDeclaration("1.0", "utf-8", "no"); - var sitemap = GenerateSitemap(rootTopic, includeMetadata); + var sitemap = GenerateSitemap(rootTopic); var settings = indent? SaveOptions.None : SaveOptions.DisableFormatting; /*-------------------------------------------------------------------------------------------------------------------------- @@ -123,21 +87,6 @@ public ActionResult Index(bool indent = false, bool includeMetadata = false) { } - /*============================================================================================================================ - | GET: /SITEMAP/EXTENDED - \---------------------------------------------------------------------------------------------------------------------------*/ - /// - /// Provides the Sitemap.org sitemap for the site, including extended metadata attributes. - /// - /// - /// Introducing the metadata makes the sitemap considerably larger. However, it also means that some agents will index the - /// additional information and make it available for querying. For instance, the (now defunct) Google Custom Search Engine - /// (CSE) would previously allow queries to be filtered based on metadata attributes exposed via the sitemap. - /// - /// Optionally enables indentation of XML elements in output for human readability. - /// A Sitemap.org sitemap. - public ActionResult Extended(bool indent = false) => Index(indent, true); - /*============================================================================================================================ | METHOD: GENERATE SITEMAP \---------------------------------------------------------------------------------------------------------------------------*/ @@ -145,13 +94,12 @@ public ActionResult Index(bool indent = false, bool includeMetadata = false) { /// Given a root topic, generates an XML-formatted sitemap. /// /// The topic to add to the sitemap. - /// Optionally enables extended metadata associated with each topic. /// A Sitemap.org sitemap. - private XDocument GenerateSitemap(Topic rootTopic, bool includeMetadata = false) => + private XDocument GenerateSitemap(Topic rootTopic) => new( new XElement(_sitemapNamespace + "urlset", from topic in rootTopic.Children - select AddTopic(topic, includeMetadata) + select AddTopic(topic) ) ); @@ -159,16 +107,15 @@ select AddTopic(topic, includeMetadata) | METHOD: ADD TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Given a , adds it to a given . + /// Given a , returns the sitemap url elements for it and its descendants. /// /// The topic to add to the sitemap. - /// Optionally enables extended metadata associated with each topic. - private List AddTopic(Topic topic, bool includeMetadata = false) { + private List AddTopic(Topic topic) { /*-------------------------------------------------------------------------------------------------------------------------- | Establish return collection \-------------------------------------------------------------------------------------------------------------------------*/ - var topics = new List(); + List topics = []; /*-------------------------------------------------------------------------------------------------------------------------- | Validate topic @@ -183,22 +130,17 @@ private List AddTopic(Topic topic, bool includeMetadata = false) { /*-------------------------------------------------------------------------------------------------------------------------- | Establish variables \-------------------------------------------------------------------------------------------------------------------------*/ - var domain = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; - var lastModified = new DateTime(Math.Max(topic.LastModified.Ticks, new DateTime(2000, 1, 1).Ticks)); + var domain = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; + var lastModified = new DateTime(Math.Max(topic.LastModified.Ticks, new DateTime(2000, 1, 1).Ticks)); /*-------------------------------------------------------------------------------------------------------------------------- | Establish root element \-------------------------------------------------------------------------------------------------------------------------*/ - var topicElement = new XElement(_sitemapNamespace + "url", + var topicElement = new XElement(_sitemapNamespace + "url", new XElement(_sitemapNamespace + "loc", domain + topic.GetWebPath()), new XElement(_sitemapNamespace + "changefreq", "monthly"), new XElement(_sitemapNamespace + "lastmod", lastModified.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), - new XElement(_sitemapNamespace + "priority", 1), - includeMetadata? new XElement(_pagemapNamespace + "PageMap", - getAttributes(), - getRelationships(), - getReferences() - ) : null + new XElement(_sitemapNamespace + "priority", 1) ); if ( !SkippedContentTypes.Any(c => topic.ContentType?.Equals(c, StringComparison.OrdinalIgnoreCase)?? false) && @@ -213,61 +155,11 @@ private List AddTopic(Topic topic, bool includeMetadata = false) { | Iterate over children \-------------------------------------------------------------------------------------------------------------------------*/ foreach (var childTopic in topic.Children) { - topics.AddRange(AddTopic(childTopic, includeMetadata)); + topics.AddRange(AddTopic(childTopic)); } return topics; - /*-------------------------------------------------------------------------------------------------------------------------- - | Get attributes - \-------------------------------------------------------------------------------------------------------------------------*/ - XElement getAttributes() => - new(_pagemapNamespace + "DataObject", - new XAttribute("type", "Attributes"), - new XElement(_pagemapNamespace + "Attribute", - new XAttribute("name", "ContentType"), - new XText(topic.ContentType?? "Page") - ), - from attribute in topic.Attributes - let attributeValue = topic.Attributes.GetValue(attribute.Key) - where !ExcludedAttributes.Contains(attribute.Key, StringComparer.OrdinalIgnoreCase) - where attributeValue?.Length < 256 - select new XElement(_pagemapNamespace + "Attribute", - new XAttribute("name", attribute.Key), - new XText(attributeValue ?? "") - ) - ); - - /*-------------------------------------------------------------------------------------------------------------------------- - | Get relationships - \-------------------------------------------------------------------------------------------------------------------------*/ - IEnumerable getRelationships() => - from relationship in topic.Relationships - select new XElement(_pagemapNamespace + "DataObject", - new XAttribute("type", relationship.Key), - from relatedTopic in relationship.Values - select new XElement(_pagemapNamespace + "Attribute", - new XAttribute("name", "TopicKey"), - new XText(relatedTopic.GetUniqueKey().Replace("Root:", "", StringComparison.OrdinalIgnoreCase)) - ) - ); - - /*-------------------------------------------------------------------------------------------------------------------------- - | Get references - \-------------------------------------------------------------------------------------------------------------------------*/ - XElement? getReferences() => - topic.References.Count is 0? - null : - new XElement(_pagemapNamespace + "DataObject", - new XAttribute("type", "References"), - from reference in topic.References - where reference.Value is not null - select new XElement(_pagemapNamespace + "Attribute", - new XAttribute("name", reference.Key), - new XText(reference.Value!.GetUniqueKey().Replace("Root:", "", StringComparison.OrdinalIgnoreCase)) - ) - ); - } } //Class \ No newline at end of file diff --git a/OnTopic.AspNetCore.Mvc/Controllers/TopicController.cs b/OnTopic.AspNetCore.Mvc/Controllers/TopicController.cs index c761af9f..a83b6954 100644 --- a/OnTopic.AspNetCore.Mvc/Controllers/TopicController.cs +++ b/OnTopic.AspNetCore.Mvc/Controllers/TopicController.cs @@ -47,10 +47,10 @@ public class TopicController(ITopicRepository topicRepository, ITopicMappingServ /// The Topic associated with the current request. public Topic? CurrentTopic { get { - field ??= TopicRepository.Load(RouteData); + field ??= TopicRepository.Load(RouteData); return field; } - set => field = value; + set => field = value; } /*============================================================================================================================ @@ -67,7 +67,7 @@ public async virtual Task IndexAsync(string path) { /*-------------------------------------------------------------------------------------------------------------------------- | Establish default view model \-------------------------------------------------------------------------------------------------------------------------*/ - var topicViewModel = await _topicMappingService.MapAsync(CurrentTopic).ConfigureAwait(false); + var topicViewModel = await _topicMappingService.MapAsync(CurrentTopic).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Validate dependencies diff --git a/OnTopic.AspNetCore.Mvc/Models/NavigationViewModel{T}.cs b/OnTopic.AspNetCore.Mvc/Models/NavigationViewModel{T}.cs index 1d34f9ca..5523ff3a 100644 --- a/OnTopic.AspNetCore.Mvc/Models/NavigationViewModel{T}.cs +++ b/OnTopic.AspNetCore.Mvc/Models/NavigationViewModel{T}.cs @@ -55,8 +55,8 @@ public class NavigationViewModel where T : class, IHierarchicalTopicViewModel /// /// /// In order to determine whether any given , the views - /// will need to know where in the hierarchy the user currently is. By storing this on the used as the root view model for every navigation component, we ensure that the views + /// will need to know where in the hierarchy the user currently is. By storing this on the used as the root view model for every navigation component, we ensure that the views /// always have access to this information. /// /// diff --git a/OnTopic.AspNetCore.Mvc/ServiceCollectionExtensions.cs b/OnTopic.AspNetCore.Mvc/ServiceCollectionExtensions.cs index bff32352..5d7587bf 100644 --- a/OnTopic.AspNetCore.Mvc/ServiceCollectionExtensions.cs +++ b/OnTopic.AspNetCore.Mvc/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.TagHelpers; +using Microsoft.AspNetCore.OutputCaching; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -75,8 +76,8 @@ public static IMvcBuilder AddTopicSupport(this IMvcBuilder services) { public static IRouteBuilder MapTopicRoute( this IRouteBuilder routes, string rootTopic, - string controller = "Topic", - string action = "Index" + string controller = "Topic", + string action = "Index" ) => routes.MapRoute( name: $"{rootTopic}Topic", @@ -95,8 +96,8 @@ public static IRouteBuilder MapTopicRoute( public static ControllerActionEndpointConventionBuilder MapTopicRoute( this IEndpointRouteBuilder routes, string rootTopic, - string controller = "Topic", - string action = "Index" + string controller = "Topic", + string action = "Index" ) => routes.MapControllerRoute( name: $"{rootTopic}Topic", @@ -120,8 +121,8 @@ public static ControllerActionEndpointConventionBuilder MapTopicRoute( public static ControllerActionEndpointConventionBuilder MapTopicAreaRoute( this IEndpointRouteBuilder routes, string areaName, - string? controller = null, - string action = "Index" + string? controller = null, + string action = "Index" ) => routes.MapAreaControllerRoute( name: $"TopicAreas", @@ -217,8 +218,8 @@ public static void MapImplicitAreaControllerRoute(this IEndpointRouteBuilder rou /// Adds the /Error/{errorCode} endpoint route for the . /// /// - /// This allows the to be used in conjunction with e.g., the , by providing a route for capturing the + /// This allows the to be used in conjunction with e.g., the , by providing a route for capturing the /// errorCode. /// /// The this route is being added to. @@ -239,19 +240,44 @@ public static ControllerActionEndpointConventionBuilder MapTopicErrors( | EXTENSION: MAP TOPIC SITEMAP (IENDPOINTROUTEBUILDER) \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Adds the Sitemap/{action=Index} endpoint route for the OnTopic sitemap. + /// Adds the Sitemap/{action=Index} endpoint route for the OnTopic sitemap, with a default server-side output + /// caching policy. /// /// - /// For most implementations, this will be covered by the default route, such as that implemented by the standard method that ships - /// with ASP.NET. This extension method is provided as a convenience method for implementations that aren't using the - /// standard route, for whatever reason, and want a specific route setup for the sitemap. + /// + /// For most implementations, this will be covered by the default route, such as that implemented by the standard method that ships + /// with ASP.NET. This extension method is provided as a convenience method for implementations that aren't using the + /// standard route, for whatever reason, and want a specific route setup for the sitemap. + /// + /// + /// The output caching policy is inert until the host also registers and calls + /// . Without that, the sitemap + /// still renders correctly on every request via the dedicated , but it isn't cached. + /// + /// + /// The policy varies by host and scheme, in addition to the indent query parameter, since renders every <loc> from the requesting host and scheme; without this, a deployment + /// serving multiple hosts (e.g., apex and www), or redirecting HTTP to HTTPS, could serve one host's cached URLs + /// to another. + /// /// - public static ControllerActionEndpointConventionBuilder MapTopicSitemap(this IEndpointRouteBuilder routes) => + /// The this route is being added to. + /// The duration to cache the rendered sitemap for. Defaults to sixty minutes. + public static ControllerActionEndpointConventionBuilder MapTopicSitemap( + this IEndpointRouteBuilder routes, + TimeSpan? cacheDuration = null + ) => routes.MapControllerRoute( name: "TopicSitemap", pattern: "Sitemap/{action=Index}", defaults: new { controller = "Sitemap" } + ).CacheOutput(policy => policy + .Expire(cacheDuration?? TimeSpan.FromMinutes(60)) + .SetVaryByHost(true) + .SetVaryByQuery("indent") + .VaryByValue(context => new("scheme", context.Request.Scheme)) ); /*============================================================================================================================ diff --git a/OnTopic.AspNetCore.Mvc/TopicRepositoryExtensions.cs b/OnTopic.AspNetCore.Mvc/TopicRepositoryExtensions.cs index 72793089..01c5114a 100644 --- a/OnTopic.AspNetCore.Mvc/TopicRepositoryExtensions.cs +++ b/OnTopic.AspNetCore.Mvc/TopicRepositoryExtensions.cs @@ -27,8 +27,8 @@ public static class TopicRepositoryExtensions { /// of the box routes, such as controller and action, the defines /// additional topic-specific routes, such as rootTopic and path. These can be combined to identify a topic /// in the repository. By using the extension method, callers needn't assemble their own - /// prior to calling , assuming they are using the standard routing - /// variables. + /// prior to calling , assuming they are using the + /// standard routing variables. /// public static Topic? Load( this ITopicRepository topicRepository, @@ -59,24 +59,24 @@ RouteData routeData | case particular routes aren't present. That said, if they are defined, but should be excluded from a fallback, then | that path does need to be defined—thus e.g. {area}/{controller}/{path}. \-------------------------------------------------------------------------------------------------------------------------*/ - var paths = new List() { + List paths = [ cleanPath($"{rootTopic}/{path}"), cleanPath($"{area}/{controller}/{action}/{path}"), cleanPath($"{area}/{controller}/{path}"), cleanPath($"{area}/{action}/{path}"), cleanPath($"{area}/{path}") - }; + ]; /*-------------------------------------------------------------------------------------------------------------------------- | Load by path \-------------------------------------------------------------------------------------------------------------------------*/ - var topic = (Topic?)null; + var topic = (Topic?)null; foreach (var searchPath in paths) { if (topic is not null) break; if (String.IsNullOrEmpty(searchPath)) continue; try { - topic = topicRepository.Load(searchPath); + topic = topicRepository.Load(searchPath).GetAwaiter().GetResult(); } catch (InvalidKeyException) { //As route data comes from user-submitted requests, it's expected that some may contain invalid keys. From this diff --git a/OnTopic.AspNetCore.Mvc/TopicViewResult.cs b/OnTopic.AspNetCore.Mvc/TopicViewResult.cs index 8093cd6d..2908fc06 100644 --- a/OnTopic.AspNetCore.Mvc/TopicViewResult.cs +++ b/OnTopic.AspNetCore.Mvc/TopicViewResult.cs @@ -35,7 +35,7 @@ public TopicViewResult( object viewModel, string? contentType = null, string? view = null - ) : base() { + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -94,7 +94,7 @@ public override async Task ExecuteResultAsync(ActionContext context) { /*-------------------------------------------------------------------------------------------------------------------------- | Call associated executor \-------------------------------------------------------------------------------------------------------------------------*/ - var executor = context.HttpContext.RequestServices.GetRequiredService>(); + var executor = context.HttpContext.RequestServices.GetRequiredService>(); await executor.ExecuteAsync(context, this).ConfigureAwait(false); } diff --git a/OnTopic.AspNetCore.Mvc/TopicViewResultExecutor.cs b/OnTopic.AspNetCore.Mvc/TopicViewResultExecutor.cs index c5aa032d..f087af3e 100644 --- a/OnTopic.AspNetCore.Mvc/TopicViewResultExecutor.cs +++ b/OnTopic.AspNetCore.Mvc/TopicViewResultExecutor.cs @@ -75,7 +75,7 @@ public ViewEngineResult FindView(ActionContext actionContext, TopicViewResult vi var viewEngine = viewResult.ViewEngine?? ViewEngine; var requestContext = actionContext.HttpContext.Request; var view = (ViewEngineResult?)null; - var searchedPaths = new List(); + List searchedPaths = []; /*-------------------------------------------------------------------------------------------------------------------------- | Cache content type as route variable @@ -95,10 +95,10 @@ public ViewEngineResult FindView(ActionContext actionContext, TopicViewResult vi | Determines if the view is defined in the querystring. \-------------------------------------------------------------------------------------------------------------------------*/ if (requestContext.Query.ContainsKey("View")) { - var queryStringValue = requestContext.Query["View"].First(); + var queryStringValue = requestContext.Query["View"].First(); if (queryStringValue is not null) { - view = viewEngine.FindView(actionContext, queryStringValue, isMainPage: true); - searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; + view = viewEngine.FindView(actionContext, queryStringValue, isMainPage: true); + searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; } } @@ -110,16 +110,16 @@ public ViewEngineResult FindView(ActionContext actionContext, TopicViewResult vi if (header is null) { continue; } - var value = header.Replace("+", "-", StringComparison.Ordinal); + var value = header.Replace("+", "-", StringComparison.Ordinal); if (value.Contains('/', StringComparison.Ordinal)) { - value = value[(value.IndexOf('/', StringComparison.Ordinal)+1)..]; + value = value[(value.IndexOf('/', StringComparison.Ordinal)+1)..]; } if (value.Contains(';', StringComparison.Ordinal)) { - value = value[..(value.IndexOf(';', StringComparison.Ordinal))]; + value = value[..(value.IndexOf(';', StringComparison.Ordinal))]; } if (value is not null) { - view = viewEngine.FindView(actionContext, value, isMainPage: true); - searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; + view = viewEngine.FindView(actionContext, value, isMainPage: true); + searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; } if (view?.Success ?? false) { break; @@ -137,10 +137,10 @@ public ViewEngineResult FindView(ActionContext actionContext, TopicViewResult vi \-------------------------------------------------------------------------------------------------------------------------*/ if (!view?.Success ?? true) { if (routeData.Values.TryGetValue("action", out var action)) { - var actionName = action?.ToString()?.Replace("Async", "", StringComparison.OrdinalIgnoreCase); + var actionName = action?.ToString()?.Replace("Async", "", StringComparison.OrdinalIgnoreCase); if (actionName is not null) { - view = ViewEngine.FindView(actionContext, actionName, isMainPage: true); - searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; + view = ViewEngine.FindView(actionContext, actionName, isMainPage: true); + searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; } } } @@ -152,16 +152,16 @@ public ViewEngineResult FindView(ActionContext actionContext, TopicViewResult vi | as it is set as the default View value for the Topic \-------------------------------------------------------------------------------------------------------------------------*/ if (!(view?.Success ?? false) && !String.IsNullOrEmpty(topicView)) { - view = viewEngine.FindView(actionContext, topicView, isMainPage: true); - searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; + view = viewEngine.FindView(actionContext, topicView, isMainPage: true); + searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; } /*-------------------------------------------------------------------------------------------------------------------------- | Default to content type \-------------------------------------------------------------------------------------------------------------------------*/ if (!view?.Success ?? true) { - view = viewEngine.FindView(actionContext, contentType, isMainPage: true); - searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; + view = viewEngine.FindView(actionContext, contentType, isMainPage: true); + searchedPaths = [.. searchedPaths.Union(view.SearchedLocations ?? [])]; } /*-------------------------------------------------------------------------------------------------------------------------- @@ -189,9 +189,9 @@ public async Task ExecuteAsync(ActionContext context, TopicViewResult result) { /*-------------------------------------------------------------------------------------------------------------------------- | Find view \-------------------------------------------------------------------------------------------------------------------------*/ - var viewEngineResult = FindView(context, result); + var viewEngineResult = FindView(context, result); viewEngineResult.EnsureSuccessful(originalLocations: null); - var view = viewEngineResult.View; + var view = viewEngineResult.View; /*-------------------------------------------------------------------------------------------------------------------------- | Execute diff --git a/OnTopic.AspNetCore.Mvc/_filters/TopicResponseCacheAttribute.cs b/OnTopic.AspNetCore.Mvc/_filters/TopicResponseCacheAttribute.cs index 4a39cfc7..2f7254d3 100644 --- a/OnTopic.AspNetCore.Mvc/_filters/TopicResponseCacheAttribute.cs +++ b/OnTopic.AspNetCore.Mvc/_filters/TopicResponseCacheAttribute.cs @@ -25,8 +25,8 @@ namespace OnTopic.AspNetCore.Mvc; /// The Page content type has a topic reference to a CacheProfile content type, which contains settings for /// configuring HTTP response headers. The evaluates the current to /// determine which, if any, CacheProfile it is associated with, and applies the settings to the HTTP response -/// headers. If a CacheProfile is not configured, it will default to the CacheProfile with the of Default. +/// headers. If a CacheProfile is not configured, it will default to the CacheProfile with the of Default. /// /// /// This filter is enabled automatically when is @@ -73,10 +73,10 @@ public override void OnActionExecuting(ActionExecutingContext context) { \-------------------------------------------------------------------------------------------------------------------------*/ // Lookup the default cache profile for reference - _defaultCacheProfile ??= controller.TopicRepository.Load("Configuration:CacheProfiles:Default"); + _defaultCacheProfile ??= controller.TopicRepository.Load("Configuration:CacheProfiles:Default").GetAwaiter().GetResult(); // Ensure the above lookup is only performed once per application - _defaultCacheProfile ??= new Topic("ImplicitDefault", "CacheProfile"); + _defaultCacheProfile ??= new("ImplicitDefault", "CacheProfile"); /*-------------------------------------------------------------------------------------------------------------------------- | Identify cache profile diff --git a/OnTopic.AspNetCore.Mvc/_filters/ValidateTopicAttribute.cs b/OnTopic.AspNetCore.Mvc/_filters/ValidateTopicAttribute.cs index dab68480..4856ce39 100644 --- a/OnTopic.AspNetCore.Mvc/_filters/ValidateTopicAttribute.cs +++ b/OnTopic.AspNetCore.Mvc/_filters/ValidateTopicAttribute.cs @@ -75,7 +75,7 @@ public override void OnActionExecuting(ActionExecutingContext context) { if (currentTopic is null) { if (!AllowNull) { - context.Result = controller.NotFound(); + context.Result = controller.NotFound(); } return; } @@ -86,7 +86,7 @@ public override void OnActionExecuting(ActionExecutingContext context) { //### TODO JJC082817: Should allow this to be bypassed for administrators; requires introduction of Role dependency //### e.g., if (!Roles.IsUserInRole(Page?.User?.Identity?.Name ?? "", "Administrators")) {...} if (currentTopic.IsDisabled) { - context.Result = new UnauthorizedResult(); + context.Result = new UnauthorizedResult(); return; } @@ -96,7 +96,7 @@ public override void OnActionExecuting(ActionExecutingContext context) { var redirectUrl = currentTopic.Attributes.GetValue("URL"); if (!String.IsNullOrEmpty(redirectUrl)) { - context.Result = controller.RedirectPermanent(redirectUrl); + context.Result = controller.RedirectPermanent(redirectUrl); return; } @@ -107,7 +107,7 @@ public override void OnActionExecuting(ActionExecutingContext context) { | the request is valid, but forbidden. \-------------------------------------------------------------------------------------------------------------------------*/ if (currentTopic is { ContentType: "List"} or { Parent.ContentType: "List" }) { - context.Result = new StatusCodeResult(403); + context.Result = new StatusCodeResult(403); return; } @@ -118,7 +118,7 @@ public override void OnActionExecuting(ActionExecutingContext context) { | indicate that the request is valid, but forbidden. Unlike nested topics, children of containers are potentially valid. \-------------------------------------------------------------------------------------------------------------------------*/ if (currentTopic.ContentType is "Container") { - context.Result = new StatusCodeResult(403); + context.Result = new StatusCodeResult(403); return; } @@ -129,8 +129,8 @@ public override void OnActionExecuting(ActionExecutingContext context) { | redirected to the first (non-hidden, non-disabled) page in the page group. \-------------------------------------------------------------------------------------------------------------------------*/ if (currentTopic.ContentType is "PageGroup") { - var target = currentTopic.Children.Where(t => t.IsVisible()).FirstOrDefault()?.GetWebPath(); - context.Result = target is null? new StatusCodeResult(403) : controller.Redirect(target); + var target = currentTopic.Children.Where(t => t.IsVisible()).FirstOrDefault()?.GetWebPath(); + context.Result = target is null? new StatusCodeResult(403) : controller.Redirect(target); return; } @@ -142,7 +142,7 @@ public override void OnActionExecuting(ActionExecutingContext context) { | same case as assigned in the topic graph, URLs that vary only by case will be redirected to the expected case. \-------------------------------------------------------------------------------------------------------------------------*/ if (!currentTopic.GetWebPath().Equals(context.HttpContext.Request.Path, StringComparison.Ordinal)) { - context.Result = controller.RedirectPermanent(currentTopic.GetWebPath()); + context.Result = controller.RedirectPermanent(currentTopic.GetWebPath()); return; } diff --git a/OnTopic.Data.Caching/CachedTopicRepository.cs b/OnTopic.Data.Caching/CachedTopicRepository.cs index 0efe09d0..0ec3c0ae 100644 --- a/OnTopic.Data.Caching/CachedTopicRepository.cs +++ b/OnTopic.Data.Caching/CachedTopicRepository.cs @@ -3,6 +3,8 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ +using System.Collections.Concurrent; +using OnTopic.Collections.Specialized; using OnTopic.Internal.Diagnostics; using OnTopic.Querying; using OnTopic.Repositories; @@ -16,23 +18,29 @@ namespace OnTopic.Data.Caching; /// Provides data access to topics stored in memory. /// /// -/// Concrete implementation of the class, which provides a wrapper -/// for an actual data access class. +/// Concrete implementation of the class, which provides a wrapper for an actual data access +/// class. /// -public class CachedTopicRepository : TopicRepositoryDecorator { +public class CachedTopicRepository : TopicRepositoryDecorator, ITopicLazyLoader { /*============================================================================================================================ | VARIABLES \---------------------------------------------------------------------------------------------------------------------------*/ private readonly Topic _cache; + private readonly Dictionary _topicKeyIndex = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _absentTopicIdIndex = new(); + private readonly HashSet _absentUniqueKeyIndex = new(StringComparer.OrdinalIgnoreCase); + private readonly object _syncLock = new(); + private readonly ConcurrentDictionary _loadGates = new(); + private readonly ConcurrentDictionary _keyLoadGates = new(StringComparer.OrdinalIgnoreCase); /*============================================================================================================================ | CONSTRUCTOR \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Instantiates a new instance of the with a dependency on an underlying in order to provide necessary data access. + /// Instantiates a new instance of the with a dependency on an underlying in order to provide necessary data access. /// /// /// A concrete instance of an , which will be used for data access. @@ -41,9 +49,17 @@ public class CachedTopicRepository : TopicRepositoryDecorator { public CachedTopicRepository(ITopicRepository topicRepository) : base(topicRepository) { /*-------------------------------------------------------------------------------------------------------------------------- - | Ensure topics are loaded + | Seed root topic and its immediate children (without grandchildren) + >------------------------------------------------------------------------------------------------------------------------- + | The top-level topics under Root typically represent distinct content buckets (e.g., Web, Configuration) that are commonly + | referenced individually via e.g., relationships used to delegate navigation, so fully loading this shallow tier up front + | avoids the predictable, immediate lazy-load of Root.Children that would otherwise follow. Each child's own Children remain + | deferred, preserving the benefits of lazy loading below this boundary. \-------------------------------------------------------------------------------------------------------------------------*/ - var rootTopic = TopicRepository.Load(); + var rootTopic = TopicRepository + .Load("Root", referenceTopic: null, payload: TopicPayload.All) + .GetAwaiter() + .GetResult(); Contract.Assume( rootTopic, @@ -52,34 +68,143 @@ public CachedTopicRepository(ITopicRepository topicRepository) : base(topicRepos ); /*-------------------------------------------------------------------------------------------------------------------------- - | Ensure topics are loaded + | Establish cache \-------------------------------------------------------------------------------------------------------------------------*/ _cache = rootTopic; + /*-------------------------------------------------------------------------------------------------------------------------- + | Eager-load Root:Configuration subtree (required for content-type descriptor resolution) + \-------------------------------------------------------------------------------------------------------------------------*/ + TopicRepository + .Load("Root:Configuration", referenceTopic: _cache, payload: TopicPayload.All, depth: -1) + .GetAwaiter() + .GetResult(); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Populate key index from seeded topics + >--------------------------------------------------------------------------------------------------------------------------- + | The live id index needs no seeding here: Any ITopicRepository.Load() call attaches its results directly into the graph + | of the referenceTopic it's given, which builds that topic's live index; the Root:Configuration load above did so via + | _cache, and every topic attached since keeps it current. + \-------------------------------------------------------------------------------------------------------------------------*/ + foreach (var topic in _cache.FindAll()) { + IndexTopic(topic); + } + } /*============================================================================================================================ | METHOD: LOAD \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override Topic? Load(int topicId, Topic? referenceTopic = null, bool isRecursive = true) { + /// + /// Returns a cached topic if it satisfies the requested and ; an + /// insufficient hit is topped up via before being returned. On a + /// miss, falls through to the underlying repository with @LoadAscendants enabled so the full ancestor chain is + /// fetched and merged into the live graph, using if supplied, or the cache root + /// otherwise, so the underlying load seeds its working index from, and can attach directly to, the referenced graph. This + /// fall-through is serialized per via the same gate the hit path uses, + /// with a double-checked re-read of both the live index and the Missing ID index under the gate, so concurrent requests for + /// the same uncached ID merge exactly once rather than racing. Missing IDs are recorded to prevent redundant round-trips + /// for topics that do not exist. + /// + /// This only covers duplicates with the same identity; it does not extend to concurrent loads that merge into + /// overlapping regions of the graph under different identities (e.g., an ancestor and one of its not-yet-loaded + /// descendants). See for the full concurrency + /// contract. + /// + /// + public override async Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Handle request for entire tree \-------------------------------------------------------------------------------------------------------------------------*/ if (topicId < 0) { + await EnsureLoaded(_cache, payload, depth).ConfigureAwait(false); return _cache; } /*-------------------------------------------------------------------------------------------------------------------------- - | Recursive search + | Lookup by topic identifier; top up and return on a hit, or skip a known miss, before falling through to a fresh load \-------------------------------------------------------------------------------------------------------------------------*/ - return _cache.FindFirst(t => t.Id.Equals(topicId)); + var (resident, isAbsent) = GetPreviousLoad(topicId); + + if (resident is not null) { + await EnsureLoaded(resident, payload, depth).ConfigureAwait(false); + return resident; + } + + if (isAbsent) { + return null; + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | On miss: Load with ancestors and merge result into the live graph, serialized per ID to prevent concurrent per-topic loads + \-------------------------------------------------------------------------------------------------------------------------*/ + return await WithLoadGate(_loadGates, topicId, async () => { + + // Second escape hatch: A prior holder may have loaded this ID, or recorded a miss, while this thread waited + var (existingTopic, existingIsAbsent) = GetPreviousLoad(topicId); + + // Return match + if (existingTopic is not null) { + var gate = payload & ~(TopicPayload.Relationships | TopicPayload.References); + if (((ITopicLazyLoadable)existingTopic).IsLoaded(gate, depth)) { + return existingTopic; + } + } + + // If there was a previous load attempt, return early + else if (existingIsAbsent) { + return null; + } + + // Insufficient? Load with ancestors and merge into whichever instance is already available, if any + var freshlyLoaded = await TopicRepository + .Load(topicId, existingTopic?? referenceTopic?? _cache, payload, depth) + .ConfigureAwait(false); + + // If it's missing, populate the index so we don't try loading it again + if (freshlyLoaded is null) { + lock (_syncLock) { + _absentTopicIdIndex.Add(topicId); + } + } + + // Return the loaded topic, if present + return freshlyLoaded; + + }).ConfigureAwait(false); } /// - public override Topic? Load(string uniqueKey, Topic? referenceTopic = null, bool isRecursive = true) { + /// + /// Returns a cached topic if it satisfies the requested and ; an + /// insufficient hit is topped up via before being returned. On a + /// miss, falls through to the underlying repository with @LoadAscendants enabled so the full ancestor chain is + /// fetched and merged into the live graph, using if supplied, or the cache root + /// otherwise, so the underlying load seeds its working index from, and can attach directly to, the resident graph. This + /// fall-through is serialized per via , with a double-checked + /// reread of both the key and the missing-key index under the gate, so concurrent requests for the same uncached key merge + /// exactly once. Missing keys are recorded to prevent redundant round-trips for topics that do not exist. + /// + /// This only covers same-identity duplicates; it does not extend to concurrent loads that merge into overlapping + /// regions under different identities. See for + /// the full concurrency contract. + /// + /// + public override async Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -89,33 +214,508 @@ public CachedTopicRepository(ITopicRepository topicRepository) : base(topicRepos } /*-------------------------------------------------------------------------------------------------------------------------- - | Lookup by TopicKey + | Normalize key: Accept partial paths such as "Web:Valid:Child" in addition to the canonical "Root:Web:Valid:Child" \-------------------------------------------------------------------------------------------------------------------------*/ - return _cache.GetByUniqueKey(uniqueKey); + if ( + !uniqueKey.StartsWith(_cache.Key + ":", StringComparison.OrdinalIgnoreCase) && + !uniqueKey.Equals(_cache.Key, StringComparison.OrdinalIgnoreCase) + ) { + uniqueKey = $"{_cache.Key}:{uniqueKey.TrimStart(':')}"; + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Lookup by unique key; top up and return on a hit, or skip a known miss, before falling through to a fresh load + \-------------------------------------------------------------------------------------------------------------------------*/ + var (resident, isAbsent) = GetPreviousLoad(uniqueKey); + + if (resident is not null) { + await EnsureLoaded(resident, payload, depth).ConfigureAwait(false); + return resident; + } + + if (isAbsent) { + return null; + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | On miss: Load with ancestors and merge result into the live graph, serialized per key to prevent concurrent loads for the + | same uncached uniqueKey + \-------------------------------------------------------------------------------------------------------------------------*/ + return await WithLoadGate(_keyLoadGates, uniqueKey, async () => { + + // Second escape hatch: A prior holder may have loaded this key, or recorded a miss, while this thread waited + var (existingTopic, existingIsAbsent) = GetPreviousLoad(uniqueKey); + + // Return match + if (existingTopic is not null) { + var gate = payload & ~(TopicPayload.Relationships | TopicPayload.References); + if (((ITopicLazyLoadable)existingTopic).IsLoaded(gate, depth)) { + return existingTopic; + } + } + + // If there was a previous load attempt, return early + else if (existingIsAbsent) { + return null; + } + + // Insufficient? Load with ancestors and merge into whichever instance is already available, if any + var freshlyLoaded = await TopicRepository + .Load(uniqueKey, existingTopic?? referenceTopic?? _cache, payload, depth) + .ConfigureAwait(false); + + // If it's missing, populate the index so we don't try loading it again + if (freshlyLoaded is null) { + lock (_syncLock) { + _absentUniqueKeyIndex.Add(uniqueKey); + } + } + + // Return the loaded topic, if present + return freshlyLoaded; + + }).ConfigureAwait(false); } + /*============================================================================================================================ + | METHODS: TOPIC LAZY LOADER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// - public override Topic? Load(int topicId, DateTime version, Topic? referenceTopic = null) { + public virtual async Task EnsureLoaded(Topic topic, TopicPayload payload, CancellationToken cancellationToken = default) { /*-------------------------------------------------------------------------------------------------------------------------- - | Normalize parameters + | Validate parameters \-------------------------------------------------------------------------------------------------------------------------*/ - version = NormalizeToUtc(version); + Contract.Requires(topic); /*-------------------------------------------------------------------------------------------------------------------------- - | Validate parameters + | Filter to pending (i.e., not yet Loaded) payload \-------------------------------------------------------------------------------------------------------------------------*/ - Contract.Requires(version.Date < DateTime.UtcNow, "The version requested must be a valid historical date."); - Contract.Requires( - version.Date >= new DateTime(2014, 12, 9), - "The version is expected to have been created since version support was introduced into the topic library." - ); + var rawTopic = (ITopicLazyLoadable)topic; + payload = rawTopic.FilterPayload(payload); + + if (payload is TopicPayload.None) { + return; + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Delegate to the inner resolver; captures missing targets in the Deferred collections + >------------------------------------------------------------------------------------------------------------------------- + | Relationships and References are withheld from the inner delegation: This cache is the outermost resolver, so it alone + | is responsible for resolving deferred association targets, via its own Load()—which checks the flat index before + | falling through to the inner repository. If the inner repository (e.g., SqlTopicRepository) were also asked to resolve + | them, it would do so via its own, non-cache-aware Load(), producing a duplicate Topic instance for any target that's + | already present in this cache. + \-------------------------------------------------------------------------------------------------------------------------*/ + if (TopicRepository is ITopicLazyLoader loader) { + var innerPayload = payload & ~(TopicPayload.Relationships | TopicPayload.References); + if (innerPayload is not TopicPayload.None) { + + // Serialize fetches and merges (children, extended attributes, version history) per topic + await WithLoadGate(_loadGates, topic.Id, async () => { + + // Second escape hatch: Re-filter under the gate, since a prior holder may have merged some or all of this payload + var remainingPayload = rawTopic.FilterPayload(innerPayload); + if (remainingPayload is not TopicPayload.None) { + await loader.EnsureLoaded(topic, remainingPayload, cancellationToken).ConfigureAwait(false); + } + + }, cancellationToken).ConfigureAwait(false); + + } + } /*-------------------------------------------------------------------------------------------------------------------------- - | Return appropriate topic + | Resolve any relationship and reference targets via the cache layer \-------------------------------------------------------------------------------------------------------------------------*/ - return TopicRepository.Load(topicId, version, referenceTopic?? _cache); + await LoadDeferredAssociations(topic, payload, cancellationToken).ConfigureAwait(false); + + } + + /*============================================================================================================================ + | METHODS: EVENT HANDLERS + \---------------------------------------------------------------------------------------------------------------------------*/ + + /// + /// + /// Adds the newly loaded topic to the index and clears any entries previously known to be missing, so a topic that was + /// missing on an earlier lookup can be found now. This automatically indexes any descendants loaded alongside the topic, + /// for cases where a non-zero depth was specified on . + /// Ancestors pulled in via @LoadAscendants sit above the topic, so , + /// which only walks downward, never reaches them; they are indexed by walking up the parent chain instead. + /// + /// The live id index needs no attention here, as it is managed via the before this event + /// even fires. A historical version load ( not ) is skipped + /// entirely: Its topic is never attached to the resident graph, so it must not enter the key index either. + /// + /// + protected override void OnTopicLoaded(TopicLoadEventArgs args) { + + // Setup + Contract.Requires(args); + base.OnTopicLoaded(args); + + // Historical version loads are not part of the resident graph; neither index should reflect them + if (args.Version is not null) { + return; + } + + lock (_syncLock) { + + // Index the loaded topic and any descendants that came back attached; FindAll() is lazy-safe and naturally returns just + // the topic itself when nothing further is present, so this is correct whether or not the load was recursive. + foreach (var topic in args.Topic.FindAll()) { + if (_topicKeyIndex.ContainsKey(topic.GetUniqueKey())) { + continue; + } + IndexTopic(topic); + _absentTopicIdIndex.Remove(topic.Id); + _absentUniqueKeyIndex.Remove(topic.GetUniqueKey()); + } + + // Index any ancestors pulled in via @LoadAscendants, which sit above the requested topic and so are missed by FindAll(). + // Walk up from the parent, stopping at the first already indexed ancestor: The cache is always rooted, so everything + // above an existing ancestor is itself already loaded and indexed. + for (var ancestor = args.Topic.Parent; ancestor is not null; ancestor = ancestor.Parent) { + if (_topicKeyIndex.ContainsKey(ancestor.GetUniqueKey())) { + break; + } + IndexTopic(ancestor); + _absentTopicIdIndex.Remove(ancestor.Id); + _absentUniqueKeyIndex.Remove(ancestor.GetUniqueKey()); + } + + } + + } + + /// + /// + /// Adds newly created topics to the flat index. When the save is recursive, all present descendants are indexed as well, + /// since only one event fires for the root of a recursive save. Also clears any + /// entries known to be missing so that a previously missing ID or key that is now created can be found on subsequent + /// lookups. The live id index needs no attention here: 's setter already indexed each newly created + /// topic, via the registry's hooks, at the moment its persisted identifier was assigned. + /// + protected override void OnTopicSaved(TopicSaveEventArgs args) { + + // Setup + Contract.Requires(args); + base.OnTopicSaved(args); + + // Index newly created topics and, when saved recursively, any new descendants + if (args.IsNew) { + lock (_syncLock) { + foreach (var topic in args.Topic.FindAll()) { + IndexTopic(topic); + _absentTopicIdIndex.Remove(topic.Id); + _absentUniqueKeyIndex.Remove(topic.GetUniqueKey()); + } + } + } + + } + + /// + /// + /// Removes the deleted topic and all of its descendants from the flat index. Called after the topic has been detached from + /// its parent's collection but before the topic graph is torn down, so on the deleted topic still returns the full subtree. The live id index needs no + /// attention here: detaches the topic from its parent before raising + /// this event, so the registry's detach hook has already pruned the subtree from it. + /// + protected override void OnTopicDeleted(TopicEventArgs args) { + + // Setup + Contract.Requires(args); + base.OnTopicDeleted(args); + + // Remove the deleted subtree from the key index + lock (_syncLock) { + foreach (var topic in args.Topic.FindAll()) { + _topicKeyIndex.Remove(topic.GetUniqueKey()); + } + } + + } + + /// + /// + /// Rebuilds the unique-key index entries for the moved topic and all of its descendants. The move has already completed by + /// the time this fires, so the old root key is reconstructed from and the topic's + /// (unchanged) . + /// + protected override void OnTopicMoved(TopicMoveEventArgs args) { + + // Setup + Contract.Requires(args); + base.OnTopicMoved(args); + + // Reconstruct the old root unique key from the source parent and the (unchanged) topic key + var oldRootUniqueKey = args.Source is null + ? args.Topic.Key + : $"{args.Source.GetUniqueKey()}:{args.Topic.Key}"; + + // Reindex topic and children + RekeyTopicSubtree(args.Topic, oldRootUniqueKey); + + } + + /// + /// + /// Rebuilds the unique-key index entries for the renamed topic and all of its descendants. The rename has already been + /// applied to by the time this fires, so the old root key is reconstructed from the (unchanged) + /// parent path and . + /// + protected override void OnTopicRenamed(TopicRenameEventArgs args) { + + // Setup + Contract.Requires(args); + base.OnTopicRenamed(args); + + // Reconstruct the old root unique key from the (unchanged) parent path and the original key + var oldRootUniqueKey = args.Topic.Parent is null + ? args.OriginalKey + : $"{args.Topic.Parent.GetUniqueKey()}:{args.OriginalKey}"; + + // Reindex topic and children + RekeyTopicSubtree(args.Topic, oldRootUniqueKey); + + } + + /*============================================================================================================================ + | METHOD: REKEY TOPIC SUBTREE + \---------------------------------------------------------------------------------------------------------------------------*/ + + /// + /// Removes stale _topicByKey entries for and its descendants by swapping the prefix for the current one, then reindexes the subtree under its current unique keys. + /// + private void RekeyTopicSubtree(Topic topic, string oldRootUniqueKey) { + + // Establish variables + var newRootUniqueKey = topic.GetUniqueKey(); + + // Remove each stale unique-key entry and replace it with the current unique key + lock (_syncLock) { + foreach (var subtopic in topic.FindAll()) { + var currentKey = subtopic.GetUniqueKey(); + var oldKey = oldRootUniqueKey + currentKey[newRootUniqueKey.Length..]; + _topicKeyIndex.Remove(oldKey); + _topicKeyIndex[currentKey] = subtopic; + } + } + + } + + /*============================================================================================================================ + | METHOD: INDEX TOPIC + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Adds or updates in the unique-key index. + /// + /// + /// The live id index () is maintained separately by the hooks, so needs no counterpart here. Callers are responsible for holding + /// before invoking this method, except during construction where single-threaded access is guaranteed. + /// + private void IndexTopic(Topic topic) => _topicKeyIndex[topic.GetUniqueKey()] = topic; + + /*============================================================================================================================ + | METHOD: ENSURE LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Confirms that already satisfies the requested and scope and, if not, tops it up in place; the caller's own reference to reflects + /// whatever is added. + /// + /// + /// + /// Relationships and references are excluded from the sufficiency gate since Load() never guarantees a fully + /// resolved target graph, so gating on them would prevent convergence and force a reload on every hit. + /// + /// + /// A single-topic shortfall is topped up via , which converges LoadState in a single batched round-trip. Any other shortfall, including a whole-tree + /// request, performs one deep against the + /// underlying repository, using itself as the reference into the topic graph, so the underlying + /// load merges the result directly into it. This deep load is serialized per topic via the same + /// gate as , since both mutate the same 's collections and must not merge concurrently; a double-checked re-read under the gate lets a waiter skip a + /// now-redundant fetch a prior load already satisfied. It then looks up any in-graph associations outside the gate (), so any relationships or references + /// that were just loaded are connected without a further trip. + /// + /// + /// The already-resident topic to confirm or top up. + /// The flags the caller requires to be loaded. + /// The number of tiers of descendants the caller requires, not merely itself. + private async Task EnsureLoaded( + Topic topic, + TopicPayload payload, + int depth + ) { + + // Narrow the sufficiency gate to exclude relationships and references, which Load() never guarantees are fully resolved + var gate = payload & ~(TopicPayload.Relationships | TopicPayload.References); + + // Return immediately if the resident topic already satisfies the requested scope + var rawTopic = (ITopicLazyLoadable)topic; + if (rawTopic.IsLoaded(gate, depth)) { + return; + } + + // Top up a single-topic shortfall via the loader, which converges LoadState in a single round-trip + if (depth is 0) { + await rawTopic.EnsureLoaded(gate).ConfigureAwait(false); + return; + } + + // Top-up any other shortfall via one deep load, serialized per topic to prevent concurrent same-topic merges + var loaded = await WithLoadGate(_loadGates, topic.Id, async () => { + + // Second escape hatch: A prior holder may have already merged this (or a deeper) region under the gate + if (rawTopic.IsLoaded(gate, depth)) { + return null; + } + return await TopicRepository.Load(topic.Id, topic, payload, depth).ConfigureAwait(false); + + }).ConfigureAwait(false); + + // Resolve associations outside the gate; a waiter that returned at the double-check gate relies on the first holder's + // resolution (of the same or a deeper region) or the normal deferred lazy-load, since associations are not gated + if (loaded is not null) { + + // Opportunistically connect any relationship or reference targets that are now resident in the merged region, regardless + // of whether relationships or references were themselves part of the requested payload + foreach (var descendant in loaded.FindAll()) { + await ResolveAssociations(descendant, TopicPayload.Relationships | TopicPayload.References).ConfigureAwait(false); + } + + } + + } + + /*============================================================================================================================ + | METHOD: GET PREVIOUS LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Looks up in the live index and, on a miss, reports whether it was previously recorded as + /// absent. + /// + /// + /// Centralizes the lookup mechanics shared by 's pre- and post-gate + /// checks; each caller still decides for itself what a hit, a miss, or a recorded absence means at that point in the flow. + /// + private (Topic? Resident, bool IsAbsent) GetPreviousLoad(int topicId) { + + // Attempt to lookup the item + _cache.GetLiveTopicIndex().TryGetValue(topicId, out var resident); + + // If it's found report that + if (resident is not null) { + return (resident, false); + } + + // Otherwise, report of it's already reported as missing + lock (_syncLock) { + return (null, _absentTopicIdIndex.Contains(topicId)); + } + + } + + /// + /// Looks up in the key index and, on a miss, reports whether it was previously recorded as + /// absent. + /// + /// + /// Centralizes the lookup mechanics shared by 's pre- and post-gate + /// checks; each caller still decides for itself what a hit, a miss, or a recorded absence means at that point in the flow. + /// Assumes has already been normalized to its canonical form. Unlike its counterpart, both checks share one block, since —a plain , not the lock-free the id + /// overload reads from—isn't safe to read without it. + /// + private (Topic? Resident, bool IsAbsent) GetPreviousLoad(string uniqueKey) { + + // Attempt to lookup the item + lock (_syncLock) { + if (_topicKeyIndex.TryGetValue(uniqueKey, out var resident)) { + return (resident, false); + } + return (null, _absentUniqueKeyIndex.Contains(uniqueKey)); + } + + } + + /*============================================================================================================================ + | METHOD: WITH LOAD GATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Serializes against any other in-flight call sharing in , via a persistent, per-key acquired before, and released after, runs. + /// + /// + /// Shared by every concurrency gate with the same identity in this class: Each supplies its own sufficiency check and load + /// logic via , since that varies by call site, while this method owns only the acquire and + /// release ceremony common to all of them. + /// + /// + /// The per-key gate dictionary to acquire 's from. + /// + /// The identity to serialize concurrent calls against. + /// The sufficiency check and load logic to run once the gate is acquired. + /// An optional token that can cancel waiting on the gate itself. + private static async Task WithLoadGate( + ConcurrentDictionary gates, + TKey key, + Func> execute, + CancellationToken cancellationToken = default + ) where TKey: notnull { + + // Ensure the gate is established + var gate = gates.GetOrAdd(key, _ => new(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Execute the action + try { + return await execute().ConfigureAwait(false); + } + + // Release the gate + finally { + gate.Release(); + } + + } + + /// + /// + /// for gated work that requires a return ; this one does not. + /// + private static async Task WithLoadGate( + ConcurrentDictionary gates, + TKey key, + Func execute, + CancellationToken cancellationToken = default + ) where TKey: notnull { + + // Ensure the gate is established + var gate = gates.GetOrAdd(key, _ => new(1, 1)); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Execute the action + try { + await execute().ConfigureAwait(false); + } + + // Release the gate + finally { + gate.Release(); + } } diff --git a/OnTopic.Data.Sql.Database.Tests/StoredProcedures.resx b/OnTopic.Data.Sql.Database.Tests/StoredProcedures.resx index b3f95e0f..5e8497f0 100644 --- a/OnTopic.Data.Sql.Database.Tests/StoredProcedures.resx +++ b/OnTopic.Data.Sql.Database.Tests/StoredProcedures.resx @@ -234,14 +234,12 @@ EXECUTE [dbo].[GetTopicVersion] -- ESTABLISH VARIABLES -------------------------------------------------------------------------------------------------------------------------------- DECLARE @TopicID AS INT, - @DeepLoad AS BIT, @UniqueKey AS NVARCHAR (255); -------------------------------------------------------------------------------------------------------------------------------- -- SET VARIABLES -------------------------------------------------------------------------------------------------------------------------------- -SELECT @DeepLoad = 1, - @UniqueKey = 'GetTopicsTest'; +SELECT @UniqueKey = 'GetTopicsTest'; SELECT @TopicID = TopicID FROM Topics @@ -251,9 +249,8 @@ WHERE TopicKey = @UniqueKey -- EXECUTE PROCEDURE -------------------------------------------------------------------------------------------------------------------------------- EXECUTE [dbo].[GetTopics] - @TopicID, - @DeepLoad, - NULL; + @TopicID = @TopicID, + @LoadDescendants = 1; -------------------------------------------------------------------------------------------------------------------------------- diff --git a/OnTopic.Data.Sql.Database/OnTopic.Data.Sql.Database.sqlproj b/OnTopic.Data.Sql.Database/OnTopic.Data.Sql.Database.sqlproj index 631bb12e..e631140e 100644 --- a/OnTopic.Data.Sql.Database/OnTopic.Data.Sql.Database.sqlproj +++ b/OnTopic.Data.Sql.Database/OnTopic.Data.Sql.Database.sqlproj @@ -93,6 +93,7 @@ + diff --git a/OnTopic.Data.Sql.Database/Stored Procedures/GetSitemap.sql b/OnTopic.Data.Sql.Database/Stored Procedures/GetSitemap.sql new file mode 100644 index 00000000..b18c0c0d --- /dev/null +++ b/OnTopic.Data.Sql.Database/Stored Procedures/GetSitemap.sql @@ -0,0 +1,47 @@ +-------------------------------------------------------------------------------------------------------------------------------- +-- GET SITEMAP +-------------------------------------------------------------------------------------------------------------------------------- +-- Returns the minimal data the sitemap renders: The topic rows, then the handful of indexed attributes the sitemap evaluates. +-- SqlDataReaderExtensions.LoadTopicGraph stitches these two sets and passed over the result sets it would otherwise read +-- (extended attributes, relationships, references, history), which this sproc simply does not return. Deliberately omits the +-- nested-set descent, extended-attribute blobs, relationships, references, and version history. +-- +-- HasChildren and HasExtendedAttributes must be present so LoadTopicGraph's reader contract is satisfied (it reads both columns +-- unconditionally), but their values don't need to be computed: This sproc returns the entire flattened tree in one pass, so +-- every topic's children are already present in the graph regardless of the flag, and the graph is not subject to lazy-loading +-- (i.e., no ITopicLazyLoader stamped), so LoadState is never consulted to trigger a fill regardless. NULL leaves both +-- boundaries at their default of LoadState.Loaded (KeyedTopicCollection's default), matching the resolver-free invariant. +-------------------------------------------------------------------------------------------------------------------------------- + +CREATE PROCEDURE [dbo].[GetSitemap] +AS + +-------------------------------------------------------------------------------------------------------------------------------- +-- SELECT TOPICS +-------------------------------------------------------------------------------------------------------------------------------- +SELECT Topics.TopicID, + Topics.ContentType, + Topics.ParentID, + Topics.TopicKey, + HasChildren = CAST(NULL AS BIT), + HasExtendedAttributes = CAST(NULL AS BIT) +FROM Topics AS Topics +ORDER BY Topics.RangeLeft + +-------------------------------------------------------------------------------------------------------------------------------- +-- SELECT ATTRIBUTES +-------------------------------------------------------------------------------------------------------------------------------- +-- Filtered to exactly the keys AddTopic evaluates plus LastModified; this IN list is coupled to the controller's inclusion +-- logic and must grow with it. +SELECT Attributes.TopicID, + Attributes.AttributeKey, + Attributes.AttributeValue, + Attributes.Version +FROM AttributeIndex AS Attributes +WHERE Attributes.AttributeKey IN ( + 'IsPrivateBranch', + 'NoIndex', + 'IsDisabled', + 'Url', + 'LastModified' + ) \ No newline at end of file diff --git a/OnTopic.Data.Sql.Database/Stored Procedures/GetTopicUpdates.sql b/OnTopic.Data.Sql.Database/Stored Procedures/GetTopicUpdates.sql index 98bba426..a767c41f 100644 --- a/OnTopic.Data.Sql.Database/Stored Procedures/GetTopicUpdates.sql +++ b/OnTopic.Data.Sql.Database/Stored Procedures/GetTopicUpdates.sql @@ -15,9 +15,12 @@ SELECT TopicID, ContentType, ParentID, TopicKey, - 0 AS SortOrder + 0 AS SortOrder, + HasChildren = NULL, + HasExtendedAttributes = NULL FROM Topics WHERE LastModified > @Since +ORDER BY RangeLeft -------------------------------------------------------------------------------------------------------------------------------- -- SELECT TOPIC ATTRIBUTES diff --git a/OnTopic.Data.Sql.Database/Stored Procedures/GetTopicVersion.sql b/OnTopic.Data.Sql.Database/Stored Procedures/GetTopicVersion.sql index 0b37a0c8..7014cc32 100644 --- a/OnTopic.Data.Sql.Database/Stored Procedures/GetTopicVersion.sql +++ b/OnTopic.Data.Sql.Database/Stored Procedures/GetTopicVersion.sql @@ -20,7 +20,9 @@ SELECT TopicID, ContentType, ParentID, TopicKey, - 0 AS SortOrder + 0 AS SortOrder, + HasChildren = NULL, + HasExtendedAttributes = NULL FROM Topics WHERE TopicID = @TopicID diff --git a/OnTopic.Data.Sql.Database/Stored Procedures/GetTopics.sql b/OnTopic.Data.Sql.Database/Stored Procedures/GetTopics.sql index 0d6d9a28..fdfdd3de 100644 --- a/OnTopic.Data.Sql.Database/Stored Procedures/GetTopics.sql +++ b/OnTopic.Data.Sql.Database/Stored Procedures/GetTopics.sql @@ -6,8 +6,14 @@ -------------------------------------------------------------------------------------------------------------------------------- CREATE PROCEDURE [dbo].[GetTopics] - @TopicID INT = -1, - @DeepLoad BIT = 1, + @TopicID INT = -1, + @Depth INT = 0, + @LoadAscendants BIT = 0, + @IncludeIndexed BIT = 1, + @IncludeExtended BIT = 1, + @IncludeRelationships BIT = 1, + @IncludeReferences BIT = 1, + @IncludeHistory BIT = 1, @UniqueKey NVARCHAR(255) = NULL AS @@ -40,9 +46,11 @@ CLUSTERED INDEX IX_C_Topics_TopicID ) -------------------------------------------------------------------------------------------------------------------------------- --- SELECT TOPIC AND DESCENDENTS +-- SELECT TOPIC AND DESCENDANTS (FULL SUBTREE) -------------------------------------------------------------------------------------------------------------------------------- -IF @DeepLoad = 1 +-- A @Depth of -1 requests the entire subtree, efficiently expressed via a nested-set range join. +-------------------------------------------------------------------------------------------------------------------------------- +IF @Depth = -1 BEGIN INSERT #Topics ( TopicID, @@ -65,18 +73,65 @@ IF @DeepLoad = 1 END -------------------------------------------------------------------------------------------------------------------------------- --- SELECT TOPIC ONLY +-- SELECT TOPIC AND DESCENDANTS (BOUNDED) +-------------------------------------------------------------------------------------------------------------------------------- +-- A @Depth of 1 or more requests a bounded number of tiers below the seed topic, via a recursive CTE over ParentID. The seed is +-- included at level 0. SortOrder is populated from RangeLeft, guaranteeing parents precede children and preserve sibling order. -------------------------------------------------------------------------------------------------------------------------------- -ELSE +ELSE IF @Depth >= 1 BEGIN + ;WITH DescendantsCTE AS ( + SELECT TopicID, + RangeLeft, + Level = 0 + FROM Topics + WHERE TopicID = @TopicID + UNION ALL + SELECT T1.TopicID, + T1.RangeLeft, + Level = DescendantsCTE.Level + 1 + FROM Topics AS T1 + INNER JOIN DescendantsCTE + ON T1.ParentID = DescendantsCTE.TopicID + WHERE DescendantsCTE.Level < @Depth + ) INSERT #Topics ( TopicID, SortOrder ) SELECT TopicID, - 1 - FROM Topics - WHERE TopicID = @TopicID + RangeLeft + FROM DescendantsCTE + OPTION (MAXRECURSION 0) + END + +-------------------------------------------------------------------------------------------------------------------------------- +-- SELECT TOPIC AND ANCESTOR CHAIN +-------------------------------------------------------------------------------------------------------------------------------- +-- Ancestors are rows whose nested-set range contains the requested node's RangeLeft, i.e., the mirror of the descendant query +-- above. This can be combined with @Depth to load both the subtree and its ancestor chain in a single query. The NOT EXISTS +-- guard prevents duplicate inserts when both are requested. +-------------------------------------------------------------------------------------------------------------------------------- +IF @LoadAscendants = 1 + BEGIN + INSERT #Topics ( + TopicID, + SortOrder + ) + SELECT T1.TopicID, + T1.RangeLeft + FROM Topics AS T1 + INNER JOIN Topics AS T2 + ON T2.RangeLeft + BETWEEN T1.RangeLeft + AND T1.RangeRight + AND T2.TopicID = @TopicID + WHERE NOT EXISTS ( + SELECT 1 + FROM #Topics + WHERE TopicID = T1.TopicID + ) + ORDER BY T1.RangeLeft OPTION ( OPTIMIZE FOR ( @TopicID UNKNOWN @@ -84,6 +139,22 @@ ELSE ) END +-------------------------------------------------------------------------------------------------------------------------------- +-- SELECT SINGLE TOPIC (NO SCOPE) +-------------------------------------------------------------------------------------------------------------------------------- +-- Inserts only the requested topic; used by the lazy-load resolver to fill a single topic's extended attributes without +-- traversing the tree in either direction. +-------------------------------------------------------------------------------------------------------------------------------- +IF @Depth = 0 AND @LoadAscendants = 0 + BEGIN + INSERT #Topics ( + TopicID, + SortOrder + ) + SELECT @TopicID, + 0 + END + -------------------------------------------------------------------------------------------------------------------------------- -- SELECT KEY ATTRIBUTES -------------------------------------------------------------------------------------------------------------------------------- @@ -91,7 +162,31 @@ SELECT Topics.TopicID, ContentType, ParentID, TopicKey, - SortOrder + SortOrder, + HasChildren = CAST( + CASE + WHEN Topics.RangeRight - Topics.RangeLeft > 1 + THEN 1 + ELSE 0 + END AS BIT + ), + HasExtendedAttributes = + CASE + WHEN @IncludeExtended = 0 + THEN CAST( + CASE + WHEN EXISTS ( + SELECT 1 + FROM ExtendedAttributeIndex AS Extended + WHERE Extended.TopicID = Topics.TopicID + AND Extended.AttributesXml <> '' + ) + THEN 1 + ELSE 0 + END AS BIT + ) + ELSE NULL + END FROM Topics AS Topics JOIN #Topics AS Storage ON Storage.TopicID = Topics.TopicID @@ -107,6 +202,7 @@ SELECT Attributes.TopicID, FROM AttributeIndex AS Attributes JOIN #Topics AS Storage ON Storage.TopicID = Attributes.TopicID +WHERE @IncludeIndexed = 1 -------------------------------------------------------------------------------------------------------------------------------- -- SELECT EXTENDED ATTRIBUTES @@ -117,6 +213,7 @@ SELECT Attributes.TopicID, FROM ExtendedAttributeIndex AS Attributes JOIN #Topics AS Storage ON Storage.TopicID = Attributes.TopicID +WHERE @IncludeExtended = 1 -------------------------------------------------------------------------------------------------------------------------------- -- SELECT RELATIONSHIPS @@ -128,6 +225,7 @@ SELECT Source_TopicID, FROM RelationshipIndex AS Relationships JOIN #Topics AS Storage ON Storage.TopicID = Relationships.Source_TopicID +WHERE @IncludeRelationships = 1 -------------------------------------------------------------------------------------------------------------------------------- -- SELECT REFERENCES @@ -138,6 +236,7 @@ SELECT Source_TopicID, FROM ReferenceIndex AS TopicReferences JOIN #Topics AS Storage ON Storage.TopicID = TopicReferences.Source_TopicID +WHERE @IncludeReferences = 1 -------------------------------------------------------------------------------------------------------------------------------- -- SELECT HISTORY @@ -146,4 +245,5 @@ SELECT History.TopicID, Version FROM VersionHistoryIndex AS History JOIN #Topics AS Storage - ON Storage.TopicID = History.TopicID; \ No newline at end of file + ON Storage.TopicID = History.TopicID +WHERE @IncludeHistory = 1; \ No newline at end of file diff --git a/OnTopic.Data.Sql.Database/Tables/Topics.sql b/OnTopic.Data.Sql.Database/Tables/Topics.sql index c4bda366..ace9e6da 100644 --- a/OnTopic.Data.Sql.Database/Tables/Topics.sql +++ b/OnTopic.Data.Sql.Database/Tables/Topics.sql @@ -53,4 +53,18 @@ CREATE NONCLUSTERED INDEX [IX_Topics_RangeRight] ON [dbo].[Topics] ( [RangeRight] ASC + ); + +GO + +-------------------------------------------------------------------------------------------------------------------------------- +-- PARENT ID (INDEX) +-------------------------------------------------------------------------------------------------------------------------------- +-- Provides a dedicated index for evaluating a topic's immediate children, e.g. via the depth-bound recursive CTE in GetTopics. +-- The (TopicKey, ParentID) unique constraint already indexes ParentID, but in the wrong order for this predicate. +-------------------------------------------------------------------------------------------------------------------------------- +CREATE NONCLUSTERED +INDEX [IX_Topics_ParentID] + ON [dbo].[Topics] ( + [ParentID] ASC ); \ No newline at end of file diff --git a/OnTopic.Data.Sql/Models/AttributeValuesDataTable.cs b/OnTopic.Data.Sql/Models/AttributeValuesDataTable.cs index fd3d86cb..17f853ee 100644 --- a/OnTopic.Data.Sql/Models/AttributeValuesDataTable.cs +++ b/OnTopic.Data.Sql/Models/AttributeValuesDataTable.cs @@ -3,7 +3,7 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ -using OnTopic.Attributes; + using OnTopic.Collections.Specialized; namespace OnTopic.Data.Sql.Models; diff --git a/OnTopic.Data.Sql/Properties/AssemblyInfo.cs b/OnTopic.Data.Sql/Properties/AssemblyInfo.cs index e314ced8..dc3570f8 100644 --- a/OnTopic.Data.Sql/Properties/AssemblyInfo.cs +++ b/OnTopic.Data.Sql/Properties/AssemblyInfo.cs @@ -8,6 +8,7 @@ | USING DIRECTIVES (GLOBAL) \-----------------------------------------------------------------------------------------------------------------------------*/ global using System.Data; +global using System.Data.Common; global using Microsoft.Data.SqlClient; global using OnTopic.Internal.Diagnostics; @@ -25,4 +26,4 @@ [assembly: ComVisible(false)] [assembly: CLSCompliant(true)] [assembly: InternalsVisibleTo("OnTopic.Tests")] -[assembly: Guid("1de1f923-c7c2-435b-b49a-975acbcb5ff0")] +[assembly: Guid("1de1f923-c7c2-435b-b49a-975acbcb5ff0")] \ No newline at end of file diff --git a/OnTopic.Data.Sql/SqlCommandExtensions.cs b/OnTopic.Data.Sql/SqlCommandExtensions.cs index 44265bc1..018cc2b3 100644 --- a/OnTopic.Data.Sql/SqlCommandExtensions.cs +++ b/OnTopic.Data.Sql/SqlCommandExtensions.cs @@ -29,7 +29,7 @@ internal static int GetReturnCode(this SqlCommand command, string sqlParameter = command.Parameters.Contains($"@{sqlParameter}"), $"The call to the {command.CommandText} stored procedure did not return the expected 'ReturnCode' parameter." ); - var returnCode = command.Parameters[$"@{sqlParameter}"].Value?.ToString(); + var returnCode = command.Parameters[$"@{sqlParameter}"].Value?.ToString(); if (Int32.TryParse(returnCode, NumberStyles.Integer, CultureInfo.InvariantCulture, out var returnValue)) { return returnValue; } diff --git a/OnTopic.Data.Sql/SqlDataReaderExtensions.cs b/OnTopic.Data.Sql/SqlDataReaderExtensions.cs index b6ee0d64..6a6110d1 100644 --- a/OnTopic.Data.Sql/SqlDataReaderExtensions.cs +++ b/OnTopic.Data.Sql/SqlDataReaderExtensions.cs @@ -7,6 +7,7 @@ using System.Net; using OnTopic.Collections.Specialized; using OnTopic.Querying; +using OnTopic.Repositories; namespace OnTopic.Data.Sql; @@ -35,6 +36,12 @@ internal static class SqlDataReaderExtensions { /// topics and populate their attributes, associations, and children. /// /// The with output from the GetTopics stored procedure. + /// + /// The that was passed to the underlying query (i.e., the root of the requested subtree or the topic + /// whose ancestors were requested). Used to identify which newly loaded topics are ancestors so that their can be correctly stamped as . The default is -1, which + /// applies when the root was loaded, or when no single seed applies (e.g., the GetTopicUpdates path). + /// /// /// When loading a single topic or branch, offers a reference topic graph that can be used to ensure that topic /// associations—such as references, relationships, and —are integrated with existing entities. @@ -45,45 +52,103 @@ internal static class SqlDataReaderExtensions { /// behavior is overwritten to accept whatever value is submitted. This can be used, for instance, to prevent an update /// from being persisted to the data store on . /// - /// - /// Optionally disables populating external references such as and . This is useful for cases where it's known that a shallow copy is being retrieved, and - /// thus external references aren't likely to be available. - /// - internal static Topic? LoadTopicGraph( - this IDataReader reader, - Topic? referenceTopic = null, - bool? markDirty = null, - bool includeExternalReferences = true + /// An optional token that can be used to cancel the operation. + /*============================================================================================================================ + | METHOD: LOAD TOPIC GRAPH + \---------------------------------------------------------------------------------------------------------------------------*/ + internal static async Task LoadTopicGraph( + this DbDataReader reader, + int seedTopicId = -1, + Topic? referenceTopic = null, + bool? markDirty = null, + CancellationToken cancellationToken = default ) { /*-------------------------------------------------------------------------------------------------------------------------- | Establish topic index + >--------------------------------------------------------------------------------------------------------------------------- + | Null signals that no root has been established yet: AddTopic() uses that to distinguish between a graph's root and an + | orphaned row that couldn't be attached and was thus skipped. A referenceTopic supplies an live index; a cold load + | establishes its own root from the first row. \-------------------------------------------------------------------------------------------------------------------------*/ - var sqlDataReader = reader as SqlDataReader; - var topics = referenceTopic is not null? referenceTopic.GetRootTopic().GetTopicIndex() : new(); - var rootTopicId = -1; + var topics = referenceTopic?.GetLiveTopicIndex(); + var rootTopic = (Topic?)null; + var preExistingIds = new HashSet(topics?.Keys ?? []); + var seedTopic = (Topic?)null; /*-------------------------------------------------------------------------------------------------------------------------- | Populate topics \-------------------------------------------------------------------------------------------------------------------------*/ Debug.WriteLine("SqlTopicRepository.Load(): AddTopic() [" + DateTime.Now + "]"); - while (reader.Read()) { - if (rootTopicId < 0) { - rootTopicId = reader.GetTopicId(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { + + // Add the topic to the topic graph; a null result means the row couldn't be attached and was skipped + var addedTopic = reader.AddTopic(topics, markDirty); + if (addedTopic is null) { + continue; + } + + var rawTopic = (ITopicBackingAccessor)addedTopic; + + // The first topic returned is the root topic; materialize its live index so later rows can resolve against it + if (rootTopic is null) { + rootTopic = addedTopic; + topics ??= addedTopic.GetLiveTopicIndex(); + } + + // If loading the entire tree, the rootTopic is also the seedTopic + if (seedTopicId < 0) { + seedTopic ??= addedTopic; } - reader.AddTopic(topics, markDirty); + + // Otherwise, check if the addedTopic is the seedTopic + else if (addedTopic.Id == seedTopicId) { + seedTopic = addedTopic; + } + + // The extended attributes are complete if HasExtendedAttributes is not true: NULL means extended attributes were included + // in this load, and false means the topic has no extended attributes at all; either way, nothing is deferred + var hasExtendedAttributes = reader.GetNullableBoolean("HasExtendedAttributes"); + rawTopic.Attributes.LoadState = ConvergeLoadState( + rawTopic.Attributes.LoadState, + preExistingIds.Contains(addedTopic.Id), + isComplete: hasExtendedAttributes is not true + ); + + // HasChildren is NULL when the column is not applicable (e.g., in version or update paths); skip those topics + // This applies to pre-existing topics too, since a differential load must be able to converge children LoadState as well + if (reader.GetNullableBoolean("HasChildren") is { } hasChildren) { + rawTopic.Children.LoadState = ConvergeLoadState( + rawTopic.Children.LoadState, + preExistingIds.Contains(addedTopic.Id), + isComplete: !hasChildren + ); + + // Any rows after the seed are a genuine child, indicating that the parent's full child set was returned. This is only + // meaningful here, where HasChildren is populated (GetTopics' ordered, complete result set); GetTopicUpdates' + // Refresh() batch leaves HasChildren NULL for every row, since it is an unordered, possibly disconnected set of changed + // topics, not a complete child listing. The parent may also be unresolved, hence the null-conditional. + if (seedTopic is not null && addedTopic != seedTopic) { + (addedTopic.Parent as ITopicLazyLoadable)?.SetLoadState(TopicPayload.Children, LoadState.Loaded); + } + } + } + /*-------------------------------------------------------------------------------------------------------------------------- + | An empty result set never established a root, leaving topics null; fall back to an empty index for the passes below + \-------------------------------------------------------------------------------------------------------------------------*/ + topics ??= new(); + /*-------------------------------------------------------------------------------------------------------------------------- | Read attributes \-------------------------------------------------------------------------------------------------------------------------*/ Debug.WriteLine("SqlTopicRepository.Load(): SetIndexedAttributes() [" + DateTime.Now + "]"); // Move to TopicAttributes dataset - reader.NextResult(); + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); - while (reader.Read()) { + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { reader.SetIndexedAttributes(topics, markDirty); } @@ -92,12 +157,12 @@ internal static class SqlDataReaderExtensions { \-------------------------------------------------------------------------------------------------------------------------*/ Debug.WriteLine("SqlTopicRepository.Load(): SetExtendedAttributes() [" + DateTime.Now + "]"); - // Move to extened attributes dataset - reader.NextResult(); + // Move to extended attributes dataset + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); // Loop through each extended attribute record associated with a specific topic - while (reader.Read()) { - sqlDataReader?.SetExtendedAttributes(topics, markDirty); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { + (reader as SqlDataReader)?.SetExtendedAttributes(topics, markDirty); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -106,13 +171,11 @@ internal static class SqlDataReaderExtensions { Debug.WriteLine("SqlTopicRepository.Load(): SetRelationships() [" + DateTime.Now + "]"); // Move to the relationships dataset - reader.NextResult(); + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); // Loop through each relationship; multiple records may exist per topic - if (includeExternalReferences) { - while (reader.Read()) { - reader.SetRelationships(topics, markDirty); - } + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { + reader.SetRelationships(topics, markDirty); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -121,10 +184,10 @@ internal static class SqlDataReaderExtensions { Debug.WriteLine("SqlTopicRepository.Load(): SetReferences() [" + DateTime.Now + "]"); // Move to the version history dataset - reader.NextResult(); + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); // Loop through each version; multiple records may exist per topic - while (reader.Read()) { + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { reader.SetReferences(topics, markDirty); } @@ -134,20 +197,17 @@ internal static class SqlDataReaderExtensions { Debug.WriteLine("SqlTopicRepository.Load(): SetVersionHistory() [" + DateTime.Now + "]"); // Move to the version history dataset - reader.NextResult(); + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); // Loop through each version; multiple records may exist per topic - while (reader.Read()) { + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { reader.SetVersionHistory(topics); } /*-------------------------------------------------------------------------------------------------------------------------- | Return objects \-------------------------------------------------------------------------------------------------------------------------*/ - if (topics.TryGetValue(rootTopicId, out var rootTopic)) { - return rootTopic; - } - return topics.Values.FirstOrDefault(); + return seedTopic; } @@ -155,18 +215,33 @@ internal static class SqlDataReaderExtensions { | METHOD: ADD TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Given the primary topic attributes from the TopicIndex view, establishes a barebones - /// instance and adds it to the collection. + /// Given the primary topic attributes from the TopicIndex view, establishes a barebones instance + /// and attaches it to its parent, if resolvable. /// + /// + /// Attach-first: A new row is never separately added to an index and then reconciled with its parent; assigning attaches it to the graph immediately, and the hook indexes + /// it as a side effect. A row whose parent cannot be resolved is unreachable from the returned graph and is skipped (i.e., + /// returns ), unless is itself , meaning no + /// root has been established yet for this load, in which case the row becomes the root of a fresh graph and is created + /// unattached. Callers must materialize a live index from that root before the next row is processed, so that the row can + /// resolve against it. + /// /// The with output from the GetTopics stored procedure. - /// A of topics to be loaded. + /// + /// The live index of topics resolved so far, or if this load hasn't yet established a root. + /// /// - /// Specified whether the target collection value should be marked as dirty, assuming the value changes. By default, it - /// will be marked dirty if the value is new or has changed from a previous value. By setting this parameter, that - /// behavior is overwritten to accept whatever value is submitted. This can be used, for instance, to prevent an update - /// from being persisted to the data store on . + /// Specifies whether the target collection value should be marked as dirty, assuming the value changes. By default, it will + /// be marked dirty if the value is new or has changed from a previous value. By setting this parameter, that behavior is + /// overwritten to accept whatever value is submitted. This can be used, for instance, to prevent an update from being + /// persisted to the data store on . /// - private static void AddTopic(this IDataReader reader, TopicIndex topics, bool? markDirty) { + /// + /// The resolved or newly created ; if the row is unreachable from the returned + /// graph and was skipped. + /// + private static Topic? AddTopic(this IDataReader reader, TopicIndex? topics, bool? markDirty) { /*-------------------------------------------------------------------------------------------------------------------------- | Identify attributes @@ -176,34 +251,88 @@ private static void AddTopic(this IDataReader reader, TopicIndex topics, bool? m var contentType = reader.GetString("ContentType"); var parentId = reader.GetInteger("ParentID"); var wasDirty = false; + Topic current; /*-------------------------------------------------------------------------------------------------------------------------- - | Establish topic + | New row: Attach first, per the database ordering which guarantees parents are delivered before children \-------------------------------------------------------------------------------------------------------------------------*/ - if (!topics.TryGetValue(topicId, out var current)) { - current = TopicFactory.Create(key, contentType, topicId); - topics.Add(current.Id, current); + if (topics is null || !topics.TryGetValue(topicId, out var existing)) { + current = TopicFactory.Create(key, contentType, topicId); + + // Default to NotLoaded; a corresponding row in the version history dataset, if any, promotes this to Loaded + ((ITopicBackingAccessor)current).VersionHistory.LoadState = LoadState.NotLoaded; + + // No root established yet: This row is the root of a fresh graph, so create it unattached + if (topics is null) { } + + // Parent is available: Attach immediately, and the hook indexes the new topic and its (empty) subtree + else if (parentId >= 0 && topics.TryGetValue(parentId, out var parentTopic)) { + current.Parent = parentTopic; + } + + // Parent is neither available nor previously returned: treat as an orphan and skip (generally unexpected) + else { + return null; + } + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Pre-existing row: Update in place, including re-parenting if it moved, assuming the new parent is available + \-------------------------------------------------------------------------------------------------------------------------*/ else { + current = existing; wasDirty = current.IsDirty(); current.Key = key; current.ContentType = contentType; + if (parentId >= 0 && current.Parent?.Id != parentId && topics.TryGetValue(parentId, out var newParent)) { + current.Parent = newParent; + } } /*-------------------------------------------------------------------------------------------------------------------------- - | Assign parent + | Mark clean \-------------------------------------------------------------------------------------------------------------------------*/ - if (parentId >= 0 && current.Parent?.Id != parentId && topics.TryGetValue(parentId, out var parentTopic)) { - current.Parent = parentTopic; + if (wasDirty is false && markDirty is false) { + current.MarkClean(); } /*-------------------------------------------------------------------------------------------------------------------------- - | Mark clean + | Return the topic created \-------------------------------------------------------------------------------------------------------------------------*/ - if (wasDirty is false && markDirty is not null and false) { - current.MarkClean(); + return current; + + } + + /*============================================================================================================================ + | METHOD: FILL CHILDREN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Reads the children result set (the first result set) from a GetTopics response, adds each child to the index via , and marks the 's Children as after a successful fill. + /// + /// + /// The , positioned at the first result set of the GetTopics response. + /// + /// The topic whose immediate children are being loaded. + /// The to populate with the new child topics. + /// An optional token that can be used to cancel the operation. + internal static async Task FillChildren( + this DbDataReader reader, + Topic parent, + TopicIndex topics, + CancellationToken cancellationToken + ) { + + // Loop through each record, delegating to the shared AddChildTopic() + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { + reader.AddChildTopic(parent, topics); } + // Mark confirmed children payload as Loaded + ((ITopicLazyLoadable)parent).SetLoadState(TopicPayload.Children, LoadState.Loaded); + } /*============================================================================================================================ @@ -221,7 +350,7 @@ private static void AddTopic(this IDataReader reader, TopicIndex topics, bool? m /// behavior is overwritten to accept whatever value is submitted. This can be used, for instance, to prevent an update /// from being persisted to the data store on . /// - private static void SetIndexedAttributes(this IDataReader reader, TopicIndex topics, bool? markDirty) { + internal static void SetIndexedAttributes(this IDataReader reader, TopicIndex topics, bool? markDirty) { /*-------------------------------------------------------------------------------------------------------------------------- | Identify attributes @@ -234,12 +363,16 @@ private static void SetIndexedAttributes(this IDataReader reader, TopicIndex top /*-------------------------------------------------------------------------------------------------------------------------- | Identify topic \-------------------------------------------------------------------------------------------------------------------------*/ - var current = topics[topicId]; + // Absent from topics means the topic was orphaned and skipped by AddTopic(); its attribute rows are ignored in kind + if (!topics.TryGetValue(topicId, out var current)) { + return; + } + var rawTopic = (ITopicBackingAccessor)current; /*-------------------------------------------------------------------------------------------------------------------------- | Set attribute value \-------------------------------------------------------------------------------------------------------------------------*/ - current.Attributes.SetValue(attributeKey, attributeValue, markDirty, version, false); + rawTopic.Attributes.SetValue(attributeKey, attributeValue, markDirty, version, false); } @@ -262,7 +395,17 @@ private static void SetIndexedAttributes(this IDataReader reader, TopicIndex top /// behavior is overwritten to accept whatever value is submitted. This can be used, for instance, to prevent an update /// from being persisted to the data store on . /// - private static void SetExtendedAttributes(this SqlDataReader reader, TopicIndex topics, bool? markDirty) { + /// + /// When true, skips any attribute key whose in-memory record is already dirty. This is used by the lazy-load + /// resolver so that a value set by the call while the extended boundary was is not + /// silently overwritten by the blob merge. + /// + internal static void SetExtendedAttributes( + this SqlDataReader reader, + TopicIndex topics, + bool? markDirty, + bool preserveDirty = false + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Identify attributes @@ -279,7 +422,11 @@ private static void SetExtendedAttributes(this SqlDataReader reader, TopicIndex /*-------------------------------------------------------------------------------------------------------------------------- | Identify the current topic \-------------------------------------------------------------------------------------------------------------------------*/ - var current = topics[topicId]; + // Absent from topics means the topic was orphaned and skipped by AddTopic(); its attribute rows are ignored in kind + if (!topics.TryGetValue(topicId, out var current)) { + return; + } + var rawTopic = (ITopicBackingAccessor)current; /*-------------------------------------------------------------------------------------------------------------------------- | Handle scenario where there isn't an element @@ -310,7 +457,11 @@ private static void SetExtendedAttributes(this SqlDataReader reader, TopicIndex | Set attribute value \-----------------------------------------------------------------------------------------------------------------------*/ if (String.IsNullOrEmpty(attributeValue)) continue; - current.Attributes.SetValue(attributeKey, attributeValue, markDirty, version, true); + + // Skip keys already dirty in memory to avoid clobbering unsaved values during a lazy fill + if (preserveDirty && rawTopic.Attributes.IsDirty(attributeKey)) continue; + + rawTopic.Attributes.SetValue(attributeKey, attributeValue, markDirty, version, true); } while (xmlReader.Name is "attribute"); @@ -334,7 +485,7 @@ private static void SetExtendedAttributes(this SqlDataReader reader, TopicIndex /// behavior is overwritten to accept whatever value is submitted. This can be used, for instance, to prevent an update /// from being persisted to the data store on . /// - private static void SetRelationships(this IDataReader reader, TopicIndex topics, bool? markDirty = false) { + internal static void SetRelationships(this IDataReader reader, TopicIndex topics, bool? markDirty = false) { /*-------------------------------------------------------------------------------------------------------------------------- | Identify attributes @@ -347,7 +498,12 @@ private static void SetRelationships(this IDataReader reader, TopicIndex topics, /*-------------------------------------------------------------------------------------------------------------------------- | Identify affected topics \-------------------------------------------------------------------------------------------------------------------------*/ - var current = topics[sourceTopicId]; + // A source absent from topics was orphaned and skipped by AddTopic(); its relationship rows are skipped in kind, rather + // than resolved, so an orphan never registers on a target's IncomingRelationships + if (!topics.TryGetValue(sourceTopicId, out var current)) { + return; + } + var rawTopic = (ITopicBackingAccessor)current; var related = (Topic?)null; // Fetch the related topic @@ -355,9 +511,9 @@ private static void SetRelationships(this IDataReader reader, TopicIndex topics, related = relatedTopic; } - // Bypass if the target object is missing + // When the target is absent, defer it for resolution on next access if (related is null) { - current.Relationships.IsFullyLoaded = false; + rawTopic.Relationships.Deferred.SetValue(relationshipKey, targetTopicId); return; } @@ -365,10 +521,10 @@ private static void SetRelationships(this IDataReader reader, TopicIndex topics, | Set relationship on object \-------------------------------------------------------------------------------------------------------------------------*/ if (!isDeleted) { - current.Relationships.SetValue(relationshipKey, related, markDirty); + rawTopic.Relationships.SetValue(relationshipKey, related, markDirty); } - else if (current.Relationships.Contains(relationshipKey, related)) { - current.Relationships.Remove(relationshipKey, related); + else if (rawTopic.Relationships.Contains(relationshipKey, related)) { + rawTopic.Relationships.Remove(relationshipKey, related); } } @@ -391,7 +547,7 @@ private static void SetRelationships(this IDataReader reader, TopicIndex topics, /// behavior is overwritten to accept whatever value is submitted. This can be used, for instance, to prevent an update /// from being persisted to the data store on . /// - private static void SetReferences(this IDataReader reader, TopicIndex topics, bool? markDirty) { + internal static void SetReferences(this IDataReader reader, TopicIndex topics, bool? markDirty) { /*-------------------------------------------------------------------------------------------------------------------------- | Identify attributes @@ -403,27 +559,106 @@ private static void SetReferences(this IDataReader reader, TopicIndex topics, bo /*-------------------------------------------------------------------------------------------------------------------------- | Identify affected topics \-------------------------------------------------------------------------------------------------------------------------*/ - var current = topics[sourceTopicId]; + // A source absent from topics was orphaned and skipped by AddTopic(); its reference rows are skipped in kind, rather than + // resolved, so an orphan never registers on a target's IncomingRelationships + if (!topics.TryGetValue(sourceTopicId, out var current)) { + return; + } + var rawTopic = (ITopicBackingAccessor)current; var referenced = (Topic?)null; - // Fetch the related topic - if (targetTopicId is null) { + // This happens when the reference has been deleted, so SetValue() will remove the reference + if (targetTopicId is null) { } + + // Attempt to get a reference to the target via the topic index else if (topics.TryGetValue(targetTopicId.Value, out var referencedTopic)) { referenced = referencedTopic; } + + // When the target isn't (yet) available, defer it to be lazy loaded when the references are accessed else { - current.References.IsFullyLoaded = false; + rawTopic.References.Deferred.SetValue(referenceKey, targetTopicId.Value); return; } /*-------------------------------------------------------------------------------------------------------------------------- | Set reference on object \-------------------------------------------------------------------------------------------------------------------------*/ - current.References.SetValue(referenceKey, referenced, markDirty); + rawTopic.References.SetValue(referenceKey, referenced, markDirty); + + } + + + /*============================================================================================================================ + | METHOD: ADD CHILD TOPIC + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Processes a single row from the children result set of a GetTopics response: Attaches the child via , then stamps its Children.LoadState and Attributes.LoadState based on the HasChildren + /// and HasExtendedAttributes database hints. Returns when the row represents the itself (which the stored procedure includes alongside its children), or when + /// skipped it as an orphan, which is unexpected here. + /// + /// The , positioned at a row in the children result set. + /// The topic whose children are being loaded; rows matching this ID are skipped. + /// The live of 's graph. + private static Topic? AddChildTopic(this IDataReader reader, Topic parent, TopicIndex topics) { + + // Capture pre-existing status before AddTopic() introduces the topic to the index + var wasPreExisting = topics.ContainsKey(reader.GetTopicId()); + + // Add or update the topic in the index; parent is always available, so a null result here isn't expected in practice + var addedTopic = reader.AddTopic(topics, markDirty: false); + + // Skip the parent record, which the stored procedure returns alongside its children, or for an orphaned row (unexpected) + if (addedTopic is null || addedTopic.Id == parent.Id) { + return null; + } + + var rawTopic = (ITopicBackingAccessor)addedTopic; + + // The extended attributes are completely loaded if HasExtendedAttributes is not true: NULL means this fill included + // extended attributes, and false means the child has none at all; either way, nothing is deferred. True means extended + // attributes exist but weren't requested this fill, deferring to lazy loading. + rawTopic.Attributes.LoadState = ConvergeLoadState( + rawTopic.Attributes.LoadState, + wasPreExisting, + isComplete: reader.GetNullableBoolean("HasExtendedAttributes") is not true + ); + + // The children property is completely loaded if HasChildren is not true. This fill only refreshes the child's own row, + // never its children, so a pre-existing child already Loaded from a prior Load() is preserved rather than downgraded; this + // fill returned no information about whether that boundary is complete + rawTopic.Children.LoadState = ConvergeLoadState( + rawTopic.Children.LoadState, + wasPreExisting, + isComplete: reader.GetNullableBoolean("HasChildren") is not true + ); + + // Return the topic created + return addedTopic; } + /*============================================================================================================================ + | METHOD: CONVERGE LOAD STATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Determines the converged for a boundary given whether this load fully provided it. + /// + /// + /// A load never downgrades a boundary it didn't fully provide. If , the boundary is promoted + /// to regardless of prior state. Otherwise, a pre-existing boundary is left untouched, + /// preserving whatever it already knew, while a freshly introduced boundary is set to , + /// deferring the fetch. + /// + /// The boundary's current . + /// Whether the topic was already resident in the topic index before this load began. + /// Whether this load fully provided the boundary. + private static LoadState ConvergeLoadState(LoadState current, bool isPreExisting, bool isComplete) => + isComplete? LoadState.Loaded : isPreExisting? current : LoadState.NotLoaded; + /*============================================================================================================================ | METHOD: SET VERSION HISTORY \---------------------------------------------------------------------------------------------------------------------------*/ @@ -437,7 +672,7 @@ private static void SetReferences(this IDataReader reader, TopicIndex topics, bo /// /// The with output from the GetTopics stored procedure. /// A of topics to be loaded. - private static void SetVersionHistory(this IDataReader reader, TopicIndex topics) { + internal static void SetVersionHistory(this IDataReader reader, TopicIndex topics) { /*-------------------------------------------------------------------------------------------------------------------------- | Identify attributes @@ -448,14 +683,22 @@ private static void SetVersionHistory(this IDataReader reader, TopicIndex topics /*-------------------------------------------------------------------------------------------------------------------------- | Identify topic \-------------------------------------------------------------------------------------------------------------------------*/ - var current = topics[topicId]; + // Absent from topics means the topic was orphaned and skipped by AddTopic(); its version rows are ignored in kind + if (!topics.TryGetValue(topicId, out var current)) { + return; + } + var rawTopic = (ITopicBackingAccessor)current; /*-------------------------------------------------------------------------------------------------------------------------- | Set history + >------------------------------------------------------------------------------------------------------------------------- + | A row being present, regardless of its content, means version history was fetched for this topic; promote the state + | to Loaded so subsequent access doesn't trigger a redundant fill. \-------------------------------------------------------------------------------------------------------------------------*/ - if (!current.VersionHistory.Contains(dateTime)) { - current.VersionHistory.Add(dateTime); + if (!rawTopic.VersionHistory.Contains(dateTime)) { + rawTopic.VersionHistory.Add(dateTime); } + rawTopic.VersionHistory.LoadState = LoadState.Loaded; } @@ -492,6 +735,17 @@ private static string GetString(this IDataReader reader, string columnName) => private static bool GetBoolean(this IDataReader reader, string columnName) => reader.GetBoolean(reader.GetOrdinal(columnName)); + /*============================================================================================================================ + | METHOD: GET NULLABLE BOOLEAN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Retrieves a nullable boolean value by column name. + /// + /// The object. + /// The name of the column to retrieve the value from. + private static bool? GetNullableBoolean(this IDataReader reader, string columnName) => + reader.IsDBNull(reader.GetOrdinal(columnName))? null : reader.GetBoolean(reader.GetOrdinal(columnName)); + /*============================================================================================================================ | METHOD: GET INTEGER \---------------------------------------------------------------------------------------------------------------------------*/ diff --git a/OnTopic.Data.Sql/SqlSitemapTopicRepository.cs b/OnTopic.Data.Sql/SqlSitemapTopicRepository.cs new file mode 100644 index 00000000..b4bce0a6 --- /dev/null +++ b/OnTopic.Data.Sql/SqlSitemapTopicRepository.cs @@ -0,0 +1,102 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Repositories; + +namespace OnTopic.Data.Sql; + +/*============================================================================================================================== +| CLASS: SQL SITEMAP TOPIC REPOSITORY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides data access to the minimal graph required to render the sitemap, sourced from Microsoft SQL +/// Server. +/// +/// +/// Concrete implementation of the interface. Unlike , +/// accepts no referenceTopic to merge into, raises no +/// event, and stamps no : Each call returns an entirely fresh, detached graph, with no +/// relationship to any other topic graph in memory, intended to be discarded once the response is rendered. Caching can be +/// done at the controller level of the rendered XML. +/// +public class SqlSitemapTopicRepository : ISitemapTopicRepository { + + /*============================================================================================================================ + | PRIVATE VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly string _connectionString; + + /*============================================================================================================================ + | CONSTRUCTOR + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Instantiates a new instance of the with a dependency on a connection string to + /// provide necessary access to a SQL database. + /// + /// A connection string to a SQL server that contains the Topics database. + /// A new instance of the . + public SqlSitemapTopicRepository(string connectionString) { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Validate parameters + \-------------------------------------------------------------------------------------------------------------------------*/ + Contract.Requires(!String.IsNullOrWhiteSpace(connectionString), nameof(connectionString)); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Set private fields + \-------------------------------------------------------------------------------------------------------------------------*/ + _connectionString = connectionString; + + } + + /*============================================================================================================================ + | METHOD: LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + public async Task Load() { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Establish database connection + \-------------------------------------------------------------------------------------------------------------------------*/ + var topic = (Topic?)null; + + using var connection = new SqlConnection(_connectionString); + using var command = new SqlCommand("GetSitemap", connection) { + CommandType = CommandType.StoredProcedure, + CommandTimeout = 120 + }; + + /*-------------------------------------------------------------------------------------------------------------------------- + | Process database query + \-------------------------------------------------------------------------------------------------------------------------*/ + try { + await connection.OpenAsync().ConfigureAwait(false); + using var reader = (SqlDataReader)await command.ExecuteReaderAsync().ConfigureAwait(false); + topic = await reader.LoadTopicGraph(referenceTopic: null, markDirty: false).ConfigureAwait(false); + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Catch exception + \-------------------------------------------------------------------------------------------------------------------------*/ + catch (SqlException exception) { + throw new TopicRepositoryException($"Topics failed to load: '{exception.Message}'", exception); + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Validate results + \-------------------------------------------------------------------------------------------------------------------------*/ + Contract.Assume( + topic, + "The 'GetSitemap' stored procedure did not return a topic graph." + ); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Return objects + \-------------------------------------------------------------------------------------------------------------------------*/ + return topic; + + } + +} //Class \ No newline at end of file diff --git a/OnTopic.Data.Sql/SqlTopicRepository.cs b/OnTopic.Data.Sql/SqlTopicRepository.cs index ba5c00a1..dd4c61f4 100644 --- a/OnTopic.Data.Sql/SqlTopicRepository.cs +++ b/OnTopic.Data.Sql/SqlTopicRepository.cs @@ -21,7 +21,7 @@ namespace OnTopic.Data.Sql; /// /// Concrete implementation of the class. /// -public class SqlTopicRepository : TopicRepository, ITopicRepository { +public class SqlTopicRepository : TopicRepository, ITopicRepository, ITopicLazyLoader { /*============================================================================================================================ | PRIVATE VARIABLES @@ -37,7 +37,7 @@ public class SqlTopicRepository : TopicRepository, ITopicRepository { /// /// A connection string to a SQL server that contains the Topics database. /// A new instance of the SqlTopicRepository. - public SqlTopicRepository(string connectionString) : base() { + public SqlTopicRepository(string connectionString) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -55,7 +55,12 @@ public SqlTopicRepository(string connectionString) : base() { | METHOD: LOAD \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override Topic Load(string uniqueKey, Topic? referenceTopic = null, bool isRecursive = true) { + public override async Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -84,8 +89,8 @@ public override Topic Load(string uniqueKey, Topic? referenceTopic = null, bool \-------------------------------------------------------------------------------------------------------------------------*/ try { - connection.Open(); - command.ExecuteNonQuery(); + await connection.OpenAsync().ConfigureAwait(false); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); topicId = command.GetReturnCode(); @@ -108,12 +113,24 @@ public override Topic Load(string uniqueKey, Topic? referenceTopic = null, bool /*-------------------------------------------------------------------------------------------------------------------------- | Return topic \-------------------------------------------------------------------------------------------------------------------------*/ - return Load(topicId, referenceTopic, isRecursive); + return await Load(topicId, referenceTopic, payload, depth).ConfigureAwait(false); } /// - public override Topic Load(int topicId, Topic? referenceTopic = null, bool isRecursive = true) { + public override async Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Normalize depth + \-------------------------------------------------------------------------------------------------------------------------*/ + if (payload.HasFlag(TopicPayload.Children) && depth is 0) { + depth = 1; + } /*-------------------------------------------------------------------------------------------------------------------------- | Establish database connection @@ -130,15 +147,20 @@ public override Topic Load(int topicId, Topic? referenceTopic = null, bool isRec | Establish query parameters \-------------------------------------------------------------------------------------------------------------------------*/ command.AddParameter("TopicID", topicId); - command.AddParameter("DeepLoad", isRecursive); + command.AddParameter("Depth", depth); + command.AddParameter("LoadAscendants", topicId >= 0); + command.AddParameter("IncludeExtended", payload.HasFlag(TopicPayload.ExtendedAttributes)); + command.AddParameter("IncludeRelationships", true); + command.AddParameter("IncludeReferences", true); + command.AddParameter("IncludeHistory", payload.HasFlag(TopicPayload.VersionHistory)); /*-------------------------------------------------------------------------------------------------------------------------- | Process database query \-------------------------------------------------------------------------------------------------------------------------*/ try { - connection.Open(); - using var reader = command.ExecuteReader(); - topic = reader.LoadTopicGraph(referenceTopic, false); + await connection.OpenAsync().ConfigureAwait(false); + using var reader = (SqlDataReader)await command.ExecuteReaderAsync().ConfigureAwait(false); + topic = await reader.LoadTopicGraph(topicId, referenceTopic, false).ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -153,7 +175,7 @@ public override Topic Load(int topicId, Topic? referenceTopic = null, bool isRec \-------------------------------------------------------------------------------------------------------------------------*/ if (topic is null) { if (topicId == -1) { - topic = TopicFactory.Create("Root", "Container"); + topic = TopicFactory.Create("Root", "Container"); } else { throw new TopicNotFoundException(topicId); @@ -173,7 +195,7 @@ public override Topic Load(int topicId, Topic? referenceTopic = null, bool isRec /*-------------------------------------------------------------------------------------------------------------------------- | Raise event \-------------------------------------------------------------------------------------------------------------------------*/ - OnTopicLoaded(new(topic, isRecursive)); + OnTopicLoaded(new(topic, depth)); /*-------------------------------------------------------------------------------------------------------------------------- | Return objects @@ -183,7 +205,13 @@ public override Topic Load(int topicId, Topic? referenceTopic = null, bool isRec } /// - public override Topic Load(int topicId, DateTime version, Topic? referenceTopic = null) { + /// + /// Always returns a detached graph, populated exclusively from the historical dataset; it is never + /// merged into a resident graph, and relationship and reference targets are left in Deferred rather than resolved. + /// Callers that need a historical version merged into a live to e.g., commit a rollback should use instead, which performs that merge before persisting the result. + /// + public override async Task Load(int topicId, DateTime version) { /*-------------------------------------------------------------------------------------------------------------------------- | Normalize parameters @@ -200,31 +228,10 @@ public override Topic Load(int topicId, DateTime version, Topic? referenceTopic ); /*-------------------------------------------------------------------------------------------------------------------------- - | Clear associations - >------------------------------------------------------------------------------------------------------------------------- - | Because we don't (currently) track version as part of the .NET data model for relationships or topic references, there's - | no easy way to determine if an association should be deleted when doing a rollback. As such, existing associations - | should be deleted, assuming a `referenceTopic` is passed, and it contains the `topicId`. + | Establish database connection \-------------------------------------------------------------------------------------------------------------------------*/ var topic = (Topic?)null; - if (referenceTopic?.Id == topicId) { - topic = referenceTopic; - } - else if (referenceTopic is not null) { - topic = referenceTopic.GetRootTopic().FindFirst(t => t.Id == topicId); - } - - if (topic is not null) { - foreach (var relationship in topic.Relationships) { - topic.Relationships.Clear(relationship.Key); - } - topic.References.Clear(); - } - - /*-------------------------------------------------------------------------------------------------------------------------- - | Establish database connection - \-------------------------------------------------------------------------------------------------------------------------*/ using var connection = new SqlConnection(_connectionString); using var command = new SqlCommand("GetTopicVersion", connection) { CommandType = CommandType.StoredProcedure, @@ -243,19 +250,18 @@ public override Topic Load(int topicId, DateTime version, Topic? referenceTopic | Process database query \-------------------------------------------------------------------------------------------------------------------------*/ try { - connection.Open(); - using var reader = command.ExecuteReader(); - topic = reader.LoadTopicGraph(referenceTopic, includeExternalReferences: referenceTopic is not null); + await connection.OpenAsync().ConfigureAwait(false); + using var reader = (SqlDataReader)await command.ExecuteReaderAsync().ConfigureAwait(false); + + // Load the historical version as a detached topic + topic = await reader.LoadTopicGraph(topicId).ConfigureAwait(false); + } /*-------------------------------------------------------------------------------------------------------------------------- | Catch exception \-------------------------------------------------------------------------------------------------------------------------*/ catch (SqlException exception) { - if (topic is not null) { - topic.Relationships.IsFullyLoaded = false; - topic.References.IsFullyLoaded = false; - } throw new TopicRepositoryException($"Topics failed to load: '{exception.Message}'", exception); } @@ -266,23 +272,10 @@ public override Topic Load(int topicId, DateTime version, Topic? referenceTopic throw new TopicNotFoundException(topicId); } - /*-------------------------------------------------------------------------------------------------------------------------- - | Delete orphaned attributes - >------------------------------------------------------------------------------------------------------------------------- - | If a referenceTopic is passed, and it contains the `topicId`, then that instance will be updated with the previous - | version. In that case, however, any attributes which were first introduced after that version won't be overwritten. - | That's because there isn't a previous value associated with that key to overwrite the current value. In those cases, - | those attributes must be manually removed. - \-------------------------------------------------------------------------------------------------------------------------*/ - var orphanedAttributes = topic.Attributes.Where(a => a.LastModified > version).ToList(); - foreach (var attribute in orphanedAttributes) { - topic.Attributes.Remove(attribute.Key); - } - /*-------------------------------------------------------------------------------------------------------------------------- | Raise event \-------------------------------------------------------------------------------------------------------------------------*/ - OnTopicLoaded(new(topic, false, version)); + OnTopicLoaded(new(topic, 0, version)); /*-------------------------------------------------------------------------------------------------------------------------- | Return objects @@ -295,7 +288,7 @@ public override Topic Load(int topicId, DateTime version, Topic? referenceTopic | METHOD: REFRESH \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Refresh(Topic referenceTopic, DateTime since) { + public override async Task Refresh(Topic referenceTopic, DateTime since) { /*-------------------------------------------------------------------------------------------------------------------------- | Normalize parameters @@ -331,9 +324,9 @@ public override void Refresh(Topic referenceTopic, DateTime since) { | Process database query \-------------------------------------------------------------------------------------------------------------------------*/ try { - connection.Open(); - using var reader = command.ExecuteReader(); - reader.LoadTopicGraph(referenceTopic.GetRootTopic(), false); + await connection.OpenAsync().ConfigureAwait(false); + using var reader = (SqlDataReader)await command.ExecuteReaderAsync().ConfigureAwait(false); + await reader.LoadTopicGraph(-1, referenceTopic.GetRootTopic(), false).ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -345,11 +338,154 @@ public override void Refresh(Topic referenceTopic, DateTime since) { } + /*============================================================================================================================ + | METHODS: TOPIC LAZY LOADER + \---------------------------------------------------------------------------------------------------------------------------*/ + + /// + public virtual async Task EnsureLoaded(Topic topic, TopicPayload payload, CancellationToken cancellationToken = default) { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Validate parameters + \-------------------------------------------------------------------------------------------------------------------------*/ + Contract.Requires(topic); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Skip for new topics, as there's no persistent data to fetch + \-------------------------------------------------------------------------------------------------------------------------*/ + if (topic.IsNew) { + return; + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Filter to pending (not yet Loaded) payload + \-------------------------------------------------------------------------------------------------------------------------*/ + payload = ((ITopicLazyLoadable)topic).FilterPayload(payload); + + if (payload is TopicPayload.None) { + return; + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Resolve any relationship and reference targets first + \-------------------------------------------------------------------------------------------------------------------------*/ + // Resolve any deferred relationship/reference targets by loading each individually; these were left unresolved by the + // initial Load() because their targets weren't part of that call's ascendant/descendant scope, and couldn't be found in the + // referenceTopic, if provided. + if (payload.HasFlag(TopicPayload.Relationships) || payload.HasFlag(TopicPayload.References)) { + await LoadDeferredAssociations(topic, payload, cancellationToken).ConfigureAwait(false); + } + + // Exit early if nothing else is pending so we don't open a database connection unnecessarily + if ( + !payload.HasFlag(TopicPayload.Children) && + !payload.HasFlag(TopicPayload.ExtendedAttributes) && + !payload.HasFlag(TopicPayload.VersionHistory) + ) { + return; + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Establish database connection + \-------------------------------------------------------------------------------------------------------------------------*/ + using var connection = new SqlConnection(_connectionString); + using var command = new SqlCommand("GetTopics", connection) { + CommandType = CommandType.StoredProcedure + }; + + // Set the stored procedure parameters based on the TopicPayload enum values + AddEnsureLoadedParameters(command, topic.Id, payload); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Process database query + >--------------------------------------------------------------------------------------------------------------------------- + | Use the full live graph as the topic index so already-resident relationship targets are found without extra round-trips. + | When filling Children, associations for the parent/seed topic are re-fetched alongside the children's; the + | DeferredAssociationCollection.SetValue() deduplicate those values so reprocessing doesn't accumulate duplicate entries in + | the Deferred collection. + \-------------------------------------------------------------------------------------------------------------------------*/ + var topics = topic.GetLiveTopicIndex(); + var rawTopic = (ITopicBackingAccessor)topic; + + try { + + // Setup + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + using var reader = (SqlDataReader)await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + + // Children: Fill first result set; FillChildrenAsync() sets each child's Children.LoadState and marks the parent Loaded + if (payload.HasFlag(TopicPayload.Children)) { + await reader.FillChildren(topic, topics, cancellationToken).ConfigureAwait(false); + } + + // Otherwise, skip the first result set since the topic is already resident + else { + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); + } + + // Indexed attributes + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { + reader.SetIndexedAttributes(topics, markDirty: false); + } + + // Extended attributes + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { + reader.SetExtendedAttributes(topics, markDirty: false, preserveDirty: true); + } + + // Relationships (will be empty, unless loading children) + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { + reader.SetRelationships(topics, markDirty: false); + } + + // References (will be empty, unless loading children) + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { + reader.SetReferences(topics, markDirty: false); + } + + // History + await reader.NextResultAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { + reader.SetVersionHistory(topics); + } + + } + catch (SqlException exception) { + throw new TopicRepositoryException($"Topic payload failed to load: '{exception.Message}'", exception); + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Raise event for each newly loaded child + >--------------------------------------------------------------------------------------------------------------------------- + | Children filled here, as opposed to the initial recursive Load(), are new Topic instances introduced to the graph for the + | first time and are conceptually the same as being loaded via Load(), just via a different entry point. + \-------------------------------------------------------------------------------------------------------------------------*/ + if (payload.HasFlag(TopicPayload.Children)) { + foreach (var child in topic.Children) { + OnTopicLoaded(new(child, depth: 0)); + } + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Mark confirmed payload as Loaded + >--------------------------------------------------------------------------------------------------------------------------- + | Children is excluded: Its LoadState is set inside FillChildren() after a successful fill. Relationships and References + | are computed from Deferred.Count and require no explicit assignment here. History is set directly by SetVersionHistory() + | as rows are read, since every persisted topic has at least one version. Only Extended Attributes needs to be set here. + \-------------------------------------------------------------------------------------------------------------------------*/ + ((ITopicLazyLoadable)topic).SetLoadState(payload & TopicPayload.ExtendedAttributes, LoadState.Loaded); + + } + /*============================================================================================================================ | METHOD: SAVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - protected override sealed void SaveTopic( + protected override sealed async Task SaveTopic( [NotNull]Topic topic, DateTime version, bool persistRelationships @@ -358,11 +494,13 @@ bool persistRelationships /*-------------------------------------------------------------------------------------------------------------------------- | Define variables \-------------------------------------------------------------------------------------------------------------------------*/ + var rawTopic = (ITopicBackingAccessor)topic; var isTopicDirty = topic.IsDirty(); - var areRelationshipsDirty = topic.Relationships.IsDirty(); - var areReferencesDirty = topic.References.IsDirty(); - var areAttributesDirty = topic.Attributes.IsDirty(true); - var extendedAttributeList = GetAttributes(topic, isExtendedAttribute: true).ToList(); + var areRelationshipsDirty = rawTopic.Relationships.IsDirty(); + var areReferencesDirty = rawTopic.References.IsDirty(); + var areAttributesDirty = rawTopic.Attributes.IsDirty(true); + var extendedBoundaryLoaded = rawTopic.Attributes.LoadState is LoadState.Loaded; + var extendedAttributeList = GetAttributes(topic, isExtendedAttribute: true).ToList(); var indexedAttributeList = GetAttributes( topic : topic, isExtendedAttribute : false, @@ -370,6 +508,24 @@ bool persistRelationships excludeLastModified : !areAttributesDirty ).ToList(); + /*-------------------------------------------------------------------------------------------------------------------------- + | Ensure extended attribute blob is available before save + >------------------------------------------------------------------------------------------------------------------------- + | If the extended attribute boundary is NotLoaded and at least one extended attribute is dirty, call EnsureLoaded first so + | we write a complete snapshot rather than a partial one. When the boundary is NotLoaded and no extended attrs are dirty, + | @ExtendedAttributes is omitted (NULL), leaving the persisted blob untouched (UpdateTopic guards on IS NOT NULL). + \-------------------------------------------------------------------------------------------------------------------------*/ + if (!extendedBoundaryLoaded) { + if (extendedAttributeList.Any(a => a.IsDirty)) { + await EnsureLoaded(topic, TopicPayload.ExtendedAttributes).ConfigureAwait(false); + extendedBoundaryLoaded = true; + extendedAttributeList = GetAttributes(topic, isExtendedAttribute: true).ToList(); + } + else { + extendedAttributeList = []; + } + } + /*-------------------------------------------------------------------------------------------------------------------------- | Detect whether anything has changed >------------------------------------------------------------------------------------------------------------------------- @@ -401,7 +557,7 @@ bool persistRelationships >------------------------------------------------------------------------------------------------------------------------- | Loop through the content type's supported attributes and add attribute to null attributes if topic does not contain it. \-------------------------------------------------------------------------------------------------------------------------*/ - using var attributeValues = new AttributeValuesDataTable(); + using var attributeValues = new AttributeValuesDataTable(); if (areAttributesDirty) { @@ -418,10 +574,11 @@ bool persistRelationships /*-------------------------------------------------------------------------------------------------------------------------- | Add extended attributes \-------------------------------------------------------------------------------------------------------------------------*/ - var extendedAttributes = new StringBuilder(); + var extendedAttributes = (StringBuilder?)null; - if (areAttributesDirty) { + if (areAttributesDirty && extendedBoundaryLoaded) { + extendedAttributes = new(); extendedAttributes.Append(""); foreach (var attributeValue in extendedAttributeList) { @@ -449,7 +606,7 @@ bool persistRelationships using var connection = new SqlConnection(_connectionString); var procedureName = topic.IsNew? "CreateTopic" : "UpdateTopic"; - connection.Open(); + await connection.OpenAsync().ConfigureAwait(false); using var command = new SqlCommand(procedureName, connection) { CommandType = CommandType.StoredProcedure @@ -472,7 +629,9 @@ bool persistRelationships command.AddParameter("Version", version); if (areAttributesDirty) { command.AddParameter("Attributes", attributeValues); - command.AddParameter("ExtendedAttributes", extendedAttributes); + if (extendedAttributes is not null) { + command.AddParameter("ExtendedAttributes", extendedAttributes); + } } command.AddOutputParameter(); @@ -482,8 +641,8 @@ bool persistRelationships try { if (topic.IsNew || isTopicDirty || areAttributesDirty) { - command.ExecuteNonQuery(); - topic.Id = command.GetReturnCode(); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + topic.Id = command.GetReturnCode(); } Contract.Assume( @@ -492,11 +651,11 @@ bool persistRelationships ); if (persistRelationships && areRelationshipsDirty) { - PersistRelationships(topic, version, connection); + await PersistRelationships(topic, version, connection).ConfigureAwait(false); } if (persistRelationships && areReferencesDirty) { - PersistReferences(topic, version, connection); + await PersistReferences(topic, version, connection).ConfigureAwait(false); } } @@ -524,7 +683,7 @@ bool persistRelationships | METHOD: MOVE TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - protected override sealed void MoveTopic(Topic topic, Topic target, Topic? sibling) { + protected override sealed async Task MoveTopic(Topic topic, Topic target, Topic? sibling) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -555,8 +714,8 @@ protected override sealed void MoveTopic(Topic topic, Topic target, Topic? sibli | Process database query \-------------------------------------------------------------------------------------------------------------------------*/ try { - connection.Open(); - command.ExecuteNonQuery(); + await connection.OpenAsync().ConfigureAwait(false); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -575,7 +734,7 @@ protected override sealed void MoveTopic(Topic topic, Topic target, Topic? sibli | METHOD: DELETE TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - protected override sealed void DeleteTopic(Topic topic) { + protected override sealed async Task DeleteTopic(Topic topic) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -599,8 +758,8 @@ protected override sealed void DeleteTopic(Topic topic) { | Process database query \-------------------------------------------------------------------------------------------------------------------------*/ try { - connection.Open(); - command.ExecuteNonQuery(); + await connection.OpenAsync().ConfigureAwait(false); + await command.ExecuteNonQueryAsync().ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -615,6 +774,37 @@ protected override sealed void DeleteTopic(Topic topic) { } + /*============================================================================================================================ + | METHOD: ADD ENSURE LOADED PARAMETERS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Configures a targeting GetTopics for use by the , + /// setting the payload parameters based on the requested . + /// + /// + /// Indexed attributes and associations are only requested when filling the property, + /// as they are otherwise always loaded as part of the initial for + /// existing topics. + /// + private static void AddEnsureLoadedParameters(SqlCommand command, int topicId, TopicPayload payload) { + + // Set the topic we're working with + command.AddParameter("TopicID", topicId); + + // Scope: One tier of children when filling the Children property, otherwise just this topic's own content + command.AddParameter("Depth", payload.HasFlag(TopicPayload.Children) ? 1 : 0); + command.AddParameter("LoadAscendants", false); + + // Payload: Include only what the requested payload requires; relationships and references are loaded during the initial + // Load() call, so they do not need to be re-fetched + command.AddParameter("IncludeIndexed", payload.HasFlag(TopicPayload.Children)); + command.AddParameter("IncludeExtended", payload.HasFlag(TopicPayload.ExtendedAttributes)); + command.AddParameter("IncludeRelationships", payload.HasFlag(TopicPayload.Children)); + command.AddParameter("IncludeReferences", payload.HasFlag(TopicPayload.Children)); + command.AddParameter("IncludeHistory", payload.HasFlag(TopicPayload.VersionHistory)); + + } + /*============================================================================================================================ | METHOD: PERSIST RELATIONSHIPS \---------------------------------------------------------------------------------------------------------------------------*/ @@ -624,13 +814,27 @@ protected override sealed void DeleteTopic(Topic topic) { /// The topic object whose relationships should be persisted. /// The version that should be associated with the updated value. /// The SQL connection. - private static void PersistRelationships(Topic topic, DateTime version, SqlConnection connection) { + private static async Task PersistRelationships(Topic topic, DateTime version, SqlConnection connection) { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Determine relationship keys to persist + >--------------------------------------------------------------------------------------------------------------------------- + | Limited to dirty keys, either resolved or deferred. Each key maps to its own UpdateRelationships call, scoped to that + | key's own TVP and DeleteUnmatched, so skipping a clean key here simply leaves its existing rows untouched in SQL, while a + | also ensuring any deleted keys are correctly accounted for. + \-------------------------------------------------------------------------------------------------------------------------*/ + var rawTopic = (ITopicBackingAccessor)topic; + var dirtyDeferred = rawTopic.Relationships.Deferred.Where(deferred => deferred.IsDirty).ToList(); + var relationshipKeys = rawTopic.Relationships.Keys + .Where(key => rawTopic.Relationships.IsDirty(key)) + .Union(dirtyDeferred.Select(deferred => deferred.Key)).ToList(); + var deferredByKey = rawTopic.Relationships.Deferred.ToLookup(deferred => deferred.Key); /*-------------------------------------------------------------------------------------------------------------------------- | Return blank if the topic has no relations. \-------------------------------------------------------------------------------------------------------------------------*/ // return if the topic has no relations - if (topic.Relationships.Keys.Count == 0) { + if (relationshipKeys.Count == 0) { return; } @@ -639,27 +843,35 @@ private static void PersistRelationships(Topic topic, DateTime version, SqlConne /*------------------------------------------------------------------------------------------------------------------------ | Iterate through each scope and persist to SQL \-----------------------------------------------------------------------------------------------------------------------*/ - foreach (var key in topic.Relationships.Keys) { + foreach (var key in relationshipKeys) { + // Setup stored procedure using var targetIds = new TopicListDataTable(); using var command = new SqlCommand("UpdateRelationships", connection) { CommandType = CommandType.StoredProcedure }; - foreach (var targetTopic in topic.Relationships.GetValues(key)) { + // Include resolved relationships + foreach (var targetTopic in rawTopic.Relationships.GetValues(key)) { if (!targetTopic.IsNew) { targetIds.AddRow(targetTopic.Id); } } + // Include deferred relationships + foreach (var deferred in deferredByKey[key]) { + targetIds.AddRow(deferred.TopicId); + } + // Add Parameters command.AddParameter("TopicID", topic.Id.ToString(CultureInfo.InvariantCulture)); command.AddParameter("RelationshipKey", key); command.AddParameter("RelatedTopics", targetIds); command.AddParameter("Version", version); - command.AddParameter("DeleteUnmatched", topic.Relationships.IsFullyLoaded); + command.AddParameter("DeleteUnmatched", true); - command.ExecuteNonQuery(); + // Execute command + await command.ExecuteNonQueryAsync().ConfigureAwait(false); } @@ -675,11 +887,6 @@ private static void PersistRelationships(Topic topic, DateTime version, SqlConne ); } - /*-------------------------------------------------------------------------------------------------------------------------- - | Return - \-------------------------------------------------------------------------------------------------------------------------*/ - return; - } /*============================================================================================================================ @@ -691,31 +898,41 @@ private static void PersistRelationships(Topic topic, DateTime version, SqlConne /// The topic object whose references should be persisted. /// The version that should be associated with the updated value. /// The SQL connection. - private static void PersistReferences(Topic topic, DateTime version, SqlConnection connection) { + private static async Task PersistReferences(Topic topic, DateTime version, SqlConnection connection) { + + var rawTopic = (ITopicBackingAccessor)topic; /*-------------------------------------------------------------------------------------------------------------------------- | Persist relations to database \-------------------------------------------------------------------------------------------------------------------------*/ try { + // Setup stored procedure using var references = new TopicReferencesDataTable(); using var command = new SqlCommand("UpdateReferences", connection) { CommandType = CommandType.StoredProcedure }; - foreach (var relatedTopic in topic.References) { + // Include resolved references + foreach (var relatedTopic in rawTopic.References) { if (!relatedTopic.Value?.IsNew?? false) { references.AddRow(relatedTopic.Key, relatedTopic.Value!.Id); } } + // Include deferred references + foreach (var deferred in rawTopic.References.Deferred) { + references.AddRow(deferred.Key, deferred.TopicId); + } + // Add Parameters command.AddParameter("TopicID", topic.Id.ToString(CultureInfo.InvariantCulture)); command.AddParameter("ReferencedTopics", references); command.AddParameter("Version", version); - command.AddParameter("DeleteUnmatched", topic.References.IsFullyLoaded); + command.AddParameter("DeleteUnmatched", true); - command.ExecuteNonQuery(); + // Execute the command + await command.ExecuteNonQueryAsync().ConfigureAwait(false); } @@ -729,11 +946,6 @@ private static void PersistReferences(Topic topic, DateTime version, SqlConnecti ); } - /*-------------------------------------------------------------------------------------------------------------------------- - | Return - \-------------------------------------------------------------------------------------------------------------------------*/ - return; - } } //Class \ No newline at end of file diff --git a/OnTopic.TestDoubles/DummyTopicRepository.cs b/OnTopic.TestDoubles/DummyTopicRepository.cs index f683903e..27cb1477 100644 --- a/OnTopic.TestDoubles/DummyTopicRepository.cs +++ b/OnTopic.TestDoubles/DummyTopicRepository.cs @@ -24,7 +24,7 @@ public class DummyTopicRepository : ObservableTopicRepository { /// Instantiates a new instance of the . /// /// A new instance of the . - public DummyTopicRepository() : base() { } + public DummyTopicRepository() { } /*============================================================================================================================ | METHOD: GET CONTENT TYPE DESCRIPTORS @@ -36,45 +36,55 @@ public DummyTopicRepository() : base() { } | METHOD: LOAD \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override Topic? Load(int topicId, Topic? referenceTopic = null, bool isRecursive = true) => null; + public override Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) => Task.FromResult(null); /// - public override Topic? Load(string uniqueKey, Topic? referenceTopic = null, bool isRecursive = true) => null; + public override Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) => Task.FromResult(null); /// - public override Topic? Load(Topic? topic, DateTime version) => throw new NotImplementedException(); + public override Task Load(Topic? topic, DateTime version) => throw new NotImplementedException(); /// - public override Topic? Load(int topicId, DateTime version, Topic? referenceTopic = null) => throw new NotImplementedException(); + public override Task Load(int topicId, DateTime version) => throw new NotImplementedException(); /*============================================================================================================================ | METHOD: ROLLBACK \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Rollback(Topic topic, DateTime version) => throw new NotImplementedException(); + public override Task Rollback(Topic topic, DateTime version) => throw new NotImplementedException(); /*============================================================================================================================ | METHOD: REFRESH \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Refresh(Topic referenceTopic, DateTime since) => throw new NotImplementedException(); + public override Task Refresh(Topic referenceTopic, DateTime since) => throw new NotImplementedException(); /*============================================================================================================================ | METHOD: SAVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Save(Topic topic, bool isRecursive = false) => throw new NotImplementedException(); + public override Task Save(Topic topic, bool isRecursive = false) => throw new NotImplementedException(); /*============================================================================================================================ | METHOD: MOVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Move(Topic topic, Topic target, Topic? sibling = null) => throw new NotImplementedException(); + public override Task Move(Topic topic, Topic target, Topic? sibling = null) => throw new NotImplementedException(); /*============================================================================================================================ | METHOD: DELETE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Delete(Topic topic, bool isRecursive = false) => throw new NotImplementedException(); + public override Task Delete(Topic topic, bool isRecursive = false) => throw new NotImplementedException(); } //Class \ No newline at end of file diff --git a/OnTopic.TestDoubles/LazyLoading/StubLazyLoadingTopicRepository.cs b/OnTopic.TestDoubles/LazyLoading/StubLazyLoadingTopicRepository.cs new file mode 100644 index 00000000..8eb8f931 --- /dev/null +++ b/OnTopic.TestDoubles/LazyLoading/StubLazyLoadingTopicRepository.cs @@ -0,0 +1,638 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Internal.Diagnostics; +using OnTopic.Querying; +using OnTopic.Repositories; + +namespace OnTopic.TestDoubles.LazyLoading; + +/*============================================================================================================================== +| CLASS: STUB LAZY TOPIC DATA REPOSITORY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a lazy-loading implementation of an , serving partially loaded topics with the +/// ability to be dynamically filled by e.g., . +/// +/// +/// +/// Unlike , which serves a fully materialized graph from the outset, this double +/// builds each shallow: Its own , , and association properties are absent until something fills them, one +/// at a time, from the record store. +/// +/// +/// Filling happens two ways, matching how a real, e.g., SQL-backed repository distinguishes a batch Load() from an +/// on-demand fill. and its overloads only connect association +/// targets already present in the graph being built, leaving the rest deferred; it never issues an additional fetch to +/// resolve a missing target. , invoked either explicitly +/// or via one of 's autoloading getters, goes further: It recursively loads whatever deferred targets it +/// can find and discards the rest as stale. +/// +/// +/// The Root:Configuration subtree (required by for content type +/// resolution) is the one exception to lazy service: It is built eagerly, with every property , exactly as production seeds it. Everything else is served lazily from the record store supplied to the constructor, +/// or from if none is supplied. +/// +/// +/// A per-topic, per-property fetch-count spy ( and ) records every fill, letting tests assert fetch-once behavior and, critically, that stamping the resolver doesn't +/// trigger lazy loading. +/// +/// +[ExcludeFromCodeCoverage] +public class StubLazyLoadingTopicRepository : TopicRepository, ITopicRepository, ITopicLazyLoader { + + /*============================================================================================================================ + | VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly Topic _root; + private readonly Dictionary _served = []; + private readonly Dictionary _keyIndex = new(StringComparer.OrdinalIgnoreCase); + private int _identity = 90000; + + private readonly IReadOnlyDictionary _store; + private readonly Dictionary<(int TopicId, TopicPayload Boundary), int> _fetchCounts = []; + + /*============================================================================================================================ + | CONSTRUCTOR + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Instantiates a new instance of the using the default, built-in seed dataset. + /// + public StubLazyLoadingTopicRepository() : this(CreateDefaultRecords()) { } + + /// + /// Instantiates a new instance of the using a custom set of s, allowing downstream consumers (e.g., the OnTopic Editor) to seed their own lazy-capable content graph + /// without rebuilding this scaffolding. See for a convenient way to construct + /// . + /// + /// The flat, SQL-free record store from which the "content" subtree is lazily served. + public StubLazyLoadingTopicRepository(IEnumerable records) { + + Contract.Requires(records, nameof(records)); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Eagerly build the Root:Configuration scaffold; every boundary defaults to Loaded, since it is never marked otherwise + \-------------------------------------------------------------------------------------------------------------------------*/ + _root = BuildEagerScaffold(); + + foreach (var topic in _root.FindAll()) { + _served[topic.Id] = topic; + _keyIndex[topic.GetUniqueKey()] = topic.Id; + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Index the lazy content record store + \-------------------------------------------------------------------------------------------------------------------------*/ + _store = records.ToDictionary(record => record.Id); + + foreach (var record in _store.Values) { + _keyIndex[GetUniqueKey(record)] = record.Id; + } + + } + + /*============================================================================================================================ + | METHOD: BUILD EAGER SCAFFOLD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Builds the minimal Root:Configuration scaffold that requires + /// for construction (content-type resolution) and that requires for Save() + /// (content-type validation). + /// + private static Topic BuildEagerScaffold() { + + var root = new Topic("Root", "Container", null, 1); + var configuration = new Topic("Configuration", "Container", root, 2); + var contentTypes = new ContentTypeDescriptor("ContentTypes", "ContentTypeDescriptor", configuration, 3); + _ = new ContentTypeDescriptor("Page", "ContentTypeDescriptor", contentTypes, 4); + + // Root's own Children property is lazy, matching ITopicRepository's documented Load() defaults; only the Configuration + // subtree required for content type resolution and Save() validation is eagerly scaffolded + ((ITopicLazyLoadable)root).SetLoadState(TopicPayload.Children, LoadState.NotLoaded); + + return root; + + } + + /*============================================================================================================================ + | METHOD: LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + + /// + public override async Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { + + // Validate unique key + if (String.IsNullOrEmpty(uniqueKey)) { + return null; + } + + // Normalize unique key by ensuring "root:" prefix + if (!uniqueKey.StartsWith(_root.Key, StringComparison.OrdinalIgnoreCase)) { + uniqueKey = $"{_root.Key}:{uniqueKey.TrimStart(':')}"; + } + + // If the root is requested, hardcode the topicId at -1 + if (uniqueKey.Equals(_root.Key, StringComparison.OrdinalIgnoreCase)) { + return await Load(-1, referenceTopic, payload, depth).ConfigureAwait(false); + } + + // If the unique key isn't in the data store, return null + if (!_keyIndex.TryGetValue(uniqueKey, out var topicId)) { + return null; + } + + // Otherwise, use the store's topicId to call the base overload + return await Load(topicId, referenceTopic, payload, depth).ConfigureAwait(false); + + } + + /// + /// + /// Returns an already- topic, without raising + /// again: The event fires only when a topic is genuinely built for the first time, mirroring how a real repository only + /// fires when something is actually pulled from the persistence store. On a miss against the record store, builds the + /// requested topic and its ancestor chain as shallow, sparse topics, and raises the event for the requested topic. Either + /// way, if or requests anything not yet loaded, it connects whatever it + /// can from the graph already built so far via ; targets that aren't yet resident stay + /// deferred. + /// + public override async Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { + + // Setup + var topic = (Topic?)null; + var isNewlyBuilt = false; + + // Attempt to retrieve topic, falling back to the topic store if needed + if (topicId < 0) { + topic = _root; + } + else if (_served.TryGetValue(topicId, out var existing)) { + topic = existing; + } + else if (_store.TryGetValue(topicId, out var record)) { + topic = BuildTopicWithAncestors(record); + isNewlyBuilt = true; + } + else { + return null; + } + + // Preload the topic with the requested payload + await FillRequestedPayload( + topic, + payload, + resolveDeferredTargets : false, + depth, + CancellationToken.None + ).ConfigureAwait(false); + + // Fire the TopicLoaded event, if newly built + if (isNewlyBuilt) { + OnTopicLoaded(new(topic, depth)); + } + + // Return the requested topic + return topic; + + } + + /// + public override async Task Load(int topicId, DateTime version) { + + // Setup + Contract.Requires(version.Date < DateTime.UtcNow, "The version requested must be a valid historical date."); + Contract.Requires( + version.Date > new DateTime(2014, 12, 9), + "The version is expected to have been created since version support was introduced into the topic library." + ); + + // Load the topic requested + var topic = await Load(topicId).ConfigureAwait(false); + + // Throw an exception if the topic doesn't exist + if (topic is null) { + throw new TopicNotFoundException(topicId); + } + + // For the stub, accept whatever version is provided + if (!topic.VersionHistory.Contains(version)) { + topic.VersionHistory.Add(version); + } + topic.LastModified = version; + + // Fire the TopicLoaded event; this is always assumed to be freshly loaded + OnTopicLoaded(new(topic, 0, version)); + + // Return the topic version + return topic; + + } + + /*============================================================================================================================ + | METHOD: REFRESH + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + public override Task Refresh(Topic referenceTopic, DateTime since) => Task.CompletedTask; + + /*============================================================================================================================ + | METHOD: SAVE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + protected override Task SaveTopic([NotNull]Topic topic, DateTime version, bool persistRelationships) { + + // For saving topics, just assign an identity + if (topic.IsNew) { + topic.Id = _identity++; + } + + return Task.CompletedTask; + + } + + /*============================================================================================================================ + | METHOD: MOVE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + protected override Task MoveTopic(Topic topic, Topic target, Topic? sibling = null) => Task.CompletedTask; + + /*============================================================================================================================ + | METHOD: DELETE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + protected override Task DeleteTopic(Topic topic) => Task.CompletedTask; + + /*============================================================================================================================ + | METHODS: TOPIC LAZY LOADER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// + /// The on-demand fill: Unlike , this recursively resolves deferred + /// relationship and reference targets via the inherited LoadDeferredAssociations, discarding whatever remains + /// unresolved as stale, assuming either or . + /// + public virtual async Task EnsureLoaded(Topic topic, TopicPayload payload, CancellationToken cancellationToken = default) { + + // Validate the input + Contract.Requires(topic, nameof(topic)); + + // There's nothing to load on a new topic + if (topic.IsNew) { + return; + } + + // Call the centralized private helper to fulfill the request + await FillRequestedPayload( + topic, + payload, + resolveDeferredTargets : true, + depth : 0, + cancellationToken + ).ConfigureAwait(false); + + } + + /*============================================================================================================================ + | METHOD: FILL REQUESTED PAYLOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// On a plain Load(), connects resident relationship and reference targets unconditionally, regardless of + /// . Either way, loads the data requested in , after filtering out + /// any already flags, recording a fetch in the spy for each property filled. Children are + /// fetched from the record store one tier at a time, decrementing at each level, until it reaches + /// 0; a of -1 descends the entire subtree. A child already present in (e.g., attached while building ancestor for a deeper + /// call, or eagerly preloaded) is reused rather than rebuilt, to avoid colliding with the existing instance already + /// attached to the graph, but is still offered the requested payload so a resident child converges to the requested scope + /// instead of being silently skipped. + /// + /// The topic whose requested payload should be filled. + /// The requested flags. + /// + /// Whether unresolved relationships and references targets should be recursively loaded, via the inherited . Set by 's on-demand fill; left by a plain + /// Load(), which only connects targets already present in the graph, via the inherited . + /// + /// + /// The number of tiers of descendants to fill below . -1 fills the full subtree; 0 + /// fills only itself, unless requests , in which case it is treated as 1, for continuity with 's + /// single-tier meaning. + /// + /// An optional token used only when resolving deferred targets. + private async Task FillRequestedPayload( + Topic topic, + TopicPayload payload, + bool resolveDeferredTargets, + int depth, + CancellationToken cancellationToken + ) { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Setup + \-------------------------------------------------------------------------------------------------------------------------*/ + var rawTopic = (ITopicBackingAccessor)topic; + + /*-------------------------------------------------------------------------------------------------------------------------- + | Relationships and references: Connect resident targets + >------------------------------------------------------------------------------------------------------------------------- + | Unconditionally connects targets already present in the graph, mirroring how LoadTopicGraph() reads relationship and + | reference rows alongside every topic row and links whatever's already resident, regardless of the requested payload. + | Runs ahead of the payload/store checks below, since it isn't gated by them in production either. Delegates to the + | inherited ResolveAssociations, which indexes the resident graph the same way LoadTopicGraph() seeds its working index + | (via GetTopicIndex()), so this double relies on the same underlying mechanism as production rather than a parallel one. + \-------------------------------------------------------------------------------------------------------------------------*/ + if (!resolveDeferredTargets) { + await ResolveAssociations(topic, TopicPayload.Relationships | TopicPayload.References).ConfigureAwait(false); + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Filter out any already loaded payloads + >--------------------------------------------------------------------------------------------------------------------------- + | The unfiltered payload is retained for propagation to children below: A property already Loaded on topic (e.g., Root's + | ExtendedAttributes, which defaults to Loaded since Root is never built from a record) doesn't imply descendants are also + | already loaded, so children must still be offered the originally requested payload, not the topic's filtered one + \-------------------------------------------------------------------------------------------------------------------------*/ + var requestedPayload = payload; + + // TopicPayload.Children continues to request one tier of children when no explicit depth is given + if (requestedPayload.HasFlag(TopicPayload.Children) && depth is 0) { + depth = 1; + } + + payload = ((ITopicLazyLoadable)topic).FilterPayload(payload); + + if (payload is TopicPayload.None && depth is 0) { + return; + } + + // Filters out just the association payloads, if present, for a later gate + var associationPayload = payload & (TopicPayload.Relationships | TopicPayload.References); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Children + >--------------------------------------------------------------------------------------------------------------------------- + | Unlike ExtendedAttributes, Children never needs a record of its own to fill: It is resolved purely by scanning the store + | for records whose ParentId matches, including Root, whose top-level records are stored with a null ParentId. Depth, not + | the Children flag, is the fetch axis: Any remaining depth descends, even if payload never requested Children directly, + | mirroring how a production @Depth-bounded fetch loads every tier within its bound regardless of which payload flags + | accompany it. + \-------------------------------------------------------------------------------------------------------------------------*/ + if (depth is not 0) { + + // Determine if the children are already loaded + var childrenAlreadyLoaded = ((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.Children); + + // Loop through each child record and build the topic from the topic store + foreach (var childRecord in _store.Values.Where(r => (r.ParentId?? _root.Id) == topic.Id).OrderBy(r => r.Id)) { + + // Reuse the child if it's already been served (e.g., attached while building an ancestor chain, or eagerly preloaded), + // rather than rebuilding it and colliding with the existing instance already attached to the graph + var isNewlyBuilt = !_served.TryGetValue(childRecord.Id, out var child); + child ??= BuildTopic(childRecord, topic); + + // Decrement the remaining depth budget for the child, unless unbounded (-1) + var childDepth = depth is -1 ? -1 : depth - 1; + + // Load the rest of the requested payload for the child, mirroring how a Children fetch also pulls in whatever else was + // requested (e.g., ExtendedAttributes, VersionHistory) for the whole scope, while relationships and references always + // ride along for free. Children rides along only while depth budget remains for the child, so the fill descends exactly + // as many tiers as requested, rather than stopping at one level or recursing unconditionally. This usesrequestedPayload + // not the filtered payload, since a property that is already Loaded on a topic doesn't imply it's also already loaded + // on the child. This applies whether the child was just built or already served, so an existing child (e.g., eagerly + // preloaded) still converges to the requested scope. + var childPayload = (childDepth is not 0 ? requestedPayload : requestedPayload & ~TopicPayload.Children) + | TopicPayload.Relationships + | TopicPayload.References; + await FillRequestedPayload( + child, + childPayload, + resolveDeferredTargets: false, + childDepth, + cancellationToken + ).ConfigureAwait(false); + + // Fire the TopicLoaded event, if newly built; an already served child was already announced when it was first built + if (isNewlyBuilt) { + OnTopicLoaded(new(child, childDepth)); + } + + } + + // Mark the children as fetched and loaded, if not already done; an already-Loaded Children collection, revisited only to + // descend for a deeper request, needs no re-fetch or re-stamp of its own + if (!childrenAlreadyLoaded) { + RecordFetch(topic.Id, TopicPayload.Children); + ((ITopicLazyLoadable)topic).SetLoadState(TopicPayload.Children, LoadState.Loaded); + } + + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Extended attributes + >--------------------------------------------------------------------------------------------------------------------------- + | Unlike Children, this requires a backing record; a topic with a pending ExtendedAttributes payload but no corresponding + | record means it was attached to the graph without ever being built from the store, which this stub has no way to fulfill, + | representing test setup error, not a legitimate state + \-------------------------------------------------------------------------------------------------------------------------*/ + if (payload.HasFlag(TopicPayload.ExtendedAttributes)) { + + _store.TryGetValue(topic.Id, out var record); + Contract.Assume( + record, + $"{nameof(StubLazyLoadingTopicRepository)} can only lazily fill topics that are defined in the record store supplied to " + + $"its constructor. Topic {topic.Id} was attached to the graph with a pending {payload} payload, but has no corresponding " + + $"record to fill it from. This is an invalid configuration." + ); + + // Load each of the extended attributes from the data store + foreach (var attribute in record.ExtendedAttributes) { + rawTopic.Attributes.SetValue(attribute.Key, attribute.Value, markDirty: false, isExtendedAttribute: true); + } + + // Mark the extended attributes as fetched and loaded + RecordFetch(topic.Id, TopicPayload.ExtendedAttributes); + ((ITopicLazyLoadable)topic).SetLoadState(TopicPayload.ExtendedAttributes, LoadState.Loaded); + + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Relationships and references: Resolve deferred targets + >------------------------------------------------------------------------------------------------------------------------- + | Gated on what was actually requested, and delegates to the inherited LoadDeferredAssociations, so this double + | utilizes that shared infrastructure. Resident targets were already connected above, regardless of this gate. + \-------------------------------------------------------------------------------------------------------------------------*/ + if (resolveDeferredTargets && associationPayload is not TopicPayload.None) { + + // Load any deferred relationships or references + await LoadDeferredAssociations(topic, associationPayload, cancellationToken).ConfigureAwait(false); + + // Mark the associations requested as fetched + if (associationPayload.HasFlag(TopicPayload.Relationships)) { + RecordFetch(topic.Id, TopicPayload.Relationships); + } + if (associationPayload.HasFlag(TopicPayload.References)) { + RecordFetch(topic.Id, TopicPayload.References); + } + + } + + } + + /*============================================================================================================================ + | METHOD: BUILD TOPIC WITH ANCESTORS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Builds the requested as a shallow, served , first building (or reusing) + /// its entire ancestor chain up to . + /// + private Topic BuildTopicWithAncestors(TopicRecord record) { + var parent = record.ParentId is { } parentId ? GetOrBuildAncestor(parentId) : _root; + return BuildTopic(record, parent); + } + + /*============================================================================================================================ + | METHOD: GET OR BUILD ANCESTOR + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the already- ancestor for , or builds it and, recursively, its + /// own ancestors, as a shallow topic. Never raises : Only the + /// originally requested topic does that; ascendants are stamped separately, via StampAscendants. + /// + private Topic GetOrBuildAncestor(int topicId) { + if (_served.TryGetValue(topicId, out var existing)) { + return existing; + } + var record = _store[topicId]; + var parent = record.ParentId is { } parentId ? GetOrBuildAncestor(parentId) : _root; + return BuildTopic(record, parent); + } + + /*============================================================================================================================ + | METHOD: BUILD TOPIC + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Constructs a single shallow for under , populates + /// its indexed attributes, marks its extended attribute, children, and association properties as , and registers it in . + /// + private Topic BuildTopic(TopicRecord record, Topic parent) { + + // Setup + var topic = new Topic(record.Key, record.ContentType, parent, record.Id); + var rawTopic = (ITopicBackingAccessor)topic; + + // Set indexed attributes + foreach (var attribute in record.IndexedAttributes) { + rawTopic.Attributes.SetValue(attribute.Key, attribute.Value, markDirty: false, isExtendedAttribute: false); + } + + // Set extended attributes + ((ITopicLazyLoadable)topic).SetLoadState(TopicPayload.ExtendedAttributes, LoadState.NotLoaded); + + // Set children + ((ITopicLazyLoadable)topic).SetLoadState(TopicPayload.Children, LoadState.NotLoaded); + + // Add relationships to Deferred + foreach (var (key, targetId) in record.Relationships) { + rawTopic.Relationships.Deferred.Add(new(key, targetId)); + } + + // Add references to Deferred + foreach (var (key, targetId) in record.References) { + rawTopic.References.Deferred.Add(new(key, targetId)); + } + + // Mark record as served so it doesn't trigger OnTopicLoaded() again + _served[record.Id] = topic; + + // Return the built topic + return topic; + + } + + /*============================================================================================================================ + | METHOD: GET UNIQUE KEY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Computes the unique key for by walking its chain through + /// the record store, without constructing or touching any instance. + /// + private string GetUniqueKey(TopicRecord record) { + List segments = [record.Key]; + var current = record; + while (current.ParentId is { } parentId) { + current = _store[parentId]; + segments.Insert(0, current.Key); + } + segments.Insert(0, _root.Key); + return String.Join(":", segments); + } + + /*============================================================================================================================ + | METHODS: FETCH SPY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the total number of genuine boundary fetches recorded across every topic. + /// + public int TotalFetches => _fetchCounts.Values.Sum(); + + /// + /// Returns the number of times was genuinely fetched for . + /// + public int GetFetchCount(int topicId, TopicPayload boundary) => _fetchCounts.GetValueOrDefault((topicId, boundary), 0); + + /// + /// Increments the fetch-count spy for on . + /// + private void RecordFetch(int topicId, TopicPayload boundary) { + var key = (topicId, boundary); + _fetchCounts[key] = _fetchCounts.GetValueOrDefault(key) + 1; + } + + /*============================================================================================================================ + | METHOD: CREATE DEFAULT RECORDS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates the built-in seed dataset used by the parameterless constructor: A four-level "Web" content subtree with an + /// extended-attribute topic, a resolvable relationship and reference pair, and a stale (dangling) relationship and + /// reference pair. + /// + private static IReadOnlyList CreateDefaultRecords() => + new StubLazyLoadingTopicRepositoryBuilder() + .AddTopic(10000, "Web", "Page", null, indexedAttributes: new Dictionary { ["Title"] = "Web" }) + .AddTopic(10001, "Web_0", "Page", 10000, indexedAttributes: new Dictionary { ["Title"] = "Web_0" }) + .AddTopic( + 10002, + "Web_0_0", + "Page", + 10001, + indexedAttributes: new Dictionary { ["Title"] = "Web_0_0" }, + extendedAttributes: new Dictionary { ["Body"] = "Extended body content for Web_0_0." } + ) + .AddTopic(10003, "Web_0_0_0", "Page", 10002, indexedAttributes: new Dictionary { ["Title"] = "Web_0_0_0" }) + .AddTopic(10004, "Web_1", "Page", 10000, indexedAttributes: new Dictionary { ["Title"] = "Web_1" }) + .AddRelationship(10004, "Related", 10002) + .AddRelationship(10001, "Related", 99999) + .AddReference(10004, "BaseTopic", 10001) + .AddReference(10001, "BaseTopic", 99998) + .Build(); + +} //Class \ No newline at end of file diff --git a/OnTopic.TestDoubles/LazyLoading/StubLazyLoadingTopicRepositoryBuilder.cs b/OnTopic.TestDoubles/LazyLoading/StubLazyLoadingTopicRepositoryBuilder.cs new file mode 100644 index 00000000..51171523 --- /dev/null +++ b/OnTopic.TestDoubles/LazyLoading/StubLazyLoadingTopicRepositoryBuilder.cs @@ -0,0 +1,123 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.TestDoubles.LazyLoading; + +/*============================================================================================================================== +| CLASS: STUB LAZY LOADING TOPIC REPOSITORY BUILDER +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a fluent builder for constructing a custom set to seed a , sparing downstream consumers from manually authoring records or rebuilding this +/// scaffolding. +/// +[ExcludeFromCodeCoverage] +public sealed class StubLazyLoadingTopicRepositoryBuilder { + + /*============================================================================================================================ + | CLASS: STAGING RECORD + \---------------------------------------------------------------------------------------------------------------------------*/ + private sealed class StagingRecord(string key, string contentType, int? parentId) { + public string Key { get; } = key; + public string ContentType { get; } = contentType; + public int? ParentId { get; } = parentId; + public Dictionary IndexedAttributes { get; } = []; + public Dictionary ExtendedAttributes { get; } = []; + public List<(string Key, int TargetId)> Relationships { get; } = []; + public List<(string Key, int TargetId)> References { get; } = []; + } + + /*============================================================================================================================ + | PRIVATE VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly Dictionary _staging = []; + private readonly List _order = []; + + /*============================================================================================================================ + | METHOD: ADD TOPIC + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Adds a new to the set being built. + /// + /// The topic's unique identifier. + /// The topic's key. + /// The topic's content type. + /// The identifier of the topic's parent, or null to attach directly under Root. + /// Attribute values always present on the topic. + /// Attribute values only present once the extended attributes are loaded. + public StubLazyLoadingTopicRepositoryBuilder AddTopic( + int id, + string key, + string contentType, + int? parentId, + IReadOnlyDictionary? indexedAttributes = null, + IReadOnlyDictionary? extendedAttributes = null + ) { + var staging = new StagingRecord(key, contentType, parentId); + if (indexedAttributes is not null) { + foreach (var attribute in indexedAttributes) { + staging.IndexedAttributes[attribute.Key] = attribute.Value; + } + } + if (extendedAttributes is not null) { + foreach (var attribute in extendedAttributes) { + staging.ExtendedAttributes[attribute.Key] = attribute.Value; + } + } + _staging[id] = staging; + _order.Add(id); + return this; + } + + /*============================================================================================================================ + | METHOD: ADD RELATIONSHIP + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Adds a relationship from the topic previously added via under to + /// , under . The target need not itself be present in the record store: + /// An absent target id produces a stale, unresolvable relationship, useful for exercising discard behavior. + /// + public StubLazyLoadingTopicRepositoryBuilder AddRelationship(int sourceId, string key, int targetId) { + _staging[sourceId].Relationships.Add((key, targetId)); + return this; + } + + /*============================================================================================================================ + | METHOD: ADD REFERENCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Adds a reference from the topic previously added via under to + /// , under . The target need not itself be present in the record store: + /// An absent target id produces a stale, unresolvable reference, useful for exercising discard behavior. + /// + public StubLazyLoadingTopicRepositoryBuilder AddReference(int sourceId, string key, int targetId) { + _staging[sourceId].References.Add((key, targetId)); + return this; + } + + /*============================================================================================================================ + | METHOD: BUILD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Finalizes the set of s built so far into an immutable list, suitable for passing to . + /// + public IReadOnlyList Build() => + _order.Select(id => { + var staging = _staging[id]; + return new TopicRecord( + id, + staging.Key, + staging.ContentType, + staging.ParentId, + new Dictionary(staging.IndexedAttributes), + new Dictionary(staging.ExtendedAttributes), + staging.Relationships.ToArray(), + staging.References.ToArray() + ); + }).ToList(); + +} //Class \ No newline at end of file diff --git a/OnTopic.TestDoubles/LazyLoading/TopicRecord.cs b/OnTopic.TestDoubles/LazyLoading/TopicRecord.cs new file mode 100644 index 00000000..31439e76 --- /dev/null +++ b/OnTopic.TestDoubles/LazyLoading/TopicRecord.cs @@ -0,0 +1,37 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.TestDoubles.LazyLoading; + +/*============================================================================================================================== +| RECORD: TOPIC RECORD +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Represents a single topic within 's flat, SQL-free record store, from which +/// shallow instances are built and lazily materialized. +/// +/// The topic's unique identifier. +/// The topic's key. +/// The topic's content type. +/// +/// The identifier of the topic's parent, or null if the topic is a top-level "content" topic, attached directly under +/// Root. +/// +/// Attribute values always present on the topic, regardless of requested payload. +/// Attribute values only merged when the extended-attribute property is materialized. +/// Relationship key/id pairs, deferred until the relationship property is materialized. +/// Reference key/id pairs, deferred until the reference property is materialized. +[ExcludeFromCodeCoverage] +public sealed record TopicRecord( + int Id, + string Key, + string ContentType, + int? ParentId, + IReadOnlyDictionary IndexedAttributes, + IReadOnlyDictionary ExtendedAttributes, + IReadOnlyList<(string Key, int TargetId)> Relationships, + IReadOnlyList<(string Key, int TargetId)> References +); \ No newline at end of file diff --git a/OnTopic.TestDoubles/Metadata/BooleanAttributeDescriptor.cs b/OnTopic.TestDoubles/Metadata/BooleanAttributeDescriptor.cs index dd414dfd..8c5da563 100644 --- a/OnTopic.TestDoubles/Metadata/BooleanAttributeDescriptor.cs +++ b/OnTopic.TestDoubles/Metadata/BooleanAttributeDescriptor.cs @@ -28,7 +28,7 @@ public BooleanAttributeDescriptor( string key, string contentType, Topic parent, - int id = -1 + int id = -1 ) : base( key, contentType, diff --git a/OnTopic.TestDoubles/Metadata/NestedTopicListAttributeDescriptor.cs b/OnTopic.TestDoubles/Metadata/NestedTopicListAttributeDescriptor.cs index 46185fd9..267abf3f 100644 --- a/OnTopic.TestDoubles/Metadata/NestedTopicListAttributeDescriptor.cs +++ b/OnTopic.TestDoubles/Metadata/NestedTopicListAttributeDescriptor.cs @@ -28,7 +28,7 @@ public NestedTopicListAttributeDescriptor( string key, string contentType, Topic parent, - int id = -1 + int id = -1 ) : base( key, contentType, @@ -39,7 +39,7 @@ public NestedTopicListAttributeDescriptor( /*-------------------------------------------------------------------------------------------------------------------------- | Initialize values \-------------------------------------------------------------------------------------------------------------------------*/ - ModelType = ModelType.NestedTopic; + ModelType = ModelType.NestedTopic; } diff --git a/OnTopic.TestDoubles/Metadata/RelationshipAttributeDescriptor.cs b/OnTopic.TestDoubles/Metadata/RelationshipAttributeDescriptor.cs index 8f47bb28..f6712d0b 100644 --- a/OnTopic.TestDoubles/Metadata/RelationshipAttributeDescriptor.cs +++ b/OnTopic.TestDoubles/Metadata/RelationshipAttributeDescriptor.cs @@ -28,7 +28,7 @@ public RelationshipAttributeDescriptor( string key, string contentType, Topic parent, - int id = -1 + int id = -1 ) : base( key, contentType, @@ -39,7 +39,7 @@ public RelationshipAttributeDescriptor( /*-------------------------------------------------------------------------------------------------------------------------- | Initialize values \-------------------------------------------------------------------------------------------------------------------------*/ - ModelType = ModelType.Relationship; + ModelType = ModelType.Relationship; } diff --git a/OnTopic.TestDoubles/Metadata/TextAttributeDescriptor.cs b/OnTopic.TestDoubles/Metadata/TextAttributeDescriptor.cs index d116628e..f2a469cc 100644 --- a/OnTopic.TestDoubles/Metadata/TextAttributeDescriptor.cs +++ b/OnTopic.TestDoubles/Metadata/TextAttributeDescriptor.cs @@ -27,8 +27,8 @@ public class TextAttributeDescriptor : AttributeDescriptor { public TextAttributeDescriptor( string key, string contentType, - Topic? parent = null, - int id = -1 + Topic? parent = null, + int id = -1 ) : base( key, contentType, diff --git a/OnTopic.TestDoubles/Metadata/TopicReferenceAttributeDescriptor.cs b/OnTopic.TestDoubles/Metadata/TopicReferenceAttributeDescriptor.cs index fbab6629..4b404133 100644 --- a/OnTopic.TestDoubles/Metadata/TopicReferenceAttributeDescriptor.cs +++ b/OnTopic.TestDoubles/Metadata/TopicReferenceAttributeDescriptor.cs @@ -28,7 +28,7 @@ public TopicReferenceAttributeDescriptor( string key, string contentType, Topic parent, - int id = -1 + int id = -1 ) : base( key, contentType, @@ -39,7 +39,7 @@ public TopicReferenceAttributeDescriptor( /*-------------------------------------------------------------------------------------------------------------------------- | Initialize values \-------------------------------------------------------------------------------------------------------------------------*/ - ModelType = ModelType.Reference; + ModelType = ModelType.Reference; } diff --git a/OnTopic.TestDoubles/StubSitemapTopicRepository.cs b/OnTopic.TestDoubles/StubSitemapTopicRepository.cs new file mode 100644 index 00000000..a2920286 --- /dev/null +++ b/OnTopic.TestDoubles/StubSitemapTopicRepository.cs @@ -0,0 +1,42 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Internal.Diagnostics; +using OnTopic.Repositories; + +namespace OnTopic.TestDoubles; + +/*============================================================================================================================== +| CLASS: STUB SITEMAP TOPIC REPOSITORY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides an backed by an existing , for testing +/// consumers of without a SQL-backed SqlSitemapTopicRepository. +/// +/// +/// Unlike a SQL-backed implementation, this does not source a lean, purpose-built graph; it simply defers to the wrapped +/// 's own , requesting +/// the full descendant tree explicitly since depth defaults to 0. +/// +/// The to source the sitemap's topic graph from. +[ExcludeFromCodeCoverage] +public class StubSitemapTopicRepository(ITopicRepository topicRepository) : ISitemapTopicRepository { + + /*============================================================================================================================ + | PRIVATE VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly ITopicRepository _topicRepository = Contract.Requires(topicRepository); + + /*============================================================================================================================ + | METHOD: LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + public async Task Load() { + var topic = await _topicRepository.Load(-1, depth: -1).ConfigureAwait(false); + Contract.Assume(topic, "The wrapped ITopicRepository did not return a topic graph."); + return topic; + } + +} //Class \ No newline at end of file diff --git a/OnTopic.TestDoubles/StubTopicRepository.cs b/OnTopic.TestDoubles/StubTopicRepository.cs index e7fe9482..9248f6dc 100644 --- a/OnTopic.TestDoubles/StubTopicRepository.cs +++ b/OnTopic.TestDoubles/StubTopicRepository.cs @@ -23,7 +23,7 @@ namespace OnTopic.TestDoubles; /// dependency on a live database or persistent data. /// [ExcludeFromCodeCoverage] -public class StubTopicRepository : TopicRepository, ITopicRepository { +public class StubTopicRepository : TopicRepository, ITopicRepository, ITopicLazyLoader { /*============================================================================================================================ | VARIABLES @@ -38,8 +38,8 @@ public class StubTopicRepository : TopicRepository, ITopicRepository { /// Instantiates a new instance of the StubTopicRepository. /// /// A new instance of the StubTopicRepository. - public StubTopicRepository() : base() { - _cache = CreateFakeData(); + public StubTopicRepository() { + _cache = CreateFakeData(); Contract.Assume(_cache); } @@ -47,62 +47,72 @@ public StubTopicRepository() : base() { | METHOD: LOAD \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override Topic? Load(int topicId, Topic? referenceTopic = null, bool isRecursive = true) { + public override Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Lookup by TopicId \-------------------------------------------------------------------------------------------------------------------------*/ - var topic = _cache; + var topic = _cache; if (topicId > 0) { - topic = _cache.FindFirst(t => t.Id.Equals(topicId)); + topic = _cache.FindFirst(t => t.Id.Equals(topicId)); } /*-------------------------------------------------------------------------------------------------------------------------- | Raise event \-------------------------------------------------------------------------------------------------------------------------*/ if (topic != null) { - OnTopicLoaded(new(topic, isRecursive)); + OnTopicLoaded(new(topic, depth)); } /*-------------------------------------------------------------------------------------------------------------------------- | Return value \-------------------------------------------------------------------------------------------------------------------------*/ - return topic; + return Task.FromResult(topic); } /// - public override Topic? Load(string uniqueKey, Topic? referenceTopic = null, bool isRecursive = true) { + public override Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters \-------------------------------------------------------------------------------------------------------------------------*/ if (String.IsNullOrEmpty(uniqueKey)) { - return null; + return Task.FromResult(null); } /*-------------------------------------------------------------------------------------------------------------------------- | Lookup by TopicKey \-------------------------------------------------------------------------------------------------------------------------*/ - var topic = _cache.GetByUniqueKey(uniqueKey); + var topic = _cache.GetByUniqueKey(uniqueKey); /*-------------------------------------------------------------------------------------------------------------------------- | Raise event \-------------------------------------------------------------------------------------------------------------------------*/ if (topic != null) { - OnTopicLoaded(new(topic, isRecursive)); + OnTopicLoaded(new(topic, depth)); } /*-------------------------------------------------------------------------------------------------------------------------- | Return topic \-------------------------------------------------------------------------------------------------------------------------*/ - return topic; + return Task.FromResult(topic); } /// - public override Topic? Load(int topicId, DateTime version, Topic? referenceTopic = null) { + public override Task Load(int topicId, DateTime version) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -116,17 +126,17 @@ public StubTopicRepository() : base() { /*-------------------------------------------------------------------------------------------------------------------------- | Lookup by TopicId \-------------------------------------------------------------------------------------------------------------------------*/ - var topic = _cache; + var topic = _cache; if (topicId > 0) { - topic = _cache.FindFirst(t => t.Id.Equals(topicId)); + topic = _cache.FindFirst(t => t.Id.Equals(topicId)); } /*-------------------------------------------------------------------------------------------------------------------------- | Raise event \-------------------------------------------------------------------------------------------------------------------------*/ if (topic != null) { - OnTopicLoaded(new(topic, false, version)); + OnTopicLoaded(new(topic, 0, version)); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -136,13 +146,13 @@ public StubTopicRepository() : base() { if (!topic.VersionHistory.Contains(version)) { topic.VersionHistory.Add(version); } - topic.LastModified = version; + topic.LastModified = version; } /*-------------------------------------------------------------------------------------------------------------------------- | Return objects \-------------------------------------------------------------------------------------------------------------------------*/ - return topic?? throw new TopicNotFoundException(topicId); + return Task.FromResult(topic ?? throw new TopicNotFoundException(topicId)); } @@ -150,34 +160,71 @@ public StubTopicRepository() : base() { | METHOD: REFRESH \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Refresh(Topic referenceTopic, DateTime since) { } + public override Task Refresh(Topic referenceTopic, DateTime since) => Task.CompletedTask; /*============================================================================================================================ | METHOD: SAVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - protected override void SaveTopic([NotNull]Topic topic, DateTime version, bool persistRelationships) { + protected override Task SaveTopic([NotNull]Topic topic, DateTime version, bool persistRelationships) { /*-------------------------------------------------------------------------------------------------------------------------- | Assign faux identity \-------------------------------------------------------------------------------------------------------------------------*/ if (topic.IsNew) { - topic.Id = _identity++; + topic.Id = _identity++; } + return Task.CompletedTask; + + } + + /*============================================================================================================================ + | METHODS: TOPIC LAZY LOADER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// + /// Stub topics always have their children fully populated in memory. For extended attributes, the boundary is promoted to + /// without merging real blob data, allowing tests to exercise the fill path without a live + /// database. + /// + public virtual Task EnsureLoaded(Topic topic, TopicPayload payload, CancellationToken cancellationToken = default) { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Validate parameters + \-------------------------------------------------------------------------------------------------------------------------*/ + Contract.Requires(topic); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Mark payload as loaded; stubs have all relationships and references pre-built in memory, so all targets are resident + | and marking Loaded is always safe. Children is already populated in the stubs and needs no action. + \-------------------------------------------------------------------------------------------------------------------------*/ + ((ITopicLazyLoadable)topic).SetLoadState(payload, LoadState.Loaded); + + // Relationships and References are computed from Deferred.Count; clear any test-seeded deferred entries to express Loaded + var rawTopic = (ITopicBackingAccessor)topic; + if (payload.HasFlag(TopicPayload.Relationships)) { + rawTopic.Relationships.Deferred.Clear(); + } + if (payload.HasFlag(TopicPayload.References)) { + rawTopic.References.Deferred.Clear(); + } + + return Task.CompletedTask; + } /*============================================================================================================================ | METHOD: MOVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - protected override void MoveTopic(Topic topic, Topic target, Topic? sibling = null) { } + protected override Task MoveTopic(Topic topic, Topic target, Topic? sibling = null) => Task.CompletedTask; /*============================================================================================================================ | METHOD: DELETE \---------------------------------------------------------------------------------------------------------------------------*/ /// - protected override void DeleteTopic(Topic topic) { } + protected override Task DeleteTopic(Topic topic) => Task.CompletedTask; /*============================================================================================================================ | METHOD: GET ATTRIBUTES (PROXY) @@ -186,8 +233,8 @@ protected override void DeleteTopic(Topic topic) { } public IEnumerable GetAttributesProxy( Topic topic, bool? isExtendedAttribute, - bool? isDirty = null, - bool excludeLastModified = false + bool? isDirty = null, + bool excludeLastModified = false ) => base.GetAttributes(topic, isExtendedAttribute, isDirty, excludeLastModified); /*============================================================================================================================ @@ -233,21 +280,21 @@ private static Topic CreateFakeData() { /*-------------------------------------------------------------------------------------------------------------------------- | Establish root \-------------------------------------------------------------------------------------------------------------------------*/ - var currentAttributeId = 800; - var rootTopic = new Topic("Root", "Container", null, currentAttributeId++); + var currentAttributeId = 800; + var rootTopic = new Topic("Root", "Container", null, currentAttributeId++); /*-------------------------------------------------------------------------------------------------------------------------- | Establish configuration \-------------------------------------------------------------------------------------------------------------------------*/ - var configuration = new Topic("Configuration", "Container", rootTopic, currentAttributeId++); - var contentTypes = new ContentTypeDescriptor("ContentTypes", "ContentTypeDescriptor", configuration, currentAttributeId++); + var configuration = new Topic("Configuration", "Container", rootTopic, currentAttributeId++); + var contentTypes = new ContentTypeDescriptor("ContentTypes", "ContentTypeDescriptor", configuration, currentAttributeId++); addAttribute(contentTypes, "Key", "TextAttributeDescriptor", false, true); addAttribute(contentTypes, "ContentType", "TextAttributeDescriptor", false, true); addAttribute(contentTypes, "Title", "TextAttributeDescriptor", true, true); addAttribute(contentTypes, "BaseTopic", "TopicReferenceAttributeDescriptor", false); - var contentTypeDescriptor = new ContentTypeDescriptor("ContentTypeDescriptor", "ContentTypeDescriptor", contentTypes, currentAttributeId++); + var contentTypeDescriptor = new ContentTypeDescriptor("ContentTypeDescriptor", "ContentTypeDescriptor", contentTypes, currentAttributeId++); addAttribute(contentTypeDescriptor, "ContentTypes", "RelationshipAttributeDescriptor"); addAttribute(contentTypeDescriptor, "Attributes", "NestedTopicListAttributeDescriptor"); @@ -257,7 +304,7 @@ private static Topic CreateFakeData() { TopicFactory.Create("LookupListItem", "ContentTypeDescriptor", contentTypes); TopicFactory.Create("List", "ContentTypeDescriptor", contentTypes); - var attributeDescriptor = new ContentTypeDescriptor("AttributeDescriptor", "ContentTypeDescriptor", contentTypes, currentAttributeId++); + var attributeDescriptor = new ContentTypeDescriptor("AttributeDescriptor", "ContentTypeDescriptor", contentTypes, currentAttributeId++); addAttribute(attributeDescriptor, "DefaultValue", "TextAttributeDescriptor", false, true); addAttribute(attributeDescriptor, "IsRequired", "TextAttributeDescriptor", false, true); @@ -269,14 +316,14 @@ private static Topic CreateFakeData() { TopicFactory.Create("TextAttributeDescriptor", "ContentTypeDescriptor", attributeDescriptor); TopicFactory.Create("TopicReferenceAttributeDescriptor", "ContentTypeDescriptor", attributeDescriptor); - var pageContentType = new ContentTypeDescriptor("Page", "ContentTypeDescriptor", contentTypes, currentAttributeId++); + var pageContentType = new ContentTypeDescriptor("Page", "ContentTypeDescriptor", contentTypes, currentAttributeId++); addAttribute(pageContentType, "MetaTitle"); addAttribute(pageContentType, "MetaDescription"); addAttribute(pageContentType, "IsHidden", "TextAttributeDescriptor", false); addAttribute(pageContentType, "TopicReference", "TopicReferenceAttributeDescriptor", false); - var contactContentType = new ContentTypeDescriptor("Contact", "ContentTypeDescriptor", contentTypes, currentAttributeId++); + var contactContentType = new ContentTypeDescriptor("Contact", "ContentTypeDescriptor", contentTypes, currentAttributeId++); addAttribute(contactContentType, "Name", isExtended: false); addAttribute(contactContentType, "AlternateEmail", isExtended: false); @@ -314,14 +361,14 @@ AttributeDescriptor addAttribute( var categories = new Topic("Categories", "Lookup", metadata, currentAttributeId++); var lookup = new Topic("LookupList", "List", categories, currentAttributeId++); - for (var i=1; i<=5; i++) { - _ = new Topic("Category" + i, "LookupListItem", lookup); + for (var i =1; i<=5; i++) { + _ = new Topic("Category" + i, "LookupListItem", lookup); } /*-------------------------------------------------------------------------------------------------------------------------- | Establish content \-------------------------------------------------------------------------------------------------------------------------*/ - var web = TopicFactory.Create("Web", "Page", rootTopic, 10000); + var web = TopicFactory.Create("Web", "Page", rootTopic, 10000); CreateFakeData(web, 2, 3); @@ -346,8 +393,8 @@ AttributeDescriptor addAttribute( /// Creates a collection of fake data recursively based on a parent topic, and set number of levels. /// private static void CreateFakeData(Topic parent, int count = 3, int depth = 3) { - for (var i = 0; i < count; i++) { - var topic = new Topic(parent.Key + "_" + i, "Page", parent, parent.Id + (int)Math.Pow(10, depth) * i); + for (var i = 1; i <= count; i++) { + var topic = new Topic(parent.Key + "_" + i, "Page", parent, parent.Id + (int)Math.Pow(10, depth) * i); topic.Attributes.SetValue("ParentKey", parent.Key); topic.Attributes.SetValue("DepthCount", (depth+i).ToString(CultureInfo.InvariantCulture)); if (depth > 0) { diff --git a/OnTopic.Tests/AttributeCollectionTest.cs b/OnTopic.Tests/AttributeCollectionTest.cs index b89423b0..082c6e1d 100644 --- a/OnTopic.Tests/AttributeCollectionTest.cs +++ b/OnTopic.Tests/AttributeCollectionTest.cs @@ -6,7 +6,10 @@ using System.Collections; using System.Globalization; using OnTopic.Collections.Specialized; +using OnTopic.Repositories; using OnTopic.Tests.Entities; +using OnTopic.Tests.TestDoubles; +using OnTopic.TestDoubles.LazyLoading; using Xunit; namespace OnTopic.Tests; @@ -67,7 +70,7 @@ public void GetValue_InheritedValue_IsReturned() { [Fact] public void GetValue_MissingValue_ReturnsDefault() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); Assert.Null(topic.Attributes.GetValue("InvalidAttribute")); Assert.Equal("Foo", topic.Attributes.GetValue("InvalidAttribute", "Foo")); @@ -84,7 +87,7 @@ public void GetValue_MissingValue_ReturnsDefault() { [Fact] public void GetValue_EmptyValue_ReturnsNull() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.Add(new("EmptyValue", "")); @@ -92,6 +95,55 @@ public void GetValue_EmptyValue_ReturnsNull() { } + + /*============================================================================================================================ + | TEST: GET VALUE: NOT LOADED: KEY ABSENT: TRIGGERS LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a topic stamped with a and a that is + /// . Confirms that a raw call, which defaults autoLoad to + /// true, still triggers a lazy load for a key that isn't present locally. This guards against over-suppression of + /// the autoload behavior. + /// + [Fact] + public void GetValue_NotLoaded_KeyAbsent_TriggersLoad() { + + var topic = new Topic("Test", "Container"); + var loader = new TrackingTopicLazyLoader(); + + ((ITopicLazyLoadable)topic).Loader = loader; + topic.Attributes.LoadState = LoadState.NotLoaded; + + topic.Attributes.GetValue("Missing"); + + Assert.True(loader.WasCalled); + + } + + /*============================================================================================================================ + | TEST: GET VALUE: LOADED: KEY ABSENT: DOES NOT TRIGGER LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a topic stamped with a whose is already + /// . Confirms that requesting an absent key never triggers a lazy load, preserving existing + /// behavior on fully loaded collections. + /// + [Fact] + public void GetValue_Loaded_KeyAbsent_DoesNotTriggerLoad() { + + var topic = new Topic("Test", "Container"); + var loader = new TrackingTopicLazyLoader(); + + ((ITopicLazyLoadable)topic).Loader = loader; + + topic.Attributes.GetValue("Missing"); + + Assert.False(loader.WasCalled); + + } + + /*============================================================================================================================ | TEST: GET INTEGER: CORRECT VALUE: IS RETURNED \---------------------------------------------------------------------------------------------------------------------------*/ @@ -101,7 +153,7 @@ public void GetValue_EmptyValue_ReturnsNull() { [Fact] public void GetInteger_CorrectValue_IsReturned() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetInteger("Number1", 1); @@ -142,7 +194,7 @@ public void GetInteger_InheritedValue_IsReturned() { [Fact] public void GetInteger_IncorrectValue_ReturnsDefault() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Number3", "Invalid"); @@ -160,7 +212,7 @@ public void GetInteger_IncorrectValue_ReturnsDefault() { [Fact] public void GetInteger_IncorrectKey_ReturnsDefault() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); Assert.Equal(0, topic.Attributes.GetInteger("InvalidKey")); Assert.Equal(5, topic.Attributes.GetInteger("InvalidKey", 5)); @@ -176,7 +228,7 @@ public void GetInteger_IncorrectKey_ReturnsDefault() { [Fact] public void GetDouble_CorrectValue_IsReturned() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetDouble("Number1", 1); @@ -217,7 +269,7 @@ public void GetDouble_InheritedValue_IsReturned() { [Fact] public void GetDouble_IncorrectValue_ReturnsDefault() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Number3", "Invalid"); @@ -235,7 +287,7 @@ public void GetDouble_IncorrectValue_ReturnsDefault() { [Fact] public void GetDouble_IncorrectKey_ReturnsDefault() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); Assert.Equal(0.0, topic.Attributes.GetDouble("InvalidKey")); Assert.Equal(5.0, topic.Attributes.GetDouble("InvalidKey", 5.0)); @@ -333,7 +385,7 @@ public void GetDateTime_IncorrectKey_ReturnsDefault() { [Fact] public void GetBoolean_CorrectValue_IsReturned() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetBoolean("IsValue1", true); topic.Attributes.SetBoolean("IsValue2", false); @@ -377,7 +429,7 @@ public void GetBoolean_InheritedValue_IsReturned() { [Fact] public void GetBoolean_IncorrectValue_ReturnDefault() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("IsValue", "Invalid"); @@ -396,7 +448,7 @@ public void GetBoolean_IncorrectValue_ReturnDefault() { [Fact] public void GetBoolean_IncorrectKey_ReturnDefault() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); Assert.False(topic.Attributes.GetBoolean("InvalidKey")); Assert.True(topic.Attributes.GetBoolean("InvalidKey", true)); @@ -404,6 +456,89 @@ public void GetBoolean_IncorrectKey_ReturnDefault() { } + /*============================================================================================================================ +| TEST: GET BOOLEAN: NOT LOADED: KEY ABSENT: SUPPRESSES AUTO LOAD +\---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a topic and a , both stamped with a and both + /// with a . Confirms that —used only for + /// always-indexed attributes—suppresses the autoload on both the topic and its base topic. + /// + [Fact] + public void GetBoolean_NotLoaded_KeyAbsent_SuppressesAutoLoad() { + + var baseTopic = new Topic("Base", "Container"); + var topic = new Topic("Test", "Container"); + var loader = new TrackingTopicLazyLoader(); + var baseLoader = new TrackingTopicLazyLoader(); + + topic.BaseTopic = baseTopic; + + ((ITopicLazyLoadable)topic).Loader = loader; + ((ITopicLazyLoadable)baseTopic).Loader = baseLoader; + + topic.Attributes.LoadState = LoadState.NotLoaded; + baseTopic.Attributes.LoadState = LoadState.NotLoaded; + + topic.Attributes.GetBoolean("Missing"); + + Assert.False(loader.WasCalled); + Assert.False(baseLoader.WasCalled); + + } + + /*============================================================================================================================ + | TEST: IS VISIBLE: NOT LOADED EXTENDED ATTRIBUTES TOPIC: PERFORMS ZERO FILLS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls on a topic with pending, not-yet-loaded extended attributes, and confirms + /// it performs zero fills: and are indexed attributes and must + /// never trigger the extended attributes to be fetched merely to determine visibility. + /// + [Fact] + public async Task IsVisible_NotLoadedExtendedAttributesTopic_PerformsZeroFills() { + + var records = new StubLazyLoadingTopicRepositoryBuilder() + .AddTopic(201, "Sparse", "Page", null, extendedAttributes: new Dictionary { ["Summary"] = "Some text." }) + .Build(); + + var stub = new StubLazyLoadingTopicRepository(records); + var topic = await stub.Load("Root:Sparse"); + + Assert.True(topic!.IsVisible()); + Assert.Equal(0, stub.GetFetchCount(201, TopicPayload.ExtendedAttributes)); + + } + + /*============================================================================================================================ + | TEST: IS HIDDEN: RESIDENT BASE TOPIC: INHERITS VALUE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Resolves a topic's reference against an already-resident base topic, and confirms is honored through the base chain once the reference is no longer deferred. + /// + [Fact] + public async Task IsHidden_ResidentBaseTopic_InheritsValue() { + + var records = new StubLazyLoadingTopicRepositoryBuilder() + .AddTopic(211, "Base", "Page", null, indexedAttributes: new Dictionary { ["IsHidden"] = "1" }) + .AddTopic(212, "Derived", "Page", null) + .AddReference(212, "BaseTopic", 211) + .Build(); + + var stub = new StubLazyLoadingTopicRepository(records); + + await stub.Load("Root:Base"); + var derived = await stub.Load("Root:Derived", null, TopicPayload.References); + var rawDerived = (ITopicBackingAccessor)derived!; + + Assert.Empty(rawDerived.References.Deferred); + Assert.True(derived.IsHidden); + Assert.Equal(0, stub.GetFetchCount(211, TopicPayload.ExtendedAttributes)); + + } + /*============================================================================================================================ | TEST: GET URI: INHERITED VALUE: IS RETURNED \---------------------------------------------------------------------------------------------------------------------------*/ @@ -456,7 +591,7 @@ public void GetUri_IncorrectValue_ReturnDefault() { /// [Fact] public void SetValue_CorrectValue_IsReturned() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Foo", "Bar"); Assert.Equal("Bar", topic.Attributes.GetValue("Foo")); } @@ -470,7 +605,7 @@ public void SetValue_CorrectValue_IsReturned() { [Fact] public void SetValue_ValueChanged_IsDirty() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Foo", "Bar", false); topic.Attributes.SetValue("Foo", "Baz"); @@ -508,7 +643,7 @@ public void Clear_NonNullableValueWithBusinessLogic_ThrowsException() { [Fact] public void Clear_ExistingValues_IsDirty() { - var topic = new Topic("Test", "Container", null, 1); + var topic = new Topic("Test", "Container", null, 1); topic.Attributes.SetValue("Foo", "Bar", false); @@ -529,7 +664,7 @@ public void Clear_ExistingValues_IsDirty() { [Fact] public void SetValue_ValueUnchanged_IsNotDirty() { - var topic = new Topic("Test", "Container", null, 1); + var topic = new Topic("Test", "Container", null, 1); topic.Attributes.SetValue("Fah", "Bar", false); topic.Attributes.SetValue("Fah", "Bar"); @@ -542,13 +677,13 @@ public void SetValue_ValueUnchanged_IsNotDirty() { | TEST: IS DIRTY: DIRTY VALUES: RETURNS TRUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Populates the with a that is marked as . Confirms that returns true. + /// Populates the with a that is marked as . Confirms that returns true. /// [Fact] public void IsDirty_DirtyValues_ReturnsTrue() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Foo", "Bar"); @@ -568,7 +703,7 @@ public void IsDirty_DirtyValues_ReturnsTrue() { [Fact] public void IsDirty_IsNew_ReturnsTrue() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Foo", "Bar", false); @@ -590,7 +725,7 @@ public void IsDirty_IsNew_ReturnsTrue() { [Fact] public void IsDirty_DeletedValues_ReturnsTrue() { - var topic = new Topic("Test", "Container", null, 1); + var topic = new Topic("Test", "Container", null, 1); topic.Attributes.SetValue("Foo", "Bar"); topic.Attributes.Remove("Foo"); @@ -610,7 +745,7 @@ public void IsDirty_DeletedValues_ReturnsTrue() { [Fact] public void IsDirty_UndeletedValues_ReturnsFalse() { - var topic = new Topic("Test", "Container", null, 1); + var topic = new Topic("Test", "Container", null, 1); topic.Attributes.SetValue("Foo", "Bar"); topic.Attributes.Remove("Foo"); @@ -633,7 +768,7 @@ public void IsDirty_UndeletedValues_ReturnsFalse() { [Fact] public void IsDirty_NoDirtyValues_ReturnsFalse() { - var topic = new Topic("Test", "Container", null, 1); + var topic = new Topic("Test", "Container", null, 1); topic.Attributes.SetValue("Foo", "Bar", false); @@ -652,7 +787,7 @@ public void IsDirty_NoDirtyValues_ReturnsFalse() { [Fact] public void IsDirty_IsNew_ReturnsFalse() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Foo", "Bar", false); @@ -670,7 +805,7 @@ public void IsDirty_IsNew_ReturnsFalse() { [Fact] public void IsDirty_MissingKey_ReturnsFalse() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); Assert.False(topic.Attributes.IsDirty("MissingKey")); @@ -688,7 +823,7 @@ public void IsDirty_MissingKey_ReturnsFalse() { [Fact] public void IsDirty_ExcludeLastModified_ReturnsFalse() { - var topic = new Topic("Test", "Container", null, 1); + var topic = new Topic("Test", "Container", null, 1); topic.Attributes.SetValue("Foo", "Bar", false); topic.Attributes.SetValue("LastModified", DateTime.Now.ToString(CultureInfo.InvariantCulture)); @@ -704,8 +839,8 @@ public void IsDirty_ExcludeLastModified_ReturnsFalse() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Populates the with a and then deletes it. Confirms - /// that the returns the new version after calling . + /// that the returns the new version after calling . /// [Fact] public void IsDirty_MarkClean_UpdatesLastModified() { @@ -737,13 +872,13 @@ public void IsDirty_MarkClean_UpdatesLastModified() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Populates the with a and then deletes it. Confirms - /// that returns false after calling . + /// that returns false after calling . /// [Fact] public void IsDirty_MarkClean_ReturnsFalse() { - var topic = new Topic("Test", "Container", null, 1); + var topic = new Topic("Test", "Container", null, 1); topic.Attributes.SetValue("Foo", "Bar"); topic.Attributes.SetValue("Baz", "Foo"); @@ -769,7 +904,7 @@ public void IsDirty_MarkClean_ReturnsFalse() { [Fact] public void IsDirty_MarkClean_ReturnsTrue() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Foo", "Bar"); @@ -790,7 +925,7 @@ public void IsDirty_MarkClean_ReturnsTrue() { [Fact] public void IsDirty_MarkAttributeClean_ReturnsFalse() { - var topic = new Topic("Test", "Container", null, 1); + var topic = new Topic("Test", "Container", null, 1); topic.Attributes.SetValue("Foo", "Bar"); topic.Attributes.MarkClean("Foo"); @@ -810,7 +945,7 @@ public void IsDirty_MarkAttributeClean_ReturnsFalse() { [Fact] public void IsDirty_AddCleanAttributeToNewTopic_ReturnsTrue() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.Add( new() { @@ -836,7 +971,7 @@ public void IsDirty_AddCleanAttributeToNewTopic_ReturnsTrue() { [Fact] public void IsDirty_MarkNewTopicAsClean_ReturnsTrue() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Foo", "Bar"); topic.Attributes.MarkClean(); @@ -854,7 +989,7 @@ public void IsDirty_MarkNewTopicAsClean_ReturnsTrue() { [Fact] public void SetValue_InvalidValue_ThrowsException() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); Assert.Throws(() => topic.Attributes.SetValue("View", "# ?") @@ -872,7 +1007,7 @@ public void SetValue_InvalidValue_ThrowsException() { [Fact] public void SetValue_DuplicateValue_ThrowsException() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.Add(new("Test", "Original")); @@ -892,7 +1027,7 @@ public void SetValue_DuplicateValue_ThrowsException() { [Fact] public void Add_ValidAttributeRecord_IsReturned() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.Add(new("View", "NewKey", false)); @@ -910,7 +1045,7 @@ public void Add_ValidAttributeRecord_IsReturned() { [Fact] public void Add_NumericValueWithBusinessLogic_IsReturned() { - var topic = new CustomTopic("Test", "Page"); + var topic = new CustomTopic("Test", "Page"); topic.Attributes.SetInteger("NumericAttribute", 1); @@ -928,7 +1063,7 @@ public void Add_NumericValueWithBusinessLogic_IsReturned() { [Fact] public void Add_BooleanValueWithBusinessLogic_IsReturned() { - var topic = new CustomTopic("Test", "Page"); + var topic = new CustomTopic("Test", "Page"); topic.Attributes.SetBoolean("BooleanAttribute", true); @@ -945,7 +1080,7 @@ public void Add_BooleanValueWithBusinessLogic_IsReturned() { [Fact] public void Add_NumericValueWithBusinessLogic_ThrowsException() { - var topic = new CustomTopic("Test", "Page"); + var topic = new CustomTopic("Test", "Page"); Assert.Throws(() => topic.Attributes.SetInteger("NumericAttribute", -1) @@ -963,8 +1098,8 @@ public void Add_NumericValueWithBusinessLogic_ThrowsException() { [Fact] public void Add_DateTimeValueWithBusinessLogic_IsReturned() { - var topic = new CustomTopic("Test", "Page"); - var dateTime = new DateTime(2021, 1, 5); + var topic = new CustomTopic("Test", "Page"); + var dateTime = new DateTime(2021, 1, 5); topic.Attributes.SetDateTime("DateTimeAttribute", dateTime); @@ -981,7 +1116,7 @@ public void Add_DateTimeValueWithBusinessLogic_IsReturned() { [Fact] public void Add_DateTimeValueWithBusinessLogic_ThrowsException() { - var topic = new CustomTopic("Test", "Page"); + var topic = new CustomTopic("Test", "Page"); Assert.Throws(() => topic.Attributes.SetDateTime("DateTimeAttribute", DateTime.MinValue) @@ -1018,7 +1153,7 @@ public void AttributeRecord_LastModified_DefaultValue() { [Fact] public void Add_InvalidAttributeRecord_ThrowsException() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); Assert.Throws(() => topic.Attributes.Add(new("View", "# ?")) @@ -1045,9 +1180,9 @@ public void Add_WithBusinessLogic_MaintainsIsDirty() { Contract.Assume(originalValue); - var index = topic.Attributes.IndexOf(originalValue); + var index = topic.Attributes.IndexOf(originalValue); - topic.Attributes[index] = new AttributeRecord("View", "NewValue", false); + topic.Attributes[index] = new AttributeRecord("View", "NewValue", false); topic.Attributes.TryGetValue("View", out var newAttribute); topic.Attributes.SetValue("View", "NewerValue", false); @@ -1069,7 +1204,7 @@ public void Add_WithBusinessLogic_MaintainsIsDirty() { [Fact] public void SetValue_EmptyAttributeRecord_Skips() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Attribute", ""); @@ -1088,7 +1223,7 @@ public void SetValue_EmptyAttributeRecord_Skips() { [Fact] public void SetValue_EmptyAttributeRecord_Replaces() { - var topic = new Topic("Test", "Container"); + var topic = new Topic("Test", "Container"); topic.Attributes.SetValue("Attribute", "New Value"); topic.Attributes.SetValue("Attribute", ""); @@ -1106,12 +1241,12 @@ public void SetValue_EmptyAttributeRecord_Replaces() { [Fact] public void GetValue_InheritFromParent_ReturnsParentValue() { - var topics = new Topic[8]; + var topics = new Topic[8]; - for (var i = 0; i <= 7; i++) { - var topic = new Topic("Topic" + i, "Container"); - if (i > 0) topic.Parent = topics[i - 1]; - topics[i] = topic; + for (var i = 0; i <= 7; i++) { + var topic = new Topic("Topic" + i, "Container"); + if (i > 0) topic.Parent = topics[i - 1]; + topics[i] = topic; } topics[0].Attributes.SetValue("Foo", "Bar"); @@ -1131,12 +1266,12 @@ public void GetValue_InheritFromParent_ReturnsParentValue() { [Fact] public void GetValue_InheritFromBase_ReturnsInheritedValue() { - var topics = new Topic[5]; + var topics = new Topic[5]; - for (var i = 0; i <= 4; i++) { - var topic = new Topic("Topic" + i, "Container"); + for (var i = 0; i <= 4; i++) { + var topic = new Topic("Topic" + i, "Container"); if (i > 0) topics[i - 1].BaseTopic = topic; - topics[i] = topic; + topics[i] = topic; } topics[4].Attributes.SetValue("Foo", "Bar"); @@ -1154,12 +1289,12 @@ public void GetValue_InheritFromBase_ReturnsInheritedValue() { [Fact] public void GetValue_ExceedsMaxHops_ReturnsDefault() { - var topics = new Topic[8]; + var topics = new Topic[8]; - for (var i = 0; i <= 7; i++) { - var topic = new Topic("Topic" + i, "Container"); + for (var i = 0; i <= 7; i++) { + var topic = new Topic("Topic" + i, "Container"); if (i > 0) topics[i - 1].BaseTopic = topic; - topics[i] = topic; + topics[i] = topic; } topics[7].Attributes.SetValue("Foo", "Bar"); diff --git a/OnTopic.Tests/BindingModels/BasicTopicBindingModel.cs b/OnTopic.Tests/BindingModels/BasicTopicBindingModel.cs index 03d94e48..75e1a95c 100644 --- a/OnTopic.Tests/BindingModels/BasicTopicBindingModel.cs +++ b/OnTopic.Tests/BindingModels/BasicTopicBindingModel.cs @@ -22,8 +22,8 @@ public class BasicTopicBindingModel : ITopicBindingModel { public BasicTopicBindingModel() { } public BasicTopicBindingModel(string key, string contentType) { - Key = key; - ContentType = contentType; + Key = key; + ContentType = contentType; } [Required] diff --git a/OnTopic.Tests/BindingModels/InvalidNestedTopicListTypeTopicBindingModel.cs b/OnTopic.Tests/BindingModels/InvalidNestedTopicListTypeTopicBindingModel.cs index 4f6c2660..b8ddc37e 100644 --- a/OnTopic.Tests/BindingModels/InvalidNestedTopicListTypeTopicBindingModel.cs +++ b/OnTopic.Tests/BindingModels/InvalidNestedTopicListTypeTopicBindingModel.cs @@ -12,8 +12,8 @@ namespace OnTopic.Tests.BindingModels; \-----------------------------------------------------------------------------------------------------------------------------*/ /// /// Provides a custom binding model with an invalid collection type—i.e., it implements a , even though nested topics are expected to return a type implementing . An should be thrown when it is mapped. +/// TValue}"/>, even though nested topics are expected to return a type implementing . An should be thrown when it is mapped. /// /// /// This is a sample class intended for test purposes only; it is not designed for use in a production environment. diff --git a/OnTopic.Tests/BindingModels/InvalidReferenceTypeTopicBindingModel.cs b/OnTopic.Tests/BindingModels/InvalidReferenceTypeTopicBindingModel.cs index 725bb867..392b92fc 100644 --- a/OnTopic.Tests/BindingModels/InvalidReferenceTypeTopicBindingModel.cs +++ b/OnTopic.Tests/BindingModels/InvalidReferenceTypeTopicBindingModel.cs @@ -12,8 +12,8 @@ namespace OnTopic.Tests.BindingModels; | BINDING MODEL: REFERENCE TYPE TOPIC (INVALID) \-----------------------------------------------------------------------------------------------------------------------------*/ /// -/// Provides a custom binding model with an invalid reference type—i.e., one that doesn't implement . An should be thrown when it is mapped. +/// Provides a custom binding model with an invalid reference type—i.e., one that doesn't implement . An should be thrown when it is mapped. /// /// /// This is a sample class intended for test purposes only; it is not designed for use in a production environment. diff --git a/OnTopic.Tests/BindingModels/InvalidRelationshipTypeTopicBindingModel.cs b/OnTopic.Tests/BindingModels/InvalidRelationshipTypeTopicBindingModel.cs index 2b414c8c..8ee6b253 100644 --- a/OnTopic.Tests/BindingModels/InvalidRelationshipTypeTopicBindingModel.cs +++ b/OnTopic.Tests/BindingModels/InvalidRelationshipTypeTopicBindingModel.cs @@ -11,8 +11,8 @@ namespace OnTopic.Tests.BindingModels; | BINDING MODEL: RELATIONSHIP TYPE TOPIC (INVALID) \-----------------------------------------------------------------------------------------------------------------------------*/ /// -/// Provides a custom binding model with an invalid —i.e., it refers to , even though the property is associated with a . +/// Provides a custom binding model with an invalid —i.e., it refers to , even though the property is associated with a . /// An should be thrown when it is mapped. /// /// diff --git a/OnTopic.Tests/BindingModels/NestedReferenceAttributeTopicBindingModel.cs b/OnTopic.Tests/BindingModels/NestedReferenceAttributeTopicBindingModel.cs new file mode 100644 index 00000000..9ce23ae0 --- /dev/null +++ b/OnTopic.Tests/BindingModels/NestedReferenceAttributeTopicBindingModel.cs @@ -0,0 +1,26 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.ViewModels.BindingModels; + +namespace OnTopic.Tests.BindingModels; + +/*============================================================================================================================== +| BINDING MODEL: NESTED REFERENCE ATTRIBUTE TOPIC +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a minimal implementation of a custom topic binding model with both a scalar value and a reference property, for +/// use as an item within a collection. +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +public class NestedReferenceAttributeTopicBindingModel : AttributeDescriptorTopicBindingModel { + + public NestedReferenceAttributeTopicBindingModel(string key) : base(key, "TextAttributeDescriptor") { } + + public AssociatedTopicBindingModel? BaseTopic { get; set; } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/CachedTopicRepositoryTest.cs b/OnTopic.Tests/CachedTopicRepositoryTest.cs new file mode 100644 index 00000000..82c14743 --- /dev/null +++ b/OnTopic.Tests/CachedTopicRepositoryTest.cs @@ -0,0 +1,535 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using System.Data; +using OnTopic.Collections; +using OnTopic.Collections.Specialized; +using OnTopic.Data.Caching; +using OnTopic.Data.Sql; +using OnTopic.Metadata; +using OnTopic.Querying; +using OnTopic.Repositories; +using OnTopic.TestDoubles.LazyLoading; +using OnTopic.Tests.TestDoubles; +using Xunit; + +namespace OnTopic.Tests; + +/*============================================================================================================================== +| CLASS: CACHED TOPIC REPOSITORY TESTS +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides unit tests for the class. +/// +/// +/// These tests drive a , which is a minimal inner that +/// calls the real, production against in-memory +/// rows, exactly as does against a live SQL data reader, rather than , which deliberately ignores referenceTopic, and thus cannot distinguish a referenceTopic from _cache: The very two defects the referenceTopic ?? _cache +/// fallback resolves are intrinsic to 's referenceTopic-seeded +/// working index, which only exercises. +/// +[ExcludeFromCodeCoverage] +public class CachedTopicRepositoryTest { + + /*============================================================================================================================ + | PROPERTY: CANCELLATION TOKEN + \---------------------------------------------------------------------------------------------------------------------------*/ + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + /*============================================================================================================================ + | TEST: LOAD: COLD MISS WITH RESIDENT PARENT: ATTACHES TO RESIDENT INSTANCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic ("Web") non-recursively, leaving , then + /// loads a direct child ("Web_0") by ID, confirming the child attaches to the existing "Web" instance, populated via its + /// , with a matching , rather than dangling off a disconnected + /// duplicate. + /// + [Fact] + public async Task Load_ColdMissWithResidentParent_AttachesToResidentInstance() { + + var inner = new FakeSqlTopicRepository() + .AddTopic(1, "Root", "Container", null) + .AddTopic(2, "Web", "Page", 1) + .AddTopic(3, "Web_0", "Page", 2); + + var cache = new CachedTopicRepository(inner); + var root = await cache.Load("Root"); + var web = await cache.Load("Web"); + + Assert.False(((ITopicLazyLoadable)web!).IsLoaded(TopicPayload.Children)); + + var web0 = await cache.Load(3); + + Assert.NotNull(web0); + Assert.Same(web, web0.Parent); + Assert.Contains(web!.Children, child => child.Id == web0.Id); + Assert.Same(root, web0.GetRootTopic()); + + } + + /*============================================================================================================================ + | TEST: LOAD: COLD MISS TWO LEVELS BELOW RESIDENT ANCESTOR: ATTACHES WHOLE CHAIN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Cold-loads a topic ("Web_0_0") two levels below the deepest resident ancestor ("Web") and confirms the entire new + /// intermediate chain ("Web_0") attaches under the resident ancestor rather than duplicating it. + /// + [Fact] + public async Task Load_ColdMissTwoLevelsBelowResidentAncestor_AttachesWholeChain() { + + var inner = new FakeSqlTopicRepository() + .AddTopic(1, "Root", "Container", null) + .AddTopic(2, "Web", "Page", 1) + .AddTopic(3, "Web_0", "Page", 2) + .AddTopic(4, "Web_0_0", "Page", 3); + + var cache = new CachedTopicRepository(inner); + var root = await cache.Load("Root"); + var web = await cache.Load("Web"); + + var web00 = await cache.Load(4); + + Assert.NotNull(web00); + Assert.Equal("Web_0", web00.Parent?.Key); + Assert.Same(web, web00.Parent?.Parent); + Assert.Contains(web!.Children, child => child.Key == "Web_0"); + Assert.Same(root, web00.GetRootTopic()); + + } + + /*============================================================================================================================ + | TEST: LOAD: COLD MISS TWO LEVELS BELOW RESIDENT ANCESTOR: INDEXES INTERMEDIATE FOR KEY HIT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Cold-loads a topic ("Web_0_0") two levels below the deepest loaded ancestor ("Web"), pulling the intermediate ("Web_0") + /// into the graph, then requests that intermediate by its unique key and confirms it resolves from the cache's flat key + /// index as a pure hit, with no fall-through to the inner repository. This exercises the ancestor crawl implemented in , which indexes ascendants that the downward-only cannot reach. + /// + [Fact] + public async Task Load_ColdMissTwoLevelsBelowResidentAncestor_IndexesIntermediateForKeyHit() { + + var inner = new FakeSqlTopicRepository() + .AddTopic(1, "Root", "Container", null) + .AddTopic(2, "Web", "Page", 1) + .AddTopic(3, "Web_0", "Page", 2) + .AddTopic(4, "Web_0_0", "Page", 3); + + var cache = new CachedTopicRepository(inner); + await cache.Load("Web"); + + // Cold-load the grandchild, pulling the not-yet-loaded intermediate "Web_0" in as an ancestor + var leaf = await cache.Load(4); + var intermediate = leaf!.Parent; + + Assert.Equal("Web_0", intermediate?.Key); + + // Count loads against the inner repository; an index hit for the intermediate makes no such round-trip + var innerLoads = 0; + inner.TopicLoaded += (_, _) => innerLoads++; + + var resolved = await cache.Load("Root:Web:Web_0"); + + Assert.Same(intermediate, resolved); + Assert.Equal(0, innerLoads); + + } + + /*============================================================================================================================ + | TEST: LOAD: AFTER ATTACHED-BUT-UNINDEXED SUBTREE: RETURNS ATTACHED INSTANCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Simulates the shape produced by , which attaches new topics to an + /// existing parent without raising (and thus without indexing them), then calls + /// for the leaf and confirms the cache returns + /// the existing attached instances rather than duplicating them. The merge-aware underlying load reuses the loaded topics + /// via the reference graph, and indexes both the leaf + /// and any previously unindexed intermediate ancestor ("Web_0") by walking up the parent chain. + /// + [Fact] + public async Task Load_AfterAttachedButUnindexedSubtree_ReturnsAttachedInstance() { + + var inner = new FakeSqlTopicRepository() + .AddTopic(1, "Root", "Container", null) + .AddTopic(2, "Web", "Page", 1) + .AddTopic(3, "Web_0", "Page", 2) + .AddTopic(4, "Web_0_0", "Page", 3); + + var cache = new CachedTopicRepository(inner); + var web = await cache.Load("Web"); + + // Attach a two-level subtree directly, bypassing Load()/TopicLoaded—mirroring Refresh()'s attach-without-index shape + var newWeb0 = new Topic("Web_0", "Page", web, 3); + var newWeb00 = new Topic("Web_0_0", "Page", newWeb0, 4); + + var loaded = await cache.Load(4); + + Assert.Same(newWeb00, loaded); + Assert.Same(newWeb0, loaded?.Parent); + Assert.Same(web, loaded?.Parent?.Parent); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: DEFERRED ASSOCIATION FALLBACK WITH RESIDENT TARGET PARENT: ATTACHES RESOLVED TARGET + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Confirms that a deferred-association fallback ( + /// calling Load(targetId) with default, parameters) resolves a target whose direct parent + /// is already resident—the same shape as Defect 1—so the resolved instance attaches to the live cache graph rather than + /// arriving dangling. + /// + [Fact] + public async Task TaskEnsureLoaded_DeferredAssociationFallbackWithResidentTargetParent_AttachesToCacheGraph() { + + var inner = new FakeSqlTopicRepository() + .AddTopic(1, "Root", "Container", null) + .AddTopic(2, "Web", "Page", 1) + .AddTopic(3, "Web_0", "Page", 2) + .AddTopic(4, "Web_0_0", "Page", 3) + .AddTopic(5, "Web_1", "Page", 2); + + inner.AddRelationship(5, "Related", 4); + + var cache = new CachedTopicRepository(inner); + var root = await cache.Load("Root"); + + // Establish "Web_0" (id 3) as resident—the direct parent of the deferred target + var web0 = await cache.Load("Web:Web_0"); + + var web1 = await cache.Load("Web:Web_1"); + var rawWeb1 = (ITopicBackingAccessor)web1!; + + Assert.NotEmpty(rawWeb1.Relationships.Deferred); + + await ((ITopicLazyLoadable)web1!).EnsureLoaded(TopicPayload.Relationships, cancellationToken: CancellationToken); + + var related = web1.Relationships.GetValues("Related").Single(); + + Assert.Same(web0, related.Parent); + Assert.Contains(web0!.Children, child => child.Id == related.Id); + Assert.Same(root, related.GetRootTopic()); + + } + + /*============================================================================================================================ + | TEST: LOAD: CONCURRENT DEPTH TOP-UP REQUESTS: FETCHES ONCE WITHOUT CORRUPTION + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Reproduces a concurrent-read race on a common topic that is loaded, but has a shallower depth than is being + /// requested: Two concurrent depth-aware + /// requests for the same cached topic must deep-load and merge exactly once, not twice, into the shared subtree. + /// + /// + /// Uses , which suspends inside its own Load until released, to + /// interleave both requests without any or other timing hack, exactly as does for the payload-only gate. Requests + /// depth: -1 rather than a finite depth: A finite-depth Load() only ever agrees with one tier shallower than requested (see ), so a finite-depth waiter's gate check would + /// never observe sufficiency and would always reissue a redundant fetch, an unrelated, pre-existing asymmetry this test + /// must avoid. + /// + [Fact] + public async Task Load_ConcurrentDepthTopUpRequests_FetchesOnceWithoutCorruption() { + + var inner = new BlockingStubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(inner); + var web = await cache.Load("Web"); + var rawWeb = (ITopicLazyLoadable)web!; + + Assert.False(rawWeb.IsLoaded(TopicPayload.Children)); + + // Baseline excludes the constructor's own "Root" and "Root:Configuration" fetches against the inner repository + var baselineFetchCount = inner.LoadFetchCount; + + // "Arm" the load gate so the first request suspends mid-fetch, then launch both requests without awaiting either + inner.ArmLoadGate(); + + var firstRequest = cache.Load(web!.Id, payload: TopicPayload.Children, depth: -1); + var secondRequest = cache.Load(web!.Id, payload: TopicPayload.Children, depth: -1); + + // Release the gate and let both requests run to completion + inner.ReleaseLoadGate(); + + await Task.WhenAll(firstRequest, secondRequest); + + // A single inner deep fetch, no duplicate children, and a fully loaded subtree confirm the race did not corrupt the merge + Assert.Equal(1, inner.LoadFetchCount - baselineFetchCount); + Assert.True(rawWeb.IsLoaded(TopicPayload.Children, depth: -1)); + Assert.Equal(2, web.Children.Count); + Assert.Equal(2, web.Children.Select(child => child.Id).Distinct().Count()); + + } + + /*============================================================================================================================ + | TEST: LOAD: CONCURRENT COLD MISS REQUESTS (BY ID): FETCHES ONCE WITHOUT CORRUPTION + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Reproduces a concurrent-read race on the same topicId that hasn't yet been loaded: Two concurrent requests for the same uncached ID must deep-load and + /// merge exactly once, not twice, into the shared cache graph, and both callers must resolve to the same attached instance. + /// + /// + /// Uses the , which suspends inside its own Load until released, + /// to interleave both requests without any or other timing hack, exactly as does for the depth gate. Requests depth: -1 + /// for the same reason that test does: A finite-depth request would never agree with the gated check from . + /// + [Fact] + public async Task Load_ConcurrentColdMissByIdRequests_FetchesOnceWithoutCorruption() { + + var inner = new BlockingStubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(inner); + + // Baseline excludes the constructor's own "Root" and "Root:Configuration" fetches against the inner repository + var baselineFetchCount = inner.LoadFetchCount; + + // "Arm" the load gate so the first request suspends mid-fetch, then launch both requests without awaiting either + inner.ArmLoadGate(); + + // "Web_0_0" (id 10002) is not yet resident: Only "Root", "Root:Configuration", and "Web" are seeded by the constructor + var firstRequest = cache.Load(10002, payload: TopicPayload.Children, depth: -1); + var secondRequest = cache.Load(10002, payload: TopicPayload.Children, depth: -1); + + // Release the gate and let both requests run to completion + inner.ReleaseLoadGate(); + + var (first, second) = (await firstRequest, await secondRequest); + + // A single inner fetch, and the same resident instance returned to both callers, confirming the race did not corrupt the + // merge + Assert.Equal(1, inner.LoadFetchCount - baselineFetchCount); + Assert.NotNull(first); + Assert.Same(first, second); + Assert.Equal("Web_0_0", first!.Key); + + } + + /*============================================================================================================================ + | TEST: LOAD: CONCURRENT COLD MISS REQUESTS (BY KEY): FETCHES ONCE WITHOUT CORRUPTION + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Reproduces a concurrent-read race on the same normalized uniqueKey that hasn't yet been loaded: Two concurrent + /// requests for the same uncached key must + /// deep-load and merge exactly once, not twice, into the shared cache graph, and both callers must resolve to the same + /// attached instance. + /// + /// + /// Uses the , exactly as does for the topicId gate. Requests + /// depth: -1 for the same reason that test does: A finite-depth request would never agree with the gated check from + /// . + /// + [Fact] + public async Task Load_ConcurrentColdMissByKeyRequests_FetchesOnceWithoutCorruption() { + + var inner = new BlockingStubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(inner); + + // Baseline excludes the constructor's own "Root" and "Root:Configuration" fetches against the inner repository + var baselineFetchCount = inner.LoadFetchCount; + + // "Arm" the load gate so the first request suspends mid-fetch, then launch both requests without awaiting either + inner.ArmLoadGate(); + + // "Web_0_0" is not yet resident: Only "Root", "Root:Configuration", and "Web" are seeded by the constructor + var firstRequest = cache.Load("Web:Web_0:Web_0_0", payload: TopicPayload.Children, depth: -1); + var secondRequest = cache.Load("Web:Web_0:Web_0_0", payload: TopicPayload.Children, depth: -1); + + // Release the gate and let both requests run to completion + inner.ReleaseLoadGate(); + + var (first, second) = (await firstRequest, await secondRequest); + + // A single inner fetch, and the same resident instance returned to both callers, confirming the race did not corrupt the + // merge + Assert.Equal(1, inner.LoadFetchCount - baselineFetchCount); + Assert.NotNull(first); + Assert.Same(first, second); + Assert.Equal("Web_0_0", first!.Key); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: CONCURRENT CHILDREN REQUESTS: FETCHES ONCE WITHOUT CORRUPTION + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Reproduces a concurrent-read race on a shared property: + /// Two concurrent requests for the same cached topic must merge exactly once, + /// not twice, into the shared . + /// + /// + /// Uses , which suspends inside its own EnsureLoaded until + /// released, to interleave both requests without any or other timing hack: The first + /// request is proven in flight because it is the one suspended on the gate; the second is proven in flight because calling + /// it synchronously (i.e., without ) before releasing the gate runs against to its own suspension + /// before the test proceeds. + /// + [Fact] + public async Task EnsureLoaded_ConcurrentChildrenRequests_FetchesOnceWithoutCorruption() { + + var inner = new BlockingStubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(inner); + var web = await cache.Load("Web"); + var rawWeb = (ITopicLazyLoadable)web!; + + Assert.False(rawWeb.IsLoaded(TopicPayload.Children)); + + // "Arm" the gate so the first request suspends mid-fetch, then launch both requests without awaiting either + inner.ArmEnsureLoadedGate(); + + var firstRequest = rawWeb.EnsureLoaded(TopicPayload.Children, CancellationToken); + var secondRequest = rawWeb.EnsureLoaded(TopicPayload.Children, CancellationToken); + + // Release the gate and let both requests run to completion + inner.ReleaseEnsureLoadedGate(); + + await Task.WhenAll(firstRequest, secondRequest); + + // A single inner fetch, no duplicate children, and a fully loaded boundary confirm the race did not corrupt the merge + Assert.Equal(1, inner.EnsureLoadedFetchCount); + Assert.True(rawWeb.IsLoaded(TopicPayload.Children)); + Assert.Equal(2, web.Children.Count); + Assert.Equal(2, web.Children.Select(child => child.Id).Distinct().Count()); + + } + + /*============================================================================================================================ + | TEST: DELETE: LOADED TOPIC: SUBSEQUENT LOAD DOES NOT RETURN DETACHED INSTANCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Deletes a loaded topic and confirms it is absent from ; i.e., the + /// detach hook in pruned it, since no + /// longer does so. + /// + /// + /// 's DeleteTopic doesn't actually do anything: The row survives in its store, + /// unlike a real deletion. That's exploited for the follow-up assertions: A subsequent + /// Load() by ID and then by unique key, both still find a row and reattach a fresh instance. If either index had + /// retained a stale entry instead of being pruned by e.g., the detach hook , that lookup would have returned the old, now-detached instance directly, without ever falling through to the inner + /// repository, so the fresh instances are themselves evidence both stale entries are gone, even though this isn't how a + /// real repository handles a delete. + /// + [Fact] + public async Task Delete_LoadedTopic_SubsequentLoadDoesNotReturnDetachedInstance() { + + var inner = new FakeSqlTopicRepository() + .AddTopic(1, "Root", "Container", null) + .AddTopic(2, "Web", "Page", 1) + .AddTopic(3, "Web_0", "Page", 2); + + var cache = new CachedTopicRepository(inner); + var root = await cache.Load("Root"); + var web = await cache.Load("Web"); + var web0 = await cache.Load("Web:Web_0"); + + Assert.Contains(web!.Children, child => child.Id == web0!.Id); + + await cache.Delete(web0!, isRecursive: false); + + Assert.DoesNotContain(web.Children, child => child.Id == web0!.Id); + Assert.False(root!.GetLiveTopicIndex().ContainsKey(web0!.Id)); + + var reloadedById = await cache.Load(web0!.Id); + + Assert.NotSame(web0, reloadedById); + + var reloadedByKey = await cache.Load("Web:Web_0"); + + Assert.NotSame(web0, reloadedByKey); + + } + + /*============================================================================================================================ + | TEST: SAVE: NEW TOPIC: RESOLVES VIA LOAD WITHOUT FALLBACK + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Saves a newly created, unsaved under a resident parent, then calls for its newly assigned ID and confirms the same + /// instance is returned directly from the live index, with no fall-through to the inner repository. Ensures the setter's indexing hook from makes a freshly saved topic resolvable by ID. + /// + [Fact] + public async Task Save_NewTopic_ResolvesViaLoadWithoutFallback() { + + var inner = new FakeSqlTopicRepository() + .AddTopic(1, "Root", "Container", null); + + var cache = new CachedTopicRepository(inner); + var root = await cache.Load("Root"); + + // Establish a minimal content type graph directly on the live root, required by Save()'s content type validation + var configuration = new Topic("Configuration", "Container", root); + var contentTypes = new ContentTypeDescriptor("ContentTypes", "ContentTypeDescriptor", configuration); + + _ = new ContentTypeDescriptor("Page", "ContentTypeDescriptor", contentTypes); + + var newChild = new Topic("NewChild", "Page", root); + + Assert.True(newChild.IsNew); + + await cache.Save(newChild); + + Assert.False(newChild.IsNew); + + var loaded = await cache.Load(newChild.Id); + + Assert.Same(newChild, loaded); + + } + + /*============================================================================================================================ + | TEST: SAVE: RECURSIVE NEW TOPICS: RESOLVE VIA LOAD WITHOUT FALLBACK + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Recursively saves a newly created parent with a newly created child underneath it, then calls for each of their newly assigned IDs and confirms both + /// resolve directly from the live index, with no fall-through to the inner repository. + /// + /// + /// Unlike , which saves a single topic, this ensures that the + /// setter's indexing hook also fires correctly for a child of a parent that was itself just assigned + /// an ID moments earlier in the same recursive save; i.e., , walked from + /// the child at the moment its own ID is assigned, correctly reaches _cache through the freshly attached parent. + /// + [Fact] + public async Task Save_RecursiveNewTopics_ResolveViaLoadWithoutFallback() { + + var inner = new FakeSqlTopicRepository() + .AddTopic(1, "Root", "Container", null); + + var cache = new CachedTopicRepository(inner); + var root = await cache.Load("Root"); + + // Establish a minimal content type graph directly on the live root, required by Save()'s content type validation + var configuration = new Topic("Configuration", "Container", root); + var contentTypes = new ContentTypeDescriptor("ContentTypes", "ContentTypeDescriptor", configuration); + + _ = new ContentTypeDescriptor("Page", "ContentTypeDescriptor", contentTypes); + + var newParent = new Topic("NewParent", "Page", root); + var newChild = new Topic("NewChild", "Page", newParent); + + await cache.Save(newParent, isRecursive: true); + + Assert.False(newParent.IsNew); + Assert.False(newChild.IsNew); + + var loadedParent = await cache.Load(newParent.Id); + var loadedChild = await cache.Load(newChild.Id); + + Assert.Same(newParent, loadedParent); + Assert.Same(newChild, loadedChild); + + } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/ContentTypeDescriptorTest.cs b/OnTopic.Tests/ContentTypeDescriptorTest.cs index 7a803b6a..987613cf 100644 --- a/OnTopic.Tests/ContentTypeDescriptorTest.cs +++ b/OnTopic.Tests/ContentTypeDescriptorTest.cs @@ -13,8 +13,8 @@ namespace OnTopic.Tests; \-----------------------------------------------------------------------------------------------------------------------------*/ /// /// Provides unit tests for the class and other types associated with it, such as , , and . +/// cref="AttributeDescriptor"/>, , and . /// [ExcludeFromCodeCoverage] public class ContentTypeDescriptorTest { @@ -150,9 +150,9 @@ public void ContentTypeDescriptor_ResetAttributeDescriptors_ReturnsUpdated() { [Fact] public void IsTypeOf_DerivedContentType_ReturnsTrue() { - var contentType = new ContentTypeDescriptor("Root", "ContentTypeDescriptor"); - for (var i = 0; i < 5; i++) { - var childContentType = new ContentTypeDescriptor("ContentType" + i, "ContentTypeDescriptor", contentType); + var contentType = new ContentTypeDescriptor("Root", "ContentTypeDescriptor"); + for (var i = 0; i < 5; i++) { + var childContentType = new ContentTypeDescriptor("ContentType" + i, "ContentTypeDescriptor", contentType); contentType = childContentType; } @@ -170,9 +170,9 @@ public void IsTypeOf_DerivedContentType_ReturnsTrue() { [Fact] public void IsTypeOf_InvalidContentType_ReturnsFalse() { - var contentType = new ContentTypeDescriptor("Root", "ContentTypeDescriptor"); - for (var i = 0; i < 5; i++) { - var childContentType = new ContentTypeDescriptor("ContentType" + i, "ContentTypeDescriptor", contentType); + var contentType = new ContentTypeDescriptor("Root", "ContentTypeDescriptor"); + for (var i = 0; i < 5; i++) { + var childContentType = new ContentTypeDescriptor("ContentType" + i, "ContentTypeDescriptor", contentType); contentType = childContentType; } @@ -194,7 +194,7 @@ public void ContentTypeDescriptorCollection_ConstructWithValues_ReturnsValues() var pageContentType = new ContentTypeDescriptor("Page", "ContentTypeDescriptor", rootContentType); _ = new ContentTypeDescriptor("Video", "ContentTypeDescriptor", pageContentType); - var contentTypeCollection = new ContentTypeDescriptorCollection(rootContentType); + var contentTypeCollection = new ContentTypeDescriptorCollection(rootContentType); Assert.Equal(3, contentTypeCollection.Count); @@ -215,7 +215,7 @@ public void ContentTypeDescriptorCollection_Refresh_ReturnsUpdated() { var videoContentType = new ContentTypeDescriptor("Video", "ContentTypeDescriptor", pageContentType); var slideshowContentType = new ContentTypeDescriptor("Slideshow", "ContentTypeDescriptor"); - var contentTypeCollection = new ContentTypeDescriptorCollection(rootContentType); + var contentTypeCollection = new ContentTypeDescriptorCollection(rootContentType); pageContentType.Children.Remove(videoContentType); pageContentType.Children.Add(slideshowContentType); diff --git a/OnTopic.Tests/ContractTest.cs b/OnTopic.Tests/ContractTest.cs index 40e13ed4..5be8e16f 100644 --- a/OnTopic.Tests/ContractTest.cs +++ b/OnTopic.Tests/ContractTest.cs @@ -73,7 +73,7 @@ public void Requires_ObjectIsNull_ThrowArgumentNullException() => [Fact] public void Requires_MessageExists_ThrowExceptionWithMessage() { - var errorMessage = "The argument cannot be null"; + var errorMessage = "The argument cannot be null"; try { Contract.Requires(false, errorMessage); @@ -88,13 +88,13 @@ public void Requires_MessageExists_ThrowExceptionWithMessage() { | TEST: REQUIRES: INVALID CONSTRUCTOR: THROW ARGUMENT EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Tests a null argument using the class, and attempts to throw a custom with the expected , but fails due to no overload with + /// Tests a null argument using the class, and attempts to throw a custom with the expected , but fails due to no overload with /// a single message parameter. In this case, it should throw a . /// [Fact] public void Requires_InvalidConstructor_ThrowArgumentException() { - var errorMessage = "The argument cannot be null"; + var errorMessage = "The argument cannot be null"; Assert.Throws(() => Contract.Requires(false, errorMessage) ); @@ -146,7 +146,7 @@ public void Assume_ConditionIsFalse_ThrowCustomExpection() => /// [Fact] public void Assume_ConditionIsFalse_ThrowCustomExpectionWithoutMessage() { - var exception = Assert.Throws(() => + var exception = Assert.Throws(() => Contract.Assume(false) ); Assert.Equal("false", exception.Message); diff --git a/OnTopic.Tests/Fixtures/TopicInfrastructureFixture.cs b/OnTopic.Tests/Fixtures/TopicInfrastructureFixture.cs index 1bdb80c8..c3b260eb 100644 --- a/OnTopic.Tests/Fixtures/TopicInfrastructureFixture.cs +++ b/OnTopic.Tests/Fixtures/TopicInfrastructureFixture.cs @@ -17,8 +17,8 @@ namespace OnTopic.Tests.Fixtures; | CLASS: TOPIC INFRASTRUCTURE FIXTURE \-----------------------------------------------------------------------------------------------------------------------------*/ /// -/// Introduces a shared context to use for unit tests depending on an , , and, optionally, an . +/// Introduces a shared context to use for unit tests depending on an , , and, optionally, an . /// /// /// This basic fixture uses the , , public ITopicMappingService MappingService { get; private set; } -} +} \ No newline at end of file diff --git a/OnTopic.Tests/Fixtures/TypeAccessorFixture.cs b/OnTopic.Tests/Fixtures/TypeAccessorFixture.cs index fa290a71..1e75ad73 100644 --- a/OnTopic.Tests/Fixtures/TypeAccessorFixture.cs +++ b/OnTopic.Tests/Fixtures/TypeAccessorFixture.cs @@ -24,7 +24,7 @@ public TypeAccessorFixture() { /*-------------------------------------------------------------------------------------------------------------------------- | Create type accessor \-------------------------------------------------------------------------------------------------------------------------*/ - TypeAccessor = new TypeAccessor(typeof(T)); + TypeAccessor = new(typeof(T)); } @@ -36,4 +36,4 @@ public TypeAccessorFixture() { /// internal TypeAccessor TypeAccessor { get; private set; } -} +} \ No newline at end of file diff --git a/OnTopic.Tests/HierarchicalTopicMappingServiceTest.cs b/OnTopic.Tests/HierarchicalTopicMappingServiceTest.cs index 129fd6ee..40a3db7c 100644 --- a/OnTopic.Tests/HierarchicalTopicMappingServiceTest.cs +++ b/OnTopic.Tests/HierarchicalTopicMappingServiceTest.cs @@ -4,10 +4,15 @@ | Project Topics Library \=============================================================================================================================*/ using OnTopic.Data.Caching; +using OnTopic.Lookup; +using OnTopic.Mapping; using OnTopic.Mapping.Hierarchical; using OnTopic.Repositories; using OnTopic.TestDoubles; +using OnTopic.TestDoubles.LazyLoading; using OnTopic.Tests.Fixtures; +using OnTopic.Tests.TestDoubles; +using OnTopic.ViewModels; using Xunit; namespace OnTopic.Tests; @@ -57,7 +62,7 @@ public HierarchicalTopicMappingServiceTest(TopicInfrastructureFixture /// Calls method - /// and ensures that the expected data is returned. + /// and ensures that the expected data is returned, with children landing in the same order as + /// (i.e., source order), confirming the sequential foreach fan-out never reorders on completion. /// [Fact] public async Task GetViewModel_WithTwoLevels_ReturnsGraph() { - var rootTopic = _topicRepository.Load("Root:Web"); - var viewModel = await _hierarchicalMappingService.GetViewModelAsync(rootTopic, 1); + var rootTopic = await _topicRepository.Load("Root:Web"); + var expectedOrder = rootTopic!.Children.Select(t => t.GetWebPath()).ToList(); + var viewModel = await _hierarchicalMappingService.GetViewModelAsync(rootTopic, 1); Assert.NotNull(viewModel); Assert.Equal(3, viewModel.Children.Count); Assert.Empty(viewModel.Children[0].Children); + Assert.Equal(expectedOrder, viewModel.Children.Select(c => c.WebPath)); } @@ -160,7 +168,7 @@ public async Task GetViewModel_WithTwoLevels_ReturnsGraph() { [Fact] public async Task GetViewModel_WithValidationDelegate_ExcludesTopics() { - var rootTopic = _topicRepository.Load("Root:Web"); + var rootTopic = await _topicRepository.Load("Root:Web"); var viewModel = await _hierarchicalMappingService .GetViewModelAsync(rootTopic, 2, (t) => t.Key.EndsWith('1')); @@ -180,8 +188,8 @@ public async Task GetViewModel_WithValidationDelegate_ExcludesTopics() { [Fact] public async Task GetViewModel_WithDisabled_ExcludesDisabled() { - var rootTopic = _topicRepository.Load("Root:Web:Web_3")!; - var disabledTopic = _topicRepository.Load("Root:Web:Web_3:Web_3_0"); + var rootTopic = (await _topicRepository.Load("Root:Web:Web_3"))!; + var disabledTopic = await _topicRepository.Load("Root:Web:Web_3:Web_3_0"); Contract.Assume(disabledTopic); @@ -199,4 +207,63 @@ public async Task GetViewModel_WithDisabled_ExcludesDisabled() { } + /*============================================================================================================================ + | TEST: GET VIEW MODEL: DEPTH TWO: WARMS ONCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with tiers: 2 against a fresh , and confirms the region is warmed in a single round-trip: An identical second call + /// is a clean, converged hit that issues no further fetches, proving the recursive descent never falls back to per-node + /// lazy loads. + /// + [Fact] + public async Task GetViewModel_DepthTwo_WarmsOnce() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + var typeLookupService = new CompositeTypeLookupService(new TopicViewModelLookupService(), new FakeViewModelLookupService()); + var mappingService = new TopicMappingService(cache, typeLookupService); + var hierarchicalService = new HierarchicalTopicMappingService(cache, mappingService); + + var webTopic = await cache.Load("Root:Web"); + + var viewModel = await hierarchicalService.GetViewModelAsync(webTopic, 2); + var fetchesAfterFirstMap = stub.TotalFetches; + + Assert.NotNull(viewModel); + + _ = await hierarchicalService.GetViewModelAsync(webTopic, 2); + + Assert.Equal(fetchesAfterFirstMap, stub.TotalFetches); + + } + + /*============================================================================================================================ + | TEST: GET VIEW MODEL: NEW SOURCE TOPIC: ISSUES NO LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls against an unsaved () and confirms the warm-up load is skipped: An unsaved topic's Id of -1 would + /// otherwise route , loading the root node, rather + /// than warming the intended region. + /// + [Fact] + public async Task GetViewModel_NewSourceTopic_IssuesNoLoad() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + var typeLookupService = new CompositeTypeLookupService(new TopicViewModelLookupService(), new FakeViewModelLookupService()); + var mappingService = new TopicMappingService(cache, typeLookupService); + var hierarchicalService = new HierarchicalTopicMappingService(cache, mappingService); + + var newTopic = new Topic("Test", "Page"); + var fetchesBeforeMap = stub.TotalFetches; + + var viewModel = await hierarchicalService.GetViewModelAsync(newTopic, 2); + + Assert.NotNull(viewModel); + Assert.Equal(fetchesBeforeMap, stub.TotalFetches); + + } + } //Class \ No newline at end of file diff --git a/OnTopic.Tests/ITopicLazyLoadableTest.cs b/OnTopic.Tests/ITopicLazyLoadableTest.cs new file mode 100644 index 00000000..664ff5fa --- /dev/null +++ b/OnTopic.Tests/ITopicLazyLoadableTest.cs @@ -0,0 +1,223 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Associations; +using OnTopic.Repositories; +using OnTopic.Tests.TestDoubles; +using Xunit; + +namespace OnTopic.Tests; + +/*============================================================================================================================== +| CLASS: TOPIC LAZY LOADABLE TEST +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides unit tests for the interface, with a particular emphasis on the recursive overload. +/// +[ExcludeFromCodeCoverage] +public class ITopicLazyLoadableTest { + + /*============================================================================================================================ + | PROPERTY: CANCELLATION TOKEN + \---------------------------------------------------------------------------------------------------------------------------*/ + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + /*============================================================================================================================ + | TEST: IS LOADED: NON-RECURSIVE: IGNORES UNLOADED CHILDREN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a topic with fully loaded extended attributes but a children collection. + /// Verifies that a non-recursive query returns true + /// once the requested payload is satisfied, regardless of the state of . + /// + [Fact] + public void IsLoaded_NonRecursive_IgnoresUnloadedChildren() { + + var topic = (ITopicLazyLoadable)new Topic("Test", "Page", null, 1) { + Children = { + LoadState = LoadState.NotLoaded + } + }; + + var result = topic.IsLoaded(TopicPayload.ExtendedAttributes, depth: 0); + + Assert.True(result); + + } + + /*============================================================================================================================ + | TEST: IS LOADED: SHALLOW SEED: RECURSIVE RETURNS FALSE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a topic with a single, unloaded child collection. Verifies that returns false for a recursive query, since the seed's are not yet loaded. + /// + [Fact] + public void IsLoaded_ShallowSeed_Recursive_ReturnsFalse() { + + var topic = (ITopicLazyLoadable)new Topic("Test", "Page", null, 1) { + Children = { + LoadState = LoadState.NotLoaded + } + }; + + Assert.False(topic.IsLoaded(TopicPayload.All, depth: -1)); + + } + + /*============================================================================================================================ + | TEST: IS LOADED: FULLY RESIDENT SUBTREE: RECURSIVE RETURNS TRUE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a three-level topic hierarchy with every collection fully loaded. Verifies that returns true once the whole subtree is loaded. + /// + [Fact] + public void IsLoaded_FullyResidentSubtree_Recursive_ReturnsTrue() { + + var parent = new Topic("Parent", "Page", null, 1); + var child = new Topic("Child", "Page", parent, 2); + _ = new Topic("Grandchild", "Page", child, 3); + + Assert.True(((ITopicLazyLoadable)parent).IsLoaded(TopicPayload.All, depth: -1)); + + } + + /*============================================================================================================================ + | TEST: IS LOADED: NOT LOADED DESCENDANT: RECURSIVE RETURNS FALSE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a three-level topic hierarchy where the middle topic's extended attributes are . Verifies that returns + /// false for a recursive query, even though the seed and its collection are fully + /// loaded. + /// + [Fact] + public void IsLoaded_NotLoadedDescendant_Recursive_ReturnsFalse() { + + var parent = new Topic("Parent", "Page", null, 1); + var child = new Topic("Child", "Page", parent, 2); + _ = new Topic("Grandchild", "Page", child, 3); + + child.Attributes.LoadState = LoadState.NotLoaded; + + Assert.False(((ITopicLazyLoadable)parent).IsLoaded(TopicPayload.ExtendedAttributes, depth: -1)); + + } + + /*============================================================================================================================ + | TEST: IS LOADED: NOT LOADED CHILDREN: EXCLUDED PAYLOAD: RECURSIVE RETURNS FALSE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a two-level topic hierarchy where the seed's collection is , and queries a payload parameter that excludes . + /// Verifies that still returns false, confirming + /// that the children gate is evaluated independently of the requested payload before recursing. + /// + [Fact] + public void IsLoaded_NotLoadedChildren_ExcludedPayload_Recursive_ReturnsFalse() { + + var parent = new Topic("Parent", "Page", null, 1); + _ = new Topic("Child", "Page", parent, 2); + + parent.Children.LoadState = LoadState.NotLoaded; + + var result = ((ITopicLazyLoadable)parent).IsLoaded(TopicPayload.ExtendedAttributes, depth: -1); + + Assert.False(result); + + } + + /*============================================================================================================================ + | TEST: IS LOADED: NOT LOADED CHILDREN: NEVER TRIGGERS A LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a topic stamped with a and a children + /// collection. Verifies that reads + /// directly and returns false without triggering a lazy load of . + /// + [Fact] + public void IsLoaded_NotLoadedChildren_NeverTriggersLoad() { + + var topic = new Topic("Test", "Page", null, 1); + var rawTopic = (ITopicLazyLoadable)topic; + var loader = new TrackingTopicLazyLoader(); + + rawTopic.Loader = loader; + topic.Children.LoadState = LoadState.NotLoaded; + + var result = rawTopic.IsLoaded(TopicPayload.All, depth: -1); + + Assert.False(result); + Assert.False(loader.WasCalled); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: NULL RESOLVER: DOES NOT THROW + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls on an in-memory topic with no + /// loader and confirms it completes without throwing. + /// + [Fact] + public void EnsureLoaded_NullResolver_DoesNotThrow() { + var topic = new Topic("Topic", "Page"); + ((ITopicLazyLoadable)topic).EnsureLoaded(TopicPayload.All, CancellationToken); + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: CLEARED RELATIONSHIP: DOES NOT RESURRECT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Registers a deferred entry, then calls on that key. Confirms that a subsequent for never reaches the ; since already purged the deferred entry, there is nothing left to resolve, and the + /// previously cleared relationship isn't resurrected. + /// + [Fact] + public async Task EnsureLoaded_ClearedRelationship_DoesNotResurrect() { + + var topic = new Topic("Test", "Page", null, 1); + var rawLoadable = (ITopicLazyLoadable)topic; + var rawTopic = (ITopicBackingAccessor)topic; + var loader = new TrackingTopicLazyLoader(); + + // Set up and clear via the backing accessor so this doesn't itself trigger a load once LoadState flips to NotLoaded; the + // loader is stamped afterward, ahead of the explicit EnsureLoaded() call below + rawTopic.Relationships.Deferred.SetValue("Related", 999); + rawTopic.Relationships.Clear("Related"); + rawLoadable.Loader = loader; + + await rawLoadable.EnsureLoaded(TopicPayload.Relationships, CancellationToken); + + Assert.False(loader.WasCalled); + Assert.Empty(rawTopic.Relationships.GetValues("Related")); + + } + + /*============================================================================================================================ + | TEST: IS NEW: NEW TOPIC: HAS NULL LOADER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Confirms that a newly constructed, unsaved carries a null . + /// + /// + /// Ensures that only stamps + /// once a topic has been loaded or saved (and thus has a stable ), so an in-memory, unsaved topic can + /// never carry one. + /// + [Fact] + public void IsNew_NewTopic_HasNullLoader() { + var topic = new Topic("Topic", "Page"); // ID = -1, IsNew = true + Assert.True(topic.IsNew); + Assert.Null(((ITopicLazyLoadable)topic).Loader); + } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/ITopicRepositoryTest.cs b/OnTopic.Tests/ITopicRepositoryTest.cs index 680bc0c2..c643717c 100644 --- a/OnTopic.Tests/ITopicRepositoryTest.cs +++ b/OnTopic.Tests/ITopicRepositoryTest.cs @@ -52,7 +52,7 @@ public ITopicRepositoryTest(TopicInfrastructureFixture fixt /*-------------------------------------------------------------------------------------------------------------------------- | Establish dependencies \-------------------------------------------------------------------------------------------------------------------------*/ - _topicRepository = fixture.CachedTopicRepository; + _topicRepository = fixture.CachedTopicRepository; } @@ -63,9 +63,9 @@ public ITopicRepositoryTest(TopicInfrastructureFixture fixt /// Loads the default topic and ensures there are the expected number of children. /// [Fact] - public void Load_Default_ReturnsTopicTopic() { + public async Task Load_Default_ReturnsTopicTopic() { - var rootTopic = _topicRepository.Load(); + var rootTopic = await _topicRepository.Load(); Assert.Equal(2, rootTopic?.Children.Count); Assert.Equal("Configuration", rootTopic?.Children.First().Key); @@ -80,8 +80,8 @@ public void Load_Default_ReturnsTopicTopic() { /// Loads topics and ensures there are the expected number of children. /// [Fact] - public void Load_ValidUniqueKey_ReturnsCorrectTopic() => - Assert.Equal("Page", _topicRepository.Load("Root:Configuration:ContentTypes:Page")?.Key); + public async Task Load_ValidUniqueKey_ReturnsCorrectTopic() => + Assert.Equal("Page", (await _topicRepository.Load("Root:Configuration:ContentTypes:Page"))?.Key); /*============================================================================================================================ | TEST: LOAD: INVALID UNIQUE KEY: RETURNS NULL @@ -90,8 +90,8 @@ public void Load_ValidUniqueKey_ReturnsCorrectTopic() => /// Loads invalid topic key and ensures a null is returned. /// [Fact] - public void Load_InvalidUniqueKey_ReturnsTopic() => - Assert.Null(_topicRepository.Load("Root:Configuration:ContentTypes:InvalidContentType")); + public async Task Load_InvalidUniqueKey_ReturnsTopic() => + Assert.Null(await _topicRepository.Load("Root:Configuration:ContentTypes:InvalidContentType")); /*============================================================================================================================ | TEST: LOAD: VALID TOPIC ID: RETURNS CORRECT TOPIC @@ -100,9 +100,9 @@ public void Load_InvalidUniqueKey_ReturnsTopic() => /// Loads topic by ID and ensures it is found. /// [Fact] - public void Load_ValidTopicId_ReturnsCorrectTopic() { + public async Task Load_ValidTopicId_ReturnsCorrectTopic() { - var topic = _topicRepository.Load(11111); + var topic = await _topicRepository.Load(11111); Assert.NotNull(topic); Assert.Equal("Web_1_1_1_1", topic?.Key); @@ -116,8 +116,8 @@ public void Load_ValidTopicId_ReturnsCorrectTopic() { /// Loads topic by an incorrect ID and ensures it a null is returned. /// [Fact] - public void Load_InvalidTopicId_ReturnsNull() => - Assert.Null(_topicRepository.Load(9999999)); + public async Task Load_InvalidTopicId_ReturnsNull() => + Assert.Null(await _topicRepository.Load(9999999)); /*============================================================================================================================ | TEST: SAVE @@ -126,17 +126,17 @@ public void Load_InvalidTopicId_ReturnsNull() => /// Saves topics and ensures their identifiers are properly set. /// [Fact] - public void Save() { + public async Task Save() { var topic = new Topic("Test", "Page"); var child = new Topic("Child", "Page", topic); - _topicRepository.Save(topic); + await _topicRepository.Save(topic); Assert.NotEqual(-1, topic.Id); Assert.Equal(-1, child.Id); - _topicRepository.Save(topic, true); + await _topicRepository.Save(topic, true); Assert.NotEqual(-1, child.Id); @@ -149,14 +149,14 @@ public void Save() { /// Moves topics and ensures their parents are correctly set. /// [Fact] - public void Move_ToNewParent_ConfirmedMove() { + public async Task Move_ToNewParent_ConfirmedMove() { var source = new Topic("OriginalParent", "Page"); var destination = new Topic("NewParent", "Page"); var topic = new Topic("Topic", "Page", source); _ = new Topic("Sibling", "Page", source); - _topicRepository.Move(topic, destination); + await _topicRepository.Move(topic, destination); Assert.Equal(topic.Parent, destination); Assert.Single(source.Children); @@ -171,13 +171,13 @@ public void Move_ToNewParent_ConfirmedMove() { /// Moves topic next to a different sibling and ensures it ends up in the correct location. /// [Fact] - public void Move_ToNewSibling_ConfirmedMove() { + public async Task Move_ToNewSibling_ConfirmedMove() { var parent = new Topic("OriginalParent", "Page"); var topic = new Topic("Topic", "Page", parent); var sibling = new Topic("Sibling", "Page", parent); - _topicRepository.Move(topic, parent, sibling); + await _topicRepository.Move(topic, parent, sibling); Assert.Equal(topic.Parent, parent); Assert.Equal(2, parent.Children.Count); @@ -193,13 +193,13 @@ public void Move_ToNewSibling_ConfirmedMove() { /// Deletes a topic to ensure it is properly removed. /// [Fact] - public void Delete_Topic_Removed() { + public async Task Delete_Topic_Removed() { var parent = new Topic("OriginalParent", "Page"); var topic = new Topic("Topic", "Page", parent); _ = new Topic("child", "Page", topic); - _topicRepository.Delete(topic, true); + await _topicRepository.Delete(topic, true); Assert.Empty(parent.Children); @@ -214,14 +214,14 @@ public void Delete_Topic_Removed() { /// and not the immediate . /// [Fact] - public void Delete_DeleteEvent_IsFired() { + public async Task Delete_DeleteEvent_IsFired() { var topic = new Topic("Test", "Page"); var hasFired = false; - _topicRepository.Save(topic); + await _topicRepository.Save(topic); _topicRepository.TopicDeleted += eventHandler; - _topicRepository.Delete(topic); + await _topicRepository.Delete(topic); Assert.True(hasFired); diff --git a/OnTopic.Tests/KeyedTopicCollectionTest.cs b/OnTopic.Tests/KeyedTopicCollectionTest.cs index 72c88e53..5dc8c5ec 100644 --- a/OnTopic.Tests/KeyedTopicCollectionTest.cs +++ b/OnTopic.Tests/KeyedTopicCollectionTest.cs @@ -26,9 +26,9 @@ public class KeyedTopicCollectionTest { [Fact] public void SetTopic_Indexer_ReturnsTopic() { - var topics = new KeyedTopicCollection(); + var topics = new KeyedTopicCollection(); - for (var i = 0; i < 10; i++) { + for (var i = 0; i < 10; i++) { topics.Add(new("Topic" + i, "Page")); } @@ -45,13 +45,13 @@ public void SetTopic_Indexer_ReturnsTopic() { [Fact] public void Constructor_IEnumerable_SeedsTopics() { - var topics = new List(); + List topics = []; - for (var i = 0; i < 10; i++) { + for (var i = 0; i < 10; i++) { topics.Add(new("Topic" + i, "Page")); } - var topicsCollection = new KeyedTopicCollection(topics); + var topicsCollection = new KeyedTopicCollection(topics); Assert.Equal(10, topicsCollection.Count); @@ -61,15 +61,15 @@ public void Constructor_IEnumerable_SeedsTopics() { | TEST: INSERT ITEM: DUPLICATE KEY: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Attempts to add two instances with the same to a and confirms that a is correctly thrown. + /// Attempts to add two instances with the same to a and confirms that a is correctly thrown. /// [Fact] public void InsertItem_DuplicateKey_ThrowsException() => Assert.Throws(() => new KeyedTopicCollection { - new Topic("Key", "Page"), - new Topic("Key", "Page") + new("Key", "Page"), + new("Key", "Page") } ); @@ -94,8 +94,8 @@ public void ReadOnlyKeyedTopicCollection_EmptyCollection() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Establishes a with a backing and - /// confirms that it successfully returns a by using . + /// confirms that it successfully returns a by using . /// [Fact] public void ReadOnlyKeyedTopicCollection_GetValue_ReturnsValue() { @@ -153,13 +153,13 @@ public void ReadOnlyKeyedTopicCollection_GetValue_ReturnsNull() => [Fact] public void AsReadOnly_ReturnsReadOnlyKeyedTopicCollection() { - var topics = new KeyedTopicCollection(); + var topics = new KeyedTopicCollection(); - for (var i = 0; i < 10; i++) { + for (var i = 0; i < 10; i++) { topics.Add(new("Topic" + i, "Page")); } - var readOnlyCollection = topics.AsReadOnly(); + var readOnlyCollection = topics.AsReadOnly(); Assert.Equal(10, readOnlyCollection.Count); Assert.Equal("Topic0", readOnlyCollection.First().Key); @@ -175,13 +175,13 @@ public void AsReadOnly_ReturnsReadOnlyKeyedTopicCollection() { [Fact] public void AsReadOnly_ReturnsReadOnlyTopicCollection() { - var topics = new TopicCollection(); + var topics = new TopicCollection(); - for (var i = 0; i < 10; i++) { + for (var i = 0; i < 10; i++) { topics.Add(new("Topic" + i, "Page")); } - var readOnlyCollection = topics.AsReadOnly(); + var readOnlyCollection = topics.AsReadOnly(); Assert.Equal(10, readOnlyCollection.Count); Assert.Equal("Topic0", readOnlyCollection.First().Key); diff --git a/OnTopic.Tests/LazyLoadingTopicRepositoryTest.cs b/OnTopic.Tests/LazyLoadingTopicRepositoryTest.cs new file mode 100644 index 00000000..071a951b --- /dev/null +++ b/OnTopic.Tests/LazyLoadingTopicRepositoryTest.cs @@ -0,0 +1,1041 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Associations; +using OnTopic.Data.Caching; +using OnTopic.Repositories; +using OnTopic.TestDoubles.LazyLoading; +using Xunit; + +namespace OnTopic.Tests; + +/*============================================================================================================================== +| CLASS: LAZY LOADING TOPIC REPOSITORY TESTS +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides unit tests for the store-independent, lazy-loading , evaluated through +/// the , a lazy test double that serves shallow topics from a flat, SQL-free +/// record store and loads each property on demand. +/// +/// +/// +/// is a standalone , never wrapped by a +/// . This matters: Were it wrapped in e.g., 's +/// shared field pattern, then , which is raised whenever the double builds a +/// topic for the first time, whether requested directly against the inner repository or through the decorator, would +/// synchronously re-enter the outer cache's handler, restamping that topic's as the +/// cache rather than the double itself. That would silently reroute every autoloading getter to , which delegates children and extended attributes to the inner resolver but +/// withholds relationships and references, resolving them itself via LoadDeferredAssociations instead. The double's +/// own fetch-count spy would then never see association fetches. So the standalone-mechanism tests (groups A through G, and +/// J) use , while the decorator-specific tests (groups H and I) use , which wraps its own, separate instance. +/// +/// +/// Both repositories share the same built-in seed dataset (see 's default +/// constructor): A four-level Root:Web content subtree of Web, Web_0, Web_0_0 (carrying an +/// extended attribute), Web_0_0_0, plus a sibling Web_1, with a resolvable relationship and reference pair +/// (Web_1, Web_0_0 / Web_0) and a stale, unresolvable pair (Web_0, a nonexistent target). +/// +/// +[ExcludeFromCodeCoverage] +public class LazyLoadingTopicRepositoryTest { + + /*============================================================================================================================ + | PRIVATE VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + readonly StubLazyLoadingTopicRepository _loadingTopicRepository; + readonly CachedTopicRepository _cachedTopicRepository; + + /*============================================================================================================================ + | PROPERTY: CANCELLATION TOKEN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Shorthand for 's . + /// + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + /*============================================================================================================================ + | CONSTRUCTOR + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Initializes a new instance of the with two independent repositories: A + /// standalone for evaluating the repository directly, and a second, separate + /// instance wrapped by a for evaluating decorator-specific behavior. + /// + public LazyLoadingTopicRepositoryTest() { + _loadingTopicRepository = new(); + _cachedTopicRepository = new(new StubLazyLoadingTopicRepository()); + } + + #region A: Genuine Deferral on Load + + /*============================================================================================================================ + | TEST: LOAD: DEFAULT PAYLOAD: CHILDREN NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic with the default payload and confirms its property is genuinely absent: Not + /// merely flagged , but backed by an empty collection. + /// + [Fact] + public async Task Load_DefaultPayload_ChildrenNotLoaded() { + + var topic = await _loadingTopicRepository.Load("Root:Web"); + + Assert.False(((ITopicLazyLoadable)topic!).IsLoaded(TopicPayload.Children)); + Assert.Empty(((ITopicBackingAccessor)topic).Children); + + } + + /*============================================================================================================================ + | TEST: LOAD: DEFAULT PAYLOAD: EXTENDED ATTRIBUTES NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic with an extended attribute and confirms the extended-attribute boundary is genuinely absent: The attribute + /// value itself is missing from the backing collection, not merely flagged. + /// + [Fact] + public async Task Load_DefaultPayload_ExtendedAttributesNotLoaded() { + + var topic = await _loadingTopicRepository.Load("Root:Web:Web_0:Web_0_0"); + + Assert.False(((ITopicLazyLoadable)topic!).IsLoaded(TopicPayload.ExtendedAttributes)); + Assert.False(topic.Attributes.Contains("Body")); + + } + + /*============================================================================================================================ + | TEST: LOAD: DEFAULT PAYLOAD: ASSOCIATIONS DEFERRED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic with both a relationship and a reference and confirms both association properties are , with genuine DeferredAssociation entries recorded, not resolved targets. + /// + [Fact] + public async Task Load_DefaultPayload_AssociationsDeferred() { + + var topic = await _loadingTopicRepository.Load("Root:Web:Web_1"); + var rawTopic = (ITopicBackingAccessor)topic!; + var lazyTopic = (ITopicLazyLoadable)topic!; + + Assert.False(lazyTopic.IsLoaded(TopicPayload.Relationships)); + Assert.False(lazyTopic.IsLoaded(TopicPayload.References)); + Assert.NotEmpty(rawTopic.Relationships.Deferred); + Assert.NotEmpty(rawTopic.References.Deferred); + + } + + #endregion + + #region B: On-Demand Materialization via the Autoloading Getters + + /*============================================================================================================================ + | TEST: CHILDREN: NOT LOADED: MATERIALIZES REAL CHILDREN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Touches on a topic whose children are and confirms the + /// getter returns the actual child topics from the record store, not an empty collection with a flipped flag. + /// + [Fact] + public async Task Children_NotLoaded_MaterializesRealChildren() { + + var topic = await _loadingTopicRepository.Load("Root:Web"); + var children = topic!.Children; + + Assert.Equal(2, children.Count); + Assert.Contains(children, child => child.Key == "Web_0"); + Assert.Contains(children, child => child.Key == "Web_1"); + + } + + /*============================================================================================================================ + | TEST: RELATIONSHIPS: NOT LOADED: MATERIALIZES TARGETS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Touches on a topic with a resolvable relationship target and confirms the getter + /// connects the real target object. + /// + [Fact] + public async Task Relationships_NotLoaded_MaterializesTargets() { + + var topic = await _loadingTopicRepository.Load("Root:Web:Web_1"); + var related = topic!.Relationships.GetValues("Related"); + + Assert.Single(related); + Assert.Equal("Web_0_0", related[0].Key); + + } + + /*============================================================================================================================ + | TEST: REFERENCES: NOT LOADED: MATERIALIZES TARGETS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Touches on a topic with a resolvable reference target and confirms the getter lazy loads + /// the target object. + /// + [Fact] + public async Task References_NotLoaded_MaterializesTargets() { + + var topic = await _loadingTopicRepository.Load("Root:Web:Web_1"); + + Assert.True(topic!.References.Contains("BaseTopic")); + Assert.Equal("Web_0", topic.References["BaseTopic"].Value?.Key); + + } + + #endregion + + #region C: On-Demand Materialization via Async Ensure Loaded + + /*============================================================================================================================ + | TEST: ENSURE LOADED: CHILDREN: MATERIALIZES BEFORE ACCESS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Awaits for , then + /// confirms the boundary is already and backed by real data before the getter is touched. + /// + [Fact] + public async Task EnsureLoaded_Children_MaterializesBeforeAccess() { + + var topic = await _loadingTopicRepository.Load("Root:Web"); + var rawTopic = (ITopicLazyLoadable)topic!; + + await rawTopic.EnsureLoaded(TopicPayload.Children, cancellationToken: CancellationToken); + + Assert.True(rawTopic.IsLoaded(TopicPayload.Children)); + Assert.Equal(2, ((ITopicBackingAccessor)topic).Children.Count); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: EXTENDED ATTRIBUTES: MATERIALIZES REAL VALUE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Awaits for and confirms the extended attribute getter returns the real value from the record + /// store, not merely a flipped . This is a distinct autoload seam from + /// and the association getters: It lives in AttributeCollection.GetValue, not directly on a + /// property getter. + /// + [Fact] + public async Task EnsureLoaded_ExtendedAttributes_MaterializesRealValue() { + + var topic = await _loadingTopicRepository.Load("Root:Web:Web_0:Web_0_0"); + var rawTopic = (ITopicLazyLoadable)topic!; + + await rawTopic.EnsureLoaded(TopicPayload.ExtendedAttributes, cancellationToken: CancellationToken); + + Assert.True(rawTopic.IsLoaded(TopicPayload.ExtendedAttributes)); + Assert.Equal("Extended body content for Web_0_0.", topic.Attributes.GetValue("Body")); + + } + + #endregion + + #region D: Fetch-Once (Spy) + + /*============================================================================================================================ + | TEST: CHILDREN: ACCESSED TWICE: FETCHES ONCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Touches twice and confirms the record store is only fetched once, via the double's + /// per-topic, per-property fetch-count spy. + /// + [Fact] + public async Task Children_AccessedTwice_FetchesOnce() { + + var topic = await _loadingTopicRepository.Load("Root:Web"); + + _ = topic!.Children; + _ = topic.Children; + + Assert.Equal(1, _loadingTopicRepository.GetFetchCount(topic.Id, TopicPayload.Children)); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: ALREADY LOADED: DOES NOT FETCH + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic with children already requested, then calls again for the property payload, and + /// confirms no additional fetch is recorded. + /// + [Fact] + public async Task EnsureLoaded_AlreadyLoaded_DoesNotFetch() { + + var topic = await _loadingTopicRepository.Load("Root:Web", payload: TopicPayload.Children); + var fetchesAfterLoad = _loadingTopicRepository.TotalFetches; + + await ((ITopicLazyLoadable)topic!).EnsureLoaded(TopicPayload.Children, cancellationToken: CancellationToken); + + Assert.Equal(fetchesAfterLoad, _loadingTopicRepository.TotalFetches); + + } + + /*============================================================================================================================ + | TEST: RELATIONSHIPS: ACCESSED TWICE: FETCHES ONCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Touches twice on a topic with a resolvable target and confirms the record store is + /// only fetched once, via the double's per-topic, per-property fetch-count spy. + /// + [Fact] + public async Task Relationships_AccessedTwice_FetchesOnce() { + + var topic = await _loadingTopicRepository.Load("Root:Web:Web_1"); + + _ = topic!.Relationships.GetValues("Related"); + _ = topic.Relationships.GetValues("Related"); + + Assert.Equal(1, _loadingTopicRepository.GetFetchCount(topic.Id, TopicPayload.Relationships)); + + } + + /*============================================================================================================================ + | TEST: REFERENCES: ACCESSED TWICE: FETCHES ONCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Touches twice on a topic with a resolvable target and confirms the record store is only + /// fetched once, via the double's per-topic, per-property fetch-count spy. + /// + [Fact] + public async Task References_AccessedTwice_FetchesOnce() { + + var topic = await _loadingTopicRepository.Load("Root:Web:Web_1"); + + _ = topic!.References.Contains("BaseTopic"); + _ = topic.References.Contains("BaseTopic"); + + Assert.Equal(1, _loadingTopicRepository.GetFetchCount(topic.Id, TopicPayload.References)); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: MIXED PROPERTIES: ONLY FETCHES PENDING PROPERTY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads for a topic that also carries an unloaded extended attribute, then calls with both properties requested + /// together, and confirms only the still-pending property is fetched: The + /// already loaded property is filtered out and left untouched, per the fetch-count spy. + /// + [Fact] + public async Task EnsureLoaded_Properties_OnlyFetchesPendingProperty() { + + var topic = await _loadingTopicRepository.Load("Root:Web:Web_0:Web_0_0"); + var rawTopic = (ITopicLazyLoadable)topic!; + + await rawTopic.EnsureLoaded(TopicPayload.Children, cancellationToken: CancellationToken); + await rawTopic.EnsureLoaded( + TopicPayload.Children | TopicPayload.ExtendedAttributes, + cancellationToken: CancellationToken + ); + + Assert.Equal(1, _loadingTopicRepository.GetFetchCount(topic.Id, TopicPayload.Children)); + Assert.Equal(1, _loadingTopicRepository.GetFetchCount(topic.Id, TopicPayload.ExtendedAttributes)); + Assert.Equal("Extended body content for Web_0_0.", topic.Attributes.GetValue("Body")); + + } + + #endregion + + #region E: Recursive Lazy Descent + + /*============================================================================================================================ + | TEST: CHILDREN: MATERIALIZED CHILD: IS ITSELF LAZY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Materializes the first level of a topic's children and confirms a materialized child reports its own children as , with no fetch yet recorded for that child, then touches the child's own and confirms a separate, later fetch materializes the next level. Proves that nothing trickles past + /// the level actually accessed. + /// + [Fact] + public async Task Children_MaterializedChild_IsItselfLazy() { + + var topic = await _loadingTopicRepository.Load("Root:Web"); + var web0 = topic!.Children["Web_0"]; + + Assert.False(((ITopicLazyLoadable)web0).IsLoaded(TopicPayload.Children)); + Assert.Equal(0, _loadingTopicRepository.GetFetchCount(web0.Id, TopicPayload.Children)); + + _ = web0.Children; + + Assert.Equal(1, _loadingTopicRepository.GetFetchCount(web0.Id, TopicPayload.Children)); + + } + + #endregion + + #region F: Resolver Stamping through the Public Path + + /*============================================================================================================================ + | TEST: LOAD: SERVED NODE: IS STAMPED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic and confirms it carries a non-null , stamped through the public + /// /event path. + /// + [Fact] + public async Task Load_ServedNode_IsStamped() { + + var topic = await _loadingTopicRepository.Load("Root:Web"); + + Assert.NotNull(((ITopicLazyLoadable)topic!).Loader); + + } + + /*============================================================================================================================ + | TEST: CHILDREN: MATERIALIZED CHILDREN: ARE STAMPED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads children via and + /// confirms each child carries a non-null , which is what enables recursive lazy + /// loading (see ): The per-child OnTopicLoaded event raised + /// during materialization is what stamps them. + /// + [Fact] + public async Task Children_MaterializedChildren_AreStamped() { + + var topic = await _loadingTopicRepository.Load("Root:Web"); + + await ((ITopicLazyLoadable)topic!).EnsureLoaded(TopicPayload.Children, cancellationToken: CancellationToken); + + var children = ((ITopicBackingAccessor)topic).Children; + + Assert.Equal(2, children.Count); + + foreach (var child in children) { + Assert.NotNull(((ITopicLazyLoadable)child).Loader); + } + + } + + /*============================================================================================================================ + | TEST: LOAD: DEEP NODE: ASCENDANTS ARE STAMPED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a deeply nested topic from a standalone repository and confirms that an ascendant, never itself the target of a + /// Load() call, is nonetheless stamped with an . + /// + [Fact] + public async Task Load_DeepNode_AscendantsAreStamped() { + + var topicRepository = new StubLazyLoadingTopicRepository(); + var topic = await topicRepository.Load("Root:Web:Web_0:Web_0_0:Web_0_0_0"); + var ascendant = topic?.Parent?.Parent; + + Assert.NotNull(ascendant); + Assert.NotNull((ascendant as ITopicLazyLoadable)?.Loader); + + } + + /*============================================================================================================================ + | TEST: LOAD: DEEP NODE: RELOAD IS IDEMPOTENT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Reloads the same deep topic twice and confirms ascendants remain correctly stamped, with no spurious fetches triggered + /// by the second load, an indirect check of the ascendant-stamping's short-circuit correctness. + /// + [Fact] + public async Task Load_DeepNode_ReloadIsIdempotent() { + + var topicRepository = new StubLazyLoadingTopicRepository(); + var uniqueKey = "Root:Web:Web_0:Web_0_0:Web_0_0_0"; + + _ = await topicRepository.Load(uniqueKey); + + var fetchesAfterFirstLoad = topicRepository.TotalFetches; + var reloaded = await topicRepository.Load(uniqueKey); + + Assert.Equal(fetchesAfterFirstLoad, topicRepository.TotalFetches); + Assert.NotNull((reloaded?.Parent?.Parent as ITopicLazyLoadable)?.Loader); + + } + + /*============================================================================================================================ + | TEST: SAVE: NEW TOPIC: STAMPS RESOLVER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Saves a new and confirms the repository stamps a onto it so that + /// deferred boundaries can be populated on demand after the save. + /// + [Fact] + public async Task Save_NewTopic_StampsResolver() { + + var parent = await _loadingTopicRepository.Load("Root:Web:Web_0:Web_0_0"); + var topic = new Topic("Test", "Page", parent); + + await _loadingTopicRepository.Save(topic); + + Assert.NotNull(((ITopicLazyLoadable)topic).Loader); + + } + + #endregion + + #region G: Force-Load Gate (Stamping Must Not Fill) + + /*============================================================================================================================ + | TEST: CHILDREN: MATERIALIZED: STAMPING DOES NOT LOAD GRANDCHILDREN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads one level of children and confirms the loader-stamping pass triggered by each child's OnTopicLoaded event + /// does not, in turn, force-load its own children. Each child's own property is , so the gate at LazyLoadingTopicRepository.StampLoader, which only recurses into a + /// topic's already-loaded children, stamps the child without descending. Were the gate removed, StampLoader's + /// recursion would autoload every child's children, and the spy would show fetches for them; instead it shows none. + /// + [Fact] + public async Task Children_Materialized_StampingDoesNotLoadGrandchildren() { + + var topic = await _loadingTopicRepository.Load("Root:Web"); + var children = topic!.Children; + var web0 = children["Web_0"]; + var web1 = children["Web_1"]; + + Assert.Equal(1, _loadingTopicRepository.GetFetchCount(topic.Id, TopicPayload.Children)); + Assert.Equal(0, _loadingTopicRepository.GetFetchCount(web0.Id, TopicPayload.Children)); + Assert.Equal(0, _loadingTopicRepository.GetFetchCount(web1.Id, TopicPayload.Children)); + + } + + #endregion + + #region H: Deferred-Association Resolution through the Cache Decorator + + /*============================================================================================================================ + | TEST: ENSURE LOADED: STALE RELATIONSHIP TARGET: IS DISCARDED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls on a topic with a deferred relationship whose target is absent + /// from the underlying record store, and confirms the association resolves to nothing: The deferred entry is dropped, not + /// left dangling, while the property still ends up . + /// + [Fact] + public async Task EnsureLoaded_StaleRelationshipTarget_IsDiscarded() { + + var source = await _cachedTopicRepository.Load("Root:Web:Web_0"); + + await _cachedTopicRepository.EnsureLoaded(source!, TopicPayload.Relationships, cancellationToken: CancellationToken); + + Assert.Equal(LoadState.Loaded, source!.Relationships.LoadState); + Assert.Empty(source.Relationships.GetValues("Related")); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: STALE REFERENCE TARGET: IS DISCARDED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls on a topic with a deferred reference whose target is absent from + /// the underlying record store, and confirms the association resolves to nothing: The deferred entry is dropped, not left + /// dangling, while the property still ends up . + /// + [Fact] + public async Task EnsureLoaded_StaleReferenceTarget_IsDiscarded() { + + var source = await _cachedTopicRepository.Load("Root:Web:Web_0"); + + await _cachedTopicRepository.EnsureLoaded(source!, TopicPayload.References, cancellationToken: CancellationToken); + + Assert.Equal(LoadState.Loaded, source!.References.LoadState); + Assert.False(source.References.Contains("BaseTopic")); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: MISSING RELATIONSHIP TARGET: RESOLVES AND CONNECTS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls on a topic whose Relationships.LoadState is NotLoaded + /// , confirming that the loader re-queries for the topic's relationships, loads a target initially absent from the + /// cache, and connects the edge. The relationship-target complement to . + /// + [Fact] + public async Task EnsureLoaded_MissingRelationshipTarget_ResolvesAndConnects() { + + // The cache seeds only Root and Root:Configuration; "Web" (id 10000) is initially absent from the cache + var root = (await _cachedTopicRepository.Load(-1))!; + ((ITopicBackingAccessor)root).Relationships.Deferred.Add(new("_stub", 10000)); + + await _cachedTopicRepository.EnsureLoaded(root, TopicPayload.Relationships, cancellationToken: CancellationToken); + + Assert.Equal(LoadState.Loaded, root.Relationships.LoadState); + Assert.Equal(10000, root.Relationships.GetValues("_stub")[0].Id); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: MISSING REFERENCE TARGET: RESOLVES AND CONNECTS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls on a topic whose References.LoadState is NotLoaded, + /// confirming that the loader re-queries for the topic's references, loads a target initially absent from the cache, and + /// connects the edge. The reference-target complement to . + /// + [Fact] + public async Task EnsureLoaded_MissingReferenceTarget_ResolvesAndConnects() { + + // The cache seeds only Root and Root:Configuration; "Web" (id 10000) is initially absent from the cache + var root = (await _cachedTopicRepository.Load(-1))!; + ((ITopicBackingAccessor)root).References.Deferred.Add(new("_stub", 10000)); + + await _cachedTopicRepository.EnsureLoaded(root, TopicPayload.References, cancellationToken: CancellationToken); + + Assert.Equal(LoadState.Loaded, root.References.LoadState); + Assert.Equal(10000, root.References["_stub"].Value?.Id); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: RELATIONSHIPS: ALREADY LOADED: SKIPS FILL + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls on a topic whose relationships are already and confirms it returns immediately without re-querying the underlying record store, per the + /// inner 's fetch-count spy. + /// + [Fact] + public async Task EnsureLoaded_Relationships_AlreadyLoaded_SkipsFill() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + + // The root's relationships start as Loaded (Deferred is empty); no fetch has been recorded against it + var root = (await cache.Load(-1))!; + var fetchesAfterLoad = stub.TotalFetches; + + await cache.EnsureLoaded(root, TopicPayload.Relationships, cancellationToken: CancellationToken); + + Assert.Equal(LoadState.Loaded, root.Relationships.LoadState); + Assert.Equal(fetchesAfterLoad, stub.TotalFetches); + + } + + #endregion + + #region I: Decorator Stamp Precedence + + /*============================================================================================================================ + | TEST: LOAD: DECORATED: OUTER RESOLVER WINS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic through a wrapping the lazy double and confirms the loaded topic's + /// is the outer cache instance, not the inner double, verifying the synchronous + /// re-entry described on LazyLoadingTopicRepository.OnTopicLoaded. + /// + [Fact] + public async Task Load_Decorated_OuterResolverWins() { + + var topic = await _cachedTopicRepository.Load("Root:Web"); + + Assert.Same(_cachedTopicRepository, ((ITopicLazyLoadable)topic!).Loader); + + } + + #endregion + + #region J: Edge Cases + + /*============================================================================================================================ + | TEST: ENSURE LOADED: NEW TOPIC: DOES NOT FETCH + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls directly against an in-memory + /// attached to nothing, and confirms no fetch is recorded. Distinct from the gating case covered in TopicTest, this + /// is asserted through the repository's own spy, not merely the absence of a resolver call. + /// + [Fact] + public async Task EnsureLoaded_NewTopic_DoesNotFetch() { + + var topic = new Topic("Test", "Page"); + + await _loadingTopicRepository.EnsureLoaded(topic, TopicPayload.Children, cancellationToken: CancellationToken); + + Assert.Equal(0, _loadingTopicRepository.TotalFetches); + + } + + #endregion + + #region K: In-Graph Association Resolution + + /*============================================================================================================================ + | TEST: ENSURE LOADED: TARGETS RESIDENT IN GRAPH: RESOLVE AND CLEAR DEFERRED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a target topic into the repository's graph ahead of time, then loads a second topic whose deferred relationship + /// and reference entries point at it, and confirms that connects both associations to the + /// in-graph instance and clears their Deferred entries, without either target needing to be (re)built from the + /// record store. + /// + [Fact] + public async Task EnsureLoaded_TargetsResidentInGraph_ResolveAndClearDeferred() { + + var web00 = await _loadingTopicRepository.Load("Root:Web:Web_0:Web_0_0"); + var web0 = web00!.Parent; + var topic = await _loadingTopicRepository.Load("Root:Web:Web_1"); + var rawTopic = (ITopicBackingAccessor)topic!; + + await ((ITopicLazyLoadable)topic!).EnsureLoaded( + TopicPayload.Relationships | TopicPayload.References, + cancellationToken: CancellationToken + ); + + var related = topic.Relationships.GetValues("Related"); + + Assert.Single(related); + Assert.Same(web00, related[0]); + Assert.Same(web0, topic.References["BaseTopic"].Value); + Assert.Empty(rawTopic.Relationships.Deferred); + Assert.Empty(rawTopic.References.Deferred); + + } + + #endregion + + #region L: Sufficiency-Gated Cache Hits + + /*============================================================================================================================ + | TEST: LOAD: NARROW PAYLOAD HIT: TOPS UP AND CONVERGES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic with the default payload, then loads it again, non-recursively, requesting , and confirms the second call returns the exact same, resident instance, but now + /// filled with the extended attribute value, and not merely a flipped . + /// + [Fact] + public async Task Load_NarrowPayloadHit_TopsUpAndConverges() { + + var topic = await _cachedTopicRepository.Load("Root:Web:Web_0:Web_0_0"); + + Assert.False(((ITopicLazyLoadable)topic!).IsLoaded(TopicPayload.ExtendedAttributes)); + + var reloaded = await _cachedTopicRepository.Load( + "Root:Web:Web_0:Web_0_0", + topic, + TopicPayload.ExtendedAttributes, + 0 + ); + + Assert.Same(topic, reloaded); + Assert.True(((ITopicLazyLoadable)reloaded!).IsLoaded(TopicPayload.ExtendedAttributes)); + Assert.Equal("Extended body content for Web_0_0.", reloaded.Attributes.GetValue("Body")); + + } + + /*============================================================================================================================ + | TEST: LOAD: RECURSIVE HIT: CONVERGES SUBTREE THEN CLEAN HIT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a subtree recursively with the full payload, then repeats the identical call, and confirms the second call is a + /// genuine, converged hit: The same instance is returned and no further fetches are recorded against the underlying , proving converged on every resident descendant rather + /// than merely on the seed. + /// + [Fact] + public async Task Load_RecursiveHit_ConvergesSubtreeThenCleanHit() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + var gate = TopicPayload.All & ~(TopicPayload.Relationships | TopicPayload.References); + + var seed = await cache.Load("Root:Web:Web_0", null, TopicPayload.All, -1); + + Assert.True(((ITopicLazyLoadable)seed!).IsLoaded(gate, depth: -1)); + + var fetchesAfterFirstLoad = stub.TotalFetches; + var reloaded = await cache.Load("Root:Web:Web_0", null, TopicPayload.All, -1); + + Assert.Same(seed, reloaded); + Assert.Equal(fetchesAfterFirstLoad, stub.TotalFetches); + + } + + /*============================================================================================================================ + | TEST: LOAD: RECURSIVE TOP UP ON RESIDENT SEED: ANCESTORS STAY NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a deep, shallow seed, then tops it up recursively for and , and confirms the seed and its descendants converge while the seed's ascendant + /// remains , matching the parent plan distinction of ascendants vs. seed graph. + /// + [Fact] + public async Task Load_RecursiveTopUpOnResidentSeed_AncestorsStayNotLoaded() { + + var seed = await _cachedTopicRepository.Load("Root:Web:Web_0:Web_0_0"); + + Assert.False(((ITopicLazyLoadable)seed!).IsLoaded(TopicPayload.Children)); + + var deep = await _cachedTopicRepository.Load( + "Root:Web:Web_0:Web_0_0", + seed, + TopicPayload.Children | TopicPayload.ExtendedAttributes, + -1 + ); + + var ancestor = deep!.Parent; + + Assert.Same(seed, deep); + Assert.True( + ((ITopicLazyLoadable)deep).IsLoaded(TopicPayload.Children | TopicPayload.ExtendedAttributes, depth: -1) + ); + Assert.Equal("Extended body content for Web_0_0.", deep.Attributes.GetValue("Body")); + Assert.False(((ITopicLazyLoadable)ancestor!).IsLoaded(TopicPayload.Children)); + + } + + /*============================================================================================================================ + | TEST: LOAD: RECURSIVE TOP UP: IN-GRAPH CORE CONNECTS MERGED REGION + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic with deferred relationship and reference targets that are not yet loaded, then recursively tops up its + /// shared ancestor so both targets are pulled into the same merged graph, and confirms the in-graph association are + /// correctly connected and clearing Deferred without a further round-trip dedicated to associations. + /// + [Fact] + public async Task Load_RecursiveTopUp_InGraphCoreConnectsMergedRegion() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + + var topic = await cache.Load("Root:Web:Web_1"); + var rawTopic = (ITopicBackingAccessor)topic!; + + Assert.NotEmpty(rawTopic.Relationships.Deferred); + Assert.NotEmpty(rawTopic.References.Deferred); + + var web = await cache.Load("Root:Web", null, TopicPayload.Children, -1); + var web00 = web!.Children["Web_0"].Children["Web_0_0"]; + var related = topic!.Relationships.GetValues("Related"); + + Assert.Single(related); + Assert.Same(web00, related[0]); + Assert.Equal("Web_0", topic.References["BaseTopic"].Value?.Key); + Assert.Empty(rawTopic.Relationships.Deferred); + Assert.Empty(rawTopic.References.Deferred); + + } + + /*============================================================================================================================ + | TEST: LOAD: WHOLE TREE TOP UP: MATERIALIZES THEN CLEAN HIT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Seeds the cache with the default Root seed established by the constructor + /// (Root plus its immediate children, per its eager top-tier load, but not their descendants), then requests the + /// whole tree recursively via the topicId < 0 branch, and confirms every descendant is materialized and , and that a third, identical call is a genuine, converged hit against the same instance with + /// no further fetches, thus exercising the 's own lazy Root boundary, + /// as per 's documented lazy defaults, alongside EnsureLoaded's whole-tree + /// branch. + /// + [Fact] + public async Task Load_WholeTreeTopUp_MaterializesThenCleanHit() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + var gate = TopicPayload.All & ~(TopicPayload.Relationships | TopicPayload.References); + + var seed = await cache.Load(-1, null, TopicPayload.None, 0); + + Assert.True(((ITopicLazyLoadable)seed!).IsLoaded(TopicPayload.Children)); + + var loaded = await cache.Load(-1, seed, TopicPayload.All, -1); + + Assert.Same(seed, loaded); + Assert.True(((ITopicLazyLoadable)loaded!).IsLoaded(gate, depth: -1)); + + var web = loaded.Children["Web"]; + + Assert.True(web.Children.Contains("Web_0")); + Assert.True(web.Children.Contains("Web_1")); + Assert.True(web.Children["Web_0"].Children.Contains("Web_0_0")); + Assert.True(web.Children["Web_0"].Children["Web_0_0"].Children.Contains("Web_0_0_0")); + Assert.Equal( + "Extended body content for Web_0_0.", + web.Children["Web_0"].Children["Web_0_0"].Attributes.GetValue("Body") + ); + + var fetchesAfterLoad = stub.TotalFetches; + var reloaded = await cache.Load(-1, null, TopicPayload.All, -1); + + Assert.Same(loaded, reloaded); + Assert.Equal(fetchesAfterLoad, stub.TotalFetches); + + } + + /*============================================================================================================================ + | TEST: LOAD: DEPTH TWO THEN UNBOUNDED: TOPS UP REMAINING TIERS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a subtree to depth: 2, confirms the gate agrees only through depth: 1 (i.e., the deepest fetched + /// tier's own children are not yet resolved), then tops the same seed up to depth: -1 and confirms the whole subtree + /// converges against the same instance, proving a partial-depth region reissues one deep load and merges without a + /// perpetual-reload loop. + /// + [Fact] + public async Task Load_DepthTwoThenUnbounded_TopsUpRemainingTiers() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + var gate = TopicPayload.All & ~(TopicPayload.Relationships | TopicPayload.References); + + var seed = await cache.Load("Root:Web", null, TopicPayload.All, 2); + + Assert.True(((ITopicLazyLoadable)seed!).IsLoaded(gate, depth: 1)); + Assert.False(((ITopicLazyLoadable)seed).IsLoaded(gate, depth: 2)); + + var deep = await cache.Load("Root:Web", seed, TopicPayload.All, -1); + + Assert.Same(seed, deep); + Assert.True(((ITopicLazyLoadable)deep!).IsLoaded(gate, depth: -1)); + + } + + /*============================================================================================================================ + | TEST: LOAD: DEPTH TWO THEN DEPTH ONE: IS CLEAN HIT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a subtree to depth: 2, then re-requests it at the shallower depth: 1, and confirms the second call + /// is a converged hit: The same instance is returned and no further fetches are recorded, proving the gate treats a deeper + /// resident region as sufficient for a shallower request rather than re-fetching. + /// + [Fact] + public async Task Load_DepthTwoThenDepthOne_IsCleanHit() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + + var seed = await cache.Load("Root:Web", null, TopicPayload.All, 2); + var fetchesAfterFirstLoad = stub.TotalFetches; + + var reloaded = await cache.Load("Root:Web", seed, TopicPayload.All, 1); + + Assert.Same(seed, reloaded); + Assert.Equal(fetchesAfterFirstLoad, stub.TotalFetches); + + } + + #endregion + + #region M: Depth-Limited Loading + + /*============================================================================================================================ + | TEST: LOAD: DEPTH TWO: LOADS TWO TIERS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic with depth: 2 and confirms exactly two tiers of descendants are materialized: Both the seed's and + /// its child's are , while the grandchild's own children remain + /// , with no fetch recorded against it. Proves depth is modeled by decrementing per level, + /// not merely riding along as part of indefinite recursion (as would be expected with -1). + /// + [Fact] + public async Task Load_DepthTwo_LoadsTwoTiers() { + + var topic = await _loadingTopicRepository.Load("Root:Web", depth: 2); + var web0 = topic!.Children["Web_0"]; + var web00 = web0.Children["Web_0_0"]; + + Assert.True(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.Children)); + Assert.True(((ITopicLazyLoadable)web0).IsLoaded(TopicPayload.Children)); + Assert.False(((ITopicLazyLoadable)web00).IsLoaded(TopicPayload.Children)); + Assert.Equal(0, _loadingTopicRepository.GetFetchCount(web00.Id, TopicPayload.Children)); + + } + + /*============================================================================================================================ + | TEST: LOAD: DEPTH TWO: IS LOADED AGREES AT DEPTH ONE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a topic with depth: 2 and confirms agrees + /// with exactly what was materialized. + /// + /// + /// Since is both the traversal axis and, here, the tested payload, the two are off by + /// one: A depth: 2 load promotes tiers 0 and 1 to but leaves tier 2 , since only its rows, and not its own children, were fetched. checks that every visited tier, including the deepest one, itself + /// satisfies the requested payload, so asking for Children at depth: 2 also demands the tier-2's own + /// Children be resolved, which a depth: 2 load never promotes. The query that agrees with a depth: 2 + /// load is therefore depth: 1, not depth: 2. + /// + [Fact] + public async Task Load_DepthTwo_IsLoadedAgreesAtDepthOne() { + + var topic = await _loadingTopicRepository.Load("Root:Web", depth: 2); + var rawTopic = (ITopicLazyLoadable)topic!; + + Assert.True(rawTopic.IsLoaded(TopicPayload.Children, depth: 1)); + Assert.False(rawTopic.IsLoaded(TopicPayload.Children, depth: 2)); + Assert.False(rawTopic.IsLoaded(TopicPayload.Children, depth: -1)); + + } + + #endregion + + #region N: Deferred Dirty-State Propagation + + /*============================================================================================================================ + | TEST: ENSURE LOADED: DIRTY DEFERRED TARGET: RESOLVES AS DIRTY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Marks a loaded topic's deferred relationship entry as dirty, as 's merge does, via , then resolves it via and confirms the resolved relationship is itself marked dirty, so that a subsequent + /// would persist it. + /// + [Fact] + public async Task EnsureLoaded_DirtyDeferredTarget_ResolvesAsDirty() { + + // The target must not yet be loaded when "Web_1" loads; otherwise Load()'s own resolution (i.e., FillRequestedPayload's + // resolveDeferredTargets) would resolve "Related" immediately, with the default, non-dirty flag, before this test ever gets + // a chance to restamp the entry as dirty + var topic = await _loadingTopicRepository.Load("Root:Web:Web_1"); + var rawTopic = (ITopicBackingAccessor)topic!; + var targetId = rawTopic.Relationships.Deferred.Single(d => d.Key == "Related").TopicId; + + rawTopic.Relationships.Deferred.SetValue("Related", targetId, isDirty: true); + + var target = await _loadingTopicRepository.Load("Root:Web:Web_0:Web_0_0"); + + await _loadingTopicRepository.EnsureLoaded(topic!, TopicPayload.Relationships, cancellationToken: CancellationToken); + + Assert.Contains(target, topic!.Relationships.GetValues("Related")); + Assert.True(topic.Relationships.IsDirty()); + + } + + /*============================================================================================================================ + | TEST: ENSURE LOADED: CLEAN DEFERRED TARGET: RESOLVES AS NOT DIRTY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Resolves a loaded topic's non-dirty deferred relationship entry via and + /// confirms the resolved relationship is not marked dirty, since it merely reflects data already present in the persistence + /// store, thus the counterpart to . + /// + [Fact] + public async Task EnsureLoaded_CleanDeferredTarget_ResolvesAsNotDirty() { + + // As in EnsureLoaded_DirtyDeferredTarget_ResolvesAsDirty, "Web_1" must load before its target, so "Related" stays deferred + // until EnsureLoaded resolves it, rather than being eagerly resolved by Load()'s own resolution + var topic = await _loadingTopicRepository.Load("Root:Web:Web_1"); + + var target = await _loadingTopicRepository.Load("Root:Web:Web_0:Web_0_0"); + + await _loadingTopicRepository.EnsureLoaded(topic!, TopicPayload.Relationships, cancellationToken: CancellationToken); + + Assert.Contains(target, topic!.Relationships.GetValues("Related")); + Assert.False(topic.Relationships.IsDirty()); + + } + + #endregion + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/MemberAccessorTest.cs b/OnTopic.Tests/MemberAccessorTest.cs index f69738b4..93ce19ae 100644 --- a/OnTopic.Tests/MemberAccessorTest.cs +++ b/OnTopic.Tests/MemberAccessorTest.cs @@ -158,8 +158,8 @@ public void IsSettable_ReadOnlyProperty_ReturnsFalse() { | TEST: GET VALUE: VALID PROPERTY: RETURNS VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new from a , and attempts to call with a compliant object, expecting that the correct value will be returned. + /// Assembles a new from a , and attempts to call with a compliant object, expecting that the correct value will be returned. /// [Fact] public void GetValue_ValidProperty_ReturnsValue() { @@ -178,8 +178,8 @@ public void GetValue_ValidProperty_ReturnsValue() { | TEST: GET VALUE: VALID METHOD: RETURNS VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new from a , and attempts to call with a compliant object, expecting that the correct value will be returned. + /// Assembles a new from a , and attempts to call with a compliant object, expecting that the correct value will be returned. /// [Theory] [InlineData(15)] @@ -203,8 +203,8 @@ public void GetValue_ValidMethod_ReturnsValue(int? value) { | TEST: GET VALUE: TYPE MISMATCH: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new from a , and attempts to call with an object that doesn't contain the , expecting that an + /// Assembles a new from a , and attempts to call with an object that doesn't contain the , expecting that an /// will be thrown. /// [Fact] @@ -224,8 +224,8 @@ public void GetValue_TypeMismatch_ThrowsException() { | TEST: SET VALUE: VALID PROPERTY: SETS VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new from a , and attempts to call with a compliant object, expecting that the correct value will be + /// Assembles a new from a , and attempts to call with a compliant object, expecting that the correct value will be /// set. /// [Theory] @@ -249,8 +249,8 @@ public void SetValue_ValidProperty_SetsValue(int? value) { | TEST: SET VALUE: VALID METHOD: SETS VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new from a , and attempts to call with a compliant object, expecting that the correct value will be + /// Assembles a new from a , and attempts to call with a compliant object, expecting that the correct value will be /// set. /// [Theory] @@ -274,9 +274,9 @@ public void SetValue_ValidMethod_SetsValue(int? value) { | TEST: SET VALUE: MEMBER TYPE MISMATCH: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new from a , and attempts to call with an object that isn't compatible with the , expecting that an will be thrown. + /// Assembles a new from a , and attempts to call with an object that isn't compatible with the , expecting that an will be thrown. /// [Fact] public void SetValue_MemberTypeMismatch_ThrowsException() { @@ -396,7 +396,7 @@ public void IsValid_InvalidSetterMethod_ReturnsFalse() public void IsValid_Constructor_ReturnsFalse() { var type = typeof(MemberAccessorViewModel); - var memberInfo = type.GetConstructor(Array.Empty())!; + var memberInfo = type.GetConstructor([])!; Assert.NotNull(memberInfo); Assert.False(MemberAccessor.IsValid(memberInfo)); diff --git a/OnTopic.Tests/ReverseTopicMappingServiceTest.cs b/OnTopic.Tests/ReverseTopicMappingServiceTest.cs index 629b7e89..c1caa9a5 100644 --- a/OnTopic.Tests/ReverseTopicMappingServiceTest.cs +++ b/OnTopic.Tests/ReverseTopicMappingServiceTest.cs @@ -15,6 +15,7 @@ using OnTopic.TestDoubles.Metadata; using OnTopic.Tests.BindingModels; using OnTopic.Tests.Fixtures; +using OnTopic.Tests.TestDoubles; using Xunit; namespace OnTopic.Tests; @@ -141,7 +142,7 @@ public async Task Map_Existing_ReturnsUpdatedTopic() { target.Title = "Original Attribute"; target.DefaultValue = "Hello"; target.IsRequired = true; - target.IsExtendedAttribute= false; + target.IsExtendedAttribute = false; target.Attributes.SetValue("Description", "Original Description"); @@ -244,10 +245,10 @@ public async Task Map_Relationships_ReturnsMappedTopic() { topic.Relationships.SetValue("ContentTypes", contentTypes[4]); - for (var i = 0; i < 3; i++) { + for (var i = 0; i < 3; i++) { bindingModel.ContentTypes.Add( new() { - UniqueKey = contentTypes[i].GetUniqueKey() + UniqueKey = contentTypes[i].GetUniqueKey() } ); } @@ -261,7 +262,7 @@ public async Task Map_Relationships_ReturnsMappedTopic() { Assert.False(target?.PermittedContentTypes.Contains(contentTypes[3])); //Revert state - _topicRepository.Delete(topic); + await _topicRepository.Delete(topic); } @@ -280,7 +281,7 @@ public async Task Map_Relationships_ThrowException() { bindingModel.ContentTypes.Add( new() { - UniqueKey = "Root:Configuration:InvalidKey" + UniqueKey = "Root:Configuration:InvalidKey" } ); @@ -325,6 +326,112 @@ public async Task Map_NestedTopics_ReturnsMappedTopic() { } + /*============================================================================================================================ + | TEST: MAP: NESTED TOPICS: STAGGERED COMPLETION: PRESERVES SOURCE ORDER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes a backed by a whose + /// per-item topic reference lookups resolve out of call order: The first-declared item resolves slowest, the last-declared + /// item resolves instantly. Confirms nested topics still land in the binding model's source order, since maps and adds each child sequentially rather than racing completions. + /// + [Fact] + public async Task Map_NestedTopics_StaggeredCompletion_PreservesSourceOrder() { + + // Declared in call order; delays fall in reverse, so the first-added item resolves last + List<(string UniqueKey, TimeSpan Delay)> attributes = [ + ("Root:Configuration:ContentTypes:Attributes:Key", TimeSpan.FromMilliseconds(120)), + ("Root:Configuration:ContentTypes:Attributes:ContentType", TimeSpan.FromMilliseconds(60)), + ("Root:Configuration:ContentTypes:Attributes:Title", TimeSpan.Zero) + ]; + + var delaysByKey = attributes.ToDictionary(attribute => attribute.UniqueKey, attribute => attribute.Delay); + var topicRepository = new StaggeredStubTopicRepository(delaysByKey); + var mappingService = new ReverseTopicMappingService(topicRepository); + var bindingModel = new ContentTypeDescriptorTopicBindingModel("Test"); + + for (var i = 0; i < attributes.Count; i++) { + bindingModel.Attributes.Add( + new NestedReferenceAttributeTopicBindingModel($"Attribute{i + 1}") { + BaseTopic = new() { + UniqueKey = attributes[i].UniqueKey + } + } + ); + } + + var topic = new ContentTypeDescriptor("Test", "ContentTypeDescriptor"); + var target = (ContentTypeDescriptor?)await mappingService.MapAsync(bindingModel, topic); + var container = target?.Children.GetValue("Attributes"); + + Assert.NotNull(container); + Assert.Equal( + Enumerable.Range(1, attributes.Count).Select(i => $"Attribute{i}"), + container.Children.Select(child => child.Key) + ); + + } + + /*============================================================================================================================ + | TEST: MAP: SPARSE TOPIC: FILLS EXTENDED ATTRIBUTES ONCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Maps a scalar-only binding model onto a target stamped with a whose are . Confirms warms exactly once at the start of the map, rather than leaving it to the attribute + /// collection's own synchronous autoload. + /// + [Fact] + public async Task Map_ScalarProperties_FillsExtendedAttributesOnce() { + + var bindingModel = new TextAttributeTopicBindingModel("Test") { + ContentType = "TextAttributeDescriptor", + DefaultValue = "World" + }; + + var target = new TextAttributeDescriptor("Test", "TextAttributeDescriptor"); + var loader = new TrackingTopicLazyLoader(markLoaded: true); + + ((ITopicLazyLoadable)target).Loader = loader; + target.Attributes.LoadState = LoadState.NotLoaded; + + _ = await _mappingService.MapAsync(bindingModel, target); + + Assert.Equal(1, loader.CallCount); + Assert.Equal(TopicPayload.ExtendedAttributes, loader.Payloads[0]); + + } + + /*============================================================================================================================ + | TEST: MAP: NESTED TOPICS: FILLS CONTAINER CHILDREN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Maps a nested-topic binding model onto a target whose Attributes container is stamped with its own and left , even though the target's own are already loaded. Confirms warms the container + /// independently before PopulateTargetCollectionAsync probes its existing children. + /// + [Fact] + public async Task Map_NestedTopics_FillsContainerChildren() { + + var bindingModel = new ContentTypeDescriptorTopicBindingModel("Test"); + + bindingModel.Attributes.Add(new TextAttributeTopicBindingModel("Attribute1")); + + var target = new ContentTypeDescriptor("Test", "ContentTypeDescriptor"); + var container = new Topic("Attributes", "List", target); + var containerLoader = new TrackingTopicLazyLoader(markLoaded: true); + + ((ITopicLazyLoadable)container).Loader = containerLoader; + container.Children.LoadState = LoadState.NotLoaded; + + _ = (ContentTypeDescriptor?)await _mappingService.MapAsync(bindingModel, target); + + Assert.Equal(1, containerLoader.CallCount); + Assert.Equal(TopicPayload.Children, containerLoader.Payloads[0]); + + } + /*============================================================================================================================ | TEST: MAP: TOPIC REFERENCES: RETURNS MAPPED TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ @@ -334,7 +441,7 @@ public async Task Map_NestedTopics_ReturnsMappedTopic() { [Fact] public async Task Map_TopicReferences_ReturnsMappedTopic() { - var topic = _topicRepository.Load("Root:Configuration:ContentTypes:Attributes:Title"); + var topic = await _topicRepository.Load("Root:Configuration:ContentTypes:Attributes:Title"); Contract.Assume(topic); @@ -362,8 +469,8 @@ public async Task Map_TopicReferences_ReturnsMappedTopic() { [Fact] public async Task Map_NullTopicReference_Delete() { - var topic = _topicRepository.Load("Root:Configuration:ContentTypes:Attributes:Title"); - var baseTopic = _topicRepository.Load("Root:Configuration:ContentTypes:Attributes:Key"); + var topic = await _topicRepository.Load("Root:Configuration:ContentTypes:Attributes:Title"); + var baseTopic = await _topicRepository.Load("Root:Configuration:ContentTypes:Attributes:Key"); Contract.Assume(topic); @@ -478,7 +585,7 @@ public async Task Map_NullProperty_MapsDefaultValue() { var target = await _mappingService.MapAsync(bindingModel); - Assert.Equal("Default page description", target?.Attributes.GetValue("MetaDescription")); + Assert.Equal("Default page description", target?.Attributes.GetValue("MetaDescription")); } @@ -618,8 +725,8 @@ await _mappingService.MapAsync(bindingModel).ConfigureAwait(false) \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Maps a content type that has a nested topic that implements an invalid collection type—i.e., it implements a , even though nestd topics are expected to return a type implementing . This is invalid, and expected to throw an . + /// cref="Dictionary{TKey, TValue}"/>, even though nestd topics are expected to return a type implementing . This is invalid, and expected to throw an . /// [Fact] public async Task Map_InvalidNestedTopicListType_ThrowsInvalidOperationException() { @@ -636,9 +743,9 @@ await _mappingService.MapAsync(bindingModel).ConfigureAwait(false) | TEST: MAP: INVALID TOPIC REFERENCE TYPE: THROWS INVALID OPERATION EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Maps a content type that has a reference that implements an invalid type—i.e., it implements a , even though references are expected to return a type implementing . This is invalid, and expected to throw an . + /// Maps a content type that has a reference that implements an invalid type—i.e., it implements a , even though references are expected to return a type implementing . This is invalid, and expected to throw an . /// [Fact] public async Task Map_InvalidTopicReferenceType_ThrowsInvalidOperationException() { @@ -666,7 +773,7 @@ public async Task Map_DisabledProperty_IsNotMapped() { UnmappedAttribute = "Hello World" }; - var target = await _mappingService.MapAsync(bindingModel); + var target = await _mappingService.MapAsync(bindingModel); Assert.Null(target?.Attributes.GetValue("UnmappedAttribute", null)); diff --git a/OnTopic.Tests/Schemas/AttributesDataTable.cs b/OnTopic.Tests/Schemas/AttributesDataTable.cs index b4e090d3..0dc8dae0 100644 --- a/OnTopic.Tests/Schemas/AttributesDataTable.cs +++ b/OnTopic.Tests/Schemas/AttributesDataTable.cs @@ -82,7 +82,7 @@ public void AddRow(int topicId, string attributeKey, string? attributeValue, Dat /*-------------------------------------------------------------------------------------------------------------------------- | Create new row \-------------------------------------------------------------------------------------------------------------------------*/ - var row = NewRow(); + var row = NewRow(); row["TopicId"] = topicId; row["AttributeKey"] = attributeKey; diff --git a/OnTopic.Tests/Schemas/ExtendedAttributesDataTable.cs b/OnTopic.Tests/Schemas/ExtendedAttributesDataTable.cs index 136e6f9d..353a6b85 100644 --- a/OnTopic.Tests/Schemas/ExtendedAttributesDataTable.cs +++ b/OnTopic.Tests/Schemas/ExtendedAttributesDataTable.cs @@ -74,7 +74,7 @@ public void AddRow(int topicId, XmlDocument xml, DateTime? version = null) { /*-------------------------------------------------------------------------------------------------------------------------- | Create new row \-------------------------------------------------------------------------------------------------------------------------*/ - var row = NewRow(); + var row = NewRow(); row["TopicId"] = topicId; row["AttributesXml"] = xml; diff --git a/OnTopic.Tests/Schemas/RelationshipsDataTable.cs b/OnTopic.Tests/Schemas/RelationshipsDataTable.cs index e312e548..c465c65e 100644 --- a/OnTopic.Tests/Schemas/RelationshipsDataTable.cs +++ b/OnTopic.Tests/Schemas/RelationshipsDataTable.cs @@ -90,7 +90,7 @@ public void AddRow(int sourceTopicId, string relationshipKey, int targetTopicId, /*-------------------------------------------------------------------------------------------------------------------------- | Create new row \-------------------------------------------------------------------------------------------------------------------------*/ - var row = NewRow(); + var row = NewRow(); row["Source_TopicId"] = sourceTopicId; row["RelationshipKey"] = relationshipKey; diff --git a/OnTopic.Tests/Schemas/TopicReferencesDataTable.cs b/OnTopic.Tests/Schemas/TopicReferencesDataTable.cs index 4758b5e9..ccc42ced 100644 --- a/OnTopic.Tests/Schemas/TopicReferencesDataTable.cs +++ b/OnTopic.Tests/Schemas/TopicReferencesDataTable.cs @@ -82,7 +82,7 @@ public void AddRow(int sourceTopicId, string referenceKey, int? targetTopicId, D /*-------------------------------------------------------------------------------------------------------------------------- | Create new row \-------------------------------------------------------------------------------------------------------------------------*/ - var row = NewRow(); + var row = NewRow(); row["Source_TopicId"] = sourceTopicId; row["ReferenceKey"] = referenceKey; diff --git a/OnTopic.Tests/Schemas/TopicsDataTable.cs b/OnTopic.Tests/Schemas/TopicsDataTable.cs index 85c0f443..b13fd38c 100644 --- a/OnTopic.Tests/Schemas/TopicsDataTable.cs +++ b/OnTopic.Tests/Schemas/TopicsDataTable.cs @@ -63,6 +63,24 @@ public TopicsDataTable() : base("Topics") { AllowDBNull = true }); + /*-------------------------------------------------------------------------------------------------------------------------- + | Add HasChildren column + \-------------------------------------------------------------------------------------------------------------------------*/ + Columns.Add(new DataColumn() { + DataType = typeof(bool), + ColumnName = "HasChildren", + AllowDBNull = true + }); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Add HasExtendedAttributes column + \-------------------------------------------------------------------------------------------------------------------------*/ + Columns.Add(new DataColumn() { + DataType = typeof(bool), + ColumnName = "HasExtendedAttributes", + AllowDBNull = true + }); + } /*============================================================================================================================ @@ -71,7 +89,14 @@ public TopicsDataTable() : base("Topics") { /// /// Adds a new to the . /// - public void AddRow(int topicId, string topicKey, string contentType, int? parentId = null) { + public void AddRow( + int topicId, + string topicKey, + string contentType, + int? parentId = null, + bool? hasChildren = null, + bool? hasExtendedAttributes = null + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Verify parameters @@ -83,12 +108,14 @@ public void AddRow(int topicId, string topicKey, string contentType, int? parent /*-------------------------------------------------------------------------------------------------------------------------- | Create new row \-------------------------------------------------------------------------------------------------------------------------*/ - var row = NewRow(); + var row = NewRow(); row["TopicId"] = topicId; row["TopicKey"] = topicKey; row["ContentType"] = contentType; - row["ParentId"] = parentId.HasValue? (object)parentId : DBNull.Value; + row["ParentId"] = parentId.HasValue? parentId : DBNull.Value; + row["HasChildren"] = hasChildren.HasValue? hasChildren.Value : DBNull.Value; + row["HasExtendedAttributes"] = hasExtendedAttributes.HasValue? hasExtendedAttributes.Value : DBNull.Value; /*-------------------------------------------------------------------------------------------------------------------------- | Add row to table diff --git a/OnTopic.Tests/Schemas/VersionHistoryDataTable.cs b/OnTopic.Tests/Schemas/VersionHistoryDataTable.cs index 4973d3c4..fce2a573 100644 --- a/OnTopic.Tests/Schemas/VersionHistoryDataTable.cs +++ b/OnTopic.Tests/Schemas/VersionHistoryDataTable.cs @@ -66,7 +66,7 @@ public void AddRow(int topicId, DateTime version) { /*-------------------------------------------------------------------------------------------------------------------------- | Create new row \-------------------------------------------------------------------------------------------------------------------------*/ - var row = NewRow(); + var row = NewRow(); row["TopicId"] = topicId; row["Version"] = version; diff --git a/OnTopic.Tests/SqlTopicRepositoryTest.cs b/OnTopic.Tests/SqlTopicRepositoryTest.cs index 86cbb637..3efc8cc2 100644 --- a/OnTopic.Tests/SqlTopicRepositoryTest.cs +++ b/OnTopic.Tests/SqlTopicRepositoryTest.cs @@ -7,9 +7,14 @@ using System.Text; using Microsoft.Data.SqlClient; using OnTopic.Associations; +using OnTopic.Collections.Specialized; using OnTopic.Data.Sql; +using OnTopic.Data.Sql.Models; +using OnTopic.Querying; +using OnTopic.Repositories; using OnTopic.Tests.Schemas; using Xunit; +using TopicReferencesDataTable = OnTopic.Tests.Schemas.TopicReferencesDataTable; namespace OnTopic.Tests; @@ -22,26 +27,34 @@ namespace OnTopic.Tests; [ExcludeFromCodeCoverage] public class SqlTopicRepositoryTest { + /*============================================================================================================================ + | PROPERTY: CANCELLATION TOKEN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Shorthand for 's . + /// + private static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + /*============================================================================================================================ | TEST: LOAD TOPIC GRAPH: WITH TOPIC: RETURNS TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a record and confirms that a topic with those values is returned. + /// Calls with a record and confirms that + /// a topic with those values is returned. /// [Fact] - public void LoadTopicGraph_WithTopic_ReturnsTopic() { + public async Task LoadTopicGraph_WithTopic_ReturnsTopic() { using var topics = new TopicsDataTable(); - topics.AddRow(1, "Root", "Container", null); + topics.AddRow(1, "Root", "Container"); using var tableReader = new DataTableReader(topics); - var topic = tableReader.LoadTopicGraph(); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); Assert.NotNull(topic); - Assert.Equal(1, topic?.Id); + Assert.Equal(1, topic.Id); } @@ -49,12 +62,11 @@ public void LoadTopicGraph_WithTopic_ReturnsTopic() { | TEST: LOAD TOPIC GRAPH: WITH NEW PARENT: UPDATES PARENT \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a record that represents a different parent than the existing referenceTopic and confirms that - /// the topic's parent is updated. + /// Calls with a record that represents a + /// different parent than the existing referenceTopic and confirms that the topic's parent is updated. /// [Fact] - public void LoadTopicGraph_WithNewParent_UpdatesParent() { + public async Task LoadTopicGraph_WithNewParent_UpdatesParent() { using var topics = new TopicsDataTable(); @@ -67,7 +79,7 @@ public void LoadTopicGraph_WithNewParent_UpdatesParent() { using var tableReader = new DataTableReader(topics); - tableReader.LoadTopicGraph(topic); + await tableReader.LoadTopicGraph(referenceTopic: topic, cancellationToken: CancellationToken); Assert.Equal(parent2, child.Parent); @@ -77,25 +89,25 @@ public void LoadTopicGraph_WithNewParent_UpdatesParent() { | TEST: LOAD TOPIC GRAPH: WITH ATTRIBUTES: RETURNS ATTRIBUTES \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with an record and confirms that a topic with those values is returned. + /// Calls with an record and confirms + /// that a topic with those values is returned. /// [Fact] - public void LoadTopicGraph_WithAttributes_ReturnsAttributes() { + public async Task LoadTopicGraph_WithAttributes_ReturnsAttributes() { using var topics = new TopicsDataTable(); using var attributes = new AttributesDataTable(); - topics.AddRow(1, "Root", "Container", null); + topics.AddRow(1, "Root", "Container"); attributes.AddRow(1, "Test", "Value"); - using var tableReader = new DataTableReader(new DataTable[] { topics, attributes }); + using var tableReader = new DataTableReader([topics, attributes]); - var topic = tableReader.LoadTopicGraph(); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); Assert.NotNull(topic); - Assert.Equal(1, topic?.Id); - Assert.Equal("Value", topic?.Attributes.GetValue("Test")); + Assert.Equal(1, topic.Id); + Assert.Equal("Value", topic.Attributes.GetValue("Test")); } @@ -103,12 +115,11 @@ public void LoadTopicGraph_WithAttributes_ReturnsAttributes() { | TEST: LOAD TOPIC GRAPH: WITH NULL ATTRIBUTES: REMOVES ATTRIBUTE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with an record representing a deleted attribute and confirms that an existing reference topic with that - /// attribute has the value removed. + /// Calls with an record representing + /// a deleted attribute and confirms that an existing reference topic with that attribute has the value removed. /// [Fact] - public void LoadTopicGraph_WithNullAttributes_RemovesAttribute() { + public async Task LoadTopicGraph_WithNullAttributes_RemovesAttribute() { using var topics = new TopicsDataTable(); using var attributes = new AttributesDataTable(); @@ -120,9 +131,9 @@ public void LoadTopicGraph_WithNullAttributes_RemovesAttribute() { topics.AddRow(1, "Root", "Container"); attributes.AddRow(1, "Test", null); - using var tableReader = new DataTableReader(new DataTable[] { topics, attributes }); + using var tableReader = new DataTableReader([topics, attributes]); - tableReader.LoadTopicGraph(topic); + await tableReader.LoadTopicGraph(referenceTopic: topic, cancellationToken: CancellationToken); Assert.Null(topic.Attributes.GetValue("Test")); @@ -132,28 +143,28 @@ public void LoadTopicGraph_WithNullAttributes_RemovesAttribute() { | TEST: LOAD TOPIC GRAPH: WITH RELATIONSHIP: RETURNS RELATIONSHIP \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a record and confirms that a topic with those values is returned. + /// Calls with a record and + /// confirms that a topic with those values is returned. /// [Fact] - public void LoadTopicGraph_WithRelationship_ReturnsRelationship() { + public async Task LoadTopicGraph_WithRelationship_ReturnsRelationship() { using var topics = new TopicsDataTable(); using var empty = new AttributesDataTable(); using var relationships = new RelationshipsDataTable(); - topics.AddRow(1, "Root", "Container", null); + topics.AddRow(1, "Root", "Container"); topics.AddRow(2, "Web", "Container", 1); relationships.AddRow(1, "Test", 2, false); - using var tableReader = new DataTableReader(new DataTable[] { topics, empty, empty, relationships }); + using var tableReader = new DataTableReader([topics, empty, empty, relationships]); - var topic = tableReader.LoadTopicGraph(); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); Assert.NotNull(topic); - Assert.Equal(1, topic?.Id); - Assert.Equal(2, topic?.Relationships.GetValues("Test").FirstOrDefault()?.Id); - Assert.True(topic?.Relationships.IsFullyLoaded); + Assert.Equal(1, topic.Id); + Assert.Equal(2, topic.Relationships.GetValues("Test").FirstOrDefault()?.Id); + Assert.True(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.Relationships)); } @@ -161,28 +172,27 @@ public void LoadTopicGraph_WithRelationship_ReturnsRelationship() { | TEST: LOAD TOPIC GRAPH: WITH MISSING RELATIONSHIP: NOT FULLY LOADED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a record that is missing and confirms that returns false. + /// Calls with a record that is + /// missing and confirms that returns . /// [Fact] - public void LoadTopicGraph_WithMissingRelationship_NotFullyLoaded() { + public async Task LoadTopicGraph_WithMissingRelationship_NotFullyLoaded() { using var topics = new TopicsDataTable(); using var empty = new AttributesDataTable(); using var relationships = new RelationshipsDataTable(); - topics.AddRow(1, "Root", "Container", null); + topics.AddRow(1, "Root", "Container"); relationships.AddRow(1, "Test", 2, false); - using var tableReader = new DataTableReader(new DataTable[] { topics, empty, empty, relationships }); + using var tableReader = new DataTableReader([topics, empty, empty, relationships]); - var topic = tableReader.LoadTopicGraph(); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); Assert.NotNull(topic); Assert.Equal(1, topic.Id); Assert.Empty(topic.Relationships); - Assert.False(topic.Relationships.IsFullyLoaded); + Assert.False(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.Relationships)); } @@ -190,173 +200,639 @@ public void LoadTopicGraph_WithMissingRelationship_NotFullyLoaded() { | TEST: LOAD TOPIC GRAPH: WITH REFERENCE: RETURNS REFERENCE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a record and confirms that a topic with those values is returned. + /// Calls with a record and + /// confirms that a topic with those values is returned. /// [Fact] - public void LoadTopicGraph_WithReference_ReturnsReference() { + public async Task LoadTopicGraph_WithReference_ReturnsReference() { using var topics = new TopicsDataTable(); using var empty = new AttributesDataTable(); using var references = new TopicReferencesDataTable(); - topics.AddRow(1, "Root", "Container", null); + topics.AddRow(1, "Root", "Container"); topics.AddRow(2, "Web", "Container", 1); references.AddRow(1, "Test", 2); - using var tableReader = new DataTableReader(new DataTable[] { topics, empty, empty, empty, references }); + using var tableReader = new DataTableReader([topics, empty, empty, empty, references]); - var topic = tableReader.LoadTopicGraph(); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); Assert.NotNull(topic); - Assert.Equal(1, topic?.Id); - Assert.Equal(2, topic?.References.GetValue("Test")?.Id); - Assert.True(topic?.References.IsDirty()); + Assert.Equal(1, topic.Id); + Assert.Equal(2, topic.References.GetValue("Test")?.Id); + Assert.True(topic.References.IsDirty()); } /*============================================================================================================================ - | TEST: LOAD TOPIC GRAPH: WITH EXTERNAL REFERENCE: RETURNS REFERENCE + | TEST: LOAD TOPIC GRAPH: WITH DELETED REFERENCE: REMOVES EXISTING REFERENCE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a record and confirms that a topic with those values is returned. + /// Calls with a record and + /// confirms that existing references on a reference topic are deleted if they are null in the . /// [Fact] - public void LoadTopicGraph_WithExternalReference_ReturnsReference() { + public async Task LoadTopicGraph_WithDeletedReference_RemovesExistingReference() { using var topics = new TopicsDataTable(); using var empty = new AttributesDataTable(); using var references = new TopicReferencesDataTable(); - var referenceTopic = new Topic("Web", "Container", null, 2); + var referenceTopic = new Topic("Web", "Container", null, 1); + + referenceTopic.References.SetValue("Reference", referenceTopic); + + topics.AddRow(1, "Web", "Container"); + references.AddRow(1, "Reference", null); + + using var tableReader = new DataTableReader([topics, empty, empty, empty, references]); + + await tableReader.LoadTopicGraph(1, referenceTopic, false, cancellationToken: CancellationToken); + + Assert.Null(referenceTopic.References.GetValue("Reference")); + Assert.Equal(LoadState.Loaded, referenceTopic.References.LoadState); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH MISSING REFERENCE: NOT FULLY LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a record that is + /// missing and confirms that returns . + /// + [Fact] + public async Task LoadTopicGraph_WithMissingReference_NotFullyLoaded() { + + using var topics = new TopicsDataTable(); + using var empty = new AttributesDataTable(); + using var references = new TopicReferencesDataTable(); - topics.AddRow(1, "Root", "Container", null); + topics.AddRow(1, "Root", "Container"); references.AddRow(1, "Test", 2); - using var tableReader = new DataTableReader(new DataTable[] { topics, empty, empty, empty, references }); + using var tableReader = new DataTableReader([topics, empty, empty, empty, references]); - var topic = tableReader.LoadTopicGraph(referenceTopic, false); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); Assert.NotNull(topic); - Assert.Equal(1, topic?.Id); - Assert.Equal(2, topic?.References.GetValue("Test")?.Id); - Assert.True(topic?.References.IsFullyLoaded); - Assert.False(topic?.References.IsDirty()); + Assert.Equal(1, topic.Id); + Assert.Empty(topic.References); + Assert.False(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.References)); } /*============================================================================================================================ - | TEST: LOAD TOPIC GRAPH: WITH DELETED REFERENCE: REMOVES EXISTING REFERENCE + | TEST: LOAD TOPIC GRAPH: WITH DELETED RELATIONSHIP: REMOVES RELATIONSHIP + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a deleted record + /// and confirms that it is deleted from the referenceTopic graph. + /// + [Fact] + public async Task LoadTopicGraph_WithDeletedRelationship_RemovesRelationship() { + + var topic = new Topic("Test", "Container", null, 1); + var child = new Topic("Child", "Container", topic, 2); + var related = new Topic("Related", "Container", topic, 3); + + child.Relationships.SetValue("Test", related); + + using var empty = new AttributesDataTable(); + using var relationships = new RelationshipsDataTable(); + + relationships.AddRow(2, "Test", 3, true); + + using var tableReader = new DataTableReader([empty, empty, empty, relationships]); + + await tableReader.LoadTopicGraph(referenceTopic: related, cancellationToken: CancellationToken); + + Assert.Empty(topic.Relationships.GetValues("Test")); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: INDEXED-ONLY WITH RELATIONSHIP: RETURNS LOADED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a record and confirms that existing references on a reference topic are deleted if they are - /// null in the . + /// Calls under an indexed-only load (i.e., extended attributes + /// deferred via HasExtendedAttributes = true) with a relationship whose target is resident, and confirms that + /// returns . /// + /// + /// Confirms that the relationship result set is still returned and is correctly established even + /// when extended attributes are deferred. + /// [Fact] - public void LoadTopicGraph_WithDeletedReference_RemovesExistingReference() { + public async Task LoadTopicGraph_IndexedOnlyWithRelationship_ReturnsLoaded() { using var topics = new TopicsDataTable(); using var empty = new AttributesDataTable(); - using var references = new TopicReferencesDataTable(); + using var relationships = new RelationshipsDataTable(); - var referenceTopic = new Topic("Web", "Container", null, 1); + topics.AddRow(1, "Root", "Container", hasExtendedAttributes: true); + topics.AddRow(2, "Web", "Container", 1, hasExtendedAttributes: false); + relationships.AddRow(1, "Test", 2, false); - referenceTopic.References.SetValue("Reference", referenceTopic); + using var tableReader = new DataTableReader([topics, empty, empty, relationships]); - topics.AddRow(1, "Web", "Container", null); - references.AddRow(1, "Reference", null); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); - using var tableReader = new DataTableReader(new DataTable[] { topics, empty, empty, empty, references }); + Assert.NotNull(topic); + Assert.True(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.Relationships)); - tableReader.LoadTopicGraph(referenceTopic, false); + } - Assert.Null(referenceTopic.References.GetValue("Reference")); - Assert.True(referenceTopic.References.IsFullyLoaded); + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: INDEXED-ONLY WITH MISSING RELATIONSHIP: RETURNS NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls under an indexed-only load with a relationship whose target is + /// non-resident, and confirms that returns . + /// + /// + /// LoadState.NotLoaded blocks DeleteUnmatched on save; the missing edge is reconnected when the outer + /// resolver calls EnsureLoaded(Relationships) for the topic. + /// + [Fact] + public async Task LoadTopicGraph_IndexedOnlyWithMissingRelationship_ReturnsNotLoaded() { + + using var topics = new TopicsDataTable(); + using var empty = new AttributesDataTable(); + using var relationships = new RelationshipsDataTable(); + + topics.AddRow(1, "Root", "Container", hasExtendedAttributes: true); + relationships.AddRow(1, "Test", 99, false); + + using var tableReader = new DataTableReader([topics, empty, empty, relationships]); + + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); + + Assert.NotNull(topic); + Assert.False(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.Relationships)); } /*============================================================================================================================ - | TEST: LOAD TOPIC GRAPH: WITH MISSING REFERENCE: NOT FULLY LOADED + | TEST: LOAD TOPIC GRAPH: INDEXED-ONLY WITH REFERENCE: RETURNS LOADED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a record that is missing and confirms that returns false. + /// Calls under an indexed-only load with a reference whose target is + /// resident, and confirms that returns . /// [Fact] - public void LoadTopicGraph_WithMissingReference_NotFullyLoaded() { + public async Task LoadTopicGraph_IndexedOnlyWithReference_ReturnsLoaded() { using var topics = new TopicsDataTable(); using var empty = new AttributesDataTable(); using var references = new TopicReferencesDataTable(); - topics.AddRow(1, "Root", "Container", null); + topics.AddRow(1, "Root", "Container", hasExtendedAttributes: true); + topics.AddRow(2, "Web", "Container", 1, hasExtendedAttributes: false); references.AddRow(1, "Test", 2); - using var tableReader = new DataTableReader(new DataTable[] { topics, empty, empty, empty, references }); + using var tableReader = new DataTableReader([topics, empty, empty, empty, references]); - var topic = tableReader.LoadTopicGraph(); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); Assert.NotNull(topic); - Assert.Equal(1, topic.Id); - Assert.Empty(topic.References); - Assert.False(topic.References.IsFullyLoaded); + Assert.True(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.References)); } /*============================================================================================================================ - | TEST: LOAD TOPIC GRAPH: WITH DELETED RELATIONSHIP: REMOVES RELATIONSHIP + | TEST: LOAD TOPIC GRAPH: INDEXED-ONLY WITH MISSING REFERENCE: RETURNS NOT LOADED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a deleted record and confirms that it is deleted from the referenceTopic graph. + /// Calls under an indexed-only load with a reference whose target is + /// non-resident, and confirms that returns . /// [Fact] - public void LoadTopicGraph_WithDeletedRelationship_RemovesRelationship() { + public async Task LoadTopicGraph_IndexedOnlyWithMissingReference_ReturnsNotLoaded() { - var topic = new Topic("Test", "Container", null, 1); - var child = new Topic("Child", "Container", topic, 2); - var related = new Topic("Related", "Container", topic, 3); + using var topics = new TopicsDataTable(); + using var empty = new AttributesDataTable(); + using var references = new TopicReferencesDataTable(); - child.Relationships.SetValue("Test", related); + topics.AddRow(1, "Root", "Container", hasExtendedAttributes: true); + references.AddRow(1, "Test", 99); + + using var tableReader = new DataTableReader([topics, empty, empty, empty, references]); + + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); + + Assert.NotNull(topic); + Assert.False(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.References)); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: PRE-EXISTING WITH DEFERRED EXTENDED ATTRIBUTES: PRESERVES LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls against a pre-existing, resident whose + /// is already , with a row indicating extended + /// attributes exist but weren't requested this load (HasExtendedAttributes = true), and confirms the resident is preserved rather than downgraded. + /// + /// + /// A load that doesn't request (e.g. a + /// top-up) must not silently discard the fact that the extended attribute property is already fully loaded; doing so would + /// trigger a needless refetch, and could clobber an unsaved local edit the next time it's touched. + /// + [Fact] + public async Task LoadTopicGraph_PreExistingWithDeferredExtendedAttributes_PreservesLoaded() { + + var topic = new Topic("Root", "Container", null, 1); + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasExtendedAttributes: true); + using var tableReader = new DataTableReader(topics); + + await tableReader.LoadTopicGraph(referenceTopic: topic, cancellationToken: CancellationToken); + + Assert.Equal(LoadState.Loaded, topic.Attributes.LoadState); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: PRE-EXISTING SINGLE CHILD: PRESERVES LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls against a pre-existing, resident that has + /// exactly one child, already fully , with a shallow reload of the seed that doesn't + /// re-return that child row, and confirms the resident is preserved, rather than downgraded. + /// + /// + /// Without this guard, a load's failure to re-return an already materialized single child (indistinguishable, by row count + /// alone, from a genuinely deferred boundary) would be misread as evidence the boundary was never loaded. + /// + [Fact] + public async Task LoadTopicGraph_PreExistingSingleChild_PreservesLoaded() { + + var topic = new Topic("Root", "Container", null, 1); + var child = new Topic("Child", "Page", topic, 2); + + ((ITopicBackingAccessor)topic).Children.LoadState = LoadState.Loaded; + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasChildren: true); + + using var tableReader = new DataTableReader(topics); + + await tableReader.LoadTopicGraph(1, referenceTopic: topic, cancellationToken: CancellationToken); + + Assert.Equal(LoadState.Loaded, ((ITopicBackingAccessor)topic).Children.LoadState); + Assert.Equal(child, ((ITopicBackingAccessor)topic).Children.Single()); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: PRE-EXISTING ANCESTOR: PRESERVES LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls for a deep seed whose ancestor is preexisting and already + /// fully , and confirms the ancestor's is preserved rather + /// than downgraded by the ancestor crawl. + /// + /// + /// @LoadAscendants is passed for every + /// call outside of the root, regardless of depth or payload, so the ancestor crawl runs on essentially every load + /// of anything beneath an already loaded ancestor. Without this guard, an already complete ancestor would be perpetually + /// reset to . + /// + [Fact] + public async Task LoadTopicGraph_PreExistingAncestor_PreservesLoaded() { + + var root = new Topic("Root", "Container", null, 1); + var ancestor = new Topic("Ancestor", "Container", root, 2); + var seed = new Topic("Seed", "Page", ancestor, 3); + + ((ITopicBackingAccessor)ancestor).Children.LoadState = LoadState.Loaded; + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasChildren: true); + topics.AddRow(2, "Ancestor", "Container", 1, hasChildren: true); + topics.AddRow(3, "Seed", "Page", 2, hasChildren: false); + + using var tableReader = new DataTableReader(topics); + + await tableReader.LoadTopicGraph(3, referenceTopic: seed, cancellationToken: CancellationToken); + + Assert.Equal(LoadState.Loaded, ((ITopicBackingAccessor)ancestor).Children.LoadState); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: FRESH ANCESTOR: SETS NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls for a deep seed whose ancestor has a sibling not returned by + /// this load, and confirms the freshly introduced ancestor's is still correctly set, + /// despite the seed's row naming the ancestor as its ParentID being processed after it. + /// + /// + /// Guards against a defect variant in the ancestor classification: If the seed's own row were allowed to credit its parent + /// as having received a "loaded" child, the ancestor would be incorrectly marked despite its + /// other child (the untouched sibling) having never been returned, thus risking DeleteUnmatched data loss on a + /// subsequent save. + /// + [Fact] + public async Task LoadTopicGraph_FreshAncestor_SetsNotLoaded() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasChildren: true); + topics.AddRow(2, "Ancestor", "Container", 1, hasChildren: true); + topics.AddRow(3, "Seed", "Page", 2, hasChildren: false); + + using var tableReader = new DataTableReader(topics); + + var topic = await tableReader.LoadTopicGraph(3, cancellationToken: CancellationToken); + + Assert.NotNull(topic); + Assert.Equal(LoadState.NotLoaded, ((ITopicBackingAccessor)topic.Parent!).Children.LoadState); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: DISCONNECTED BATCH: DOES NOT THROW + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a row whose ParentID names a topic that is + /// neither the first row nor otherwise resident, and confirms it completes without throwing. + /// + /// + /// Approximates the shape of GetTopicUpdates (used by ): An arbitrary, + /// possibly disconnected batch of individually modified topics, with HasChildren always NULL and no + /// guaranteed row order. A topic's parent may not be resolvable at all in that shape; must derive completeness from the raw ParentID column, never by navigating as an object, or this throws a . + /// + [Fact] + public async Task LoadTopicGraph_DisconnectedBatch_DoesNotThrow() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container"); + topics.AddRow(99, "Orphan", "Page", 999); + + using var tableReader = new DataTableReader(topics); + + var exception = await Record.ExceptionAsync( + async () => await tableReader.LoadTopicGraph(cancellationToken: CancellationToken) + ); + + Assert.Null(exception); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: DISCONNECTED BATCH: PRESERVES NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a GetTopicUpdates-shaped batch naming a existing, + /// parent's child, and confirms the parent's is + /// preserved, rather than being misread as a complete child listing. + /// + /// + /// A Refresh() batch is an arbitrary, unordered set of individually modified topics, not a complete child listing + /// the way a GetTopics result is. Without gating on HasChildren (always NULL in this shape), any row + /// naming a resident parent, other than the batch's arbitrary first row, would be misread as proof the parent's full child + /// set was returned, silently preventing it from lazy loading. + /// + [Fact] + public async Task LoadTopicGraph_DisconnectedBatch_PreservesNotLoaded() { + + var root = new Topic("Root", "Container", null, 1); + var parent = new Topic("Parent", "Container", root, 2); + var rawParent = (ITopicBackingAccessor)parent; + + rawParent.Children.LoadState = LoadState.NotLoaded; + + using var topics = new TopicsDataTable(); + + topics.AddRow(99, "Orphan", "Page", 999); + topics.AddRow(3, "Child", "Page", 2); + + using var tableReader = new DataTableReader(topics); + + await tableReader.LoadTopicGraph(referenceTopic: parent, cancellationToken: CancellationToken); + + Assert.Equal(LoadState.NotLoaded, rawParent.Children.LoadState); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: ORPHANED SOURCE: DOES NOT REGISTER INCOMING RELATIONSHIP + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a relationship row whose source is an orphan; i.e., its + /// ParentID doesn't resolve to a topic. Confirms the resident target's + /// gains no entry from it. + /// + /// + /// This can occur when the GetTopicUpdates stored procedure returns updates to a topic whose parent hasn't yet been + /// loaded in a lazily loaded topic tree. As a result, processing its associations would leave the resident graph holding a + /// dangling reference to a topic that was otherwise discarded with the load. AddTopic() skips the orphan, so it + /// doesn't end up in the live index, and must, in kind, skip its + /// relationship rows instead of resolving them. + /// + [Fact] + public async Task LoadTopicGraph_OrphanedSource_DoesNotRegisterIncomingRelationship() { + + using var topics = new TopicsDataTable(); using var empty = new AttributesDataTable(); using var relationships = new RelationshipsDataTable(); - relationships.AddRow(2, "Test", 3, true); + topics.AddRow(1, "Root", "Container"); + topics.AddRow(2, "Target", "Page", 1); + topics.AddRow(99, "Orphan", "Page", 999); + relationships.AddRow(99, "Test", 2, false); - using var tableReader = new DataTableReader(new DataTable[] { empty, empty, empty, relationships }); + using var tableReader = new DataTableReader([topics, empty, empty, relationships]); - tableReader.LoadTopicGraph(related); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); - Assert.Empty(topic.Relationships.GetValues("Test")); + Assert.NotNull(topic); + + var target = topic.GetLiveTopicIndex()[2]; + + Assert.Empty(target.IncomingRelationships.GetValues("Test")); } + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: REFRESH ORDERING: ATTACHES NEW PARENT AND CHILD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a GetTopicUpdates-shaped batch introducing a new + /// parent followed by its new child, which is the ordering ORDER BY RangeLeft guarantees, and confirms both attach, + /// with the child under the new parent, and both appear in the graph's live index. + /// + /// + /// Pins the ordering contract that attach-first loading depends on: A new row's parent must already be loaded, or itself + /// just attached, for the row to attach rather than being skipped as an orphan. + /// + [Fact] + public async Task LoadTopicGraph_RefreshOrdering_AttachesNewParentAndChild() { + + var root = new Topic("Root", "Container", null, 1); + + using var topics = new TopicsDataTable(); + + topics.AddRow(50, "NewParent", "Container", 1); + topics.AddRow(51, "NewChild", "Page", 50); + + using var tableReader = new DataTableReader(topics); + + await tableReader.LoadTopicGraph(referenceTopic: root, cancellationToken: CancellationToken); + var index = root.GetLiveTopicIndex(); + + Assert.True(index.ContainsKey(50)); + Assert.True(index.ContainsKey(51)); + Assert.Equal(50, index[51].Parent?.Id); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WHOLE TREE LOAD: CONVERGES NON-LEAF REGION NODES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with the default seedTopicId (-1, a whole-tree + /// load with no single seed) against a multi-level tree, and confirms a non-leaf node partway down the tree converges to + /// , rather than being misclassified as an ancestor. + /// + /// + /// The stored procedure resolves -1 to the actual root internally, so no returned row's id ever equals the literal + /// seedTopicId passed to ; the ancestor classification must not + /// mistake this for "no seed found yet" and misclassify the entire tree as ancestors. + /// + [Fact] + public async Task LoadTopicGraph_WholeTreeLoad_ConvergesNonLeafRegionNodes() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasChildren: true); + topics.AddRow(2, "Branch", "Container", 1, hasChildren: true); + topics.AddRow(3, "Leaf", "Page", 2, hasChildren: false); + + using var tableReader = new DataTableReader(topics); + + var root = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); + var branch = root!.Children["Branch"]; + + Assert.Equal(LoadState.Loaded, ((ITopicBackingAccessor)branch).Children.LoadState); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH MISSING RELATIONSHIP: SETS NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a relationship whose target is not in the live graph, + /// and confirms that is set to to + /// block DeleteUnmatched on save until the edge is resolved via EnsureLoaded. + /// + [Fact] + public async Task LoadTopicGraph_WithMissingRelationship_SetsNotLoaded() { + + using var topics = new TopicsDataTable(); + using var empty = new AttributesDataTable(); + using var relationships = new RelationshipsDataTable(); + + topics.AddRow(1, "Root", "Container"); + relationships.AddRow(1, "Test", 99, false); + + using var tableReader = new DataTableReader([topics, empty, empty, relationships]); + + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); + + Assert.NotNull(topic); + Assert.False(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.Relationships)); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH MISSING REFERENCE: SETS NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a reference whose target is not in the live graph, and + /// confirms that is set to to block + /// DeleteUnmatched on save until the reference is resolved via EnsureLoaded. + /// + [Fact] + public async Task LoadTopicGraph_WithMissingReference_SetsNotLoaded() { + + using var topics = new TopicsDataTable(); + using var empty = new AttributesDataTable(); + using var references = new TopicReferencesDataTable(); + + topics.AddRow(1, "Root", "Container"); + references.AddRow(1, "Test", 99); + + using var tableReader = new DataTableReader([topics, empty, empty, empty, references]); + + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); + + Assert.NotNull(topic); + Assert.False(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.References)); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH HISTORY DEFERRED: RETURNS NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with no records, as + /// would happen when @IncludeHistory is 0, and confirms the history boundary is , since every persisted topic is expected to have at least one version. + /// + [Fact] + public async Task LoadTopicGraph_WithHistoryDeferred_ReturnsNotLoaded() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container"); + + using var tableReader = new DataTableReader(topics); + + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); + + Assert.NotNull(topic); + Assert.False(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.VersionHistory)); + + } /*============================================================================================================================ | TEST: LOAD TOPIC GRAPH: WITH VERSION HISTORY: RETURNS VERSIONS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with an record and confirms that a topic with those values is returned. + /// Calls with an record and + /// confirms that a topic with those values is returned. /// [Fact] - public void LoadTopicGraph_WithVersionHistory_ReturnsVersions() { + public async Task LoadTopicGraph_WithVersionHistory_ReturnsVersions() { using var topics = new TopicsDataTable(); using var empty = new AttributesDataTable(); using var versions = new VersionHistoryDataTable(); - topics.AddRow(1, "Root", "Container", null); + topics.AddRow(1, "Root", "Container"); versions.AddRow(1, DateTime.MinValue); - using var tableReader = new DataTableReader(new DataTable[] { topics, empty, empty, empty, empty, versions }); + using var tableReader = new DataTableReader([topics, empty, empty, empty, empty, versions]); - var topic = tableReader.LoadTopicGraph(); + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); Assert.NotNull(topic); Assert.Equal(1, topic.Id); @@ -365,17 +841,216 @@ public void LoadTopicGraph_WithVersionHistory_ReturnsVersions() { } + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH EXTENDED DEFERRED AND BLOB: RETURNS NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a row where + /// HasExtendedAttributes is (blob deferred), and confirms the extended-attribute boundary is + /// . + /// + [Fact] + public async Task LoadTopicGraph_WithExtendedDeferredAndBlob_ReturnsNotLoaded() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasExtendedAttributes: true); + + using var tableReader = new DataTableReader(topics); + + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); + + Assert.NotNull(topic); + Assert.False(((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.ExtendedAttributes)); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH EXTENDED DEFERRED AND NO BLOB: RETURNS LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a row where + /// HasExtendedAttributes is (blob deferred, but empty), and confirms the extended-attribute + /// boundary is , thus avoiding a wasted round-trip. + /// + [Fact] + public async Task LoadTopicGraph_WithExtendedDeferredAndNoBlob_ReturnsLoaded() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasExtendedAttributes: false); + + using var tableReader = new DataTableReader(topics); + + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); + + Assert.NotNull(topic); + Assert.Equal(LoadState.Loaded, topic.Attributes.LoadState); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH EXTENDED INCLUDED AND BLOB: RETURNS LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a row where + /// HasExtendedAttributes is (extended included in result set), and confirms the + /// extended-attribute boundary is . + /// + [Fact] + public async Task LoadTopicGraph_WithExtendedIncludedAndBlob_ReturnsLoaded() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container"); + + using var tableReader = new DataTableReader(topics); + + var topic = await tableReader.LoadTopicGraph(cancellationToken: CancellationToken); + + Assert.NotNull(topic); + Assert.Equal(LoadState.Loaded, topic.Attributes.LoadState); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH HAS CHILDREN FALSE: RETURNS CHILDREN LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a row where HasChildren + /// is and confirms that is (the + /// topic is a leaf with nothing to lazy-load). + /// + [Fact] + public async Task LoadTopicGraph_WithHasChildrenFalse_ReturnsChildrenLoaded() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasChildren: false); + + using var tableReader = new DataTableReader(topics); + + var topic = await tableReader.LoadTopicGraph(1, cancellationToken: CancellationToken); + + Assert.True(((ITopicLazyLoadable)topic)?.IsLoaded(TopicPayload.Children)); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH HAS CHILDREN AND LOADED CHILDREN: RETURNS CHILDREN LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a parent row where HasChildren is and the child rows are present in the result set, confirming that is (i.e., the subtree was loaded in full). + /// + [Fact] + public async Task LoadTopicGraph_WithHasChildrenAndLoadedChildren_ReturnsChildrenLoaded() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasChildren: true); + topics.AddRow(2, "Child", "Page", 1, hasChildren: false); + + using var tableReader = new DataTableReader(topics); + + var topic = await tableReader.LoadTopicGraph(1, cancellationToken: CancellationToken); + + Assert.True(((ITopicLazyLoadable)topic)?.IsLoaded(TopicPayload.Children)); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH HAS CHILDREN AND NO RETURNED CHILDREN: SETS NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a shallow, single-row result set where HasChildren + /// is but no child rows are returned, confirming is (i.e., the seed itself is known to have children, but none were loaded). + /// + [Fact] + public async Task LoadTopicGraph_WithHasChildrenAndNoReturnedChildren_SetsNotLoaded() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasChildren: true); + + using var tableReader = new DataTableReader(topics); + + var topic = await tableReader.LoadTopicGraph(1, cancellationToken: CancellationToken); + + Assert.False(((ITopicLazyLoadable)topic)?.IsLoaded(TopicPayload.Children)); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH HAS CHILDREN ON ANCESTOR AND LOADED SUBTREE: SETS LOAD STATE CORRECTLY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a result set that includes both ancestor and a fully + /// loaded subtree, confirming that ancestor topics are stamped (i.e., partial children) + /// while subtree topics are stamped (i.e., all children present). This is the primary + /// scenario addressed by the seedTopicId parameter; i.e., loading ascendants and descendants. + /// + [Fact] + public async Task LoadTopicGraph_WithHasChildrenOnAncestorAndLoadedSubtree_SetsLoadStateCorrectly() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasChildren: true); + topics.AddRow(2, "Child", "Container", 1, hasChildren: true); + topics.AddRow(3, "Grandchild", "Page", 2, hasChildren: false); + + using var tableReader = new DataTableReader(topics); + + // The seed topic is Child (2); Root (1) is on the ancestor chain and is NotLoaded. + // The Child (seed) and Grandchild are in the fully loaded subtree and are Loaded. + var seedTopic = await tableReader.LoadTopicGraph(2, cancellationToken: CancellationToken); + var rootTopic = seedTopic?.Parent; + + Assert.Equal(LoadState.NotLoaded, rootTopic?.Children.LoadState); + Assert.Equal(LoadState.Loaded, seedTopic?.Children.LoadState); + + } + + /*============================================================================================================================ + | TEST: LOAD TOPIC GRAPH: WITH ONE LEVEL OF CHILDREN: CONVERGES SEED, LEAVES GRANDCHILDREN NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with a result set shaped like a @Depth: 1 call, with + /// the seed's immediate child present, but that child's own children are not, and confirms the + /// seed converges to while the child (which received no rows of its own) remains . + /// + [Fact] + public async Task LoadTopicGraph_WithOneLevelOfChildren_ConvergesSeedLeavesGrandchildrenNotLoaded() { + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Root", "Container", hasChildren: true); + topics.AddRow(2, "Child", "Container", 1, hasChildren: true); + + using var tableReader = new DataTableReader(topics); + + var seedTopic = await tableReader.LoadTopicGraph(1, cancellationToken: CancellationToken); + var childTopic = ((ITopicBackingAccessor?)seedTopic)?.Children.FirstOrDefault(); + + Assert.Equal(LoadState.Loaded, ((ITopicBackingAccessor?)seedTopic)?.Children.LoadState); + Assert.Equal(LoadState.NotLoaded, ((ITopicBackingAccessor?)childTopic)?.Children.LoadState); + + } + /*============================================================================================================================ | TEST: TOPIC LIST DATA TABLE: ADD ROW: SUCCEEDS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Constructs a and calls . Confirms that a with the expected data is returned. + /// Constructs a and calls . Confirms that a + /// with the expected data is returned. /// [Fact] public void TopicListDataTable_AddRow_Succeeds() { - var dataTable = new Data.Sql.Models.TopicListDataTable(); + var dataTable = new TopicListDataTable(); dataTable.AddRow(1); dataTable.AddRow(2); @@ -388,18 +1063,92 @@ public void TopicListDataTable_AddRow_Succeeds() { } + + /*============================================================================================================================ + | TEST: FILL CHILDREN: PRE-EXISTING CHILD WITHOUT EXTENDED ATTRIBUTES: CONVERGES LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls for a pre-existing, resident child topic whose is , with a row indicating the topic genuinely has no + /// extended attributes (HasExtendedAttributes = false), and confirms the property converges to rather than being left stuck. + /// + /// + /// A children-only fill returns no extended attributes, but false is still definitive: There is nothing to defer, so + /// there's no reason to leave a pre-existing child's property until something else + /// happens to touch it. + /// + [Fact] + public async Task FillChildren_PreExistingChildWithoutExtendedAttributes_ConvergesLoaded() { + + var parent = new Topic("Parent", "Container", null, 1); + var child = new Topic("Child", "Page", parent, 2); + + ((ITopicBackingAccessor)child).Attributes.LoadState = LoadState.NotLoaded; + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Parent", "Container", hasExtendedAttributes: false); + topics.AddRow(2, "Child", "Page", 1, hasExtendedAttributes: false); + + using var tableReader = new DataTableReader(topics); + + var topicIndex = parent.GetTopicIndex(); + + await tableReader.FillChildren(parent, topicIndex, CancellationToken); + + Assert.Equal(LoadState.Loaded, ((ITopicBackingAccessor)child).Attributes.LoadState); + + } + + /*============================================================================================================================ + | TEST: FILL CHILDREN: FRESH CHILD WITH EXTENDED ATTRIBUTES INCLUDED: CONVERGES LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls for a fresh (not pre-existing) child topic with a row + /// indicating extended attributes were included with this fill (HasExtendedAttributes = NULL), and confirms the + /// boundary converges to rather than . + /// + /// + /// Reproduces being called + /// with TopicPayload.Children | TopicPayload.ExtendedAttributes: A single IncludeExtended parameter scopes + /// the whole GetTopics call, so children rows come back with HasExtendedAttributes = NULL, and their extended + /// attributes are delivered in the third result set, just like the seed's own row. + /// + [Fact] + public async Task FillChildren_FreshChildWithExtendedAttributesIncluded_ConvergesLoaded() { + + var parent = new Topic("Parent", "Container", null, 1); + + using var topics = new TopicsDataTable(); + + topics.AddRow(1, "Parent", "Container"); + topics.AddRow(2, "Child", "Page", 1); + + using var tableReader = new DataTableReader(topics); + + // Attach-first: the fresh child is indexed via the attach hook into the live index, not the passed-in lookup index + var topicIndex = parent.GetLiveTopicIndex(); + + await tableReader.FillChildren(parent, topicIndex, CancellationToken); + + var child = (ITopicBackingAccessor)topicIndex[2]; + + Assert.Equal(LoadState.Loaded, child.Attributes.LoadState); + + } + /*============================================================================================================================ | TEST: ATTRIBUTE VALUES DATA TABLE: ADD ROW: SUCCEEDS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Constructs a and calls . Confirms that a with the expected data is - /// returned. + /// Constructs a , calls . Confirms that a with the expected data is returned. /// [Fact] public void AttributeValuesDataTable_AddRow_Succeeds() { - var dataTable = new Data.Sql.Models.AttributeValuesDataTable(); + var dataTable = new AttributeValuesDataTable(); dataTable.AddRow("Key", "Test"); dataTable.AddRow("ContentType", "Page"); @@ -416,9 +1165,9 @@ public void AttributeValuesDataTable_AddRow_Succeeds() { | TEST: TOPIC REFERENCES DATA TABLE: ADD ROW: SUCCEEDS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Constructs a and calls . Confirms that a with the expected data is - /// returned. + /// Constructs a and calls . Confirms that a with the + /// expected data is returned. /// [Fact] public void TopicReferencesDataTable_AddRow_Succeeds() { @@ -440,8 +1189,8 @@ public void TopicReferencesDataTable_AddRow_Succeeds() { | TEST: SQL COMMAND: ADD PARAMETER: STRING \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a object and adds a parameter to it using the extension method. + /// Creates a object and adds a parameter to it using the extension method. /// [Fact] public void SqlCommand_AddParameter_String() { @@ -465,8 +1214,8 @@ public void SqlCommand_AddParameter_String() { | TEST: SQL COMMAND: ADD PARAMETER: NULL STRING \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a object and adds a null parameter value to it using the extension method. + /// Creates a object and adds a null parameter value to it using the extension method. /// [Fact] public void SqlCommand_AddParameter_NullString() { @@ -490,8 +1239,8 @@ public void SqlCommand_AddParameter_NullString() { | TEST: SQL COMMAND: ADD PARAMETER: INT \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a object and adds a parameter to it using the extension method. + /// Creates a object and adds a parameter to it using the extension method. /// [Fact] public void SqlCommand_AddParameter_Int() { @@ -515,8 +1264,8 @@ public void SqlCommand_AddParameter_Int() { | TEST: SQL COMMAND: ADD PARAMETER: BOOL \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a object and adds a parameter to it using the extension method. + /// Creates a object and adds a parameter to it using the extension method. /// [Fact] public void SqlCommand_AddParameter_Bool() { @@ -540,8 +1289,8 @@ public void SqlCommand_AddParameter_Bool() { | TEST: SQL COMMAND: ADD PARAMETER: DATE/TIME \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a object and adds a parameter to it using the extension method. + /// Creates a object and adds a parameter to it using the extension method. /// [Fact] public void SqlCommand_AddParameter_DateTime() { @@ -566,14 +1315,14 @@ public void SqlCommand_AddParameter_DateTime() { | TEST: SQL COMMAND: ADD PARAMETER: DATA TABLE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a object and adds a parameter to it using the extension method. + /// Creates a object and adds a parameter to it using the extension method. /// [Fact] public void SqlCommand_AddParameter_DataTable() { var command = new SqlCommand(); - var dataTable = new Data.Sql.Models.TopicListDataTable(); + var dataTable = new TopicListDataTable(); command.AddParameter("Relationships", dataTable); @@ -593,8 +1342,8 @@ public void SqlCommand_AddParameter_DataTable() { | TEST: SQL COMMAND: ADD PARAMETER: STRING BUILDER \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a object and adds a parameter to it using the extension method. + /// Creates a object and adds a parameter to it using the extension method. /// [Fact] public void SqlCommand_AddParameter_StringBuilder() { @@ -619,8 +1368,8 @@ public void SqlCommand_AddParameter_StringBuilder() { | TEST: SQL COMMAND: ADD OUTPUT PARAMETER: RETURN CODE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a object and adds a parameter to it using the extension method. + /// Creates a object and adds a parameter to it using the extension method. /// [Fact] public void SqlCommand_AddOutputParameter_ReturnCode() { @@ -636,8 +1385,8 @@ public void SqlCommand_AddOutputParameter_ReturnCode() { Assert.Single(command.Parameters); Assert.True(command.Parameters.Contains("@TopicId")); Assert.Equal(5, command.GetReturnCode("TopicId")); - Assert.Equal(ParameterDirection.ReturnValue, sqlParameter?.Direction); - Assert.Equal(SqlDbType.Int, sqlParameter?.SqlDbType); + Assert.Equal(ParameterDirection.ReturnValue, sqlParameter.Direction); + Assert.Equal(SqlDbType.Int, sqlParameter.SqlDbType); command.Dispose(); @@ -647,8 +1396,8 @@ public void SqlCommand_AddOutputParameter_ReturnCode() { | TEST: SQL COMMAND: ADD OUTPUT PARAMETER: RETURN DEFAULT \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a object and adds a parameter to it using the extension method. Ensures the default return code is + /// Creates a object and adds a parameter to it using the extension method. Ensures the default return code is /// returned, if the value isn't explicitly set. /// [Fact] diff --git a/OnTopic.Tests/TestDoubles/BlockingStubLazyLoadingTopicRepository.cs b/OnTopic.Tests/TestDoubles/BlockingStubLazyLoadingTopicRepository.cs new file mode 100644 index 00000000..d8aca6ca --- /dev/null +++ b/OnTopic.Tests/TestDoubles/BlockingStubLazyLoadingTopicRepository.cs @@ -0,0 +1,132 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Repositories; +using OnTopic.TestDoubles.LazyLoading; + +namespace OnTopic.Tests.TestDoubles; + +/*============================================================================================================================== +| CLASS: BLOCKING STUB LAZY LOADING TOPIC REPOSITORY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// A that counts every +/// and call and, while "armed", suspends inside the corresponding one until released, thus +/// letting a test provably interleave two concurrent lazy loads of the same topic without or +/// other timing hacks. +/// +[ExcludeFromCodeCoverage] +internal sealed class BlockingStubLazyLoadingTopicRepository: StubLazyLoadingTopicRepository { + + /*============================================================================================================================ + | PRIVATE FIELDS + \---------------------------------------------------------------------------------------------------------------------------*/ + private TaskCompletionSource? _loadGate; + private TaskCompletionSource? _ensureLoadedGate; + + /*============================================================================================================================ + | PROPERTY: LOAD FETCH COUNT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the number of times has been called. + /// + public int LoadFetchCount { get; private set; } + + /*============================================================================================================================ + | PROPERTY: ENSURE LOADED FETCH COUNT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the number of times has been called. + /// + public int EnsureLoadedFetchCount { get; private set; } + + /*============================================================================================================================ + | METHOD: ARM LOAD GATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// "Arms" the gate so the next call suspends until is called. + /// + public void ArmLoadGate() => _loadGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /*============================================================================================================================ + | METHOD: RELEASE LOAD GATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Releases a suspended call "armed" via . + /// + public void ReleaseLoadGate() => _loadGate?.SetResult(); + + /*============================================================================================================================ + | METHOD: ARM ENSURE LOADED GATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// "Arms" the gate so the next call suspends until is + /// called. + /// + public void ArmEnsureLoadedGate() => _ensureLoadedGate = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /*============================================================================================================================ + | METHOD: RELEASE ENSURE LOADED GATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Releases a suspended call "armed" via . + /// + public void ReleaseEnsureLoadedGate() => _ensureLoadedGate?.SetResult(); + + /*============================================================================================================================ + | METHOD: FAULT ENSURE LOADED GATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Faults a suspended call "armed" via with the supplied + /// , so a test can simulate a lazy load that throws while a second pass awaits the same entry. + /// + /// The exception to surface from the suspended call. + public void FaultEnsureLoadedGate(Exception exception) => _ensureLoadedGate?.SetException(exception); + + /*============================================================================================================================ + | METHOD: LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + public override async Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { + + // Record the fetch + LoadFetchCount++; + + // If "armed", suspend until released + if (_loadGate is not null) { + await _loadGate.Task.ConfigureAwait(false); + } + + // Delegate to the base implementation to perform the actual fill + return await base.Load(topicId, referenceTopic, payload, depth).ConfigureAwait(false); + + } + + /*============================================================================================================================ + | METHODS: ENSURE LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + public override async Task EnsureLoaded(Topic topic, TopicPayload payload, CancellationToken cancellationToken = default) { + + // Record the fetch + EnsureLoadedFetchCount++; + + // If "armed", suspend until released + if (_ensureLoadedGate is not null) { + await _ensureLoadedGate.Task.ConfigureAwait(false); + } + + // Delegate to the base implementation to perform the actual fill + await base.EnsureLoaded(topic, payload, cancellationToken).ConfigureAwait(false); + + } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/TestDoubles/DummyStaticTypeLookupService.cs b/OnTopic.Tests/TestDoubles/DummyStaticTypeLookupService.cs index 1545a1bb..6b40c823 100644 --- a/OnTopic.Tests/TestDoubles/DummyStaticTypeLookupService.cs +++ b/OnTopic.Tests/TestDoubles/DummyStaticTypeLookupService.cs @@ -21,12 +21,12 @@ public class DummyStaticTypeLookupService: StaticTypeLookupService { | CONSTRUCTOR \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a new instance of a . Optionally accepts a list of instances and a default value. + /// Establishes a new instance of a . Optionally accepts a list of instances and a default value. /// /// The list of instances to expose as part of this service. public DummyStaticTypeLookupService( - IEnumerable? types = null + IEnumerable? types = null ): base(types) { } diff --git a/OnTopic.Tests/TestDoubles/FakeSqlTopicRepository.cs b/OnTopic.Tests/TestDoubles/FakeSqlTopicRepository.cs new file mode 100644 index 00000000..c9aa93ad --- /dev/null +++ b/OnTopic.Tests/TestDoubles/FakeSqlTopicRepository.cs @@ -0,0 +1,270 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using System.Data; +using OnTopic.Data.Sql; +using OnTopic.Repositories; +using OnTopic.TestDoubles.LazyLoading; +using OnTopic.Tests.Schemas; + +namespace OnTopic.Tests.TestDoubles; + +/*============================================================================================================================== +| CLASS: FAKE SQL TOPIC REPOSITORY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// A fake for that serves an in-memory row set through the real, production , exactly as feeds it from a live SQL data +/// reader. Every request loads the full ascendant chain from the requested topic to the root, matching production's +/// @LoadAscendants = (topicId >= 0) behavior. +/// +/// +/// Unlike and stubs that +/// each maintain a single, persistent, already materialized graph and simply return existing instances +/// from it, this is a fake: It rebuilds a fresh subgraph from its row store on every call, via the same +/// entry point uses, and relies on the +/// caller's referenceTopic to reconcile new rows against an already resident graph, exactly as production does. That +/// is the specific mechanism under test in 's referenceTopic ?? _cache +/// regression tests: Neither of the other two doubles can distinguish a referenceTopic from a +/// resident one, since neither ever produces a duplicate instance to reconcile in the first place. +/// +[ExcludeFromCodeCoverage] +internal sealed class FakeSqlTopicRepository : TopicRepository { + + /*============================================================================================================================ + | VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly Dictionary _rows = []; + private readonly List<(int SourceId, string Key, int TargetId)> _relationships = []; + private readonly List<(int SourceId, string Key, int TargetId)> _historicalRelationships = []; + private readonly Dictionary _keyIndex = new(StringComparer.OrdinalIgnoreCase); + private int _identity = 90000; + + /*============================================================================================================================ + | METHOD: ADD TOPIC + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Registers a row in the fake row store, keyed by , and indexes its unique key for lookups. + /// + public FakeSqlTopicRepository AddTopic(int id, string key, string contentType, int? parentId) { + _rows[id] = (key, contentType, parentId); + _keyIndex[GetUniqueKey(id)] = id; + return this; + } + + /*============================================================================================================================ + | METHOD: ADD RELATIONSHIP + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Registers a relationship row, returned alongside its source topic's ascendant chain on a subsequent . + /// + public void AddRelationship(int sourceId, string key, int targetId) => _relationships.Add((sourceId, key, targetId)); + + /*============================================================================================================================ + | METHOD: ADD HISTORICAL RELATIONSHIP + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Registers a relationship row belonging to a historical version, returned independently of the current relationships + /// added by , and instead returned by a call to . + /// + public void AddHistoricalRelationship(int sourceId, string key, int targetId) => + _historicalRelationships.Add((sourceId, key, targetId)); + + /*============================================================================================================================ + | METHOD: GET UNIQUE KEY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Computes the unique key for by walking its ParentId chain through the row store. + /// + private string GetUniqueKey(int id) { + List segments = []; + var current = (int?)id; + while (current is { } currentId) { + segments.Insert(0, _rows[currentId].Key); + current = _rows[currentId].ParentId; + } + return String.Join(":", segments); + } + + /*============================================================================================================================ + | METHOD: LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + public override async Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { + if (!_keyIndex.TryGetValue(uniqueKey, out var topicId)) { + return null; + } + return await Load(topicId, referenceTopic, payload, depth).ConfigureAwait(false); + } + + /// + /// + /// Builds the requested topic's ascendant chain into fresh / rows on every call, then feeds them through the real —reproducing + /// the new instance per call, reconciled against the referenceTopic behavior of . + /// + public override async Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { + + // Bypass for rowstore misses + if (!_rows.ContainsKey(topicId)) { + return null; + } + + // Build the ascendant chain, root-first, mirroring production's @LoadAscendants = (topicId >= 0) + List chain = []; + var current = (int?)topicId; + while (current is { } id) { + chain.Insert(0, id); + current = _rows[id].ParentId; + } + + // Delegate to the shared graph builder, seeded with the ascendant chain and current relationships + var topic = await LoadTopicGraph( + topicId, + referenceTopic, + populateTopics, + relationshipRows : _relationships.Where(r => chain.Contains(r.SourceId)) + ).ConfigureAwait(false); + + // Raise the TopicLoaded event + OnTopicLoaded(new(topic!, depth)); + + // Finally, return the seed topic + return topic; + + // Populates the ascendant chain's rows into the source data table + void populateTopics(TopicsDataTable topics) { + foreach (var id in chain) { + var (key, contentType, parentId) = _rows[id]; + var hasChildren = _rows.Values.Any(row => row.ParentId == id); + topics.AddRow(id, key, contentType, parentId, hasChildren: hasChildren); + } + } + + } + + /// + /// + /// Unlike , this builds a single-row , + /// without the ascendant chain, and populated from rather than , then feeds it through with noreferenceTopic + /// , mirroring production's detached GetTopicVersion: The returned has no and no resolved associations, only Deferred entries. + /// + public override async Task Load(int topicId, DateTime version) { + + // Bypass for rowstore misses + if (!_rows.TryGetValue(topicId, out var row)) { + return null; + } + + var (key, contentType, _) = row; + + // Delegate to the shared graph builder, seeded with a single disconnected row and no referenceTopic, per the detached + // contract, alongside historical rather than current relationships + var topic = await LoadTopicGraph( + topicId, + referenceTopic : null, + populateTopics : topics => topics.AddRow(topicId, key, contentType), + relationshipRows : _historicalRelationships.Where(r => r.SourceId == topicId) + ).ConfigureAwait(false); + + // Raise the TopicLoaded event + OnTopicLoaded(new(topic!, 0, version)); + + // Finally, return the detached topic + return topic; + + } + + /*============================================================================================================================ + | METHOD: LOAD TOPIC GRAPH + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Shared core behind and : + /// Builds a fresh set of and rows, then feeds them + /// through the real , exactly as does + /// from a live reader. + /// + /// The to seed the load with. + /// The reference topic graph to reconcile new rows against, if any. + /// Adds whatever topic row(s) the caller's scenario requires to the source data table. + /// The relationship rows to feed alongside the topic row(s). + private static async Task LoadTopicGraph( + int topicId, + Topic? referenceTopic, + Action populateTopics, + IEnumerable<(int SourceId, string Key, int TargetId)> relationshipRows + ) { + + // Define source data tables + using var topics = new TopicsDataTable(); + using var attributes = new AttributesDataTable(); + using var extendedAttributes = new AttributesDataTable(); + using var relationships = new RelationshipsDataTable(); + + // Build the topic data + populateTopics(topics); + + // Build the relationship data + foreach (var (sourceId, key, targetId) in relationshipRows) { + relationships.AddRow(sourceId, key, targetId, isDeleted: false); + } + + // Establish data table reader, which simulates the return from the GetTopics stored procedure + using var tableReader = new DataTableReader([topics, attributes, extendedAttributes, relationships]); + + // Delegate to the standard LoadTopicGraph from the SQL provider + return await tableReader.LoadTopicGraph( + topicId, + referenceTopic, + cancellationToken : CancellationToken.None + ).ConfigureAwait(false); + + } + + /*============================================================================================================================ + | METHOD: REFRESH + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + public override Task Refresh(Topic referenceTopic, DateTime since) => Task.CompletedTask; + + /*============================================================================================================================ + | METHOD: SAVE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + protected override Task SaveTopic(Topic topic, DateTime version, bool persistRelationships) { + if (topic.IsNew) { + topic.Id = _identity++; + } + return Task.CompletedTask; + } + + /*============================================================================================================================ + | METHOD: MOVE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + protected override Task MoveTopic(Topic topic, Topic target, Topic? sibling = null) => Task.CompletedTask; + + /*============================================================================================================================ + | METHOD: DELETE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + protected override Task DeleteTopic(Topic topic) => Task.CompletedTask; + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/TestDoubles/FakeViewModelLookupService.cs b/OnTopic.Tests/TestDoubles/FakeViewModelLookupService.cs index eee92154..04f2ccde 100644 --- a/OnTopic.Tests/TestDoubles/FakeViewModelLookupService.cs +++ b/OnTopic.Tests/TestDoubles/FakeViewModelLookupService.cs @@ -27,7 +27,7 @@ public class FakeViewModelLookupService: TopicViewModelLookupService { /// Instantiates a new instance of the . /// /// A new instance of the . - public FakeViewModelLookupService() : base() { + public FakeViewModelLookupService() { /*-------------------------------------------------------------------------------------------------------------------------- | Add test specific view models @@ -35,12 +35,18 @@ public FakeViewModelLookupService() : base() { Add(typeof(AmbiguousRelationTopicViewModel)); Add(typeof(AscendentSpecializedTopicViewModel)); Add(typeof(AscendentTopicViewModel)); + Add(typeof(CircularConstructorTopicViewModel)); Add(typeof(CircularTopicViewModel)); + Add(typeof(ConcurrentExpansionRootTopicViewModel)); + Add(typeof(ConcurrentExpansionSharedTopicViewModel)); + Add(typeof(ConcurrentReferenceTopicViewModel)); Add(typeof(ConstructedTopicViewModel)); Add(typeof(DefaultValueTopicViewModel)); Add(typeof(DescendentSpecializedTopicViewModel)); Add(typeof(DescendentTopicViewModel)); Add(typeof(DisableMappingTopicViewModel)); + Add(typeof(ExpansionParentTopicViewModel)); + Add(typeof(ExpansionSharedTopicViewModel)); Add(typeof(FallbackViewModel)); Add(typeof(FilteredTopicViewModel)); Add(typeof(FlattenChildrenTopicViewModel)); @@ -58,6 +64,7 @@ public FakeViewModelLookupService() : base() { Add(typeof(RelationWithChildrenTopicViewModel)); Add(typeof(RequiredObjectTopicViewModel)); Add(typeof(RequiredTopicViewModel)); + Add(typeof(SharedConcurrentTopicViewModel)); Add(typeof(TopicReferenceAttributeDescriptorTopicViewModel)); Add(typeof(TopicReferenceTopicViewModel)); diff --git a/OnTopic.Tests/TestDoubles/RendezvousTopicLazyLoader.cs b/OnTopic.Tests/TestDoubles/RendezvousTopicLazyLoader.cs new file mode 100644 index 00000000..08c6fd1e --- /dev/null +++ b/OnTopic.Tests/TestDoubles/RendezvousTopicLazyLoader.cs @@ -0,0 +1,139 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Repositories; + +namespace OnTopic.Tests.TestDoubles; + +/*============================================================================================================================== +| CLASS: RENDEZVOUS TOPIC LAZY LOADER +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// An that suspends each call until a fixed number +/// of concurrent passes have arrived, then releases them together, so a stress test can force two concurrent mapping passes +/// to modify a shared collection at the same time without timing hacks. Conceptually, this is an asynchronous, cyclic for a fixed number of mapping passes. +/// +/// +/// +/// Unlike a repository-backed lazy loader, this performs no fetching: The stress test wires the shared topic's associations +/// before mapping, so only needs to a) "rendezvous" the concurrent passes and +/// b) mark as so the base pass's nested-topic search, which +/// reads the autoloading getter, doesn't re-enter this loader. The rendezvous rearms after +/// each release, so a single instance serves every repetition. +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +[ExcludeFromCodeCoverage] +internal sealed class RendezvousTopicLazyLoader: ITopicLazyLoader { + + /*============================================================================================================================ + | PRIVATE FIELDS + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly object _lock = new(); + private readonly int _participantCount; + private readonly TimeSpan _timeout; + private TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _arrivals; + + /*============================================================================================================================ + | CONSTRUCTOR + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Instantiates a new instance of the that releases once passes have arrived. + /// + /// The number of concurrent passes to await before releasing them together. + /// + /// The number of seconds an arrived pass waits for the others before throwing, guarding against a hang if the expected + /// concurrency never materializes. + /// + public RendezvousTopicLazyLoader(int participantCount = 2, int timeoutSeconds = 10) { + _participantCount = participantCount; + _timeout = TimeSpan.FromSeconds(timeoutSeconds); + } + + /*============================================================================================================================ + | METHOD: ENSURE LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + async Task ITopicLazyLoader.EnsureLoaded(Topic topic, TopicPayload payload, CancellationToken cancellationToken) { + + // Suspend until the concurrent passes rendezvous, so they resume together and race on the shared collection + await Rendezvous(cancellationToken).ConfigureAwait(false); + + // Mark children Loaded so the base pass's nested-topic probe, which reads the autoloading Topic.Children getter, doesn't + // re-enter this loader. Relationships needs no such treatment: Its targets are preloaded, so its (derived) LoadState is + // already Loaded and so relationship never autoload. + ((ITopicBackingAccessor)topic).Children.LoadState = LoadState.Loaded; + + } + + /*============================================================================================================================ + | METHOD: RENDEZVOUS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Suspends the caller until passes have arrived, then releases them together and re-arms + /// for the next batch. + /// + /// A token used to cancel the wait. + private Task Rendezvous(CancellationToken cancellationToken) { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Register arrival and, if last, re-arm the gate for the next batch + \-------------------------------------------------------------------------------------------------------------------------*/ + TaskCompletionSource gate; + bool release; + lock (_lock) { + gate = _gate; + release = ++_arrivals >= _participantCount; + if (release) { + _arrivals = 0; + _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | The last arrival releases the batch and proceeds without waiting + \-------------------------------------------------------------------------------------------------------------------------*/ + if (release) { + gate.TrySetResult(); + return Task.CompletedTask; + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Earlier arrivals wait for the last, throwing on timeout so a broken assumption fails loudly instead of hanging + \-------------------------------------------------------------------------------------------------------------------------*/ + return AwaitGate(gate, cancellationToken); + + } + + /*============================================================================================================================ + | METHOD: AWAIT GATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Waits for to be released, throwing a if the batch never + /// completes. + /// + /// The gate to wait on for the current batch. + /// A token used to cancel the wait. + private async Task AwaitGate(TaskCompletionSource gate, CancellationToken cancellationToken) { + + // Race the gate against a timeout, so an unexpected participant count can't hang the test suite + var completed = await Task.WhenAny(gate.Task, Task.Delay(_timeout, cancellationToken)).ConfigureAwait(false); + + // Surface a failed rendezvous as an exception rather than proceeding with a corrupt result + if (completed != gate.Task) { + throw new TimeoutException( + $"The rendezvous timed out after {_timeout.TotalSeconds:0} seconds waiting for {_participantCount} concurrent " + + $"passes; the expected concurrency did not occur." + ); + } + + } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/TestDoubles/StaggeredStubTopicRepository.cs b/OnTopic.Tests/TestDoubles/StaggeredStubTopicRepository.cs new file mode 100644 index 00000000..d87ae28e --- /dev/null +++ b/OnTopic.Tests/TestDoubles/StaggeredStubTopicRepository.cs @@ -0,0 +1,68 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Repositories; +using OnTopic.TestDoubles; + +namespace OnTopic.Tests.TestDoubles; + +/*============================================================================================================================== +| CLASS: STAGGERED STUB TOPIC REPOSITORY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// A that delays by a per-key , letting a test invert completion order relative to call order. +/// +/// +/// +/// This is similar to , except that it staggers calls to , not . +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +[ExcludeFromCodeCoverage] +internal sealed class StaggeredStubTopicRepository: StubTopicRepository { + + /*============================================================================================================================ + | PRIVATE FIELDS + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly IReadOnlyDictionary _delaysByKey; + + /*============================================================================================================================ + | CONSTRUCTOR + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Initializes a new instance of the with a delay for each unique key that + /// should complete out of call order. + /// + /// A map of unique topic key to the delay that should precede its resolution. + public StaggeredStubTopicRepository(IReadOnlyDictionary delaysByKey) { + _delaysByKey = delaysByKey; + } + + /*============================================================================================================================ + | METHOD: LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + public override async Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) { + + // Delay resolution of this key, if configured + if (_delaysByKey.TryGetValue(uniqueKey, out var delay) && delay > TimeSpan.Zero) { + await Task.Delay(delay).ConfigureAwait(false); + } + + // Delegate to the base implementation to perform the actual lookup + return await base.Load(uniqueKey, referenceTopic, payload, depth).ConfigureAwait(false); + + } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/TestDoubles/StaggeredTopicLazyLoader.cs b/OnTopic.Tests/TestDoubles/StaggeredTopicLazyLoader.cs new file mode 100644 index 00000000..12d2e0a2 --- /dev/null +++ b/OnTopic.Tests/TestDoubles/StaggeredTopicLazyLoader.cs @@ -0,0 +1,41 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Repositories; + +namespace OnTopic.Tests.TestDoubles; + +/*============================================================================================================================== +| CLASS: STAGGERED TOPIC LAZY LOADER +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// An that suspends for a fixed before completing, so a test can attach different instances to sibling topics and force their loads to +/// genuinely complete out of source order. +/// +/// +/// +/// This is similar to , except that it staggers calls to , not . +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +[ExcludeFromCodeCoverage] +internal sealed class StaggeredTopicLazyLoader(TimeSpan delay): ITopicLazyLoader { + + /*============================================================================================================================ + | METHOD: ENSURE LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + async Task ITopicLazyLoader.EnsureLoaded(Topic topic, TopicPayload payload, CancellationToken cancellationToken) { + if (delay > TimeSpan.Zero) { + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + ((ITopicBackingAccessor)topic).Children.LoadState = LoadState.Loaded; + } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/TestDoubles/TrackingTopicLazyLoader.cs b/OnTopic.Tests/TestDoubles/TrackingTopicLazyLoader.cs new file mode 100644 index 00000000..793f9d67 --- /dev/null +++ b/OnTopic.Tests/TestDoubles/TrackingTopicLazyLoader.cs @@ -0,0 +1,68 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Repositories; + +namespace OnTopic.Tests.TestDoubles; + +/*============================================================================================================================== +| CLASS: TRACKING TOPIC LAZY LOADER +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// A minimal spy that records whether it was invoked, without performing any actual loading. +/// +/// +/// By default, this doesn't mutate , so a stamped topic remains +/// even after a call, letting tests assert that a specific code path either suppresses or triggers autoloading. Pass +/// to instead simulate a real loader's fill, marking the requested payload on each call, when a test needs to confirm that a caller warms a payload exactly once +/// rather than relying on this spy's inertness to inflate the count. +/// +[ExcludeFromCodeCoverage] +internal sealed class TrackingTopicLazyLoader(bool markLoaded = false) : ITopicLazyLoader { + + /*============================================================================================================================ + | PRIVATE FIELDS + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly List _payloads = []; + + /*============================================================================================================================ + | PROPERTY: WAS CALLED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns if was invoked. + /// + public bool WasCalled => _payloads.Count > 0; + + /*============================================================================================================================ + | PROPERTY: CALL COUNT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the number of times was invoked. + /// + public int CallCount => _payloads.Count; + + /*============================================================================================================================ + | PROPERTY: PAYLOADS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the passed to each invocation of , in + /// call order. + /// + public IReadOnlyList Payloads => _payloads; + + /*============================================================================================================================ + | METHOD: ENSURE LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + Task ITopicLazyLoader.EnsureLoaded(Topic topic, TopicPayload payload, CancellationToken cancellationToken) { + _payloads.Add(payload); + if (markLoaded) { + ((ITopicLazyLoadable)topic).SetLoadState(payload, LoadState.Loaded); + } + return Task.CompletedTask; + } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/TopicIndexRegistryTest.cs b/OnTopic.Tests/TopicIndexRegistryTest.cs new file mode 100644 index 00000000..f25366df --- /dev/null +++ b/OnTopic.Tests/TopicIndexRegistryTest.cs @@ -0,0 +1,251 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Collections.Specialized; +using OnTopic.Querying; +using OnTopic.Repositories; +using Xunit; + +namespace OnTopic.Tests; + +/*============================================================================================================================== +| CLASS: TOPIC INDEX REGISTRY TEST +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides unit tests for the live, incrementally maintained , accessed via . +/// +[ExcludeFromCodeCoverage] +public class TopicIndexRegistryTest { + + /*============================================================================================================================ + | TEST: GET LIVE TOPIC INDEX: SAME GRAPH: RETURNS SAME INSTANCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls twice on the same graph, confirming the same instance is + /// returned both times, and that a subsequently attached child is present without a rebuild. + /// + [Fact] + public void GetLiveTopicIndex_SameGraph_ReturnsSameInstance() { + + var root = new Topic("Root", "Container", null, 1); + + var firstIndex = root.GetLiveTopicIndex(); + var secondIndex = root.GetLiveTopicIndex(); + + Assert.Same(firstIndex, secondIndex); + + var child = new Topic("Child", "Page", root, 2); + + Assert.Same(child, firstIndex[2]); + + } + + /*============================================================================================================================ + | TEST: ON ATTACHED: SUBTREE: INDEXES DESCENDANTS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Attaches a subtree of already populated instances under a graph with a live index and confirms every + /// non- descendant is indexed. + /// + [Fact] + public void OnAttached_Subtree_IndexesDescendants() { + + var root = new Topic("Root", "Container", null, 1); + var index = root.GetLiveTopicIndex(); + + var branch = new Topic("Branch", "Container", null, 2); + var leaf = new Topic("Leaf", "Page", branch, 3); + var newLeaf = new Topic("NewLeaf", "Page", branch); + + branch.Parent = root; + + Assert.Same(branch, index[2]); + Assert.Same(leaf, index[3]); + Assert.False(index.ContainsKey(newLeaf.Id)); + + } + + /*============================================================================================================================ + | TEST: ON DETACHED: SUBTREE: REMOVES DESCENDANTS AND SUPPORTS MOVE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Detaches a subtree from a graph with a live index and confirms its descendants are removed; then re-attaches it under a + /// different parent in the same graph and confirms they're restored. + /// + [Fact] + public void OnDetached_Subtree_RemovesDescendants_AndSupportsMove() { + + var root = new Topic("Root", "Container", null, 1); + var branchA = new Topic("BranchA", "Container", root, 2); + var branchB = new Topic("BranchB", "Container", root, 3); + var leaf = new Topic("Leaf", "Page", branchA, 4); + + var index = root.GetLiveTopicIndex(); + + Assert.True(index.ContainsKey(4)); + + branchA.Children.Remove(leaf.Key); + + Assert.False(index.ContainsKey(4)); + + leaf.Parent = branchB; + + Assert.True(index.ContainsKey(4)); + Assert.Same(branchB, index[4].Parent); + + } + + /*============================================================================================================================ + | TEST: ON ID ASSIGNED: MATERIALIZED INDEX: INDEXES TOPIC + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Attaches a new, unsaved to a graph with a live index, then assigns its , thus + /// simulating , and confirms it then appears in the index. + /// + [Fact] + public void OnIdAssigned_MaterializedIndex_IndexesTopic() { + + var root = new Topic("Root", "Container", null, 1); + var index = root.GetLiveTopicIndex(); + var newTopic = new Topic("New", "Page", root); + + Assert.True(newTopic.IsNew); + Assert.False(index.ContainsKey(newTopic.Id)); + + newTopic.Id = 42; + + Assert.Same(newTopic, index[42]); + + } + + /*============================================================================================================================ + | TEST: CLEAR ITEMS: MATERIALIZED INDEX: INVALIDATES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Clears a topic's children and confirms the next call returns a + /// rebuilt index that no longer contains the cleared topics, at any depth. + /// + [Fact] + public void ClearItems_MaterializedIndex_Invalidates() { + + var root = new Topic("Root", "Container", null, 1); + var child = new Topic("Child", "Page", root, 2); + + _ = new Topic("Grandchild", "Page", child, 3); + + var firstIndex = root.GetLiveTopicIndex(); + + Assert.True(firstIndex.ContainsKey(2)); + Assert.True(firstIndex.ContainsKey(3)); + + root.Children.Clear(); + + var secondIndex = root.GetLiveTopicIndex(); + + Assert.False(secondIndex.ContainsKey(2)); + Assert.False(secondIndex.ContainsKey(3)); + + } + + /*============================================================================================================================ + | TEST: GET LIVE TOPIC INDEX: SEPARATE GRAPHS: RETURNS INDEPENDENT INDEXES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes two separate topic graphs and confirms their live indexes are independent of one another. + /// + [Fact] + public void GetLiveTopicIndex_SeparateGraphs_ReturnsIndependentIndexes() { + + var rootA = new Topic("RootA", "Container", null, 1); + var rootB = new Topic("RootB", "Container", null, 2); + + _ = new Topic("ChildA", "Page", rootA, 3); + + var indexA = rootA.GetLiveTopicIndex(); + var indexB = rootB.GetLiveTopicIndex(); + + Assert.NotSame(indexA, indexB); + Assert.True(indexA.ContainsKey(3)); + Assert.False(indexB.ContainsKey(3)); + + } + + /*============================================================================================================================ + | TEST: ON ATTACHED: RE-ROOTED GRAPH: DOES NOT RESURRECT STALE INDEX + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Builds the live index of a standalone graph, attaches its root under another graph (merging it in), detaches it again, + /// and confirms a subsequent call returns a freshly rebuilt index + /// rather than the stale, pre-merge one. + /// + /// + /// Evaluates both and directly, + /// since a topic that has been fully detached from its parent has no public path to being a root again ('s back-reference isn't cleared by removal alone, only by + /// assigning a new one). This test evaluates the internal hook contract, not a publicly reachable sequence. + /// + [Fact] + public void OnAttached_ReRootedGraph_DoesNotResurrectStaleIndex() { + + var standaloneRoot = new Topic("Standalone", "Container", null, 5); + + _ = new Topic("StandaloneChild", "Page", standaloneRoot, 6); + + var staleIndex = standaloneRoot.GetLiveTopicIndex(); + + Assert.True(staleIndex.ContainsKey(6)); + + var mainRoot = new Topic("Main", "Container", null, 1); + + _ = mainRoot.GetLiveTopicIndex(); + + TopicIndexRegistry.OnAttached(mainRoot, standaloneRoot); + TopicIndexRegistry.OnDetached(mainRoot, standaloneRoot); + + var rebuiltIndex = standaloneRoot.GetLiveTopicIndex(); + + Assert.NotSame(staleIndex, rebuiltIndex); + Assert.True(rebuiltIndex.ContainsKey(6)); + + } + + /*============================================================================================================================ + | TEST: ON ATTACHED: NOT LOADED INTERMEDIATE: INDEXES PHYSICAL DESCENDANTS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Attaches a subtree whose intermediate node has but also + /// present children, and confirms every physical descendant is indexed regardless; detaches it and confirms every physical + /// descendant is pruned regardless. + /// + /// + /// traverses raw, bypassing the autoloading getter, and the registry's hooks + /// must not reintroduce a LoadState gate. + /// + [Fact] + public void OnAttached_NotLoadedIntermediate_IndexesPhysicalDescendants() { + + var root = new Topic("Root", "Container", null, 1); + var index = root.GetLiveTopicIndex(); + + var intermediate = new Topic("Intermediate", "Container", null, 2); + + _ = new Topic("PhysicalChild", "Page", intermediate, 3); + + ((ITopicBackingAccessor)intermediate).Children.LoadState = LoadState.NotLoaded; + + intermediate.Parent = root; + + Assert.True(index.ContainsKey(2)); + Assert.True(index.ContainsKey(3)); + + root.Children.Remove(intermediate.Key); + + Assert.False(index.ContainsKey(2)); + Assert.False(index.ContainsKey(3)); + + } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/TopicMappingServiceTest.cs b/OnTopic.Tests/TopicMappingServiceTest.cs index 56f154ab..b44fc322 100644 --- a/OnTopic.Tests/TopicMappingServiceTest.cs +++ b/OnTopic.Tests/TopicMappingServiceTest.cs @@ -6,16 +6,20 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using OnTopic.Data.Caching; +using OnTopic.Lookup; using OnTopic.Mapping; using OnTopic.Mapping.Internal; using OnTopic.Metadata; using OnTopic.Repositories; using OnTopic.TestDoubles; +using OnTopic.TestDoubles.LazyLoading; using OnTopic.TestDoubles.Metadata; using OnTopic.Tests.Entities; using OnTopic.Tests.Fixtures; +using OnTopic.Tests.TestDoubles; using OnTopic.Tests.ViewModels; using OnTopic.Tests.ViewModels.Metadata; +using OnTopic.ViewModels; using Xunit; namespace OnTopic.Tests; @@ -35,6 +39,7 @@ public class TopicMappingServiceTest { \---------------------------------------------------------------------------------------------------------------------------*/ readonly ITopicRepository _topicRepository; readonly ITopicMappingService _mappingService; + readonly ITypeLookupService _typeLookupService; /*============================================================================================================================ | CONSTRUCTOR @@ -60,6 +65,7 @@ public TopicMappingServiceTest(TopicInfrastructureFixture f \-------------------------------------------------------------------------------------------------------------------------*/ _topicRepository = fixture.CachedTopicRepository; _mappingService = fixture.MappingService; + _typeLookupService = fixture.TypeLookupService; } @@ -74,8 +80,8 @@ public TopicMappingServiceTest(TopicInfrastructureFixture f /// /// The includes functionality to map properties to attributes via a constructor that /// accepts a . This introduces some overhead which is not cost effective if there are - /// not any attributes that map to properties. For larger numbers of mapped attributes, however, the can reduce the mapping time considerably, while also giving more control over the model + /// not any attributes that map to properties. For larger numbers of mapped attributes, however, the can reduce the mapping time considerably, while also giving more control over the model /// construction to the model developer. This test is intended to help identify and optimize that threshold based on /// improvements to the underlying , , and convenience method. @@ -98,14 +104,14 @@ public async Task Map_LoadTesting_EvaluateThreshold() { \-------------------------------------------------------------------------------------------------------------------------*/ var topic = new Topic("Test", "ContentList", null); - for (var i = 0; i <= propertyCount; i++) { + for (var i = 0; i <= propertyCount; i++) { topic.Attributes.SetInteger("Property"+i, i); } /*-------------------------------------------------------------------------------------------------------------------------- | Run load testing \-------------------------------------------------------------------------------------------------------------------------*/ - for (var i = 0; i < runs; i++) { + for (var i = 0; i < runs; i++) { await _mappingService.MapAsync(topic); } @@ -186,7 +192,7 @@ public async Task Map_LoadTesting_EvaluateTime() { /*-------------------------------------------------------------------------------------------------------------------------- | Run load testing \-------------------------------------------------------------------------------------------------------------------------*/ - for (var i = 0; i <= runs; i++) { + for (var i = 0; i <= runs; i++) { await _mappingService.MapAsync(topic); } @@ -321,6 +327,48 @@ public async Task Map_AttributeDictionary_ReturnsNewModel() { } + /*============================================================================================================================ + | TEST: MAP: ATTRIBUTE DICTIONARY: NOT LOADED: RETURNS EXTENDED ATTRIBUTES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes a and maps a sparse topic whose extended attributes are still to a view model with an constructor. Confirms the extended + /// attribute is present in the mapped result, i.e., that + /// autoloads the blob rather than silently omitting it by enumerating only resident attributes. + /// + [Fact] + public async Task Map_AttributeDictionary_NotLoaded_ReturnsExtendedAttributes() { + + var records = new StubLazyLoadingTopicRepositoryBuilder() + .AddTopic( + 221, + "Sparse", + "Page", + null, + indexedAttributes : new Dictionary { + ["Title"] = "Value", + ["ShortTitle"] = "Short Title", + ["Subtitle"] = "Subtitle", + ["MetaTitle"] = "Meta Title", + ["MetaDescription"] = "Meta Description" + }, + extendedAttributes : new Dictionary { + ["MappedProperty"] = "Mapped Value" + } + ) + .Build(); + + var stub = new StubLazyLoadingTopicRepository(records); + var topic = await stub.Load("Root:Sparse"); + + Contract.Assume(topic); + + var target = await _mappingService.MapAsync(topic); + + Assert.Equal("Mapped Value", target?.MappedProperty); + + } + /*============================================================================================================================ | TEST: MAP: CONSTRUCTOR: RETURNS NEW MODEL \---------------------------------------------------------------------------------------------------------------------------*/ @@ -421,6 +469,196 @@ await _mappingService.MapAsync(topic).ConfigureAwait( } + /*============================================================================================================================ + | TEST: MAP: CONSTRUCTOR (RECORD): RETURNS NEW MODEL + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes a and maps a positional record whose constructor accepts a + /// non-cyclic topic reference, confirming that the reference is resolved and the record is constructed as expected. + /// + /// + /// As this is a mapping of a positional record that carries constructor parameters, it also confirms that the + /// primary constructor is correctly selected and its parameters mapped. + /// + [Fact] + public async Task Map_ConstructorRecord_ReturnsNewModel() { + + var topic = new Topic("Parent", "CircularConstructor", null, 1); + var child = new Topic("Child", "CircularConstructor", null, 2); + + topic.References.SetValue("Self", child); + + var target = await _mappingService.MapAsync(topic); + + Assert.NotNull(target); + Assert.Equal("Parent", target.Key); + Assert.NotNull(target.Self); + Assert.Equal("Child", target.Self.Key); + Assert.Null(target.Self.Self); + + } + + /*============================================================================================================================ + | TEST: MAP: CONSTRUCTOR (RECORD): THROWS EXCEPTION + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes a and maps a positional record whose constructor references the + /// topic being mapped, and confirms that this circular constructor reference is detected and a is thrown. + /// + [Fact] + public async Task Map_ConstructorRecord_ThrowsException() { + + var topic = new Topic("Topic", "CircularConstructor", null, 1); + + topic.References.SetValue("Self", topic); + + await Assert.ThrowsAsync(async () => + await _mappingService.MapAsync(topic).ConfigureAwait(false) + ); + + } + + /*============================================================================================================================ + | TEST: MAP: CONCURRENT SIBLINGS: RETURNS SHARED INSTANCE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Confirms that two concurrent branches mapping the same topic to the same type within a single pass share one instance, + /// rather than the second branch mistaking the first's still-initializing entry for a circular constructor reference. + /// + /// + /// Uses , which suspends inside its own EnsureLoaded until + /// released, to hold the branch that wins construction of the shared model mid-constructor, guaranteeing the second branch + /// reaches the still-initializing entry and thus must await its completion. The shared topic is loaded through the + /// repository so it is stamped for lazy loading, and its constructor's collection parameter actually engages the gate; the + /// root is a plain whose in-memory references cannot trip the gate before the shared model is + /// constructed. Asserting the in-process task has not completed proves the first branch actually suspended, so the test + /// cannot pass without exercising the await path. + /// + [Fact] + public async Task Map_ConcurrentSiblings_ReturnsSharedInstance() { + + var (inner, cache, mappingService) = CreateGatedMappingService(); + + var shared = await cache.Load("Web"); + + Contract.Assume(shared); + + var root = new Topic("ConcurrentRoot", "Container", null, 5); + + root.References.SetValue("FirstReference", shared); + root.References.SetValue("SecondReference", shared); + + // "Arm" the gate so the branch that constructs the shared model suspends mid-constructor + inner.ArmEnsureLoadedGate(); + + var mapTask = mappingService.MapAsync(root); + + // Prove the first branch is genuinely suspended, so the second must await its completion + Assert.False(mapTask.IsCompleted); + + inner.ReleaseEnsureLoadedGate(); + + var result = await mapTask; + + Assert.NotNull(result); + Assert.NotNull(result.FirstReference); + Assert.NotNull(result.SecondReference); + Assert.Same(result.FirstReference, result.SecondReference); + + } + + /*============================================================================================================================ + | TEST: MAP: CONCURRENT SIBLINGS: OBSERVES FAULT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Confirms that when the branch constructing a shared model faults, a concurrent branch awaiting the same entry observes + /// the exception rather than hanging on a mapping that will never complete. + /// + /// + /// Uses the same setup as , but releases the gate with a fault so + /// the constructing branch throws while the second branch is awaiting the entry's completion. That the map throws, rather + /// than deadlocking, is what confirms the faulted entry releases its waiter. + /// + [Fact] + public async Task Map_ConcurrentSiblings_ObservesFault() { + + var (inner, cache, mappingService) = CreateGatedMappingService(); + + var shared = await cache.Load("Web"); + + Contract.Assume(shared); + + var root = new Topic("ConcurrentRoot", "Container", null, 5); + + root.References.SetValue("FirstReference", shared); + root.References.SetValue("SecondReference", shared); + + // "Arm" the gate so the branch that constructs the shared model suspends mid-constructor + inner.ArmEnsureLoadedGate(); + + var mapTask = mappingService.MapAsync(root); + + // Prove the first branch is genuinely suspended, so the second must await its completion + Assert.False(mapTask.IsCompleted); + + inner.FaultEnsureLoadedGate(new InvalidOperationException("Simulated load failure.")); + + await Assert.ThrowsAsync(async () => await mapTask.ConfigureAwait(false)); + + } + + /*============================================================================================================================ + | TEST: MAP: CONCURRENT SHARED COLLECTION: POPULATES DETERMINISTICALLY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Stress test confirming that two concurrent passes expanding the same shared model with disjoint associations, each + /// populating the same collection from a different source, deterministically produce the union of both sources without + /// corrupting the shared list. + /// + /// + /// A parent references one shared topic twice, with disjoint sets ( and ). Whichever pass wins + /// construction populates the shared list from one source; + /// the other expands it from the other, so the result should always be the union of both, regardless of which pass wins. A + /// holds the two passes at the collection warm-up until both arrive, then releases + /// them together, so their list mutations genuinely overlap. Repeated many times to give the race a chance to manifest; + /// without the shared-list mutation guard, the concurrent adds corrupt the list, dropping items or throwing. + /// + [Fact] + public async Task Map_ConcurrentSharedCollection_PopulatesDeterministically() { + + const int relationshipCount = 12; + const int incomingCount = 12; + const int repetitions = 100; + + var loader = new RendezvousTopicLazyLoader(participantCount: 2); + + for (var repetition = 0; repetition < repetitions; repetition++) { + + var root = BuildConcurrentExpansionGraph(loader, relationshipCount, incomingCount, out var shared); + + // Guard against an incomplete setup: Both sources must be loaded before mapping; read them via the backing accessor so + // the precondition check doesn't itself trip the loader's rendezvous and hang + var backing = (ITopicBackingAccessor)shared; + Assert.Equal(relationshipCount, backing.Relationships.GetValues("Related").Count); + Assert.Equal(incomingCount, shared.IncomingRelationships.GetValues("Related").Count); + + var result = await _mappingService.MapAsync(root); + + Assert.NotNull(result); + Assert.NotNull(result.RelationshipsView); + Assert.NotNull(result.IncomingView); + + // Both references resolve to the one shared instance, whose list holds the union of both sources + Assert.Same(result.RelationshipsView, result.IncomingView); + Assert.NotNull(result.RelationshipsView.Related); + Assert.Equal(relationshipCount + incomingCount, result.RelationshipsView.Related.Count); + + } + + } + /*============================================================================================================================ | TEST: MAP: DISABLED PROPERTY: RETURNS NULL \---------------------------------------------------------------------------------------------------------------------------*/ @@ -588,8 +826,8 @@ public async Task Map_AlternateAttributeKey_ReturnsMappedModel() { | TEST: MAPPED TOPIC CACHE: TRY GET VALUE: RETURNS ENTRY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and then confirms that it is returned via . + /// Establishes a and then confirms that it is returned via . /// [Fact] public void MappedTopicCache_TryGetValue_ReturnsEntry() { @@ -612,7 +850,7 @@ public void MappedTopicCache_TryGetValue_ReturnsEntry() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Establishes a and then confirms that it is not returned via if the doesn't match. + /// .TryGetValue"/> if the doesn't match. /// [Fact] public void MappedTopicCache_TryGetValue_ReturnsNull() { @@ -715,8 +953,6 @@ public void MappedTopicCacheEntry_GetMissingAssociations_ReturnsDifference() { var difference = cacheEntry.GetMissingAssociations(associations); - cacheEntry.AddMissingAssociations(difference); - Assert.True(difference.HasFlag(AssociationTypes.References)); Assert.False(difference.HasFlag(AssociationTypes.Children)); Assert.False(difference.HasFlag(AssociationTypes.Parents)); @@ -724,24 +960,128 @@ public void MappedTopicCacheEntry_GetMissingAssociations_ReturnsDifference() { } /*============================================================================================================================ - | TEST: MAPPED TOPIC CACHE ENTRY: ADD MISSING ASSOCIATIONS: SETS UNION + | TEST: MAPPED TOPIC CACHE ENTRY: ADD MISSING ASSOCIATIONS: RETURNS NEWLY ADDED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a with a set of , and then confirms that - /// its correctly extends the missing - /// associations. + /// Establishes a and then confirms that two overlapping calls to its return disjoint flags (each reporting only what it + /// newly added) whose union is the missing set, and that the recorded + /// reflect both calls. /// + /// + /// Each association may only be added once. Even though both requests include , + /// only the first call adds it; the second call sees it as already recorded and returns only the remainder. This is what + /// ensures two concurrent passes map disjoint associations rather than both mapping the overlap. + /// [Fact] - public void MappedTopicCacheEntry_AddMissingAssociations_SetsUnion() { + public void MappedTopicCacheEntry_AddMissingAssociations_ReturnsNewlyAdded() { var cacheEntry = new MappedTopicCacheEntry() { Associations = AssociationTypes.Children }; - var associations = AssociationTypes.Children | AssociationTypes.Parents; - cacheEntry.AddMissingAssociations(associations); + var firstResult = cacheEntry.AddMissingAssociations(AssociationTypes.Children | AssociationTypes.Parents); + var secondResult = cacheEntry.AddMissingAssociations(AssociationTypes.Parents | AssociationTypes.References); + + Assert.Equal(AssociationTypes.Parents, firstResult); + Assert.Equal(AssociationTypes.References, secondResult); + Assert.Equal(AssociationTypes.Children | AssociationTypes.Parents | AssociationTypes.References, cacheEntry.Associations); + + } + + /*============================================================================================================================ + | TEST: MAPPED TOPIC CACHE ENTRY: IS INITIALIZING: REFLECTS COMPLETION STATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes a set of instances and confirms that is derived from the completion state: A fresh entry is initializing, a + /// completed entry is not, and a faulted entry remains initializing so an awaiting pass observes the fault instead of a + /// null instance. + /// + [Fact] + public void MappedTopicCacheEntry_IsInitializing_ReflectsCompletionState() { + + var completed = new MappedTopicCacheEntry(); + var faulted = new MappedTopicCacheEntry(); + + Assert.True(completed.IsInitializing); + Assert.True(faulted.IsInitializing); + + completed.Complete(new EmptyViewModel(), AssociationTypes.None); + faulted.Fault(new InvalidOperationException()); + + Assert.False(completed.IsInitializing); + Assert.True(faulted.IsInitializing); + + // Observe the faulted task so its exception isn't surfaced as unobserved + Assert.NotNull(faulted.Completion.Exception); + + } + + /*============================================================================================================================ + | TEST: MAPPED TOPIC CACHE ENTRY: COMPLETION: SETTLES ON COMPLETE OR FAULT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes a pair of instances and confirms that resolves when the entry is completed, and throws the recorded exception when the + /// entry is faulted, so that a second pass awaiting the entry is always released rather than left hanging. + /// + [Fact] + public async Task MappedTopicCacheEntry_Completion_SettlesOnCompleteOrFault() { + + var completed = new MappedTopicCacheEntry(); + var faulted = new MappedTopicCacheEntry(); + + completed.Complete(new EmptyViewModel(), AssociationTypes.None); + faulted.Fault(new InvalidOperationException("Construction failed.")); - Assert.Equal(AssociationTypes.Children | AssociationTypes.Parents, cacheEntry.Associations); + Assert.True(completed.Completion.IsCompletedSuccessfully); + await Assert.ThrowsAsync(async () => await faulted.Completion.ConfigureAwait(false)); + + } + + /*============================================================================================================================ + | TEST: MAPPED TOPIC CACHE ENTRY: COMPLETE: RETAINS FIRST RESULT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes a and confirms that only the first call takes effect: A later duplicate registration is + /// ignored, keeping both the and its stable. + /// + [Fact] + public void MappedTopicCacheEntry_Complete_RetainsFirstResult() { + + var entry = new MappedTopicCacheEntry(); + var first = new EmptyViewModel(); + var second = new EmptyViewModel(); + + entry.Complete(first, AssociationTypes.Children); + entry.Complete(second, AssociationTypes.Parents); + + Assert.Same(first, entry.MappedTopic); + Assert.Equal(AssociationTypes.Children, entry.Associations); + + } + + /*============================================================================================================================ + | TEST: MAP PATH: CONTAINS: DETECTS PAIRS ON PATH + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes a two-frame and confirms that recognizes a + /// topic and view model type pair anywhere on the path, whether at the current frame or an ancestor, while rejecting pairs + /// that are not on the path, including one whose topic identifier matches but whose type does not. + /// + [Fact] + public void MapPath_Contains_DetectsPairsOnPath() { + + var root = new MapPath(1, typeof(EmptyViewModel), null); + var child = new MapPath(2, typeof(KeyOnlyTopicViewModel), root); + + Assert.True(child.Contains(2, typeof(KeyOnlyTopicViewModel))); + Assert.True(child.Contains(1, typeof(EmptyViewModel))); + Assert.False(child.Contains(3, typeof(EmptyViewModel))); + Assert.False(child.Contains(1, typeof(KeyOnlyTopicViewModel))); } @@ -831,7 +1171,7 @@ public async Task Map_AlternateRelationship_ReturnsCorrectRelationship() { ambiguousRelation.Relationships.SetValue("RelationshipAlias", topic); incomingRelation.Relationships.SetValue("AmbiguousRelationship", topic); - var target = await _mappingService.MapAsync(topic); + var target = await _mappingService.MapAsync(topic); Assert.NotNull(target); Assert.Single(target.RelationshipAlias); @@ -849,7 +1189,7 @@ public async Task Map_AlternateRelationship_ReturnsCorrectRelationship() { [Fact] public async Task Map_CustomCollection_ReturnsCollection() { - var topic = (ContentTypeDescriptor?)_topicRepository.Load("Root:Configuration:ContentTypes:Page"); + var topic = (ContentTypeDescriptor?)await _topicRepository.Load("Root:Configuration:ContentTypes:Page"); var target = await _mappingService.MapAsync(topic); Assert.NotNull(topic); @@ -932,6 +1272,42 @@ public async Task Map_Children_ReturnsMappedModel() { )); } + /*============================================================================================================================ + | TEST: MAP: CHILDREN: STAGGERED COMPLETION: PRESERVES SOURCE ORDER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Establishes a with children whose mapping tasks are forced to complete in reverse of + /// source order, and tests that the mapped collection nonetheless preserves source order. + /// + /// + /// Each child is stamped with its own and left + /// for , so mapping it genuinely awaits a delay before completing. The first child gets + /// the longest delay and the last gets none, so completion order is the reverse of source order; if collection population + /// added results in completion order rather than source order, this would come back reversed. + /// + [Fact] + public async Task Map_Children_StaggeredCompletion_PreservesSourceOrder() { + + var topic = new Topic("Test", "Descendent"); + var childKeys = new[] { "ChildTopic1", "ChildTopic2", "ChildTopic3", "ChildTopic4" }; + + for (var index = 0; index < childKeys.Length; index++) { + var child = new Topic(childKeys[index], "Descendent", topic); + var delay = TimeSpan.FromMilliseconds((childKeys.Length - index) * 25); + ((ITopicLazyLoadable)child).Loader = new StaggeredTopicLazyLoader(delay); + ((ITopicBackingAccessor)child).Children.LoadState = LoadState.NotLoaded; + } + + var target = await _mappingService.MapAsync(topic); + + Assert.NotNull(target); + Assert.Equal(childKeys.Length, target.Children.Count); + for (var index = 0; index < childKeys.Length; index++) { + Assert.Equal(childKeys[index], target.Children[index].Key); + } + + } + /*============================================================================================================================ | TEST: MAP: WITH DISABLED: SKIPS DISABLED \---------------------------------------------------------------------------------------------------------------------------*/ @@ -977,7 +1353,7 @@ public async Task Map_MapToParent_ReturnsMappedModel() { topic.Attributes.SetValue("AncillaryKey", "Ancillary Key"); topic.Attributes.SetValue("AliasedKey", "Aliased Key"); - var target = await _mappingService.MapAsync(topic); + var target = await _mappingService.MapAsync(topic); Assert.Equal("Test", target?.Primary?.Key); Assert.Equal("Aliased Key", target?.Alternate?.Key); @@ -995,7 +1371,7 @@ public async Task Map_MapToParent_ReturnsMappedModel() { [Fact] public async Task Map_MapAs_ReturnsTopicReference() { - var topicReference = _topicRepository.Load(11111); + var topicReference = await _topicRepository.Load(11111); Contract.Assume(topicReference); @@ -1003,7 +1379,7 @@ public async Task Map_MapAs_ReturnsTopicReference() { topic.References.SetValue("TopicReference", topicReference); - var target = (MapAsTopicViewModel?)await _mappingService.MapAsync(topic); + var target = (MapAsTopicViewModel?)await _mappingService.MapAsync(topic); Assert.NotNull(target?.TopicReference); Assert.IsType(target?.TopicReference); @@ -1020,7 +1396,7 @@ public async Task Map_MapAs_ReturnsTopicReference() { [Fact] public async Task Map_MapAs_ReturnsRelationships() { - var relatedTopic = _topicRepository.Load(11111); + var relatedTopic = await _topicRepository.Load(11111); Contract.Assume(relatedTopic); @@ -1028,7 +1404,7 @@ public async Task Map_MapAs_ReturnsRelationships() { topic.Relationships.SetValue("Relationships", relatedTopic); - var target = (MapAsTopicViewModel?)await _mappingService.MapAsync(topic); + var target = (MapAsTopicViewModel?)await _mappingService.MapAsync(topic); Assert.NotNull(target); Assert.Single(target.Relationships); @@ -1036,6 +1412,36 @@ public async Task Map_MapAs_ReturnsRelationships() { } + /*============================================================================================================================ + | TEST: MAP: RELATIONSHIP ONLY: DOES NOT FILL CHILDREN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Maps a , whose only collection is explicitly typed , against a , and confirms that no + /// synchronous fill occurs. Prior to fixing GetSourceCollectionAsync's + /// collection probes, the NestedTopics probe's signature (source.Children.Contains) was evaluated + /// unconditionally when the argument was constructed, silently triggering the lazy loading of the children regardless of + /// which collection type the view model actually requested. + /// + [Fact] + public async Task Map_RelationshipOnly_DoesNotFillChildren() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + var typeLookupService = new CompositeTypeLookupService(new TopicViewModelLookupService(), new FakeViewModelLookupService()); + var mappingService = new TopicMappingService(cache, typeLookupService); + + var topic = await cache.Load("Root:Web:Web_0"); + + Contract.Assume(topic); + + var target = await mappingService.MapAsync(topic); + + Assert.NotNull(target); + Assert.Equal(0, stub.GetFetchCount(topic.Id, TopicPayload.Children)); + + } + /*============================================================================================================================ | TEST: MAP: TOPIC REFERENCES AS ATTRIBUTE: RETURNS MAPPED MODEL \---------------------------------------------------------------------------------------------------------------------------*/ @@ -1046,7 +1452,7 @@ public async Task Map_MapAs_ReturnsRelationships() { [Fact] public async Task Map_TopicReferencesAsAttribute_ReturnsMappedModel() { - var topicReference = _topicRepository.Load(11111); + var topicReference = await _topicRepository.Load(11111); Contract.Assume(topicReference); @@ -1070,7 +1476,7 @@ public async Task Map_TopicReferencesAsAttribute_ReturnsMappedModel() { [Fact] public async Task Map_TopicReferences_ReturnsMappedModel() { - var topicReference = _topicRepository.Load(11111); + var topicReference = await _topicRepository.Load(11111); var topic = new Topic("Test", "TopicReference"); @@ -1287,6 +1693,78 @@ public async Task Map_CachedTopic_ReturnsProgressiveReference() { } + /*============================================================================================================================ + | TEST: MAP: EXPANSION PASS: DOES NOT DUPLICATE NESTED TOPICS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Maps an , which encounters the same source topic twice with disjoint + /// associations, confirming that the second (expansion) pass does not re-append the ungated nested-topics collection that + /// the initial pass already populated. + /// + /// + /// Reliability rests on the eager repository mapping the two collections sequentially, so the encounters are strictly + /// ordered: The first builds and fills the cached view model, and the second, requesting a disjoint association, hits the + /// cache and runs an expansion pass (rather than a second, concurrent initial pass). is then filled once by the ungated nested-topics probe on the initial pass + /// and skipped on the expansion pass, so the count is 2 whichever collection reflection maps first. + /// + [Fact] + public async Task Map_ExpansionPass_DoesNotDuplicateNestedTopics() { + + var parent = new Topic("Parent", "ExpansionParent", null, 700); + var shared = new Topic("Shared", "ExpansionShared", parent, 701); + var categories = new Topic("Categories", "List", shared, 702); + _ = new Topic("Category1", "KeyOnly", categories, 703); + _ = new Topic("Category2", "KeyOnly", categories, 704); + + parent.Relationships.SetValue("Related", shared); + + var target = await _mappingService.MapAsync(parent); + var mappedShared = target?.Children.FirstOrDefault(); + + //Assert.Same confirms both collections resolved to the same cached instance, so the second reach was a cache hit and, given + //the disjoint associations, ran an expansion pass + Assert.NotNull(mappedShared); + Assert.Same(mappedShared, target?.Related.FirstOrDefault()); + Assert.Equal(2, mappedShared.Categories.Count); + + } + + /*============================================================================================================================ + | TEST: MAP: EXPANSION PASS: DOES NOT REMAP COMPATIBLE PROPERTY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Maps an , which encounters the same source topic twice with disjoint + /// associations, and confirms that the second (expansion) pass does not reassign the compatible property that the initial pass already mapped. + /// + /// + /// Reliability rests on the eager repository mapping the two collections sequentially, so the encounters are strictly + /// ordered: The first builds and fills the cached view model, and the second, requesting a disjoint association, hits the + /// cache and runs an expansion pass (rather than a second, concurrent initial pass). The compatible is then assigned once on the initial pass and skipped on the expansion pass, so + /// is 1 whichever collection reflection maps first. + /// + [Fact] + public async Task Map_ExpansionPass_DoesNotRemapCompatibleProperty() { + + var parent = new Topic("Parent", "ExpansionParent", null, 710); + var shared = new Topic("Shared", "ExpansionShared", parent, 711); + + parent.Relationships.SetValue("Related", shared); + + var target = await _mappingService.MapAsync(parent); + var mappedShared = target?.Children.FirstOrDefault(); + + //Assert.Same confirms both collections resolved to the same cached instance, so the second reach was a cache hit and, given + //the disjoint associations, ran an expansion pass rather than passing vacuously + Assert.NotNull(mappedShared); + Assert.Same(mappedShared, target?.Related.FirstOrDefault()); + Assert.Equal("Shared", mappedShared.Key); + Assert.Equal(1, mappedShared.KeyMapCount); + + } + /*============================================================================================================================ | TEST: MAP: CIRCULAR REFERENCE: RETURNS MAPPED PARENT \---------------------------------------------------------------------------------------------------------------------------*/ @@ -1333,6 +1811,34 @@ public async Task Map_FilterByCollectionType_ReturnsFilteredCollection() { } + /*============================================================================================================================ + | TEST: MAP: DESCENDENT: DOES NOT FILL RELATIONSHIPS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Maps a , whose only collection is , against a + /// , and confirms that no synchronous + /// fill occurs. The inverse of : The relationship probe's method call + /// (source.Relationships.Contains) was likewise evaluated unconditionally. + /// + [Fact] + public async Task Map_Descendent_DoesNotFillRelationships() { + + var stub = new StubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(stub); + var typeLookupService = new CompositeTypeLookupService(new TopicViewModelLookupService(), new FakeViewModelLookupService()); + var mappingService = new TopicMappingService(cache, typeLookupService); + + var topic = await cache.Load("Root:Web:Web_0"); + + Contract.Assume(topic); + + var target = await mappingService.MapAsync(topic); + + Assert.NotNull(target); + Assert.Equal(0, stub.GetFetchCount(topic.Id, TopicPayload.Relationships)); + + } + /*============================================================================================================================ | TEST: MAP: GETTER METHODS: MAP METHOD OUTPUT \---------------------------------------------------------------------------------------------------------------------------*/ @@ -1347,7 +1853,7 @@ public async Task Map_GetterMethods_MapMethodOutput() { var childTopic = new Topic("Child", "Page", topic); var grandChildTopic = new Topic("GrandChild", "Index", childTopic); - var target = await _mappingService.MapAsync(grandChildTopic); + var target = await _mappingService.MapAsync(grandChildTopic); Assert.Equal("Topic:Child:GrandChild", target?.UniqueKey); @@ -1389,7 +1895,7 @@ public async Task Map_ValidRequiredProperty_IsMapped() { topic.Attributes.SetValue("RequiredAttribute", "Required"); - var target = await _mappingService.MapAsync(topic); + var target = await _mappingService.MapAsync(topic); Assert.Equal("Required", target?.RequiredAttribute); @@ -1515,7 +2021,7 @@ public async Task Map_FilterByAttribute_ReturnsFilteredCollection() { childTopic4.Attributes.SetValue("SomeOtherAttribute", "ValueA"); - var target = await _mappingService.MapAsync(topic); + var target = await _mappingService.MapAsync(topic); Assert.Equal(2, target?.Children.Count); @@ -1556,7 +2062,7 @@ public async Task Map_FilterByContentType_ReturnsFilteredCollection() { var childTopic3 = new Topic("ChildTopic3", "Page", topic); _ = new Topic("ChildTopic4", "Page", childTopic3); - var target = await _mappingService.MapAsync(topic); + var target = await _mappingService.MapAsync(topic); Assert.Equal(2, target?.Children.Count); @@ -1566,22 +2072,22 @@ public async Task Map_FilterByContentType_ReturnsFilteredCollection() { | TEST: MAP: FLATTEN ATTRIBUTE: RETURNS FLAT COLLECTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and tests whether the resulting object's property is properly flattened. + /// Establishes a and tests whether the resulting object's property is properly flattened. /// [Fact] public async Task Map_FlattenAttribute_ReturnsFlatCollection() { var topic = new Topic("Test", "FlattenChildren"); - for (var i = 0; i < 5; i++) { + for (var i = 0; i < 5; i++) { var childTopic = new Topic("Child" + i, "Page", topic); - for (var j = 0; j < 5; j++) { - _ = new Topic("GrandChild" + i + j, "FlattenChildren", childTopic); + for (var j = 0; j < 5; j++) { + _ = new Topic("GrandChild" + i + j, "FlattenChildren", childTopic); } } - var target = await _mappingService.MapAsync(topic); + var target = await _mappingService.MapAsync(topic); Assert.Equal(25, target?.Children.Count); @@ -1591,8 +2097,8 @@ public async Task Map_FlattenAttribute_ReturnsFlatCollection() { | TEST: MAP: FLATTEN ATTRIBUTE: EXCLUDE TOPICS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and tests whether the resulting object's property excludes any or nested topics. + /// Establishes a and tests whether the resulting object's property excludes any or nested topics. /// [Fact] public async Task Map_FlattenAttribute_ExcludeTopics() { @@ -1605,7 +2111,7 @@ public async Task Map_FlattenAttribute_ExcludeTopics() { grandChildTopic.IsDisabled = true; - var target = await _mappingService.MapAsync(topic); + var target = await _mappingService.MapAsync(topic); Assert.NotNull(target); Assert.Single(target.Children); @@ -1622,12 +2128,12 @@ public async Task Map_FlattenAttribute_ExcludeTopics() { [Fact] public async Task Map_CachedTopic_ReturnsCachedModel() { - var cachedMappingService = new CachedTopicMappingService(_mappingService); + var cachedMappingService = new CachedTopicMappingService(_mappingService); - var topic = new Topic("Test", "Filtered", null, 5); + var topic = new Topic("Test", "Filtered", null, 5); - var target1 = (FilteredTopicViewModel?)await cachedMappingService.MapAsync(topic); - var target2 = (FilteredTopicViewModel?)await cachedMappingService.MapAsync(topic); + var target1 = (FilteredTopicViewModel?)await cachedMappingService.MapAsync(topic); + var target2 = (FilteredTopicViewModel?)await cachedMappingService.MapAsync(topic); Assert.Equal(target1, target2); @@ -1643,13 +2149,13 @@ public async Task Map_CachedTopic_ReturnsCachedModel() { [Fact] public async Task Map_CachedTopic_ReturnsUniqueReferencePerType() { - var cachedMappingService = new CachedTopicMappingService(_mappingService); + var cachedMappingService = new CachedTopicMappingService(_mappingService); - var topic = new Topic("Test", "Filtered", null, 5); + var topic = new Topic("Test", "Filtered", null, 5); - var target1 = await cachedMappingService.MapAsync(topic); - var target2 = await cachedMappingService.MapAsync(topic); - var target3 = (TopicViewModel?)await cachedMappingService.MapAsync(topic); + var target1 = await cachedMappingService.MapAsync(topic); + var target2 = await cachedMappingService.MapAsync(topic); + var target3 = (TopicViewModel?)await cachedMappingService.MapAsync(topic); Assert.Equal(target1, target2); Assert.NotEqual(target1, target3); @@ -1668,4 +2174,95 @@ public async Task Map_CachedTopic_ReturnsUniqueReferencePerType() { public static TopicViewModel? GetChildTopic(IEnumerable? topicCollection, string key) => topicCollection?.FirstOrDefault((t) => t.Key.StartsWith(key, StringComparison.Ordinal)); + /*============================================================================================================================ + | METHOD: CREATE GATED MAPPING SERVICE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Assembles a fresh over a , + /// returning the service together with that gated repository and the wrapping it. + /// + /// + /// Each call returns a new, isolated set so concurrent-mapping tests can "arm", release, or fault their own gate without + /// interfering with one another. The stateless is reused, so + /// only the gated repository is constructed per test. + /// + /// The gated repository, the cache over it, and the mapping service. + private ( + BlockingStubLazyLoadingTopicRepository Repository, + CachedTopicRepository Cache, + ITopicMappingService MappingService + ) CreateGatedMappingService() { + var inner = new BlockingStubLazyLoadingTopicRepository(); + var cache = new CachedTopicRepository(inner); + return (inner, cache, new TopicMappingService(cache, _typeLookupService)); + } + + /*============================================================================================================================ + | METHOD: BUILD CONCURRENT EXPANSION GRAPH + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Builds an in-memory graph for : A parent that + /// references a single topic twice, that topic having + /// outgoing relationships and incoming relationships under the key Related. + /// + /// + /// Everything is preloaded, so performs no fetching; only the topic is + /// stamped for lazy loading, since only its EnsureLoaded needs to "rendezvous" the two passes. Its is left so the mapper's collection warm-up actually calls + /// the loader, which then marks it before the base pass's nested-topic probe reads it. The + /// relationships are already . The shared topic deliberately has no Related child, so + /// the nested-topic probe never displaces the relationship and incoming-relationship sources. + /// + /// The lazy loader to stamp on the shared topic. + /// The number of outgoing relationships to wire under Related. + /// The number of incoming relationships to wire under Related. + /// The shared topic referenced twice by the returned parent. + /// The parent topic to map. + private static Topic BuildConcurrentExpansionGraph( + ITopicLazyLoader loader, + int relationshipCount, + int incomingCount, + out Topic shared + ) { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Establish the parent and the shared target + \-------------------------------------------------------------------------------------------------------------------------*/ + var identity = 1; + var root = new Topic("ConcurrentExpansionRoot", "ConcurrentExpansionRoot", null, identity++); + shared = new Topic("Shared", "ConcurrentExpansionShared", null, identity++); + var backing = (ITopicBackingAccessor)shared; + + /*-------------------------------------------------------------------------------------------------------------------------- + | Wire the shared target's outgoing relationships (the first source for the shared collection) + \-------------------------------------------------------------------------------------------------------------------------*/ + for (var index = 0; index < relationshipCount; index++) { + backing.Relationships.SetValue("Related", new($"Relationship_{index}", "KeyOnly", null, identity++)); + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Wire the shared target's incoming relationships (the second source), via each origin's reciprocal outgoing relationship + \-------------------------------------------------------------------------------------------------------------------------*/ + for (var index = 0; index < incomingCount; index++) { + var origin = new Topic($"Incoming_{index}", "KeyOnly", null, identity++); + origin.Relationships.SetValue("Related", shared); + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Stamp the shared target and leave children NotLoaded so the collection warm-up calls the loader exactly once per pass; the + | relationships are already Loaded (no deferred targets), so the relationship probe reads them without autoloading + \-------------------------------------------------------------------------------------------------------------------------*/ + ((ITopicLazyLoadable)shared).Loader = loader; + backing.Children.LoadState = LoadState.NotLoaded; + + /*-------------------------------------------------------------------------------------------------------------------------- + | Reference the shared target twice from the parent, so both references map it concurrently + \-------------------------------------------------------------------------------------------------------------------------*/ + root.References.SetValue("RelationshipsView", shared); + root.References.SetValue("IncomingView", shared); + + return root; + + } + } //Class \ No newline at end of file diff --git a/OnTopic.Tests/TopicQueryingTest.cs b/OnTopic.Tests/TopicQueryingTest.cs index bd139e30..97b2a7d1 100644 --- a/OnTopic.Tests/TopicQueryingTest.cs +++ b/OnTopic.Tests/TopicQueryingTest.cs @@ -51,7 +51,7 @@ public TopicQueryingTest(TopicInfrastructureFixture fixture /*-------------------------------------------------------------------------------------------------------------------------- | Establish dependencies \-------------------------------------------------------------------------------------------------------------------------*/ - _topicRepository = fixture.CachedTopicRepository; + _topicRepository = fixture.CachedTopicRepository; } @@ -167,7 +167,7 @@ public void GetByUniqueKey_RootKey_ReturnsRootTopic() { var parentTopic = new Topic("ParentTopic", "Page", null, 1); _ = new Topic("ChildTopic", "Page", parentTopic, 2); - var foundTopic = parentTopic.GetByUniqueKey("ParentTopic"); + var foundTopic = parentTopic.GetByUniqueKey("ParentTopic"); Assert.NotNull(foundTopic); Assert.Equal(parentTopic, foundTopic); @@ -186,10 +186,10 @@ public void GetByUniqueKey_ValidKey_ReturnsTopic() { var parentTopic = new Topic("ParentTopic", "Page", null, 1); var childTopic = new Topic("ChildTopic", "Page", parentTopic, 5); var grandChildTopic = new Topic("GrandChildTopic", "Page", childTopic, 20); - var greatGrandChildTopic1 = new Topic("GreatGrandChildTopic1", "Page", grandChildTopic, 7); - var greatGrandChildTopic2 = new Topic("GreatGrandChildTopic2", "Page", grandChildTopic, 7); + var greatGrandChildTopic1 = new Topic("GreatGrandChildTopic1", "Page", grandChildTopic, 7); + var greatGrandChildTopic2 = new Topic("GreatGrandChildTopic2", "Page", grandChildTopic, 7); - var foundTopic = greatGrandChildTopic1.GetByUniqueKey("ParentTopic:ChildTopic:GrandChildTopic:GreatGrandChildTopic2"); + var foundTopic = greatGrandChildTopic1.GetByUniqueKey("ParentTopic:ChildTopic:GrandChildTopic:GreatGrandChildTopic2"); Assert.Equal(greatGrandChildTopic2, foundTopic); @@ -210,7 +210,7 @@ public void GetByUniqueKey_InvalidKey_ReturnsNull() { var grandChildTopic = new Topic("GrandChildTopic", "Page", childTopic, 20); var greatGrandChildTopic = new Topic("GreatGrandChildTopic", "Page", grandChildTopic, 7); - var foundTopic = greatGrandChildTopic.GetByUniqueKey("ParentTopic:ChildTopic:GrandChildTopic:GreatGrandChildTopic2"); + var foundTopic = greatGrandChildTopic.GetByUniqueKey("ParentTopic:ChildTopic:GrandChildTopic:GreatGrandChildTopic2"); Assert.Null(foundTopic); @@ -223,10 +223,10 @@ public void GetByUniqueKey_InvalidKey_ReturnsNull() { /// Given a deeply nested , returns the expected . /// [Fact] - public void GetContentType_ValidContentType_ReturnsContentType() { + public async Task GetContentType_ValidContentType_ReturnsContentType() { - var topic = _topicRepository.Load(11111); - var contentTypeDescriptor = topic?.GetContentTypeDescriptor(); + var topic = await _topicRepository.Load(11111); + var contentTypeDescriptor = topic?.GetContentTypeDescriptor(); Assert.NotNull(contentTypeDescriptor); Assert.Equal("Page", contentTypeDescriptor?.Key); @@ -241,16 +241,16 @@ public void GetContentType_ValidContentType_ReturnsContentType() { /// /> returns null. /// [Fact] - public void GetContentType_InvalidContentType_ReturnsNull() { + public async Task GetContentType_InvalidContentType_ReturnsNull() { - var parentTopic = _topicRepository.Load(11111); + var parentTopic = await _topicRepository.Load(11111); var topic = new Topic("Test", "NonExistent", parentTopic); - var contentTypeDescriptor = topic.GetContentTypeDescriptor(); + var contentTypeDescriptor = topic.GetContentTypeDescriptor(); Assert.Null(contentTypeDescriptor); //Revert state - _topicRepository.Delete(topic); + await _topicRepository.Delete(topic); } @@ -262,92 +262,110 @@ public void GetContentType_InvalidContentType_ReturnsNull() { /// /> returns null. /// /// - /// This varies from in that it returns a valid which doesn't derive from . + /// This varies from in that it returns a valid which doesn't derive from . /// [Fact] - public void GetContentType_InvalidType_ReturnsNull() { + public async Task GetContentType_InvalidType_ReturnsNull() { - var parentTopic = _topicRepository.Load(11111); + var parentTopic = await _topicRepository.Load(11111); var topic = new Topic("Test", "Title", parentTopic); - var contentTypeDescriptor = topic.GetContentTypeDescriptor(); + var contentTypeDescriptor = topic.GetContentTypeDescriptor(); Assert.Null(contentTypeDescriptor); //Revert state - _topicRepository.Delete(topic); + await _topicRepository.Delete(topic); } /*============================================================================================================================ - | TEST: ANY DIRTY: DIRTY COLLECTION: RETURN TRUE + | TEST: ANY NEW: CONTAINS NEW: RETURN TRUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Given a with at least one that , - /// returns true. + /// Given a with at least one that , returns + /// true. /// [Fact] - public void AnyDirty_DirtyCollection_ReturnTrue() { + public void AnyNew_ContainsNew_ReturnTrue() { - var topics = new TopicCollection { - new Topic("Test", "Page") + var topics = new TopicCollection { + new("Test", "Page") }; - Assert.True(topics.AnyDirty()); + Assert.True(topics.AnyNew()); } /*============================================================================================================================ - | TEST: ANY DIRTY: CLEAN COLLECTION: RETURN FALSE + | TEST: ANY NEW: CONTAINS EXISTING: RETURN FALSE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Given a with no s that are , - /// returns false. + /// Given a with no s that are , returns + /// false. /// [Fact] - public void AnyDirty_CleanCollection_ReturnFalse() { + public void AnyNew_ContainsExisting_ReturnFalse() { - var topics = new TopicCollection { - new Topic("Test", "Page", null, 1) + var topics = new TopicCollection { + new("Test", "Page", null, 1) }; - Assert.False(topics.AnyDirty()); + Assert.False(topics.AnyNew()); } /*============================================================================================================================ - | TEST: ANY NEW: CONTAINS NEW: RETURN TRUE + | TEST: FIND FIRST: NOT LOADED CHILD: STILL FINDS RESIDENT DESCENDANT \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Given a with at least one that , returns - /// true. + /// Creates a three-level topic hierarchy and manually sets the middle topic's to despite already having a grandchild (e.g., as left behind by an ancestor crawl or a partial + /// fill). Verifies that traverses via the non-triggering backing field and so still + /// finds the grandchild, rather than treating the stamp as if the branch were empty. /// [Fact] - public void AnyNew_ContainsNew_ReturnTrue() { + public void FindFirst_WithNotLoadedChild_StillFindsResidentDescendant() { - var topics = new TopicCollection { - new Topic("Test", "Page") - }; + var parent = new Topic("Parent", "Page", null, 1); + var child = new Topic("Child", "Page", parent, 2); + var grandchild = new Topic("Grandchild", "Page", child, 3); - Assert.True(topics.AnyNew()); + child.Children.LoadState = LoadState.NotLoaded; + + var result = parent.FindFirst(t => t == grandchild); + + Assert.Equal(grandchild, result); } /*============================================================================================================================ - | TEST: ANY NEW: CONTAINS EXISTING: RETURN FALSE + | TEST: FIND ALL: PARTIALLY LOADED GRAPH: INCLUDES RESIDENT NOT LOADED SUBTREES \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Given a with no s that are , returns - /// false. + /// Creates a topic graph where one branch has a children collection despite already + /// having a grandchild (e.g., as left behind by an ancestor crawl or a partial fill). Verifies that traverses via the non-triggering backing field and so still returns the grandchild, + /// rather than treating the stamp as if the branch were empty. /// [Fact] - public void AnyNew_ContainsExisting_ReturnFalse() { + public void FindAll_WithPartiallyLoadedGraph_IncludesResidentNotLoadedSubtrees() { - var topics = new TopicCollection { - new Topic("Test", "Page", null, 1) - }; + var parent = new Topic("Parent", "Page", null, 1); + var childA = new Topic("ChildA", "Page", parent, 2); + var childB = new Topic("ChildB", "Page", parent, 3); + var grandchildA = new Topic("GrandchildA", "Page", childA, 4); + var grandchildB = new Topic("GrandchildB", "Page", childB, 5); - Assert.False(topics.AnyNew()); + childB.Children.LoadState = LoadState.NotLoaded; + + var results = parent.FindAll(); + + Assert.Contains(parent, results); + Assert.Contains(childA, results); + Assert.Contains(grandchildA, results); + Assert.Contains(childB, results); + Assert.Contains(grandchildB, results); } diff --git a/OnTopic.Tests/TopicReferenceCollectionTest.cs b/OnTopic.Tests/TopicReferenceCollectionTest.cs index 4a0ac88b..ecb989ac 100644 --- a/OnTopic.Tests/TopicReferenceCollectionTest.cs +++ b/OnTopic.Tests/TopicReferenceCollectionTest.cs @@ -4,8 +4,10 @@ | Project Topics Library \=============================================================================================================================*/ using OnTopic.Associations; +using OnTopic.Repositories; using OnTopic.Tests.Entities; using OnTopic.Collections.Specialized; +using OnTopic.TestDoubles.LazyLoading; using Xunit; namespace OnTopic.Tests; @@ -17,8 +19,8 @@ namespace OnTopic.Tests; /// Provides unit tests for the , with a particular emphasis on the custom features /// such as , , , and the cross-referencing of reciprocal values in the property. +/// SetValue(String, TValue, Boolean?, DateTime?)"/>, and the cross-referencing of reciprocal values in the property. /// [ExcludeFromCodeCoverage] public class TopicReferenceCollectionTest { @@ -47,8 +49,8 @@ public void Add_NewReference_IsDirty() { | TEST: SET VALUE: NEW REFERENCE: NOT DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new , adds a new reference using , and confirms that + /// Assembles a new , adds a new reference using , and confirms that /// is not set. /// [Fact] @@ -69,8 +71,8 @@ public void SetValue_NewReference_NotDirty() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Assembles a new with a topic reference, removes that reference using , and confirms that is set. + /// "TrackedRecordCollection{TItem, TValue, TAttribute}.RemoveItem(Int32)"/>, and confirms that is set. /// [Fact] public void Remove_ExistingReference_IsDirty() { @@ -90,10 +92,10 @@ public void Remove_ExistingReference_IsDirty() { | TEST: CLEAR: EXISTING REFERENCES: IS DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new , adds a new reference using , calls and confirms that is set. Also confirms that items are correctly removed + /// Assembles a new , adds a new reference using , calls and confirms that is set. Also confirms that items are correctly removed /// from recipricol . /// [Fact] @@ -116,8 +118,8 @@ public void Clear_ExistingReferences_IsDirty() { | TEST: ADD: NEW TOPIC: IS DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new and adds a new reference using with set to false + /// Assembles a new and adds a new reference using with set to false /// , confirming that remains true /// since the target is unsaved. /// @@ -137,8 +139,8 @@ public void Add_NewTopic_IsDirty() { | TEST: ADD: NEW REFERENCE: INCOMING RELATIONSHIP SET \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new , adds a new reference using , and confirms that + /// Assembles a new , adds a new reference using , and confirms that /// reference is correctly set. /// [Fact] @@ -157,14 +159,14 @@ public void Add_NewReference_IncomingRelationshipSet() { | TEST: REMOVE: EXISTING REFERENCE: INCOMING RELATIONSHIP REMOVED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new , adds a new reference using , removes the + /// Assembles a new , adds a new reference using , removes the /// reference using , and confirms that /// the reference is correctly removed as well. /// /// - /// This calls twice. The first to confirm that the is removed, the second to ensure that the attempt to call twice. The first to confirm that the is removed, the second to ensure that the attempt to call isn't disrupted by the fact that the is null. /// [Fact] @@ -187,8 +189,8 @@ public void Remove_ExistingReference_IncomingRelationshipRemoved() { | TEST: SET VALUE: EXISTING REFERENCE: TOPIC UPDATED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new , adds a new reference using , updates the + /// Assembles a new , adds a new reference using , updates the /// reference using , and confirms that the reference and are /// correctly updated. @@ -213,8 +215,8 @@ public void SetValue_ExistingReference_TopicUpdated() { | TEST: SET VALUE: NULL REFERENCE: TOPIC UPDATED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new , adds a new reference using , updates the + /// Assembles a new , adds a new reference using , updates the /// reference using with a null value, and confirms that the reference and are correctly removed. @@ -222,8 +224,8 @@ public void SetValue_ExistingReference_TopicUpdated() { /// /// This calls twice. The first to confirm that the is set, the second to ensure that - /// the attempt to call isn't disrupted by the fact that the will now be null. + /// the attempt to call isn't disrupted by the fact that the will now be null. /// [Fact] public void SetValue_ExistingReference_IncomingRelationshipsUpdates() { @@ -245,8 +247,8 @@ public void SetValue_ExistingReference_IncomingRelationshipsUpdates() { | TEST: SET VALUE: NULL REFERENCE: TOPIC REMOVED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new , adds a new reference using , updates the + /// Assembles a new , adds a new reference using , updates the /// reference with a null value using , and confirms that the reference is correctly removed. /// @@ -264,26 +266,6 @@ public void SetValue_NullReference_TopicRemoved() { } - /*============================================================================================================================ - | TEST: ADD: NEW REFERENCE: TOPIC IS DIRTY - \---------------------------------------------------------------------------------------------------------------------------*/ - /// - /// Assembles a new , adds a new reference, and confirms that - /// is correctly set. - /// - [Fact] - public void Add_NewReference_TopicIsDirty() { - - var topic = new Topic("Topic", "Page", null, 1); - var reference = new Topic("Reference", "Page", null, 2); - - topic.References.SetValue("Reference", reference); - - Assert.True(topic.IsDirty(true)); - Assert.False(reference.IsDirty(true)); - - } - /*============================================================================================================================ | TEST: GET TOPIC: EXISTING REFERENCE: RETURNS TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ @@ -328,8 +310,8 @@ public void GetTopic_MissingReference_ReturnsNull() { | TEST: GET TOPIC: INHERITED REFERENCE: RETURNS TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new with a , adds a new reference to the , and confirms that with a , adds a new reference to the , and confirms that correctly returns the related topic reference, inheriting from both /// and . /// @@ -352,8 +334,8 @@ public void GetTopic_InheritedReference_ReturnsTopic() { | TEST: GET TOPIC: INHERITED REFERENCE: RETURNS NULL \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new with a , adds a new reference to the , and confirms that with a , adds a new reference to the , and confirms that correctly returns null if an incorrect referencedKey is /// entered. /// @@ -375,8 +357,8 @@ public void GetTopic_InheritedReference_ReturnsNull() { | TEST: GET TOPIC: INHERITED REFERENCE WITHOUT INHERITANCE: RETURNS NULL \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Assembles a new with a , adds a new reference to the , and confirms that with a , adds a new reference to the , and confirms that correctly returns null if inheritFromBase is set to /// false. /// @@ -394,6 +376,41 @@ public void GetTopic_InheritedReferenceWithoutInheritance_ReturnsNull() { } + /*============================================================================================================================ + | TEST: IS HIDDEN: DEFERRED BASE TOPIC: RESOLVES REFERENCE BUT SKIPS EXTENDED ATTRIBUTES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Reads on a topic whose reference is still deferred (its + /// target was never loaded), and confirms the base topic is resolved so that is still + /// correctly inherited from the base, while the base topic's own extended attributes are never fetched, since IsHidden + /// is an indexed attribute that rides along for free once the base is loaded. + /// + [Fact] + public async Task IsHidden_DeferredBaseTopic_ResolvesReferenceButSkipsExtendedAttributes() { + + var records = new StubLazyLoadingTopicRepositoryBuilder() + .AddTopic(221, "Base", "Page", null, indexedAttributes: new Dictionary { ["IsHidden"] = "1" }) + .AddTopic(222, "Derived", "Page", null) + .AddReference(222, "BaseTopic", 221) + .Build(); + + var stub = new StubLazyLoadingTopicRepository(records); + + // "Base" is never loaded, so its reference stays deferred until something resolves it + var derived = await stub.Load("Root:Derived"); + var rawDerived = (ITopicBackingAccessor)derived!; + + Assert.NotEmpty(rawDerived.References.Deferred); + + // Resolving the base is required, or the topic's key attributes wouldn't even be known: this is a genuine load + Assert.True(derived!.IsHidden); + Assert.Equal(1, stub.GetFetchCount(222, TopicPayload.References)); + + // The base topic's own extended attribute blob is never fetched merely to check an indexed attribute + Assert.Equal(0, stub.GetFetchCount(221, TopicPayload.ExtendedAttributes)); + + } + /*============================================================================================================================ | TEST: ADD: TOPIC REFERENCE WITH BUSINESS LOGIC: IS RETURNED \---------------------------------------------------------------------------------------------------------------------------*/ diff --git a/OnTopic.Tests/TopicRelationshipMultiMapTest.cs b/OnTopic.Tests/TopicRelationshipMultiMapTest.cs index 48c736af..4b5604e5 100644 --- a/OnTopic.Tests/TopicRelationshipMultiMapTest.cs +++ b/OnTopic.Tests/TopicRelationshipMultiMapTest.cs @@ -134,44 +134,48 @@ public void SetValue_CreatesIncomingRelationship() { } /*============================================================================================================================ - | TEST: SET VALUE: INCOMING RELATIONSHIPS: THROWS EXCEPTION + | TEST: SET VALUE: INCOMING RELATIONSHIPS: WRITES ONE-WAY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Attempts to set a relationship on a that is marked as isIncoming - /// without setting the isIncoming parameter on and verifies that a is thrown. + /// Calls directly against a that is marked as isIncoming and confirms it writes the entry locally without + /// also writing the reciprocal relationship to on the target topic. /// [Fact] - public void SetValue_IncomingRelationships_ThrowsException() { + public void SetValue_IncomingRelationships_WritesOneWay() { var parent = new Topic("Parent", "Page"); var related = new Topic("Related", "Page"); var relationships = new TopicRelationshipMultiMap(parent, true); - Assert.Throws(() => - relationships.SetValue("Friends", related) - ); + relationships.SetValue("Friends", related); + + Assert.Contains(related, relationships.GetValues("Friends")); + Assert.Empty(related.IncomingRelationships.GetValues("Friends")); } /*============================================================================================================================ - | TEST: REMOVE: INCOMING RELATIONSHIPS: THROWS EXCEPTION + | TEST: REMOVE: INCOMING RELATIONSHIPS: REMOVES ONE-WAY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Attempts to remove a relationship from a that is marked as isIncoming - /// without setting the isIncoming parameter on and verifies that a is thrown. + /// Calls directly against a that is marked as isIncoming and confirms it removes the entry locally without also + /// removing the reciprocal relationship from on the target topic. /// [Fact] - public void Remove_IncomingRelationships_ThrowsException() { + public void Remove_IncomingRelationships_RemovesOneWay() { var parent = new Topic("Parent", "Page"); var related = new Topic("Related", "Page"); var relationships = new TopicRelationshipMultiMap(parent, true); - Assert.Throws(() => - relationships.Remove("Friends", related) - ); + relationships.SetValue("Friends", related); + related.IncomingRelationships.SetValue("Friends", parent); + relationships.Remove("Friends", related); + + Assert.Empty(relationships.GetValues("Friends")); + Assert.Contains(parent, related.IncomingRelationships.GetValues("Friends")); } @@ -187,7 +191,7 @@ public void SetValue_UpdatesKeyCount() { var parent = new Topic("Parent", "Page"); var relationships = new TopicRelationshipMultiMap(parent); - for (var i = 0; i < 5; i++) { + for (var i = 0; i < 5; i++) { relationships.SetValue("Relationship" + i, new Topic("Related" + i, "Page")); } @@ -200,8 +204,8 @@ public void SetValue_UpdatesKeyCount() { | TEST: GET ENUMERATOR: RETURNS KEY/VALUES PAIRS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Enumerates over the , ensuring that the enumerator defined by the interface implementation successfully relays the call to the underlying + /// Enumerates over the , ensuring that the enumerator defined by the interface implementation successfully relays the call to the underlying /// . /// [Fact] @@ -209,9 +213,9 @@ public void GetEnumerator_ReturnsKeyValuesPairs() { var counter = 0; var multiMap = new TopicMultiMap(); - var readOnlyRelationships = new ReadOnlyTopicMultiMap(multiMap); + var readOnlyRelationships = new ReadOnlyTopicMultiMap(multiMap); - for (var i = 0; i < 5; i++) { + for (var i = 0; i < 5; i++) { multiMap.Add(new("Relationship" + i, new())); } @@ -235,7 +239,7 @@ public void GetEnumerator_ReturnsKeyValuesPairs() { public void Indexer_ReturnsKeyValuesPair() { var multiMap = new TopicMultiMap(); - var readOnlyTopicMultiMap = new ReadOnlyTopicMultiMap(multiMap); + var readOnlyTopicMultiMap = new ReadOnlyTopicMultiMap(multiMap); var topics = new TopicCollection(); var keyValuesPair = new KeyValuesPair("Relationship", topics); var topic = new Topic("Test", "Test"); @@ -261,7 +265,7 @@ public void GetAllValues_ReturnsAllTopics() { var parent = new Topic("Parent", "Page"); var relationships = new TopicRelationshipMultiMap(parent); - for (var i = 0; i < 5; i++) { + for (var i = 0; i < 5; i++) { relationships.SetValue("Relationship" + i, new Topic("Related" + i, "Page")); } @@ -275,8 +279,8 @@ public void GetAllValues_ReturnsAllTopics() { | TEST: GET ALL VALUES: CONTENT TYPES: RETURNS ALL CONTENT TYPES \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Sets relationships in multiple namespaces, with different ContentTypes, then filters the results of by content type. + /// Sets relationships in multiple namespaces, with different ContentTypes, then filters the results of by content type. /// [Fact] public void GetAllValues_ContentTypes_ReturnsAllContentTypes() { @@ -284,7 +288,7 @@ public void GetAllValues_ContentTypes_ReturnsAllContentTypes() { var parent = new Topic("Parent", "Page"); var relationships = new TopicRelationshipMultiMap(parent); - for (var i = 0; i < 5; i++) { + for (var i = 0; i < 5; i++) { relationships.SetValue("Relationship" + i, new Topic("Related" + i, "ContentType" + i)); } @@ -317,8 +321,8 @@ public void SetTopic_IsDirty() { | TEST: SET VALUE: IS DUPLICATE: IS NOT DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Adds a duplicate topic to a and confirms that value of is false. + /// Adds a duplicate topic to a and confirms that value of is false. /// [Fact] public void SetValue_IsDuplicate_IsNotDirty() { @@ -340,8 +344,8 @@ public void SetValue_IsDuplicate_IsNotDirty() { | TEST: SET VALUE: IS DUPLICATE: STAYS DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Adds a duplicate topic to a and confirms that value of is false. + /// Adds a duplicate topic to a and confirms that value of is false. /// [Fact] public void SetSetValue_IsDuplicate_StaysDirty() { @@ -429,8 +433,8 @@ public void Remove_MissingTopic_StaysDirty() { | TEST: CLEAR: EXISTING TOPICS: IS DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Call and confirms that value of is true. + /// Call and confirms that value of is true. /// [Fact] public void Clear_ExistingTopics_IsDirty() { @@ -447,6 +451,27 @@ public void Clear_ExistingTopics_IsDirty() { } + /*============================================================================================================================ + | TEST: CLEAR: EXISTING TOPICS: REMOVES INCOMING RELATIONSHIP + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Sets a relationship and then clears it by key, and confirms that it is removed from the incoming relationships property + /// of the previously related . + /// + [Fact] + public void Clear_ExistingTopics_RemovesIncomingRelationship() { + + var topic = new Topic("Test", "Page"); + var relationships = new TopicRelationshipMultiMap(topic); + var related = new Topic("Topic", "Page"); + + relationships.SetValue("Related", related); + relationships.Clear("Related"); + + Assert.Null(related.IncomingRelationships.GetValues("Related").FirstOrDefault()); + + } + /*============================================================================================================================ | TEST: CLEAR: NO TOPICS: IS NOT DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ @@ -466,13 +491,37 @@ public void Clear_NoTopics_IsNotDirty() { } + /*============================================================================================================================ + | TEST: CLEAR: DEFERRED ENTRIES: REMOVES DEFERRED ENTRIES AND IS DIRTY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Registers a entry with no corresponding target and + /// calls , confirming that the deferred entry is purged and reports true, even though no target topic was removed. + /// + [Fact] + public void Clear_DeferredEntries_RemovesDeferredEntriesAndIsDirty() { + + var topic = new Topic("Test", "Page", null, 1); + var relationships = new TopicRelationshipMultiMap(topic); + + relationships.Deferred.SetValue("Related", 999); + relationships.Deferred.SetValue("Other", 998); + relationships.Clear("Related"); + + Assert.False(relationships.Deferred.Remove("Related")); + Assert.True(relationships.Deferred.Remove("Other")); + Assert.True(relationships.IsDirty()); + + } + /*============================================================================================================================ | TEST: SET VALUE: MARK NOT DIRTY: IS NOT DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Adds an existing to a and confirms that returns false if is called with the markDirty parameter set to false. + /// Adds an existing to a and confirms that returns false if is called with the markDirty parameter set to false. /// [Fact] public void SetValue_MarkNotDirty_IsNotDirty() { @@ -493,8 +542,8 @@ public void SetValue_MarkNotDirty_IsNotDirty() { /// /// Adds an existing to a associated with a and confirms that returns true - /// even if is called with the - /// markDirty parameter set to false. + /// even if is called with the markDirty + /// parameter set to false. /// [Fact] public void SetValue_NewParent_IsDirty() { @@ -513,10 +562,10 @@ public void SetValue_NewParent_IsDirty() { | TEST: SET VALUE: NEW TOPIC: IS DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Adds a new to a associated with an existing and confirms that returns true even if is called with the markDirty parameter - /// set to false. + /// Adds a new to a associated with an existing and confirms that returns true even if is called with the markDirty parameter set to + /// false. /// [Fact] public void SetValue_NewTopic_IsDirty() { @@ -536,8 +585,8 @@ public void SetValue_NewTopic_IsDirty() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Adds an to a associated with a . - /// Confirms that returns false after calling . + /// Confirms that returns false after calling . /// [Fact] public void IsDirty_MarkClean_ReturnsFalse() { @@ -585,9 +634,9 @@ public void IsDirty_MarkClean_ReturnsTrue() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Adds an to a associated with a . - /// Confirms that returns true even after calling if any of the s in the are marked as . + /// Confirms that returns true even after calling if any of the s in the are marked as . /// [Fact] public void IsDirty_MarkCleanWithNewTopic_ReturnsTrue() { diff --git a/OnTopic.Tests/TopicRepositoryBaseTest.cs b/OnTopic.Tests/TopicRepositoryBaseTest.cs index efa9b764..cd086be5 100644 --- a/OnTopic.Tests/TopicRepositoryBaseTest.cs +++ b/OnTopic.Tests/TopicRepositoryBaseTest.cs @@ -5,10 +5,12 @@ \=============================================================================================================================*/ using OnTopic.Collections.Specialized; using OnTopic.Data.Caching; +using OnTopic.Data.Sql; using OnTopic.Metadata; using OnTopic.Repositories; using OnTopic.TestDoubles; using OnTopic.TestDoubles.Metadata; +using OnTopic.Tests.TestDoubles; using Xunit; namespace OnTopic.Tests; @@ -44,21 +46,21 @@ public class TopicRepositoryBaseTest { /// crawling the object graph. /// public TopicRepositoryBaseTest() { - _topicRepository = new StubTopicRepository(); - _cachedTopicRepository = new CachedTopicRepository(_topicRepository); + _topicRepository = new(); + _cachedTopicRepository = new(_topicRepository); } /*============================================================================================================================ | TEST: LOAD: VALID TOPIC ID: RETURNS EXPECTED TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a valid and - /// confirms that the expected topic is returned. + /// Calls with a valid + /// and confirms that the expected topic is returned. /// [Fact] - public void Load_ValidTopicId_ReturnsExpectedTopic() { + public async Task Load_ValidTopicId_ReturnsExpectedTopic() { - var topic = _topicRepository.Load(11111); + var topic = await _topicRepository.Load(11111); Assert.Equal(11111, topic?.Id); @@ -68,36 +70,71 @@ public void Load_ValidTopicId_ReturnsExpectedTopic() { | TEST: LOAD: INVALID TOPIC ID: RETURNS EXPECTED TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with an invalid and - /// confirms that no topic is returned. + /// Calls with an invalid and confirms that no topic is returned. /// [Fact] - public void Load_InvalidTopicId_ReturnsExpectedTopic() => - Assert.Null(_topicRepository.Load(11113)); + public async Task Load_InvalidTopicId_ReturnsExpectedTopic() => + Assert.Null(await _topicRepository.Load(11113)); /*============================================================================================================================ | TEST: LOAD: NEGATIVE TOPIC ID: RETURNS ROOT TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a negative and - /// confirms that the root topic is returned. + /// Calls with a negative and confirms that the root topic is returned. /// [Fact] - public void Load_NegativeTopicId_ReturnsRootTopic() => - Assert.Equal("Root", _cachedTopicRepository.Load(-2)?.GetUniqueKey()); + public async Task Load_NegativeTopicId_ReturnsRootTopic() => + Assert.Equal("Root", (await _cachedTopicRepository.Load(-2))?.GetUniqueKey()); + + /*============================================================================================================================ + | TEST: LOAD: NARROW PAYLOAD: RETURNS TOPIC + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with payload set to and confirms that a topic is still returned. The stub always returns fully-loaded topics + /// regardless of this parameter; the test simply verifies the signature is accepted. + /// + [Fact] + public async Task Load_WithNarrowPayload_ReturnsTopic() { + + var topic = await _topicRepository.Load(11111, payload: TopicPayload.None); + + Assert.NotNull(topic); + + } + + /*============================================================================================================================ + | TEST: LOAD: NARROW PAYLOAD: EXTENDED ATTRIBUTES LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Calls with payload set to and confirms the extended-attribute boundary is . The stub does not + /// defer extended attributes; this simply confirms no regression for stub-backed tests. + /// + [Fact] + public async Task Load_WithNarrowPayload_ExtendedAttributesLoaded() { + + var topic = await _topicRepository.Load(11111, payload: TopicPayload.None); + + Assert.NotNull(topic); + Assert.Equal(LoadState.Loaded, topic.Attributes.LoadState); + + } /*============================================================================================================================ | TEST: LOAD: VALID DATE: RETURNS TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a valid date and ensures that topic + /// Calls with a valid date and ensures that topic /// with that date is returned. /// [Fact] - public void Load_ValidDate_ReturnsTopic() { + public async Task Load_ValidDate_ReturnsTopic() { var version = DateTime.UtcNow.AddDays(-1); - var topic = _cachedTopicRepository.Load(11111, version); + var topic = await _cachedTopicRepository.Load(11111, version); Assert.True(topic?.VersionHistory.Contains(version)); Assert.Equal(version.AddTicks(-(version.Ticks % TimeSpan.TicksPerSecond)), topic?.LastModified); @@ -108,18 +145,18 @@ public void Load_ValidDate_ReturnsTopic() { | TEST: ROLLBACK: TOPIC: UPDATES LAST MODIFIED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a valid date and ensures that the value is updated. + /// Calls with a valid date and ensures that the value is updated. /// [Fact] - public void Rollback_Topic_UpdatesLastModified() { + public async Task Rollback_Topic_UpdatesLastModified() { var version = DateTime.UtcNow.AddDays(-1); - var topic = _topicRepository.Load(11111); + var topic = await _topicRepository.Load(11111); if (topic is not null) { topic.VersionHistory.Add(version); - _topicRepository.Rollback(topic, version); + await _topicRepository.Rollback(topic, version); } Assert.True(topic?.VersionHistory.Contains(version)); @@ -127,16 +164,73 @@ public void Rollback_Topic_UpdatesLastModified() { } + /*============================================================================================================================ + | TEST: ROLLBACK: DIVERGENT RELATIONSHIPS: MERGES RECIPROCALLY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Rolls back a topic whose current Related relationship differs from the historical version being restored: The + /// live topic is currently related to one topic that the historical version doesn't include, and the historical version + /// includes a different topic that the live topic isn't currently related to. Confirms that, after , the live topic's relationship matches the historical version exactly, + /// and that the reciprocal on both the previously and newly related topics are + /// updated to match. + /// + /// + /// Unlike , which used , whose + /// Load(Int32, DateTime) returns the very same live instance being rolled back, short-circuiting 's merge entirely, this uss a , which + /// serves a genuinely detached historical graph via the real, production , with relationship data that can diverge from the current, live state. That divergence is what actually tests the + /// merge. + /// + [Fact] + public async Task Rollback_DivergentRelationships_MergesReciprocally() { + + // Establish a minimal content type graph, required by Save()'s content type validation + var root = new Topic("Root", "Container", null, 1); + var configuration = new Topic("Configuration", "Container", root, 2); + var contentTypes = new ContentTypeDescriptor("ContentTypes", "ContentTypeDescriptor", configuration, 3); + _ = new ContentTypeDescriptor("Page", "ContentTypeDescriptor", contentTypes, 4); + + // Establish topics: A is being rolled back; D is A's current (soon to be stale) relationship; E is A's historical + // relationship, currently unrelated + var topicA = new Topic("A", "Page", root, 100); + var topicD = new Topic("D", "Page", root, 101); + var topicE = new Topic("E", "Page", root, 102); + var version = DateTime.UtcNow.AddDays(-30); + + topicA.Relationships.SetValue("Related", topicD); + topicA.Relationships.MarkClean(); + topicA.VersionHistory.Add(version); + + // Establish repository with divergent historical data: A is historically related to E, not D + var repository = new FakeSqlTopicRepository().AddTopic(100, "A", "Page", null); + + repository.AddHistoricalRelationship(100, "Related", 102); + + // Rollback + await repository.Rollback(topicA, version); + + // A's relationship now matches the historical version + Assert.Contains(topicE, topicA.Relationships.GetValues("Related")); + Assert.DoesNotContain(topicD, topicA.Relationships.GetValues("Related")); + + // Reciprocal relationships were updated on both sides + Assert.DoesNotContain(topicA, topicD.IncomingRelationships.GetValues("Related")); + Assert.Contains(topicA, topicE.IncomingRelationships.GetValues("Related")); + + } + /*============================================================================================================================ | TEST: LOAD: FUTURE DATE: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a future and + /// Calls with a future and /// confirms that an exception is thrown. /// [Fact] - public void Load_FutureDate_ThrowsException() => - Assert.Throws(() => + public async Task Load_FutureDate_ThrowsException() => + await Assert.ThrowsAsync(() => _cachedTopicRepository.Load(1111, DateTime.UtcNow.AddDays(1)) ); @@ -144,12 +238,12 @@ public void Load_FutureDate_ThrowsException() => | TEST: LOAD: OLD DATE: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Calls with a date prior to versioning being + /// Calls with a date prior to versioning being /// introduced and ensures that an exception is thrown. /// [Fact] - public void Load_OldDate_ThrowsException() => - Assert.Throws(() => + public async Task Load_OldDate_ThrowsException() => + await Assert.ThrowsAsync(() => _cachedTopicRepository.Load(1111, new DateTime(2010, 10, 15)) ); @@ -157,10 +251,10 @@ public void Load_OldDate_ThrowsException() => | TEST: DELETE: BASE TOPIC: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Deletes a topic which other topics, outside of the graph, derive from. Expects exception. + /// Deletes a topic which other topics, outside the graph, derive from. Expects exception. /// [Fact] - public void Delete_BaseTopic_ThrowsException() { + public async Task Delete_BaseTopic_ThrowsException() { var root = new Topic("Root", "Page"); var topic = new Topic("Topic", "Page", root); @@ -169,7 +263,7 @@ public void Delete_BaseTopic_ThrowsException() { BaseTopic = child }; - Assert.Throws(() => + await Assert.ThrowsAsync(() => _topicRepository.Delete(topic, true) ); @@ -182,7 +276,7 @@ public void Delete_BaseTopic_ThrowsException() { /// Deletes a topic which another topic within the graph derives from. Expects success. /// [Fact] - public void Delete_InternallyDerivedTopic_Succeeds() { + public async Task Delete_InternallyDerivedTopic_Succeeds() { var root = new Topic("Root", "Page"); var topic = new Topic("Topic", "Page", root); @@ -191,7 +285,7 @@ public void Delete_InternallyDerivedTopic_Succeeds() { BaseTopic = child }; - _topicRepository.Delete(topic, true); + await _topicRepository.Delete(topic, true); Assert.Empty(root.Children); @@ -204,13 +298,13 @@ public void Delete_InternallyDerivedTopic_Succeeds() { /// Deletes a topic with descendant topics. Expects exception if isRecursive is set to false. /// [Fact] - public void Delete_Descendants_ThrowsException() { + public async Task Delete_Descendants_ThrowsException() { var topic = new Topic("Topic", "Page"); _ = new Topic("Child", "Page", topic); - Assert.Throws(() => - _topicRepository.Delete(topic, false) + await Assert.ThrowsAsync(() => + _topicRepository.Delete(topic) ); } @@ -222,13 +316,13 @@ public void Delete_Descendants_ThrowsException() { /// Deletes a topic with descendant topics. Expects no exception if isRecursive is set to true. /// [Fact] - public void Delete_DescendantsWithRecursive_Succeeds() { + public async Task Delete_DescendantsWithRecursive_Succeeds() { var root = new Topic("Root", "Page"); var topic = new Topic("Topic", "Page", root); _ = new Topic("Child", "Page", topic); - _topicRepository.Delete(topic, true); + await _topicRepository.Delete(topic, true); Assert.Empty(root.Children); @@ -241,13 +335,13 @@ public void Delete_DescendantsWithRecursive_Succeeds() { /// Deletes a topic with nested topics. Expects no exception, even if isRecursive is set to false. /// [Fact] - public void Delete_NestedTopics_Succeeds() { + public async Task Delete_NestedTopics_Succeeds() { var root = new Topic("Root", "Page"); var topic = new Topic("Topic", "Page", root); _ = new Topic("Child", "List", topic); - _topicRepository.Delete(topic, false); + await _topicRepository.Delete(topic); Assert.Empty(root.Children); @@ -261,7 +355,7 @@ public void Delete_NestedTopics_Succeeds() { /// target topics' collection. /// [Fact] - public void Delete_Relationships_DeleteRelationships() { + public async Task Delete_Relationships_DeleteRelationships() { var root = new Topic("Root", "Page"); var topic = new Topic("Topic", "Page", root); @@ -271,7 +365,7 @@ public void Delete_Relationships_DeleteRelationships() { child.Relationships.SetValue("Related", associated); child.References.SetValue("Referenced", associated); - _topicRepository.Delete(topic, true); + await _topicRepository.Delete(topic, true); Assert.Empty(associated.IncomingRelationships.GetValues("Related")); Assert.Empty(associated.IncomingRelationships.GetValues("Referenced")); @@ -285,7 +379,7 @@ public void Delete_Relationships_DeleteRelationships() { /// Deletes a topic with incoming relationships. Deletes the relationships or references from the associated topic. /// [Fact] - public void Delete_IncomingRelationships_DeleteAssociations() { + public async Task Delete_IncomingRelationships_DeleteAssociations() { var root = new Topic("Root", "Page"); var topic = new Topic("Topic", "Page", root); @@ -296,7 +390,7 @@ public void Delete_IncomingRelationships_DeleteAssociations() { source1.Relationships.SetValue("Associations", child); source2.References.SetValue("Associations", child); - _topicRepository.Delete(topic, true); + await _topicRepository.Delete(topic, true); Assert.Empty(source1.Relationships.GetValues("Associations")); @@ -306,8 +400,8 @@ public void Delete_IncomingRelationships_DeleteAssociations() { | TEST: GET ATTRIBUTES: ANY ATTRIBUTES: RETURNS ALL ATTRIBUTES \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Retrieves a list of attributes from a topic, without any filtering by whether or not the attribute is an . + /// Retrieves a list of attributes from a topic, without any filtering by whether the attribute is an . /// [Fact] public void GetAttributes_AnyAttributes_ReturnsAllAttributes() { @@ -326,9 +420,9 @@ public void GetAttributes_AnyAttributes_ReturnsAllAttributes() { | TEST: GET ATTRIBUTES: EMPTY ATTRIBUTES: SKIPS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Retrieves a list of attributes from a topic, without any filtering by whether or not the attribute is an . Any s with a null or empty value should - /// be skipped. + /// Retrieves a list of attributes from a topic, without any filtering by whether the attribute is an . Any s with a null or empty value should be + /// skipped. /// [Fact] public void GetAttributes_EmptyAttributes_Skips() { @@ -338,7 +432,7 @@ public void GetAttributes_EmptyAttributes_Skips() { topic.Attributes.SetValue("EmptyAttribute", ""); topic.Attributes.SetValue("NullAttribute", null); - var attributes = _topicRepository.GetAttributesProxy(topic, null); + var attributes = _topicRepository.GetAttributesProxy(topic, null).ToList(); Assert.DoesNotContain(attributes, a => a.Key is "EmptyAttribute"); Assert.DoesNotContain(attributes, a => a.Key is "NullAttribute"); @@ -401,10 +495,10 @@ public void GetAttributes_ExtendedAttributeMismatch_ReturnsExtendedAttributes() topic.Attributes.SetValue("MetaTitle", "Metatitle", markDirty: false, isExtendedAttribute: null); topic.Attributes.SetValue("Arbitrary", "Value", markDirty: false, isExtendedAttribute: true); - var dirtyExtended = _topicRepository.GetAttributesProxy(topic, true, true); - var dirtyIndexed = _topicRepository.GetAttributesProxy(topic, false, true); - var cleanExtended = _topicRepository.GetAttributesProxy(topic, true, false); - var cleanIndexed = _topicRepository.GetAttributesProxy(topic, false, false); + var dirtyExtended = _topicRepository.GetAttributesProxy(topic, true, true).ToList(); + var dirtyIndexed = _topicRepository.GetAttributesProxy(topic, false, true).ToList(); + var cleanExtended = _topicRepository.GetAttributesProxy(topic, true, false).ToList(); + var cleanIndexed = _topicRepository.GetAttributesProxy(topic, false, false).ToList(); //Expect Title, even though it isn't IsDirty Assert.Single(dirtyExtended); @@ -472,7 +566,7 @@ public void GetAttributes_ExcludeLastModified_ReturnsOtherAttributes() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Sets an arbitrary (unmatched) attribute on a with a value shorter than 255 characters, then - /// ensures that it is returned as an an indexed when calling indexed when calling . /// [Fact] @@ -493,7 +587,7 @@ public void GetAttributes_ArbitraryAttributeWithShortValue_ReturnsAsIndexedAttri \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Sets an arbitrary (unmatched) attribute on a with a value longer than 255 characters, then - /// ensures that it is returned as an an when calling when calling . /// [Fact] @@ -501,7 +595,7 @@ public void GetAttributes_ArbitraryAttributeWithLongValue_ReturnsAsExtendedAttri var topic = new Topic("Test", "ContentTypes"); - topic.Attributes.SetValue("ArbitraryAttribute", new string('x', 256)); + topic.Attributes.SetValue("ArbitraryAttribute", new('x', 256)); var attributes = _topicRepository.GetAttributesProxy(topic, true); @@ -523,9 +617,9 @@ public void GetUnmatchedAttributes_ReturnsAttributes() { topic.Attributes.SetValue("Title", "Title"); - var attributes = _topicRepository.GetUnmatchedAttributesProxy(topic); + var attributes = _topicRepository.GetUnmatchedAttributesProxy(topic).ToList(); - Assert.True(attributes.Any()); + Assert.True(attributes.Count != 0); Assert.DoesNotContain(attributes, a => a.Key is "Title"); } @@ -550,7 +644,7 @@ public void GetUnmatchedAttributes_EmptyArbitraryAttributes_ReturnsAttributes() topic.Attributes.SetValue("YetAnotherArbitraryAttribute", "Value"); topic.Attributes.SetValue("YetAnotherArbitraryAttribute", null); - var attributes = _topicRepository.GetUnmatchedAttributesProxy(topic); + var attributes = _topicRepository.GetUnmatchedAttributesProxy(topic).ToList(); Assert.Contains(attributes, a => a.Key is "ArbitraryAttribute"); Assert.Contains(attributes, a => a.Key is "YetAnotherArbitraryAttribute"); @@ -623,9 +717,9 @@ public void GetContentTypeDescriptor_GetValidContentType_ReturnsContentType() { /// attempts to retrieve it from the 's graph. /// [Fact] - public void GetContentTypeDescriptor_GetNewContentType_ReturnsFromTopicGraph() { + public async Task GetContentTypeDescriptor_GetNewContentType_ReturnsFromTopicGraph() { - var rootTopic = _topicRepository.Load("Root"); + var rootTopic = await _topicRepository.Load("Root"); var contentTypes = _topicRepository.GetContentTypeDescriptors(); var rootContentType = contentTypes.GetValue("ContentTypes"); var newContentType = new ContentTypeDescriptor("NewContentType", "ContentTypeDescriptor", rootContentType); @@ -647,13 +741,13 @@ public void GetContentTypeDescriptor_GetNewContentType_ReturnsFromTopicGraph() { /// typically only occur when initializing a new database, and is an unexpected condition. /// [Fact] - public void GetContentTypeDescriptor_MissingRootContentType_ReturnsNull() { + public async Task GetContentTypeDescriptor_MissingRootContentType_ReturnsNull() { var topicRepository = new StubTopicRepository(); - var configuration = topicRepository.Load("Root:Configuration"); + var configuration = await topicRepository.Load("Root:Configuration"); var topic = new Topic("Test", "Page"); - topicRepository.Delete(configuration!, true); + await topicRepository.Delete(configuration!, true); var contentType = topicRepository.GetContentTypeDescriptorProxy(topic); @@ -686,12 +780,12 @@ public void GetContentTypeDescriptor_GetInvalidContentType_ReturnsNull() { /// immediately reflected in the cache of s. /// [Fact] - public void Save_ContentTypeDescriptor_UpdatesContentTypeCache() { + public async Task Save_ContentTypeDescriptor_UpdatesContentTypeCache() { var contentTypes = _topicRepository.GetContentTypeDescriptors(); var topic = new ContentTypeDescriptor("NewContentType", "ContentTypeDescriptor"); - _topicRepository.Save(topic); + await _topicRepository.Save(topic); Assert.Contains(topic, contentTypes); @@ -706,7 +800,7 @@ public void Save_ContentTypeDescriptor_UpdatesContentTypeCache() { /// it the cache is updated. /// [Fact] - public void Save_ContentTypeDescriptor_UpdatesPermittedContentTypes() { + public async Task Save_ContentTypeDescriptor_UpdatesPermittedContentTypes() { var contentTypes = _topicRepository.GetContentTypeDescriptors(); var contentTypesRoot = contentTypes.GetValue("ContentTypes"); @@ -721,7 +815,7 @@ public void Save_ContentTypeDescriptor_UpdatesPermittedContentTypes() { pageContentType.Relationships.SetValue("ContentTypes", lookupContentType); - _topicRepository.Save(contentTypesRoot, true); + await _topicRepository.Save(contentTypesRoot, true); Assert.NotEqual(initialCount, pageContentType.PermittedContentTypes.Count); @@ -735,12 +829,12 @@ public void Save_ContentTypeDescriptor_UpdatesPermittedContentTypes() { /// new version. /// [Fact] - public void Save_NewTopic_UpdatesVersionHistory() { + public async Task Save_NewTopic_UpdatesVersionHistory() { - var parent = _topicRepository.Load("Root:Web:Web_3:Web_3_0"); + var parent = await _topicRepository.Load("Root:Web:Web_3:Web_3_0"); var topic = new Topic("Test", "Page", parent); - _topicRepository.Save(topic); + await _topicRepository.Save(topic); Assert.True(topic.VersionHistory.Count > 0); @@ -754,13 +848,13 @@ public void Save_NewTopic_UpdatesVersionHistory() { /// child is correctly updated. /// [Fact] - public void Save_IsRecursive_SavesChild() { + public async Task Save_IsRecursive_SavesChild() { - var parent = _topicRepository.Load("Root:Web:Web_3:Web_3_0"); + var parent = await _topicRepository.Load("Root:Web:Web_3:Web_3_0"); var topic = new Topic("Test", "Page", parent); var child = new Topic("Child", "Page", topic); - _topicRepository.Save(topic, true); + await _topicRepository.Save(topic, true); Assert.False(child.IsNew); @@ -771,19 +865,19 @@ public void Save_IsRecursive_SavesChild() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Saves a new with an unresolved and confirms that it successfully - /// resolves it by marking the collection as as false. + /// resolves it by marking the collection as as false. /// [Fact] - public void Save_UnresolvedReference_Resolves() { + public async Task Save_UnresolvedReference_Resolves() { - var parent = _topicRepository.Load("Root:Web:Web_3:Web_3_0"); + var parent = await _topicRepository.Load("Root:Web:Web_3:Web_3_0"); var topic = new Topic("Test", "Page", parent); var reference = new Topic("Reference", "Page", topic); topic.References.SetValue("Test", reference); - _topicRepository.Save(topic, true); + await _topicRepository.Save(topic, true); } @@ -795,15 +889,15 @@ public void Save_UnresolvedReference_Resolves() { /// expected if that reference cannot be resolved. /// [Fact] - public void Save_UnresolvedReference_ThrowsException() { + public async Task Save_UnresolvedReference_ThrowsException() { - var parent = _topicRepository.Load("Root:Web:Web_3:Web_3_0"); + var parent = await _topicRepository.Load("Root:Web:Web_3:Web_3_0"); var topic = new Topic("Test", "Page", parent); var reference = new Topic("Reference", "Page", parent); topic.References.SetValue("Test", reference); - Assert.Throws(() => + await Assert.ThrowsAsync(() => _topicRepository.Save(topic, true) ); @@ -817,8 +911,8 @@ public void Save_UnresolvedReference_ThrowsException() { /// expected . /// [Fact] - public void Save_InvalidContentType_ThrowsException() => - Assert.Throws(() => + public async Task Save_InvalidContentType_ThrowsException() => + await Assert.ThrowsAsync(() => _topicRepository.Save(new("Test", "InvalidContentType")) ); @@ -831,14 +925,14 @@ public void Save_InvalidContentType_ThrowsException() => /// is immediately reflected in the cache of s. /// [Fact] - public void Delete_ContentTypeDescriptor_UpdatesContentTypeCache() { + public async Task Delete_ContentTypeDescriptor_UpdatesContentTypeCache() { var contentTypes = _topicRepository.GetContentTypeDescriptors(); var contentType = contentTypes.Contains("Page")? contentTypes["Page"] : null; Contract.Assume(contentType); - _topicRepository.Delete(contentType); + await _topicRepository.Delete(contentType); Assert.DoesNotContain(contentType, contentTypes); @@ -851,7 +945,7 @@ public void Delete_ContentTypeDescriptor_UpdatesContentTypeCache() { /// Moves a after a sibling in another parent, and ensures it is set correctly. /// [Fact] - public void Move_AfterSibling_SetCorrectly() { + public async Task Move_AfterSibling_SetCorrectly() { var source = new Topic("Source", "Page"); var topic = new Topic("Test", "Page", source); @@ -859,7 +953,7 @@ public void Move_AfterSibling_SetCorrectly() { var sibling = new Topic("Sibling", "Page", target); var olderSibling = new Topic("OlderSibling", "Page", target); - _topicRepository.Move(topic, target, sibling); + await _topicRepository.Move(topic, target, sibling); Assert.Equal(target, topic.Parent); Assert.Equal(0, target.Children.IndexOf(sibling)); @@ -878,19 +972,19 @@ public void Move_AfterSibling_SetCorrectly() { /// cref="ContentTypeDescriptor"/>s. /// [Fact] - public void Move_ContentTypeDescriptor_UpdatesContentTypeCache() { + public async Task Move_ContentTypeDescriptor_UpdatesContentTypeCache() { var contentTypes = _topicRepository.GetContentTypeDescriptors(); var pageContentType = contentTypes.Contains("Page")? contentTypes["Page"] : null; var contactContentType = contentTypes.Contains("Contact")? contentTypes["Contact"] : null; - var contactAttributeCount = contactContentType?.AttributeDescriptors.Count; + var contactAttributeCount = contactContentType?.AttributeDescriptors.Count; Contract.Assume(contactContentType); Contract.Assume(pageContentType); - _topicRepository.Move(contactContentType, pageContentType); + await _topicRepository.Move(contactContentType, pageContentType); - Assert.NotEqual(contactContentType?.AttributeDescriptors.Count, contactAttributeCount); + Assert.NotEqual(contactContentType.AttributeDescriptors.Count, contactAttributeCount); } @@ -903,7 +997,7 @@ public void Move_ContentTypeDescriptor_UpdatesContentTypeCache() { /// of the child reflects the change. /// [Fact] - public void Save_AttributeDescriptor_UpdatesContentType() { + public async Task Save_AttributeDescriptor_UpdatesContentType() { var contentType = new ContentTypeDescriptor("Parent", "ContentTypeDescriptor", null, 1); var attributeList = new Topic("Attributes", "List", contentType, 2); @@ -917,7 +1011,7 @@ public void Save_AttributeDescriptor_UpdatesContentType() { Contract.Assume(newAttribute); - _topicRepository.Save(newAttribute); + await _topicRepository.Save(newAttribute); Assert.Equal(attributeCount+1, childContentType.AttributeDescriptors.Count); @@ -932,7 +1026,7 @@ public void Save_AttributeDescriptor_UpdatesContentType() { /// cref="ContentTypeDescriptor.AttributeDescriptors"/> of the child reflects the change. /// [Fact] - public void Delete_AttributeDescriptor_UpdatesContentTypeCache() { + public async Task Delete_AttributeDescriptor_UpdatesContentTypeCache() { var contentType = new ContentTypeDescriptor("Parent", "ContentTypeDescriptor"); var attributeList = new Topic("Attributes", "List", contentType); @@ -944,7 +1038,7 @@ public void Delete_AttributeDescriptor_UpdatesContentTypeCache() { var attributeCount = childContentType.AttributeDescriptors.Count; - _topicRepository.Delete(newAttribute); + await _topicRepository.Delete(newAttribute); Assert.True(childContentType.AttributeDescriptors.Count < attributeCount); @@ -954,17 +1048,17 @@ public void Delete_AttributeDescriptor_UpdatesContentTypeCache() { | TEST: LOAD: TOPIC LOADED EVENT: IS RAISED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Loads a topic using and ensures that the event is raised. + /// Loads a topic using and ensures that the + /// event is raised. /// [Fact] - public void Load_TopicLoadedEvent_IsRaised() { + public async Task Load_TopicLoadedEvent_IsRaised() { var hasFired = false; _cachedTopicRepository.TopicLoaded += eventHandler; - var topic = _topicRepository.Load("Root:Web"); + var topic = await _topicRepository.Load("Root:Web"); _cachedTopicRepository.TopicLoaded -= eventHandler; @@ -978,19 +1072,19 @@ public void Load_TopicLoadedEvent_IsRaised() { | TEST: LOAD: TOPIC LOADED EVENT: IS RAISED WITH VERSION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Loads a topic using and ensures that the event is raised. + /// Loads a topic using and ensures that the event is raised. /// [Fact] - public void Load_TopicLoadedEvent_IsRaisedWithVersion() { + public async Task Load_TopicLoadedEvent_IsRaisedWithVersion() { var hasFired = false; - var topicId = _topicRepository.Load("Root:Web")?.Id; + var topicId = (await _topicRepository.Load("Root:Web"))?.Id; var version = DateTime.UtcNow; _cachedTopicRepository.TopicLoaded += eventHandler; - var topic = _topicRepository.Load(topicId?? -1, version); + var topic = await _topicRepository.Load(topicId?? -1, version); _cachedTopicRepository.TopicLoaded -= eventHandler; @@ -1006,18 +1100,18 @@ public void Load_TopicLoadedEvent_IsRaisedWithVersion() { | TEST: DELETE: TOPIC DELETED EVENT: IS RAISED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a and then immediately deletes it. Ensures that the event is raised. + /// Creates a and then immediately deletes it. Ensures that the event is raised. /// [Fact] - public void Delete_TopicDeletedEvent_IsRaised() { + public async Task Delete_TopicDeletedEvent_IsRaised() { var topic = new Topic("Test", "Page"); var hasFired = false; - _cachedTopicRepository.Save(topic); + await _cachedTopicRepository.Save(topic); _cachedTopicRepository.TopicDeleted += eventHandler; - _cachedTopicRepository.Delete(topic); + await _cachedTopicRepository.Delete(topic); _cachedTopicRepository.TopicDeleted -= eventHandler; Assert.True(hasFired); @@ -1034,13 +1128,13 @@ public void Delete_TopicDeletedEvent_IsRaised() { /// /> event is raised. /// [Fact] - public void Save_TopicSavedEvent_IsRaised() { + public async Task Save_TopicSavedEvent_IsRaised() { var topic = new Topic("Test", "Page"); var hasFired = false; _cachedTopicRepository.TopicSaved += eventHandler; - _cachedTopicRepository.Save(topic); + await _cachedTopicRepository.Save(topic); _cachedTopicRepository.TopicSaved -= eventHandler; Assert.True(hasFired); @@ -1053,11 +1147,11 @@ public void Save_TopicSavedEvent_IsRaised() { | TEST: SAVE: TOPIC RENAMED EVENT: IS RAISED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a and then immediately saves it. Ensures that the event is raised. + /// Creates a and then immediately saves it. Ensures that the event is raised. /// [Fact] - public void Save_TopicRenamedEvent_IsRaised() { + public async Task Save_TopicRenamedEvent_IsRaised() { var topic = new Topic("Test", "Page", null, 1); var hasFired = false; @@ -1065,7 +1159,7 @@ public void Save_TopicRenamedEvent_IsRaised() { topic.Key = "New"; _cachedTopicRepository.TopicRenamed += eventHandler; - _cachedTopicRepository.Save(topic); + await _cachedTopicRepository.Save(topic); _cachedTopicRepository.TopicRenamed -= eventHandler; Assert.True(hasFired); @@ -1078,11 +1172,11 @@ public void Save_TopicRenamedEvent_IsRaised() { | TEST: SAVE: TOPIC MOVED EVENT: IS RAISED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a , changes its parent, and then saves it. Ensures that the event is raised. + /// Creates a , changes its parent, and then saves it. Ensures that the event is raised. /// [Fact] - public void Save_TopicMovedEvent_IsRaised() { + public async Task Save_TopicMovedEvent_IsRaised() { var topic = new Topic("Test", "Page", null, 1); var parent = new Topic("Products", "Page", null, 2); @@ -1091,7 +1185,7 @@ public void Save_TopicMovedEvent_IsRaised() { topic.Parent = parent; _cachedTopicRepository.TopicMoved += eventHandler; - _cachedTopicRepository.Save(topic); + await _cachedTopicRepository.Save(topic); _cachedTopicRepository.TopicMoved -= eventHandler; Assert.True(hasFired); @@ -1100,22 +1194,44 @@ public void Save_TopicMovedEvent_IsRaised() { } + /*============================================================================================================================ + | TEST: SAVE: NOT LOADED CHILDREN: SKIPS RECURSIVE DESCENT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a parent topic with a child, marks the parent's as , then saves recursively. Verifies that the child is not saved; the recursive-save loop is gated on , so a not-loaded children collection prevents descent. + /// + [Fact] + public async Task Save_NotLoadedChildren_SkipsRecursiveDescent() { + + var parent = new Topic("Parent", "Page"); + var child = new Topic("Child", "Page", parent); + + parent.Children.LoadState = LoadState.NotLoaded; + + await _topicRepository.Save(parent, isRecursive: true); + + Assert.True(child.IsNew); + + } + /*============================================================================================================================ | TEST: MOVE: TOPIC MOVED EVENT: IS RAISED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a and then immediately moves it. Ensures that the event is raised. + /// Creates a and then immediately moves it. Ensures that the + /// event is raised. /// [Fact] - public void Move_TopicMovedEvent_IsRaised() { + public async Task Move_TopicMovedEvent_IsRaised() { var topic = new Topic("Test", "Page", null, 1); var parent = new Topic("Products", "Page", null, 2); var hasFired = false; _cachedTopicRepository.TopicMoved += eventHandler; - _cachedTopicRepository.Move(topic, parent); + await _cachedTopicRepository.Move(topic, parent); _cachedTopicRepository.TopicMoved -= eventHandler; Assert.True(hasFired); @@ -1128,11 +1244,11 @@ public void Move_TopicMovedEvent_IsRaised() { | TEST: MOVE: SAME LOCATION: EVENT NOT RAISED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a and then moves it to the exact same location in the tree. Ensures that the event is not raised. + /// Creates a and then moves it to the exact same location in the tree. Ensures that the event is not raised. /// [Fact] - public void Move_SameLocation_EventNotRaised() { + public async Task Move_SameLocation_EventNotRaised() { var parent = new Topic("Parent", "Page", null, 1); var sibling = new Topic("Sibling", "Page", parent, 2); @@ -1140,7 +1256,7 @@ public void Move_SameLocation_EventNotRaised() { var hasFired = false; _cachedTopicRepository.TopicMoved += eventHandler; - _cachedTopicRepository.Move(topic, parent, sibling); + await _cachedTopicRepository.Move(topic, parent, sibling); _cachedTopicRepository.TopicMoved -= eventHandler; Assert.False(hasFired); diff --git a/OnTopic.Tests/TopicTest.cs b/OnTopic.Tests/TopicTest.cs index 0340cda3..f0598d26 100644 --- a/OnTopic.Tests/TopicTest.cs +++ b/OnTopic.Tests/TopicTest.cs @@ -6,6 +6,7 @@ using OnTopic.Collections; using OnTopic.Metadata; using OnTopic.Repositories; +using OnTopic.Tests.TestDoubles; using Xunit; namespace OnTopic.Tests; @@ -27,7 +28,7 @@ public class TopicTest { /// [Fact] public void Create_ReturnsTopic() { - var topic = TopicFactory.Create("Test", "Page"); + var topic = TopicFactory.Create("Test", "Page"); Assert.NotNull(topic); Assert.Equal("Test", topic.Key); Assert.Equal("Page", topic.ContentType); @@ -42,7 +43,7 @@ public void Create_ReturnsTopic() { /// [Fact] public void Create_ContentType_ReturnsDerivedTopic() { - var topic = TopicFactory.Create("Test", "ContentTypeDescriptor"); + var topic = TopicFactory.Create("Test", "ContentTypeDescriptor"); Assert.NotNull(topic); Assert.IsType(topic); } @@ -56,13 +57,13 @@ public void Create_ContentType_ReturnsDerivedTopic() { /// /// /// This is a special use case to address the fact that we expect concrete types of to - /// be in external plugin libraries, but the only needs to know that they're an . This is similar to how other types will fallback to if no matching type + /// be in external plugin libraries, but the only needs to know that they're an . This is similar to how other types will fallback to if no matching type /// can be found in the . /// [Fact] public void Create_AttributeDescriptor_ReturnsFallback() { - var topic = TopicFactory.Create("Test", "ArbitraryAttributeDescriptor"); + var topic = TopicFactory.Create("Test", "ArbitraryAttributeDescriptor"); Assert.NotNull(topic); Assert.IsType(topic); } @@ -79,7 +80,7 @@ public void Id_ChangeValue_ThrowsArgumentException() { var topic = new ContentTypeDescriptor("Test", "ContentTypeDescriptor", null, 123); Assert.Throws(() => - topic.Id = 124 + topic.Id = 124 ); } @@ -92,8 +93,8 @@ public void Id_ChangeValue_ThrowsArgumentException() { /// collection is updated to reflect the new . /// /// - /// By default, won't automatically update its key if the underlying changed. We have code that will handle that, however. + /// By default, won't automatically update its key if the underlying changed. We have code that will handle that, however. /// [Fact] public void Key_ChangeValue_UpdatesParent() { @@ -134,8 +135,8 @@ public void Parent_SetValue_UpdatesParent() { | TEST: PARENT: SET TO DESCENDANT: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Sets the to a that is a descendant, and ensure it throws an . + /// Sets the to a that is a descendant, and ensure it throws an . /// [Fact] public void Parent_SetToDescendant_ThrowsException() { @@ -256,6 +257,50 @@ public void Title_NullValue_ReturnsKey() { } + /*============================================================================================================================ + | TEST: TITLE: NOT LOADED: KEY ABSENT: DOES NOT TRIGGER LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a topic stamped with a and a collection. Confirms that falls back to without + /// triggering a lazy load, since Title is always expected to be indexed. + /// + [Fact] + public void Title_NotLoaded_KeyAbsent_DoesNotTriggerLoad() { + + var topic = new Topic("Test", "Page"); + var loader = new TrackingTopicLazyLoader(); + + ((ITopicLazyLoadable)topic).Loader = loader; + topic.Attributes.LoadState = LoadState.NotLoaded; + + Assert.Equal("Test", topic.Title); + Assert.False(loader.WasCalled); + + } + + /*============================================================================================================================ + | TEST: VIEW: NOT LOADED: KEY ABSENT: DOES NOT TRIGGER LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Creates a topic stamped with a and a collection. Confirms that falls back to without + /// triggering a lazy load, since View is always expected to be indexed. + /// + [Fact] + public void View_NotLoaded_KeyAbsent_DoesNotTriggerLoad() { + + var topic = new Topic("Test", "Page"); + var loader = new TrackingTopicLazyLoader(); + + ((ITopicLazyLoadable)topic).Loader = loader; + topic.Attributes.LoadState = LoadState.NotLoaded; + + Assert.Equal("", topic.View); + Assert.False(loader.WasCalled); + + } + /*============================================================================================================================ | TEST: LAST MODIFIED: UPDATE VALUE: RETURNS EXPECTED VALUE \---------------------------------------------------------------------------------------------------------------------------*/ @@ -382,7 +427,7 @@ public void BaseTopic_SetToNull_RemovesValue() { | IS DIRTY: NEW TOPIC: RETURNS TRUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates a new topic, and confirms that returns true. + /// Creates a new topic, and confirms that returns true. /// [Fact] public void IsDirty_NewTopic_ReturnsTrue() => @@ -392,7 +437,7 @@ public void IsDirty_NewTopic_ReturnsTrue() => | IS DIRTY: EXISTING TOPIC: RETURNS FALSE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates an existing topic, and confirms that returns false. + /// Creates an existing topic, and confirms that returns false. /// [Fact] public void IsDirty_ExistingTopic_ReturnsFalse() => @@ -402,8 +447,8 @@ public void IsDirty_ExistingTopic_ReturnsFalse() => | IS DIRTY: CHANGE KEY: RETURNS TRUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates an existing topic, changes the , and confirms that returns true. + /// Creates an existing topic, changes the , and confirms that returns + /// true. /// [Fact] public void IsDirty_ChangeKey_ReturnsTrue() => @@ -417,8 +462,8 @@ public void IsDirty_ChangeKey_ReturnsTrue() => | TEST: IS DIRTY: EXISTING VALUES: REMAINS CLEAN \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Creates an existing topic, and updates the , , and to their existing values. Ensures that remains false. + /// Creates an existing topic, and updates the , , and to their existing values. Ensures that remains false. /// [Fact] public void IsDirty_ExistingValue_RemainsClean() { @@ -434,85 +479,12 @@ public void IsDirty_ExistingValue_RemainsClean() { } - /*============================================================================================================================ - | IS DIRTY: CHANGE COLLECTIONS: RETURNS TRUE - \---------------------------------------------------------------------------------------------------------------------------*/ - /// - /// Creates an existing topic, changes the , , and collections, and confirms that returns - /// true. - /// - [Fact] - public void IsDirty_ChangeCollections_ReturnsTrue() { - - var topic = new Topic("Topic", "Page", null, 1); - var related = new Topic("Related", "Page", null, 2); - - topic.Attributes.SetValue("Related", related.Key); - topic.References.SetValue("Related", related); - topic.Relationships.SetValue("Related", related); - - Assert.True(topic.IsDirty(true)); - Assert.True(topic.IsDirty("Related", true)); - - } - - /*============================================================================================================================ - | MARK CLEAN: CHANGE COLLECTIONS: RESETS IS DIRTY - \---------------------------------------------------------------------------------------------------------------------------*/ - /// - /// Creates an existing topic, changes the , , and collections, and confirms that resets the - /// value of . - /// - [Fact] - public void MarkClean_ChangeCollections_ResetIsDirty() { - - var topic = new Topic("Topic", "Page", null, 1); - var related = new Topic("Related", "Page", null, 2); - - topic.Attributes.SetValue("Related", related.Key); - topic.References.SetValue("Related", related); - topic.Relationships.SetValue("Related", related); - - topic.MarkClean(true); - - Assert.False(topic.IsDirty(true)); - - } - - /*============================================================================================================================ - | MARK CLEAN: INCLUDE COLLECTIONS: RESETS IS DIRTY - \---------------------------------------------------------------------------------------------------------------------------*/ - /// - /// Creates an existing topic, changes the , , and collections, and confirms that resets the value - /// of . - /// - [Fact] - public void MarkClean_IncludeCollections_ResetsIsDirty() { - - var topic = new Topic("Topic", "Page", null, 1); - var related = new Topic("Related", "Page", null, 2); - - topic.Attributes.SetValue("Related", related.Key); - topic.References.SetValue("Related", related); - topic.Relationships.SetValue("Related", related); - - topic.MarkClean("Related", true); - - Assert.False(topic.IsDirty("Related", true)); - Assert.False(topic.IsDirty(true)); - - } - - /*============================================================================================================================ | MARK CLEAN: NEW TOPIC: REMAINS DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Creates a new and confirms that does not reset the value of - /// . Topics that are marked as cannot be clean. + /// . Topics that are marked as cannot be clean. /// [Fact] public void MarkClean_NewTopic_RemainsDirty() { @@ -520,12 +492,11 @@ public void MarkClean_NewTopic_RemainsDirty() { var topic = new Topic("Topic", "Page"); topic.Attributes.SetValue("Attribute", "Test"); - - topic.MarkClean("Attribute", true); - topic.MarkClean(true); + topic.MarkClean("Attribute"); + topic.MarkClean(); Assert.True(topic.IsDirty()); - Assert.True(topic.IsDirty("Attribute", true)); + Assert.True(topic.IsDirty("Attribute")); } diff --git a/OnTopic.Tests/TypeAccessorTest.cs b/OnTopic.Tests/TypeAccessorTest.cs index dd1f3b8c..69fceee1 100644 --- a/OnTopic.Tests/TypeAccessorTest.cs +++ b/OnTopic.Tests/TypeAccessorTest.cs @@ -348,8 +348,8 @@ public void SetValue_Names_SetsResults() { | TEST: SET PROPERTY VALUE: KEY: SETS VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and confirms that a key value can be properly set using the method. + /// Establishes a and confirms that a key value can be properly set using the method. /// [Fact] public void SetPropertyValue_Key_SetsValue() { @@ -392,8 +392,8 @@ public void SetPropertyValue_NullValue_SetsToNull() { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Establishes a and confirms that the sets the target property value to null if the value is set to . + /// String, Object?, Boolean)"/> sets the target property value to null if the value is set to . /// [Fact] public void SetPropertyValue_EmptyValue_SetsToNull() { @@ -452,8 +452,8 @@ public void SetPropertyValue_Boolean_SetsValue() { | TEST: SET PROPERTY VALUE: DATE/TIME: SETS VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and confirms that a date/time value can be properly set using the method. + /// Establishes a and confirms that a date/time value can be properly set using the method. /// [Fact] public void SetPropertyValue_DateTime_SetsValue() { @@ -476,8 +476,8 @@ public void SetPropertyValue_DateTime_SetsValue() { | TEST: SET PROPERTY VALUE: INVALID PROPERTY: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and confirms that an invalid property being set via the method throws an and confirms that an invalid property being set via the method throws an . /// [Fact] @@ -495,8 +495,8 @@ public void SetPropertyValue_InvalidProperty_ReturnsFalse() { | TEST: SET METHOD VALUE: VALID VALUE: SETS VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and confirms that a value can be properly set using the method. + /// Establishes a and confirms that a value can be properly set using the method. /// [Fact] public void SetMethodValue_ValidValue_SetsValue() { @@ -513,8 +513,8 @@ public void SetMethodValue_ValidValue_SetsValue() { | TEST: SET METHOD VALUE: INVALID VALUE: DOESN'T SET VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and confirms that a value set with an invalid value using the method returns false. + /// Establishes a and confirms that a value set with an invalid value using the method returns false. /// [Fact] public void SetMethodValue_InvalidValue_DoesNotSetValue() { @@ -532,8 +532,8 @@ public void SetMethodValue_InvalidValue_DoesNotSetValue() { | TEST: SET METHOD VALUE: INVALID MEMBER: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and confirms that setting an invalid method name using the method throws an exception. + /// Establishes a and confirms that setting an invalid method name using the method throws an exception. /// [Fact] public void SetMethodValue_InvalidMember_ThrowsException() { @@ -570,9 +570,9 @@ public void SetMethodValue_ValidReferenceValue_SetsValue() { | TEST: SET METHOD VALUE: INVALID REFERENCE VALUE: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and confirms that a value set with an invalid value using the method throws an . + /// Establishes a and confirms that a value set with an invalid value using the method throws an . /// [Fact] public void SetMethodValue_InvalidReferenceValue_ThrowsException() { @@ -591,8 +591,8 @@ public void SetMethodValue_InvalidReferenceValue_ThrowsException() { | TEST: SET METHOD VALUE: INVALID REFERENCE MEMBER: THROWS EXCEPTION \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and confirms that setting an invalid method name using the method returns false. + /// Establishes a and confirms that setting an invalid method name using the method returns false. /// [Fact] public void SetMethodValue_InvalidReferenceMember_ThrowsException() { @@ -610,8 +610,8 @@ public void SetMethodValue_InvalidReferenceMember_ThrowsException() { | TEST: SET METHOD VALUE: NULL REFERENCE VALUE: DOESN'T SET VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a and confirms that a value set with an null value using the method returns false. + /// Establishes a and confirms that a value set with an null value using the method returns false. /// [Fact] public void SetMethodValue_NullReferenceValue_DoesNotSetValue() { @@ -666,7 +666,7 @@ public void SetPropertyValue_ReflectionPerformance() { var topic = new Topic("Test", "ContentType"); int i; - for (i = 0; i < totalIterations; i++) { + for (i = 0; i < totalIterations; i++) { typeAccessor.SetPropertyValue(topic, "Key", "Key" + i); } diff --git a/OnTopic.Tests/TypeLookupServiceTest.cs b/OnTopic.Tests/TypeLookupServiceTest.cs index 2caba245..3c4d3366 100644 --- a/OnTopic.Tests/TypeLookupServiceTest.cs +++ b/OnTopic.Tests/TypeLookupServiceTest.cs @@ -16,8 +16,8 @@ namespace OnTopic.Tests; | CLASS: TYPE LOOKUP SERVICE TEST \-----------------------------------------------------------------------------------------------------------------------------*/ /// -/// Provides unit tests for the interface and its implementations, such as the , , , +/// Provides unit tests for the interface and its implementations, such as the , , , /// and the underlying . /// [ExcludeFromCodeCoverage] @@ -33,11 +33,11 @@ public class TypeLookupServiceTest { [Fact] public void TypeCollection_Constructor_ContainsUniqueTypes() { - var topics = new List { + List topics = [ typeof(BasicTopicBindingModel), typeof(CustomTopic), typeof(CustomTopic) - }; + ]; var typeCollection = new TypeCollection(topics); Assert.Equal(2, typeCollection.Count); @@ -56,9 +56,7 @@ public void TypeCollection_Constructor_ContainsUniqueTypes() { [Fact] public void StaticLookupService_TryAdd_ReturnsExpected() { - var topics = new List { - typeof(CustomTopic) - }; + List topics = [typeof(CustomTopic)]; var lookupService = new DummyStaticTypeLookupService(topics); Assert.False(lookupService.TryAdd(typeof(CustomTopic))); @@ -76,10 +74,7 @@ public void StaticLookupService_TryAdd_ReturnsExpected() { [Fact] public void StaticLookupService_Lookup_ReturnsFallback() { - var topics = new List { - typeof(AscendentTopicViewModel), - typeof(FallbackViewModel) - }; + List topics = [typeof(AscendentTopicViewModel), typeof(FallbackViewModel)]; var lookupService = new StaticTypeLookupService(topics); Assert.Equal(typeof(FallbackViewModel), lookupService.Lookup(nameof(EmptyViewModel), nameof(FallbackViewModel))); @@ -109,8 +104,8 @@ public void StaticLookupService_AddOrReplace_ReturnsExpected() { | TEST: DYNAMIC TYPE LOOKUP SERVICE: PREDICATE: RETURNS EXPECTED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Establishes a with a custom predicate and calls the underlying to ensure it correctly adds the expected items. + /// Establishes a with a custom predicate and calls the underlying to ensure it correctly adds the expected items. /// [Fact] public void DynamicTypeLookupService_Predicate_ReturnsExpected() { @@ -136,21 +131,17 @@ public void DynamicTypeLookupService_Predicate_ReturnsExpected() { [Fact] public void CompositeTypeLookupService_Lookup_ReturnsFallback() { - var lookupService1 = new StaticTypeLookupService( - new List { - typeof(EmptyViewModel), - typeof(FallbackViewModel), - typeof(Internal.Diagnostics.Contract) - } - ); + var lookupService1 = new StaticTypeLookupService([ + typeof(EmptyViewModel), + typeof(FallbackViewModel), + typeof(Contract) + ]); - var lookupService2 = new StaticTypeLookupService( - new List { - typeof(AscendentTopicViewModel), - typeof(FallbackViewModel), - typeof(System.Diagnostics.Contracts.Contract) - } - ); + var lookupService2 = new StaticTypeLookupService([ + typeof(AscendentTopicViewModel), + typeof(FallbackViewModel), + typeof(System.Diagnostics.Contracts.Contract) + ]); var lookupService = new CompositeTypeLookupService(lookupService1, lookupService2); @@ -170,9 +161,7 @@ public void CompositeTypeLookupService_Lookup_ReturnsFallback() { [Fact] public void DefaultTopicLookupService_Lookup_ReturnsExpected() { - var topics = new List { - typeof(CustomTopic) - }; + List topics = [typeof(CustomTopic)]; var lookupService = new DefaultTopicLookupService(topics); Assert.Equal(typeof(AttributeDescriptor), lookupService.Lookup(nameof(AttributeDescriptor))); diff --git a/OnTopic.Tests/ViewModels/AttributeDictionaryConstructorTopicViewModel.cs b/OnTopic.Tests/ViewModels/AttributeDictionaryConstructorTopicViewModel.cs index 35c1ed3f..e6d800f8 100644 --- a/OnTopic.Tests/ViewModels/AttributeDictionaryConstructorTopicViewModel.cs +++ b/OnTopic.Tests/ViewModels/AttributeDictionaryConstructorTopicViewModel.cs @@ -13,7 +13,10 @@ namespace OnTopic.Tests.ViewModels; /// Provides a strongly-typed data transfer object for testing a constructor with a . /// /// -/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// and are decorated with +/// so they can only be populated via the constructor, not the reflection-based property +/// mapper's fallback pass; this isolates tests to the constructor-dictionary path they're meant to exercise. This is a sample +/// class intended for test purposes only; it is not designed for use in a production environment. /// public record AttributeDictionaryConstructorTopicViewModel: PageTopicViewModel { @@ -27,7 +30,7 @@ public record AttributeDictionaryConstructorTopicViewModel: PageTopicViewModel { /// An of attribute values. public AttributeDictionaryConstructorTopicViewModel(AttributeDictionary attributes) : base(attributes) { Contract.Requires(attributes, nameof(attributes)); - MappedProperty = attributes.GetValue(nameof(MappedProperty)); + MappedProperty = attributes.GetValue(nameof(MappedProperty)); } /// @@ -38,7 +41,10 @@ public AttributeDictionaryConstructorTopicViewModel() { } /*============================================================================================================================ | PROPERTIES \---------------------------------------------------------------------------------------------------------------------------*/ + [DisableMapping] public string? MappedProperty { get; init; } + + [DisableMapping] public string? UnmappedProperty { get; init; } diff --git a/OnTopic.Tests/ViewModels/CircularConstructorTopicViewModel.cs b/OnTopic.Tests/ViewModels/CircularConstructorTopicViewModel.cs new file mode 100644 index 00000000..ded41685 --- /dev/null +++ b/OnTopic.Tests/ViewModels/CircularConstructorTopicViewModel.cs @@ -0,0 +1,34 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Mapping; + +namespace OnTopic.Tests.ViewModels; + +/*============================================================================================================================== +| VIEW MODEL: CIRCULAR CONSTRUCTOR TOPIC +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a strongly typed data transfer object, implemented as a positional record, for testing constructor mapping +/// of a topic reference that may form a circular reference. +/// +/// +/// +/// Unlike , which expresses its circular reference through settable properties, this +/// model maps its reference through a positional constructor parameter on a record. This allows the to be exercised for two distinct behaviors: A non-cyclic reference should map successfully, +/// while a true self-reference should be detected as a constructor cycle and throw a , +/// since a partially constructed instance cannot be returned from a constructor. +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +/// The key of the mapped topic. +/// An optional reference to another . +public record CircularConstructorTopicViewModel( + string Key, + [Include(AssociationTypes.References)] CircularConstructorTopicViewModel? Self +); \ No newline at end of file diff --git a/OnTopic.Tests/ViewModels/ConcurrentExpansionRootTopicViewModel.cs b/OnTopic.Tests/ViewModels/ConcurrentExpansionRootTopicViewModel.cs new file mode 100644 index 00000000..55d1c093 --- /dev/null +++ b/OnTopic.Tests/ViewModels/ConcurrentExpansionRootTopicViewModel.cs @@ -0,0 +1,36 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.Tests.ViewModels; + +/*============================================================================================================================== +| VIEW MODEL: CONCURRENT EXPANSION ROOT +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a parent view model that references the same source topic twice with disjoint associations, forcing two +/// concurrent mapping passes over the shared target. +/// +/// +/// +/// Both and resolve to the same source topic, but request +/// disjoint associations via . Because both reference properties are mapped concurrently (via +/// the property-level Task.WhenAll), one pass constructs the shared instance while the other expands it, each +/// populating the target's list from a different source; this +/// is the scenario that the SetCollectionValueAsync() and PopulateTargetCollectionAsync() locks protect against. +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +public class ConcurrentExpansionRootTopicViewModel { + + [Include(AssociationTypes.Relationships)] + public ConcurrentExpansionSharedTopicViewModel? RelationshipsView { get; set; } + + [Include(AssociationTypes.IncomingRelationships)] + public ConcurrentExpansionSharedTopicViewModel? IncomingView { get; set; } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/ViewModels/ConcurrentExpansionSharedTopicViewModel.cs b/OnTopic.Tests/ViewModels/ConcurrentExpansionSharedTopicViewModel.cs new file mode 100644 index 00000000..b14393d5 --- /dev/null +++ b/OnTopic.Tests/ViewModels/ConcurrentExpansionSharedTopicViewModel.cs @@ -0,0 +1,41 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.Tests.ViewModels; + +/*============================================================================================================================== +| VIEW MODEL: CONCURRENT EXPANSION SHARED +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a shared target for two concurrent mapping passes with disjoint associations, exposing a single collection () populated from two different sources. +/// +/// +/// +/// This is the referenced view model in the concurrency stress test: A single source topic is referenced twice from a +/// parent , once with and +/// once with . Both passes populate the same +/// list: One from the source's outgoing relationships, the other from its incoming relationships, thus exercising the locks +/// on SetCollectionValueAsyn() and PopulateTargetCollectionAsync(). The property is left nullable and settable so the +/// mapper both creates the backing list (SetCollectionValueAsyn()) and adds to it (PopulateTargetCollectionAsync()). +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +[SuppressMessage( + "Usage", + "CA2227:Collection properties should be read only", + Justification = "This view model intentionally exposes a settable, nullable collection property so the TopicMappingService's list-creation runs, which the concurrency test relies on to establish a creation race." +)] +public class ConcurrentExpansionSharedTopicViewModel { + + public string? Key { get; set; } + + [Collection("Related")] + public Collection? Related { get; set; } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/ViewModels/ConcurrentReferenceTopicViewModel.cs b/OnTopic.Tests/ViewModels/ConcurrentReferenceTopicViewModel.cs new file mode 100644 index 00000000..8308cc70 --- /dev/null +++ b/OnTopic.Tests/ViewModels/ConcurrentReferenceTopicViewModel.cs @@ -0,0 +1,36 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Mapping; + +namespace OnTopic.Tests.ViewModels; + +/*============================================================================================================================== +| VIEW MODEL: CONCURRENT REFERENCE TOPIC +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a strongly typed data transfer object with two topic references, both intended to resolve to the same shared . +/// +/// +/// +/// Both references are mapped as properties, so the resolves them concurrently within a +/// single mapping pass. When both point at the same topic, this drives two branches to map that topic to the same type at +/// once, which is a supported sibling concurrency scenario. The pins the mapped view model +/// type so the scenario does not depend on the shared topic's content type. +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +public class ConcurrentReferenceTopicViewModel { + + [MapAs(typeof(SharedConcurrentTopicViewModel))] + public SharedConcurrentTopicViewModel? FirstReference { get; set; } + + [MapAs(typeof(SharedConcurrentTopicViewModel))] + public SharedConcurrentTopicViewModel? SecondReference { get; set; } + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/ViewModels/ExpansionParentTopicViewModel.cs b/OnTopic.Tests/ViewModels/ExpansionParentTopicViewModel.cs new file mode 100644 index 00000000..49f15956 --- /dev/null +++ b/OnTopic.Tests/ViewModels/ExpansionParentTopicViewModel.cs @@ -0,0 +1,57 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.Tests.ViewModels; + +/*============================================================================================================================== +| VIEW MODEL: EXPANSION PARENT +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a view model whose two collections both map the same source topic to , +/// but request disjoint associations so that mapping it can exercise an association expansion pass. +/// +/// +/// +/// The relationship and the collection are populated from the same source +/// topic and mapped to the same instance, but request disjoint associations +/// ( and ). Neither association maps +/// anything on , which has no association-typed members: The disjoint requests +/// exist only so the cache sees the second encounter as missing an association and runs an expansion pass, rather than +/// returning the cached instance unchanged. What that expansion pass must not do is redo the target's non-association work. +/// +/// +/// The disjointness is all this view model contributes, and only conditionally: If one encounter finds the instance the +/// other already cached, that encounter has a missing association. Whether the encounters actually resolve that way, as an +/// ordered initial pass followed by a cache-hit expansion pass rather than two concurrent initial passes, is a property +/// of the mapping runtime, not of this view model. The tests that depend on the ordered outcome document why it holds for +/// them. +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +public class ExpansionParentTopicViewModel { + + /*============================================================================================================================ + | PROPERTY: RELATED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// A relationship collection that reaches the shared topic while requesting only . + /// + [Collection("Related", Type = CollectionType.Relationship)] + [Include(AssociationTypes.Children)] + public Collection Related { get; } = new(); + + /*============================================================================================================================ + | PROPERTY: CHILDREN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// A children collection that reaches the shared topic while requesting only . + /// + [Include(AssociationTypes.Relationships)] + public Collection Children { get; } = new(); + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/ViewModels/ExpansionSharedTopicViewModel.cs b/OnTopic.Tests/ViewModels/ExpansionSharedTopicViewModel.cs new file mode 100644 index 00000000..3c3efbcb --- /dev/null +++ b/OnTopic.Tests/ViewModels/ExpansionSharedTopicViewModel.cs @@ -0,0 +1,73 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.Tests.ViewModels; + +/*============================================================================================================================== +| VIEW MODEL: EXPANSION SHARED +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a view model that encounters more than once during a single mapping +/// operation, so that the second encounter triggers an association expansion pass (mapAssociationsOnly), while the +/// first only includes the properties. +/// +/// +/// +/// This has no association-typed members by design. Its content is , an ungated (gate ) nested-topics collection, and , a "compatible" property (i.e., mapped +/// directly from a first-class property on ). Unlike a gated association, which the cache claims once +/// and its flag check then skips on later passes, neither of these is tied to an association, so an expansion pass would +/// redundantly remap both unless the mapper explicitly skips non-association work. records how +/// many times is assigned, so a test can confirm the compatible property is not reassigned again during +/// the expansion pass. +/// +/// +/// This is only reachable in tandem with , whose two collections perform the +/// initial and expansion passes against a single, cached instance of this view model. +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +public class ExpansionSharedTopicViewModel { + + /*============================================================================================================================ + | PRIVATE VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + private int _keyMapCount; + + /*============================================================================================================================ + | PROPERTY: KEY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// A compatible property, mapped one-to-one from the source . Records each assignment via . + /// + public string? Key { + get; + set { + field = value; + _keyMapCount++; + } + } + + /*============================================================================================================================ + | PROPERTY: KEY MAP COUNT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// The number of times has been assigned by the mapping service. + /// + public int KeyMapCount => _keyMapCount; + + /*============================================================================================================================ + | PROPERTY: CATEGORIES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// An ungated nested-topics collection, mapped from the source topic's nested Categories container. + /// + public Collection Categories { get; } = new(); + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/ViewModels/FilteredInvalidTopicViewModel.cs b/OnTopic.Tests/ViewModels/FilteredInvalidTopicViewModel.cs index 5eebe086..34d971a8 100644 --- a/OnTopic.Tests/ViewModels/FilteredInvalidTopicViewModel.cs +++ b/OnTopic.Tests/ViewModels/FilteredInvalidTopicViewModel.cs @@ -10,8 +10,8 @@ namespace OnTopic.Tests.ViewModels; | VIEW MODEL: FILTERED TOPIC (INVALID) \-----------------------------------------------------------------------------------------------------------------------------*/ /// -/// Provides a strongly-typed data transfer object for testing views properties annotated with the . Includes an invalid . +/// Provides a strongly-typed data transfer object for testing views properties annotated with the . Includes an invalid . /// /// /// This is a sample class intended for test purposes only; it is not designed for use in a production environment. diff --git a/OnTopic.Tests/ViewModels/InitializedTopicViewModel.cs b/OnTopic.Tests/ViewModels/InitializedTopicViewModel.cs index 68d1b229..4d26f104 100644 --- a/OnTopic.Tests/ViewModels/InitializedTopicViewModel.cs +++ b/OnTopic.Tests/ViewModels/InitializedTopicViewModel.cs @@ -3,7 +3,6 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ -using System.ComponentModel; namespace OnTopic.Tests.ViewModels; diff --git a/OnTopic.Tests/ViewModels/LoadTestingViewModel.cs b/OnTopic.Tests/ViewModels/LoadTestingViewModel.cs index 2ee9c147..82987bef 100644 --- a/OnTopic.Tests/ViewModels/LoadTestingViewModel.cs +++ b/OnTopic.Tests/ViewModels/LoadTestingViewModel.cs @@ -11,8 +11,8 @@ namespace OnTopic.Tests.ViewModels; | VIEW MODEL: LOAD TESTING \-----------------------------------------------------------------------------------------------------------------------------*/ /// -/// Provides a simple view model with a series of properties that can be used for load testing the . +/// Provides a simple view model with a series of properties that can be used for load testing the . /// /// /// This is a sample class intended for test purposes only; it is not designed for use in a production environment. @@ -28,27 +28,27 @@ public class LoadTestingViewModel: KeyOnlyTopicViewModel { /// An of attribute values. public LoadTestingViewModel(AttributeDictionary attributes) { Contract.Requires(attributes); - Property0 = attributes.GetInteger("Property0"); - Property1 = attributes.GetInteger("Property1"); - Property2 = attributes.GetInteger("Property2"); - Property3 = attributes.GetInteger("Property3"); - Property4 = attributes.GetInteger("Property4"); - Property5 = attributes.GetInteger("Property5"); - Property6 = attributes.GetInteger("Property6"); - Property7 = attributes.GetInteger("Property7"); - Property8 = attributes.GetInteger("Property8"); - Property9 = attributes.GetInteger("Property9"); - Property10 = attributes.GetInteger("Property10"); - Property11 = attributes.GetInteger("Property11"); - Property12 = attributes.GetInteger("Property12"); - Property13 = attributes.GetInteger("Property13"); - Property14 = attributes.GetInteger("Property14"); - Property15 = attributes.GetInteger("Property15"); - Property16 = attributes.GetInteger("Property16"); - Property17 = attributes.GetInteger("Property17"); - Property18 = attributes.GetInteger("Property18"); - Property19 = attributes.GetInteger("Property19"); - Property20 = attributes.GetInteger("Property20"); + Property0 = attributes.GetInteger("Property0"); + Property1 = attributes.GetInteger("Property1"); + Property2 = attributes.GetInteger("Property2"); + Property3 = attributes.GetInteger("Property3"); + Property4 = attributes.GetInteger("Property4"); + Property5 = attributes.GetInteger("Property5"); + Property6 = attributes.GetInteger("Property6"); + Property7 = attributes.GetInteger("Property7"); + Property8 = attributes.GetInteger("Property8"); + Property9 = attributes.GetInteger("Property9"); + Property10 = attributes.GetInteger("Property10"); + Property11 = attributes.GetInteger("Property11"); + Property12 = attributes.GetInteger("Property12"); + Property13 = attributes.GetInteger("Property13"); + Property14 = attributes.GetInteger("Property14"); + Property15 = attributes.GetInteger("Property15"); + Property16 = attributes.GetInteger("Property16"); + Property17 = attributes.GetInteger("Property17"); + Property18 = attributes.GetInteger("Property18"); + Property19 = attributes.GetInteger("Property19"); + Property20 = attributes.GetInteger("Property20"); } /// diff --git a/OnTopic.Tests/ViewModels/RelationshipOnlyTopicViewModel.cs b/OnTopic.Tests/ViewModels/RelationshipOnlyTopicViewModel.cs new file mode 100644 index 00000000..6a8d8682 --- /dev/null +++ b/OnTopic.Tests/ViewModels/RelationshipOnlyTopicViewModel.cs @@ -0,0 +1,33 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.Tests.ViewModels; + +/*============================================================================================================================== +| VIEW MODEL: RELATIONSHIP ONLY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a simple view model with a single, explicitly typed property (). +/// +/// +/// +/// Intended as a stand-in for cases where a very simple view model is required for test purposes, without introducing other +/// mapping scenarios that might introduce errors, even though they've not part of the test. Unlike , whose maps , is explicitly typed as a relationship, so it exercises only the +/// relationship probe in TopicMappingService.GetSourceCollectionAsync. +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +public class RelationshipOnlyTopicViewModel: KeyOnlyTopicViewModel { + + [Collection("Related", Type = CollectionType.Relationship)] + public Collection Related { get; } = new(); + +} //Class \ No newline at end of file diff --git a/OnTopic.Tests/ViewModels/SharedConcurrentTopicViewModel.cs b/OnTopic.Tests/ViewModels/SharedConcurrentTopicViewModel.cs new file mode 100644 index 00000000..a51c84ed --- /dev/null +++ b/OnTopic.Tests/ViewModels/SharedConcurrentTopicViewModel.cs @@ -0,0 +1,36 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Mapping; + +namespace OnTopic.Tests.ViewModels; + +/*============================================================================================================================== +| VIEW MODEL: SHARED CONCURRENT TOPIC +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a strongly typed data transfer object, implemented as a positional record, for testing that two concurrent +/// branches mapping the same topic to the same view model type share a single instance. +/// +/// +/// +/// The collection is mapped through a constructor parameter, so constructing this model requires the +/// source topic's payload to be loaded. When paired with a repository that suspends inside its lazy load, this lets a test +/// hold one branch mid-construction while a second branch reaches the same still-initializing cache entry, evaluating the +/// 's support of sibling concurrency. +/// +/// +/// The actual sibling references are set up in the accompanying . +/// +/// +/// This is a sample class intended for test purposes only; it is not designed for use in a production environment. +/// +/// +/// The key of the mapped topic. +/// A collection mapped from a constructor parameter, forcing the source payload to be loaded. +public record SharedConcurrentTopicViewModel( + string Key, + [Collection("Related")] Collection? Related +); \ No newline at end of file diff --git a/OnTopic.ViewModels/BindingModels/AssociatedTopicBindingModel.cs b/OnTopic.ViewModels/BindingModels/AssociatedTopicBindingModel.cs index c82d8411..66391396 100644 --- a/OnTopic.ViewModels/BindingModels/AssociatedTopicBindingModel.cs +++ b/OnTopic.ViewModels/BindingModels/AssociatedTopicBindingModel.cs @@ -15,8 +15,8 @@ namespace OnTopic.ViewModels.BindingModels; /// /// /// While implementors may choose to create a custom implementation, the out-of- -/// the-box implementation satisfies all of the requirements of the . The only reason to implement a custom definition is if the caller needs additional +/// the-box implementation satisfies all of the requirements of the . The only reason to implement a custom definition is if the caller needs additional /// metadata for separate validation or processing. /// public record AssociatedTopicBindingModel : IAssociatedTopicBindingModel { diff --git a/OnTopic.ViewModels/BindingModels/RelatedTopicBindingModel.cs b/OnTopic.ViewModels/BindingModels/RelatedTopicBindingModel.cs index 6e0dca5a..08959a6f 100644 --- a/OnTopic.ViewModels/BindingModels/RelatedTopicBindingModel.cs +++ b/OnTopic.ViewModels/BindingModels/RelatedTopicBindingModel.cs @@ -15,8 +15,8 @@ namespace OnTopic.ViewModels.BindingModels; /// /// /// While implementors may choose to create a custom implementation, the out-of- -/// the-box implementation satisfies all of the requirements of the . The only reason to implement a custom definition is if the caller needs additional +/// the-box implementation satisfies all of the requirements of the . The only reason to implement a custom definition is if the caller needs additional /// metadata for separate validation or processing. /// [ExcludeFromCodeCoverage] diff --git a/OnTopic.ViewModels/_collections/TopicViewModelCollection{TItem}.cs b/OnTopic.ViewModels/_collections/TopicViewModelCollection{TItem}.cs index d7c39d5d..b7c1281c 100644 --- a/OnTopic.ViewModels/_collections/TopicViewModelCollection{TItem}.cs +++ b/OnTopic.ViewModels/_collections/TopicViewModelCollection{TItem}.cs @@ -4,7 +4,6 @@ | Project Topics Library \=============================================================================================================================*/ using System.Collections.ObjectModel; -using OnTopic.Internal.Diagnostics; namespace OnTopic.ViewModels; diff --git a/OnTopic/Associations/DeferredAssociation.cs b/OnTopic/Associations/DeferredAssociation.cs new file mode 100644 index 00000000..138bf707 --- /dev/null +++ b/OnTopic/Associations/DeferredAssociation.cs @@ -0,0 +1,31 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Repositories; + +namespace OnTopic.Associations; + +/*============================================================================================================================== +| RECORD: DEFERRED ASSOCIATION +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Represents a deferred association between a source and a target topic that could not be resolved to an +/// in-memory instance when loaded. +/// +/// +/// This is exposed via and +/// so that the can record missing associations during a load, and then can dynamically load +/// them later when the collection is called. +/// +/// The relationship or reference key under which the association is registered. +/// The of the target topic to be resolved. +/// +/// Whether the association should be marked as dirty once resolved, so that persists it. Defaults to false, which is appropriate for associations recorded during an ordinary load, where +/// the target is presumed to already reflect the persistence store and resolving it later shouldn't be treated as a pending +/// change. Associations representing a not-yet-persisted change are marked true, such as those transplanted by . +/// +public record DeferredAssociation(string Key, int TopicId, bool IsDirty = false); \ No newline at end of file diff --git a/OnTopic/Associations/DeferredAssociationCollection.cs b/OnTopic/Associations/DeferredAssociationCollection.cs new file mode 100644 index 00000000..f6a88eff --- /dev/null +++ b/OnTopic/Associations/DeferredAssociationCollection.cs @@ -0,0 +1,112 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using System.Collections.ObjectModel; + +namespace OnTopic.Associations; + +/*============================================================================================================================== +| CLASS: DEFERRED ASSOCIATION COLLECTION +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Represents a collection of records pending resolution via lazy loading; i.e., +/// relationships or references to targets that weren't available in the topic graph when first loaded. +/// +/// +/// Deduplicates on add so that repeated loads don't accumulate redundant entries. Identity isn't uniform: References are +/// single-valued, so an entry's identity is its alone; relationships are multivalued, +/// so an entry's identity is the full and +/// pair. This mirrors the asymmetry already represented in and . +/// +public class DeferredAssociationCollection: Collection { + + /*============================================================================================================================ + | PRIVATE VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + readonly bool _singleValued; + + /*============================================================================================================================ + | CONSTRUCTOR + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Initializes a new instance of the class. + /// + /// + /// Determines whether entries are identified by alone (true for references), + /// or by the full and pair (false + /// for relationships). + /// + public DeferredAssociationCollection(bool singleValued = false) { + _singleValued = singleValued; + } + + /*============================================================================================================================ + | METHOD: SET VALUE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Registers a deferred association, replacing any existing entry that shares the new entry's identity. + /// + /// The relationship or reference key under which the association is registered. + /// The of the target topic to be resolved. + /// Determines that the deferred entry is a modification yet to be saved. + public void SetValue(string key, int topicId, bool isDirty = false) { + Remove(key, _singleValued? null : topicId); + Add(new(key, topicId, isDirty)); + } + + /*============================================================================================================================ + | METHOD: REMOVE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Removes the deferred association(s) matching the given identity. + /// + /// + /// When is omitted, every entry registered under is removed; otherwise + /// only the exact and pair is removed. + /// + /// The relationship or reference key of the association(s) to remove. + /// The of the target topic, if scoping the removal to a single entry. + /// Returns true if one or more entries were removed; otherwise, false. + public bool Remove(string key, int? topicId = null) { + var removed = false; + for (var i = Count - 1; i >= 0; i--) { + if (this[i].Key == key && (topicId is null || this[i].TopicId == topicId)) { + RemoveAt(i); + removed = true; + if (topicId is not null) { + break; + } + } + } + return removed; + } + + /*============================================================================================================================ + | METHOD: REPLACE ALL + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Replaces every entry in the collection with the supplied , marking each as regardless of the source entry's own value. + /// + /// + /// Intended for merging a detached topic's deferred associations onto a live topic's own or wholesale, without requiring + /// the caller to individually clear and then for each entry. Every entry is + /// marked dirty because, by definition, a caller merging a new source into a resident collection is introducing a change + /// that isn't yet reflected in the persistence store, even though 's own entries are themselves + /// presumed clean in their original context (e.g., a detached historical version, freshly loaded from the persistence store + /// as-is). Because associations are saved wholesale, there's no attempt to differentiate between preexisting and genuine + /// changes as would be required for e.g., Indexed Attributes, which are only persisted if they are individually dirty. + /// + /// The entries to populate the collection with. + internal void ReplaceAll(IEnumerable source) { + Clear(); + foreach (var entry in source) { + SetValue(entry.Key, entry.TopicId, isDirty: true); + } + } + +} //Class \ No newline at end of file diff --git a/OnTopic/Associations/TopicReferenceCollection.cs b/OnTopic/Associations/TopicReferenceCollection.cs index f196dabb..47787cd0 100644 --- a/OnTopic/Associations/TopicReferenceCollection.cs +++ b/OnTopic/Associations/TopicReferenceCollection.cs @@ -40,26 +40,45 @@ public TopicReferenceCollection(Topic parentTopic) : base(parentTopic) { } AssociatedTopic.BaseTopic?.References; /*============================================================================================================================ - | IS FULLY LOADED? + | PROPERTY: LOAD STATE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Determines whether or not the collection was fully loaded from the persistence store. + /// Indicates whether the collection has been populated from the underlying , + /// allowing callers to distinguish data that is present and authoritative from data that must still be fetched. /// /// - /// - /// When loading an individual or branch from the persistence store, it is possible that topic - /// references may not be fully available. In this scenario, updating topic references while e.g. deleting unmatched - /// relationships can result in unintended data loss. To account for this, the property ' - /// tracks whether a collection was fully loaded from the persistence store; if it wasn't, the should not deleted unmatched topic references. - /// - /// - /// The property defaults to true. It should be set to false during the method if any members of the collection cannot be mapped - /// back to a valid reference in memory. - /// + /// Returns when contains values, meaning one or more references + /// aren't yet available and must be lazy loaded. Returns once is + /// empty, meaning all targets have been loaded. While , the + /// will not delete unmatched references on save, preventing unintended data loss. /// - public bool IsFullyLoaded { get; set; } = true; + public LoadState LoadState => Deferred.Count > 0 ? LoadState.NotLoaded : LoadState.Loaded; + + /*============================================================================================================================ + | PROPERTY: DEFERRED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Collects reference targets that were absent from the topic graph during an load, pending + /// resolution via lazy-loading. + /// + /// + /// Written to by the when a reference target cannot be found in the current . The resolves each entry + /// by calling the 's Load() method, assuming the topics haven't since been introduced + /// to the topic graph. + /// + public DeferredAssociationCollection Deferred { get; } = new(singleValued: true); + + /*============================================================================================================================ + | METHOD: IS DIRTY? + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// + /// Extends the base to also account for a dirty + /// entry introduced by e.g., , + /// so a reference that hasn't yet been resolved to an in-memory still marks the collection dirty. + /// + public override bool IsDirty() => base.IsDirty() || Deferred.Any(deferred => deferred.IsDirty); /*============================================================================================================================ | INSERT ITEM @@ -77,10 +96,15 @@ protected override void InsertItem(int index, TopicReferenceRecord item) { \-------------------------------------------------------------------------------------------------------------------------*/ base.InsertItem(index, item); + /*-------------------------------------------------------------------------------------------------------------------------- + | Remove any pending deferred entry for this reference key + \-------------------------------------------------------------------------------------------------------------------------*/ + Deferred.Remove(item.Key); + /*-------------------------------------------------------------------------------------------------------------------------- | Handle recipricol references \-------------------------------------------------------------------------------------------------------------------------*/ - item.Value?.IncomingRelationships.SetValue(item.Key, AssociatedTopic, null, true); + item.Value?.IncomingRelationships.SetValue(item.Key, AssociatedTopic); } @@ -105,11 +129,18 @@ protected override void SetItem(int index, TopicReferenceRecord item) { \-------------------------------------------------------------------------------------------------------------------------*/ base.SetItem(index, item); + /*-------------------------------------------------------------------------------------------------------------------------- + | Remove any pending deferred entry for this reference key + \-------------------------------------------------------------------------------------------------------------------------*/ + Deferred.Remove(item.Key); + /*-------------------------------------------------------------------------------------------------------------------------- | Handle recipricol references \-------------------------------------------------------------------------------------------------------------------------*/ - existingItem.Value?.IncomingRelationships.Remove(existingItem.Key, AssociatedTopic, true); - item?.Value?.IncomingRelationships.SetValue(item.Key, AssociatedTopic, null, true); + if (existingItem.Value != item.Value) { + existingItem.Value?.IncomingRelationships.Remove(existingItem.Key, AssociatedTopic); + item?.Value?.IncomingRelationships.SetValue(item.Key, AssociatedTopic); + } } @@ -124,7 +155,7 @@ protected override sealed void RemoveItem(int index) { \-------------------------------------------------------------------------------------------------------------------------*/ var existing = this[index]; - existing.Value?.IncomingRelationships.Remove(existing.Key, AssociatedTopic, true); + existing.Value?.IncomingRelationships.Remove(existing.Key, AssociatedTopic); /*-------------------------------------------------------------------------------------------------------------------------- | Provide base logic diff --git a/OnTopic/Associations/TopicReferenceRecord.cs b/OnTopic/Associations/TopicReferenceRecord.cs index 37c41d50..db599f4b 100644 --- a/OnTopic/Associations/TopicReferenceRecord.cs +++ b/OnTopic/Associations/TopicReferenceRecord.cs @@ -17,9 +17,9 @@ namespace OnTopic.Associations; /// /// /// -/// Provides values and metadata specific to individual attribute values, such as state (e.g., the property signifies whether the attribute value has changed) and its date. +/// Provides values and metadata specific to individual attribute values, such as state (e.g., the property signifies whether the attribute value has changed) and its date. /// /// /// Typically, the will be exposed as part of a diff --git a/OnTopic/Associations/TopicRelationshipMultiMap.cs b/OnTopic/Associations/TopicRelationshipMultiMap.cs index 9d05c67b..dfcdafa1 100644 --- a/OnTopic/Associations/TopicRelationshipMultiMap.cs +++ b/OnTopic/Associations/TopicRelationshipMultiMap.cs @@ -40,11 +40,11 @@ public class TopicRelationshipMultiMap : ReadOnlyTopicMultiMap, ITrackDirtyKeys /// /// The constructor requires a reference to a instance, which the related topics are to be associated /// with. This will be used when setting incoming relationships. In addition, a - /// may be set as if it is specifically intended to track incoming relationships; if this is - /// not set, then it will not allow incoming relationships to be set via the internal overload. + /// may be set as if it is specifically intended to track incoming relationships; when set, + /// and won't set the reciprocal + /// relationship, since in this case represents that reciprocal. /// - public TopicRelationshipMultiMap(Topic parent, bool isIncoming = false): base(new()) { + internal TopicRelationshipMultiMap(Topic parent, bool isIncoming = false): base(new()) { _parent = parent; _isIncoming = isIncoming; _storage = base.Source; @@ -54,22 +54,48 @@ public TopicRelationshipMultiMap(Topic parent, bool isIncoming = false): base(ne | METHOD: CLEAR \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Removes all objects grouped by a specific . + /// Removes every object across all relationship keys. /// /// - /// If there are any objects in the specified , then the will be marked as . + /// Delegates to for each key, which handles both the isDirty as well as the removal of + /// reciprocal relationships in . + /// + internal void Clear() { + foreach (var key in Keys) { + Clear(key); + } + } + + /// + /// Removes all objects grouped by a specific , as well as any entries registered under that key. + /// + /// + /// If there are any objects or entries registered under the specified , then the will be marked as . Delegates to for each resolved entry so the + /// reciprocal relationship is also removed from each target's . Clearing the entries prevents a subsequent from resolving and + /// resurrecting relationships this call just removed. /// /// The key of the relationship to be cleared. public void Clear(string relationshipKey) { + Contract.Requires(!String.IsNullOrWhiteSpace(relationshipKey), nameof(relationshipKey)); - if (_storage.Contains(relationshipKey)) { - var relationship = _storage.GetValues(relationshipKey); - if (relationship.Count > 0) { - _dirtyKeys.MarkAs(relationshipKey, markDirty: !_parent.IsNew); - } - _storage.Clear(relationshipKey); + + var hadLoadedValues = _storage.GetValues(relationshipKey).Count > 0; + var hadDeferredEntries = Deferred.Remove(relationshipKey); + + foreach (var topic in _storage.GetValues(relationshipKey).ToArray()) { + Remove(relationshipKey, topic); + } + + // Remove() already marks the key dirty for each resident topic it removes; if only deferred entries existed, mark it here + // so the clear isn't silently lost + if (!hadLoadedValues && hadDeferredEntries) { + _dirtyKeys.MarkAs(relationshipKey, markDirty: !_parent.IsNew); } + } /// @@ -89,21 +115,7 @@ public void Clear(string relationshipKey) { /// Returns true if the is removed; returns false if either the specified or the cannot be found. /// - public bool Remove(string relationshipKey, Topic topic) => Remove(relationshipKey, topic, false); - - /// - /// Removes a specific object associated with a specific relationship key. - /// - /// The key of the relationship. - /// The topic to be removed. - /// - /// Notes that this is setting an internal relationship, and thus shouldn't set the reciprocal relationship. - /// - /// - /// Returns true if the is removed; returns false if either the relationship key or the - /// cannot be found. - /// - internal bool Remove(string relationshipKey, Topic topic, bool isIncoming) { + public bool Remove(string relationshipKey, Topic topic) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate contracts @@ -114,14 +126,8 @@ internal bool Remove(string relationshipKey, Topic topic, bool isIncoming) { /*-------------------------------------------------------------------------------------------------------------------------- | Remove reciprocal relationship, if appropriate \-------------------------------------------------------------------------------------------------------------------------*/ - if (!isIncoming) { - if (_isIncoming) { - throw new InvalidOperationException( - "You are attempting to remove an incoming relationship on a TopicRelationshipMultiMap that is not flagged as " + - nameof(isIncoming) - ); - } - topic.IncomingRelationships.Remove(relationshipKey, _parent, true); + if (!_isIncoming) { + topic.IncomingRelationships.Remove(relationshipKey, _parent); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -149,12 +155,6 @@ internal bool Remove(string relationshipKey, Topic topic, bool isIncoming) { [Obsolete($"The {nameof(RemoveTopic)} method has been renamed to {nameof(Remove)}.", true)] public bool RemoveTopic(string relationshipKey, Topic topic) => Remove(relationshipKey, topic); - /// - [ExcludeFromCodeCoverage] - [Obsolete($"The {nameof(RemoveTopic)} method has been renamed to {nameof(Remove)}.", true)] - public bool RemoveTopic(string relationshipKey, Topic topic, bool isIncoming) => - Remove(relationshipKey, topic, isIncoming); - /*============================================================================================================================ | METHOD: SET VALUE \---------------------------------------------------------------------------------------------------------------------------*/ @@ -170,25 +170,7 @@ public bool RemoveTopic(string relationshipKey, Topic topic, bool isIncoming) => /// /// Optionally forces the collection to an state, assuming the topic was set. /// - public void SetValue(string relationshipKey, Topic topic, bool? markDirty = null) - => SetValue(relationshipKey, topic, markDirty, false); - - /// - /// Ensures that an incoming is associated with the specified . - /// - /// - /// If a relationship by a given is not currently established, it will automatically be - /// created. - /// - /// The key of the relationship. - /// The topic to be added, if it doesn't already exist. - /// - /// Notes that this is setting an internal relationship, and thus shouldn't set the reciprocal relationship. - /// - /// - /// Optionally forces the collection to an state, assuming the topic was set. - /// - internal void SetValue(string relationshipKey, Topic topic, bool? markDirty, bool isIncoming) { + public void SetValue(string relationshipKey, Topic topic, bool? markDirty = null) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate contracts @@ -210,19 +192,16 @@ internal void SetValue(string relationshipKey, Topic topic, bool? markDirty, boo else { _dirtyKeys.MarkDirty(relationshipKey); } + + // Remove any pending deferred entry for this relationship/target pair + Deferred.Remove(relationshipKey, topic.Id); } /*-------------------------------------------------------------------------------------------------------------------------- | Create reciprocal relationship, if appropriate \-------------------------------------------------------------------------------------------------------------------------*/ - if (!isIncoming) { - if (_isIncoming) { - throw new InvalidOperationException( - "You are attempting to set an incoming relationship on a TopicRelationshipMultiMap that is not flagged as " + - nameof(isIncoming) - ); - } - topic.IncomingRelationships.SetValue(relationshipKey, _parent, markDirty, true); + if (!_isIncoming) { + topic.IncomingRelationships.SetValue(relationshipKey, _parent, markDirty); } } @@ -232,39 +211,46 @@ internal void SetValue(string relationshipKey, Topic topic, bool? markDirty, boo [Obsolete($"The {nameof(SetTopic)} method has been renamed to {nameof(SetValue)}.", true)] public void SetTopic(string relationshipKey, Topic topic, bool? isDirty = null) => SetValue(relationshipKey, topic, isDirty); - /// - [ExcludeFromCodeCoverage] - [Obsolete($"The {nameof(SetTopic)} method has been renamed to {nameof(SetValue)}.", true)] - public void SetTopic(string relationshipKey, Topic topic, bool? isDirty, bool isIncoming) => - SetValue(relationshipKey, topic, isDirty, isIncoming); + /*============================================================================================================================ + | PROPERTY: LOAD STATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Indicates whether the collection has been populated from the underlying , + /// allowing callers to distinguish data that is present and authoritative from data that must still be fetched. + /// + /// + /// Returns when contains values, meaning one or more relationships + /// aren't yet available and must be lazy loaded. Returns once is + /// empty, meaning all targets have been loaded. While , the + /// will not delete unmatched relationships on save, preventing unintended data loss. + /// + public LoadState LoadState => Deferred.Count > 0 ? LoadState.NotLoaded : LoadState.Loaded; /*============================================================================================================================ - | IS FULLY LOADED? + | PROPERTY: DEFERRED \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Determines whether or not the collection was fully loaded from the persistence store. + /// Collects relationship targets that were absent from the topic graph during an load, + /// pending resolution via lazy-loading. /// /// - /// - /// When loading an individual or branch from the persistence store, it is possible that the - /// relationships may not be fully available. In this scenario, updating relationships while e.g. deleting unmatched - /// relationships can result in unintended data loss. To account for this, the property - /// tracks whether a collection was fully loaded from the persistence store; if it wasn't, the should not deleted unmatched relationships. - /// - /// - /// The property defaults to true. It should be set to false during the method if any members of the collection cannot be mapped back to - /// a valid reference in memory. - /// + /// Written to by the when a relationship target cannot be found in the current . The resolves each entry + /// by calling the 's Load() method, assuming the topics haven't since been introduced + /// to the topic graph. /// - public bool IsFullyLoaded { get; set; } = true; + public DeferredAssociationCollection Deferred { get; } = new(); /*============================================================================================================================ | METHOD: IS DIRTY? \---------------------------------------------------------------------------------------------------------------------------*/ /// - public bool IsDirty() => _dirtyKeys.IsDirty(); + /// + /// Also accounts for any dirty entries as introduced by e.g., , so a relationship that hasn't yet been resolved to an in-memory still marks the collection as dirty. + /// + public bool IsDirty() => _dirtyKeys.IsDirty() || Deferred.Any(deferred => deferred.IsDirty); /// public bool IsDirty(string key) => _dirtyKeys.IsDirty(key); diff --git a/OnTopic/Attributes/AttributeCollection.cs b/OnTopic/Attributes/AttributeCollection.cs index 627a3f99..c3d0a9f2 100644 --- a/OnTopic/Attributes/AttributeCollection.cs +++ b/OnTopic/Attributes/AttributeCollection.cs @@ -3,7 +3,9 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ + using OnTopic.Collections.Specialized; +using OnTopic.Metadata; using OnTopic.Repositories; namespace OnTopic.Attributes; @@ -18,6 +20,14 @@ namespace OnTopic.Attributes; /// objects represent individual instances of attributes associated with particular topics. /// The class tracks these through its property, which is an instance of /// the class. +/// +/// When is , iterating the collection directly (e.g., via +/// foreach or LINQ operators) returns only the indexed attributes already present and does not fetch the deferred +/// extended attribute blob; only a keyed lookup or autoloads. Callers that +/// enumerate the collection directly and require a complete set of attributes must first await with . Otherwise, a decision that +/// depends on seeing every attribute may act on a partial view without any error being raised. +/// /// public class AttributeCollection : TrackedRecordCollection { @@ -36,8 +46,8 @@ public class AttributeCollection : TrackedRecordCollection class. /// /// - /// The is intended exclusively for providing access to attributes via the property. For this reason, the constructor is marked as internal. + /// The is intended exclusively for providing access to attributes via the property. For this reason, the constructor is marked as internal. /// /// A reference to the topic that the current attribute collection is bound to. internal AttributeCollection(Topic parentTopic) : base(parentTopic) { @@ -57,6 +67,22 @@ internal AttributeCollection(Topic parentTopic) : base(parentTopic) { protected override TrackedRecordCollection? BaseCollection => AssociatedTopic.BaseTopic?.Attributes; + /*============================================================================================================================ + | PROPERTY: LOAD STATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Indicates whether the collection has been populated from the underlying , + /// allowing callers to distinguish data that is present and authoritative from data that must still be fetched. + /// + /// + /// Defaults to . When a topic is loaded without extended attributes (e.g., on a shallow + /// load), the repository conditionally sets this to to indicate that the extended + /// attribute blob has not yet been retrieved. The persistence store may optionally provide an indicator of the count + /// without returning the full data, thus allowing this to be set to if, in fact, there are + /// no extended attributes. Indexed attributes are never deferred regardless of this state. + /// + public LoadState LoadState { get; set; } = LoadState.Loaded; + /*============================================================================================================================ | METHOD: IS DIRTY \---------------------------------------------------------------------------------------------------------------------------*/ @@ -81,6 +107,46 @@ public bool IsDirty(bool excludeLastModified) (!excludeLastModified || !a.Key.StartsWith("LastModified", StringComparison.OrdinalIgnoreCase)) ); + /*============================================================================================================================ + | METHOD: GET VALUE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Retrieves the value associated with the specified , autoloading the extended attribute blob if the + /// key is not yet loaded and the extended attribute property is set to . + /// + /// + /// Indexed attributes are always loaded in the local collection; the autoload is skipped for them. A deferred key that has + /// never been fetched triggers a single synchronous blob fill through the stamped resolver; all subsequent reads find the + /// property and return immediately without an additional round-trip. Callers that know a key + /// is always indexed and, thus, never resides in the extended attribute blob, may set to + /// false to suppress this behavior. This is a correctness trade-off: An can force + /// any attribute to be treated as extended, in which case a value stored only in an unloaded blob will be missed and the + /// returned instead. It should therefore only be used for attributes known to be indexed. + /// + /// The string identifier for the . + /// A string value to which to fall back in the case the value is not found. + /// + /// Determines if the value should be inherited from the parent topic when not found locally. + /// + /// The maximum number of ancestor hops when inheriting from parent topics. + /// + /// Determines whether a extended attribute property may trigger a synchronous load when + /// is absent locally. Defaults to true. + /// + [return: NotNullIfNotNull(nameof(defaultValue))] + internal override string? GetValue( + string key, + string? defaultValue, + bool inheritFromParent, + int maxHops, + bool autoLoad = true + ) { + if (autoLoad && LoadState is LoadState.NotLoaded && !Contains(key)) { + ((ITopicLazyLoadable)AssociatedTopic).EnsureLoaded(TopicPayload.ExtendedAttributes).GetAwaiter().GetResult(); + } + return base.GetValue(key, defaultValue, inheritFromParent, maxHops, autoLoad); + } + /*============================================================================================================================ | METHOD: SET VALUE \---------------------------------------------------------------------------------------------------------------------------*/ @@ -123,9 +189,9 @@ public bool IsDirty(bool excludeLastModified) public void SetValue( string key, string? value, - bool? markDirty = null, - DateTime? version = null, - bool? isExtendedAttribute = null + bool? markDirty = null, + DateTime? version = null, + bool? isExtendedAttribute = null ) { base.SetValue(key, value, markDirty, version); if (Contains(key)) { @@ -133,7 +199,7 @@ public void SetValue( var attributeIndex = IndexOf(attributeValue); if (isExtendedAttribute is not null && isExtendedAttribute != attributeValue.IsExtendedAttribute) { attributeValue = attributeValue with { - IsExtendedAttribute = isExtendedAttribute + IsExtendedAttribute = isExtendedAttribute }; base[attributeIndex] = attributeValue; } @@ -144,14 +210,16 @@ public void SetValue( | METHOD: AS ATTRIBUTE DICTIONARY \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Gets an based on the of the current . Optionall includes attributes from any s that the derives from. + /// Gets an based on the of the current . Optionall includes attributes from any s that the derives from. /// /// /// The method will exclude attributes which correspond to properties on - /// which contain specialized getter logic, such as and . + /// which contain specialized getter logic, such as and . Unlike a direct enumeration of the collection, this autoloads the extended attribute blob for + /// each source (the current collection, and, if is true, each in the chain) that is , so the result is always complete. /// /// /// Determines if attributes from the should be included. Defaults to false. @@ -162,12 +230,16 @@ public AttributeDictionary AsAttributeDictionary(bool inheritFromBase = false) { var attributes = new AttributeDictionary(); var count = 0; while (sourceAttributes is not null && ++count < 5) { + if (sourceAttributes.LoadState is LoadState.NotLoaded) { + var associatedTopic = (ITopicLazyLoadable)sourceAttributes.AssociatedTopic; + associatedTopic.EnsureLoaded(TopicPayload.ExtendedAttributes).GetAwaiter().GetResult(); + } foreach (var attribute in sourceAttributes) { if (count is 1 || !attributes.ContainsKey(attribute.Key)) { attributes.TryAdd(attribute.Key, attribute.Value); } } - sourceAttributes = inheritFromBase? sourceAttributes.AssociatedTopic.BaseTopic?.Attributes : null; + sourceAttributes = inheritFromBase? sourceAttributes.AssociatedTopic.BaseTopic?.Attributes : null; } foreach (var attribute in _excludedAttributes) { attributes.Remove(attribute); diff --git a/OnTopic/Attributes/AttributeCollectionExtensions.cs b/OnTopic/Attributes/AttributeCollectionExtensions.cs index b0343358..02160055 100644 --- a/OnTopic/Attributes/AttributeCollectionExtensions.cs +++ b/OnTopic/Attributes/AttributeCollectionExtensions.cs @@ -48,9 +48,10 @@ public static bool GetBoolean( return AttributeValueConverter.Convert( attributes.GetValue( name, - defaultValue ? "1" : "0", + defaultValue ? "1" : "0", inheritFromParent, - inheritFromBase ? 5 : 0 + inheritFromBase ? 5 : 0, + autoLoad : false ) )?? defaultValue; } @@ -87,7 +88,8 @@ public static int GetInteger( name, defaultValue.ToString(CultureInfo.InvariantCulture), inheritFromParent, - inheritFromBase? 5 : 0 + inheritFromBase ? 5 : 0, + autoLoad : false ) )?? defaultValue; } @@ -124,7 +126,8 @@ public static double GetDouble( name, defaultValue.ToString(CultureInfo.InvariantCulture), inheritFromParent, - inheritFromBase? 5 : 0 + inheritFromBase ? 5 : 0, + autoLoad : false ) )?? defaultValue; } @@ -161,7 +164,8 @@ public static DateTime GetDateTime( name, defaultValue.ToString(CultureInfo.InvariantCulture), inheritFromParent, - inheritFromBase ? 5 : 0 + inheritFromBase ? 5 : 0, + autoLoad : false ) )?? defaultValue; } @@ -198,7 +202,8 @@ public static DateTime GetDateTime( name, null, inheritFromParent, - inheritFromBase ? 5 : 0 + inheritFromBase ? 5 : 0, + autoLoad : false ) )?? defaultValue; } diff --git a/OnTopic/Attributes/AttributeRecord.cs b/OnTopic/Attributes/AttributeRecord.cs index d0a26151..aadc3bb1 100644 --- a/OnTopic/Attributes/AttributeRecord.cs +++ b/OnTopic/Attributes/AttributeRecord.cs @@ -17,9 +17,9 @@ namespace OnTopic.Attributes; /// /// /// -/// Provides values and metadata specific to individual attribute values, such as state (e.g., the property signifies whether the attribute value has changed) and its date. +/// Provides values and metadata specific to individual attribute values, such as state (e.g., the property signifies whether the attribute value has changed) and its date. /// /// /// Typically, the will be exposed as part of a via the @@ -27,8 +27,8 @@ namespace OnTopic.Attributes; /// /// /// Be aware that while represents the value of a specific attribute, the metadata for -/// describing the purpose, constraints, and usage of that particular attribute is described by the class. +/// describing the purpose, constraints, and usage of that particular attribute is described by the class. /// /// /// This class is immutable: once it is constructed, the values cannot be changed. To change a value, callers must either @@ -72,7 +72,7 @@ public AttributeRecord( string? value, bool isDirty = true, DateTime? lastModified = null, - bool? isExtendedAttribute = null + bool? isExtendedAttribute = null ): base(key, value, isDirty, lastModified) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -101,8 +101,8 @@ public AttributeRecord( /// cref="TrackedRecord{T}.IsDirty"/> to determine if a value should be saved. If an attribute's value hasn't changed, /// but the location it should be stored has, that could potentially result in the attribute being deleted, as the /// attribute won't show up for when is called with isDirty set to - /// true and isExtendedAttribute is set to either true or false. By introducing , the is able to detect conflicts between the configuration and + /// true and isExtendedAttribute is set to either true or false. By introducing , the is able to detect conflicts between the configuration and /// the underlying data store, and ensure data is stored appropriately. /// /// diff --git a/OnTopic/Attributes/AttributeValueConverter.cs b/OnTopic/Attributes/AttributeValueConverter.cs index 934ba78e..2b5c8935 100644 --- a/OnTopic/Attributes/AttributeValueConverter.cs +++ b/OnTopic/Attributes/AttributeValueConverter.cs @@ -13,8 +13,8 @@ namespace OnTopic.Attributes; | CLASS: ATTRIBUTE VALUE CONVERTER \-----------------------------------------------------------------------------------------------------------------------------*/ /// -/// Attribute values are stored as strings, but may be deserialized to other value types using e.g. the or the . This class provides basic methods for +/// Attribute values are stored as strings, but may be deserialized to other value types using e.g. the or the . This class provides basic methods for /// converting from the string representation to supported value types. /// internal static class AttributeValueConverter { @@ -62,7 +62,7 @@ internal static class AttributeValueConverter { /// An instance of the as a . internal static object? Convert(string? value, Type type) { - var valueObject = (object?)null; + var valueObject = (object?)null; //Treat empty as null for non-strings, regardless of whether they’re nullable if (!type.Equals(typeof(string)) && String.IsNullOrWhiteSpace(value)) { @@ -72,34 +72,34 @@ internal static class AttributeValueConverter { if (value is null) return null; if (type.Equals(typeof(string))) { - valueObject = value; + valueObject = value; } else if (type.Equals(typeof(bool)) || type.Equals(typeof(bool?))) { if (value is "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase)) { - valueObject = true; + valueObject = true; } else if (value is "0" || value.Equals("false", StringComparison.OrdinalIgnoreCase)) { - valueObject = false; + valueObject = false; } } else if (type.Equals(typeof(int)) || type.Equals(typeof(int?))) { if (Int32.TryParse(value, out var intValue)) { - valueObject = intValue; + valueObject = intValue; } } else if (type.Equals(typeof(double)) || type.Equals(typeof(double?))) { if (Double.TryParse(value, out var doubleValue)) { - valueObject = doubleValue; + valueObject = doubleValue; } } else if (type.Equals(typeof(DateTime)) || type.Equals(typeof(DateTime?))) { if (DateTime.TryParse(value, out var date)) { - valueObject = date; + valueObject = date; } } else if (type.Equals(typeof(Uri))) { if (Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out var uri)) { - valueObject = uri; + valueObject = uri; } } diff --git a/OnTopic/Collections/ChildTopicCollection.cs b/OnTopic/Collections/ChildTopicCollection.cs new file mode 100644 index 00000000..10d6cba5 --- /dev/null +++ b/OnTopic/Collections/ChildTopicCollection.cs @@ -0,0 +1,109 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Collections.Specialized; +using OnTopic.Querying; +using OnTopic.Repositories; + +namespace OnTopic.Collections; + +/*============================================================================================================================== +| CLASS: CHILD TOPIC COLLECTION +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a collection of objects representing the immediate children of a . +/// +/// +/// The is intended exclusively for providing access to children via the property. For this reason, the constructor is marked as internal. +/// +public class ChildTopicCollection : KeyedTopicCollection { + + /*============================================================================================================================ + | PRIVATE VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly Topic _parent; + + /*============================================================================================================================ + | CONSTRUCTOR + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Initializes a new instance of the class. + /// + /// A reference to the topic that the current child collection is bound to. + internal ChildTopicCollection(Topic parent) { + _parent = parent; + } + + /*============================================================================================================================ + | PROPERTY: LOAD STATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Indicates whether the collection has been populated from the underlying , allowing + /// callers to distinguish data that is present and authoritative from data that must still be fetched. + /// + /// + /// + /// Defaults to , reflecting that a newly constructed, in-memory collection has nothing + /// deferred. When a topic is loaded shallowly from the persistence store, the repository conditionally sets this to to indicate that the immediate children have not yet been fetched. The persistence store + /// may optionally provide an indicator of the count without returning the full data, thus allowing this to be set to if, in fact, there are no relevant topics. + /// + /// + /// This setter exists for implementations populating or converging load state during or . + /// Setting while children remain unfetched masks the deferral from subsequent readers; + /// setting on already-resident children induces a spurious synchronous load on + /// next access. + /// + /// + public LoadState LoadState { get; set; } = LoadState.Loaded; + + /*============================================================================================================================ + | OVERRIDE: INSERT ITEM + \---------------------------------------------------------------------------------------------------------------------------*/ + /// Fires any time a is added to the collection. + /// + /// Extends the base insertion with , so that the newly attached and its subtree are reflected in the live of the topic's root, assuming that index has + /// been materialized. + /// + /// The zero-based index at which should be inserted. + /// The instance to insert. + protected sealed override void InsertItem(int index, Topic item) { + base.InsertItem(index, item); + TopicIndexRegistry.OnAttached(_parent, item); + } + + /*============================================================================================================================ + | OVERRIDE: REMOVE ITEM + \---------------------------------------------------------------------------------------------------------------------------*/ + /// Fires any time a is removed from the collection. + /// + /// Extends the base removal with , computed from the item + /// before the base removal executes, since the detach bookkeeping needs the subtree while it's still reachable. + /// + /// The zero-based index of the to remove. + protected sealed override void RemoveItem(int index) { + var item = this[index]; + TopicIndexRegistry.OnDetached(_parent, item); + base.RemoveItem(index); + } + + /*============================================================================================================================ + | OVERRIDE: CLEAR ITEMS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// Fires when the collection is cleared. + /// + /// Extends the base clear with : a bulk detach, where per-item + /// bookkeeping via isn't worth it. + /// + protected sealed override void ClearItems() { + TopicIndexRegistry.Invalidate(_parent.GetRootTopic()); + base.ClearItems(); + } + +} //Class \ No newline at end of file diff --git a/OnTopic/Collections/KeyedTopicCollection{T}.cs b/OnTopic/Collections/KeyedTopicCollection{T}.cs index 5dae6309..d62140bf 100644 --- a/OnTopic/Collections/KeyedTopicCollection{T}.cs +++ b/OnTopic/Collections/KeyedTopicCollection{T}.cs @@ -72,7 +72,7 @@ public KeyedTopicCollection(IEnumerable? topics = null) : base(StringComparer /// A {typeof(T).Name} with the Key '{item.Key}' already exists. The UniqueKey of the existing {typeof(T).Name} is /// '{GetUniqueKey()}'; the new item's is '{item.GetUniqueKey()}'. /// - protected override sealed void InsertItem(int index, T item) { + protected override void InsertItem(int index, T item) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters diff --git a/OnTopic/Collections/ReadOnlyKeyedTopicCollection{T}.cs b/OnTopic/Collections/ReadOnlyKeyedTopicCollection{T}.cs index 30d45eff..f0eb8f9d 100644 --- a/OnTopic/Collections/ReadOnlyKeyedTopicCollection{T}.cs +++ b/OnTopic/Collections/ReadOnlyKeyedTopicCollection{T}.cs @@ -27,8 +27,8 @@ public class ReadOnlyKeyedTopicCollection : ReadOnlyCollection where T : T /// Establishes a new based on an existing . /// /// The underlying . - public ReadOnlyKeyedTopicCollection(IList? innerCollection = null) : base(innerCollection?? new List()) { - _innerCollection = innerCollection as KeyedTopicCollection?? new(innerCollection); + public ReadOnlyKeyedTopicCollection(IList? innerCollection = null) : base(innerCollection ?? []) { + _innerCollection = innerCollection as KeyedTopicCollection?? new(innerCollection); } /*============================================================================================================================ diff --git a/OnTopic/Collections/ReadOnlyTopicCollection.cs b/OnTopic/Collections/ReadOnlyTopicCollection.cs index 2de234fe..86eec86d 100644 --- a/OnTopic/Collections/ReadOnlyTopicCollection.cs +++ b/OnTopic/Collections/ReadOnlyTopicCollection.cs @@ -23,7 +23,7 @@ public class ReadOnlyTopicCollection : ReadOnlyCollection { /// /// The underlying . [ExcludeFromCodeCoverage] - public ReadOnlyTopicCollection(IList? innerCollection = null) : base(innerCollection?? new List()) { + public ReadOnlyTopicCollection(IList? innerCollection = null) : base(innerCollection ?? []) { } /*============================================================================================================================ diff --git a/OnTopic/Collections/Specialized/DirtyKeyCollection.cs b/OnTopic/Collections/Specialized/DirtyKeyCollection.cs index 54cb27db..ac85fe58 100644 --- a/OnTopic/Collections/Specialized/DirtyKeyCollection.cs +++ b/OnTopic/Collections/Specialized/DirtyKeyCollection.cs @@ -25,7 +25,7 @@ internal sealed class DirtyKeyCollection : Collection, ITrackDirtyKeys { /// /// Initializes a new instance of the . /// - public DirtyKeyCollection() : base() {} + public DirtyKeyCollection() {} /*============================================================================================================================ | METHOD: IS DIRTY? diff --git a/OnTopic/Collections/Specialized/ReadOnlyTopicMultiMap.cs b/OnTopic/Collections/Specialized/ReadOnlyTopicMultiMap.cs index b040cf9b..7cc6de19 100644 --- a/OnTopic/Collections/Specialized/ReadOnlyTopicMultiMap.cs +++ b/OnTopic/Collections/Specialized/ReadOnlyTopicMultiMap.cs @@ -25,7 +25,7 @@ public class ReadOnlyTopicMultiMap: IEnumerable public ReadOnlyTopicMultiMap(TopicMultiMap source) { Contract.Requires(source, nameof(source)); - Source = source; + Source = source; } /*============================================================================================================================ @@ -37,11 +37,11 @@ public ReadOnlyTopicMultiMap(TopicMultiMap source) { /// /// /// The must be passed in via either the public - /// constructor, or must be set manually from the constructor of a derived class when using the protected constructor. + /// constructor, or must be set manually from the constructor of a derived class when using the protected constructor. /// [NotNull, DisallowNull] - protected TopicMultiMap? Source { get; init; } + private protected TopicMultiMap? Source { get; init; } /*============================================================================================================================ | PROPERTY: KEYS @@ -101,7 +101,7 @@ public ReadOnlyTopicCollection GetValues(string key) { if (Contains(key)) { return new(Source[key].Values); } - return new(new List()); + return new([]); } /// diff --git a/OnTopic/Collections/Specialized/TopicIndex.cs b/OnTopic/Collections/Specialized/TopicIndex.cs index 85cb6ee7..e7785872 100644 --- a/OnTopic/Collections/Specialized/TopicIndex.cs +++ b/OnTopic/Collections/Specialized/TopicIndex.cs @@ -3,6 +3,8 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ +using System.Collections.Concurrent; +using OnTopic.Repositories; namespace OnTopic.Collections.Specialized; @@ -12,7 +14,13 @@ namespace OnTopic.Collections.Specialized; /// /// Represents a collection of objects indexed by . /// -public class TopicIndex : Dictionary { +/// +/// Backed by , allowing an implementation to +/// share a single topic graph across concurrent callers, and most notably when caching is involved, since lazy loading +/// mutates the graph as callers read properties that aren't yet loaded. The index must tolerate concurrent reads and writes +/// regardless of which implementation is being used. +/// +public class TopicIndex : ConcurrentDictionary { /*============================================================================================================================ | CONSTRUCTOR @@ -21,10 +29,22 @@ public class TopicIndex : Dictionary { /// Initializes a new instance of the . /// /// Seeds the collection with an optional list of topic references. - public TopicIndex(IEnumerable? topics = null) : base() { + /// + /// Unsaved instances () are skipped, since their is a + /// placeholder shared by every other unsaved topic, not a real identity, and so isn't a genuine collision. Any other + /// colliding reflects corrupt data and continues to throw; e.g., a bulk seed of corrupt data should + /// fail clearly. This is deliberately stricter than the tolerate semantics of the live index's attach and detach methods, + /// which must not throw in the middle of an operation. + /// + public TopicIndex(IEnumerable? topics = null) { if (topics is not null) { - foreach(var topic in topics) { - Add(topic.Id, topic); + foreach (var topic in topics) { + if (topic.IsNew) { + continue; + } + if (!TryAdd(topic.Id, topic)) { + throw new ArgumentException($"An item with the same key has already been added. Key: {topic.Id}", nameof(topics)); + } } } } diff --git a/OnTopic/Collections/Specialized/TopicIndexRegistry.cs b/OnTopic/Collections/Specialized/TopicIndexRegistry.cs new file mode 100644 index 00000000..c8f44560 --- /dev/null +++ b/OnTopic/Collections/Specialized/TopicIndexRegistry.cs @@ -0,0 +1,162 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using System.Runtime.CompilerServices; +using OnTopic.Querying; +using OnTopic.Repositories; + +namespace OnTopic.Collections.Specialized; + +/*============================================================================================================================== +| CLASS: TOPIC INDEX REGISTRY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Maintains a single, live, incrementally maintained per topic graph, keyed by root . +/// +/// +/// +/// The graph itself remains the source of truth; an entry is a disposable derivation of it, kept in sync incrementally as +/// topics are attached, detached, or assigned IDs, but recoverable at any time by discarding it and walking the graph again +/// via . Keying by root instance also provides isolation +/// for tests, as distinct graphs produce disjoint entries, with nothing to reset, and weak keys mean a released graph still +/// releases its index without any explicit lifetime management. +/// +/// +/// Callers never write to this registry directly; the public surface is the read-only accessor. The registry itself is maintained exclusively by 's attach and detach hooks and the 's setter, which call the internal +/// members below as topics are attached, detached, or assigned a persisted identifier. +/// +/// +internal static class TopicIndexRegistry { + + /*============================================================================================================================ + | PRIVATE FIELDS + \---------------------------------------------------------------------------------------------------------------------------*/ + private static readonly ConditionalWeakTable _indexes = new(); + + /*============================================================================================================================ + | METHOD: GET OR BUILD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the live for the graph rooted at , building and storing + /// it on first access. + /// + /// + /// Seeded by walking the 's full tree via , which + /// never triggers a lazy load and includes topics under NotLoaded . + /// must genuinely be a root (i.e., Parent is null); callers reach this exclusively via + /// , which derives it from any node. + /// + /// The root of the graph whose live index should be returned. + internal static TopicIndex GetOrBuild(Topic rootTopic) => _indexes.GetValue(rootTopic, root => new(root.FindAll())); + + /*============================================================================================================================ + | METHOD: ON ATTACHED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Indexes and its physical subtree into the live index of 's root, + /// assuming that index has been materialized. + /// + /// + /// Called from , after the base insertion, with being the collection's owner rather than .Parent, as the latter is not yet set + /// when the insertion fires. 's own registry entry is removed first, in case it was itself a root + /// with a materialized index before this attach (e.g., loaded standalone, then merged under 's + /// graph): Attaching it here means it's no longer a root, so that entry is now stale, and + /// would otherwise hand it back unchanged, rather than rebuilding, if were later detached and + /// became a root again. topics are skipped, since their placeholder is not + /// a genuine identity. + /// + /// The that owns the collection was inserted into. + /// The that was attached. + internal static void OnAttached(Topic parent, Topic child) { + + // The child may itself have been a root with a stale materialized index; always drop that entry + _indexes.Remove(child); + + // Skip unless the parent's root already has a materialized index to maintain + if (!_indexes.TryGetValue(parent.GetRootTopic(), out var index)) { + return; + } + + // Index the child and its physical subtree; tolerate-resident, since a duplicate id here reflects a benign re-attach + foreach (var topic in child.FindAll()) { + if (topic.IsNew) { + continue; + } + index.TryAdd(topic.Id, topic); + } + + } + + /*============================================================================================================================ + | METHOD: ON DETACHED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Removes and its physical subtree from the live index of 's root, if + /// that index has been materialized. + /// + /// + /// Called from , before the base removal, with being the collection's owner. 's subtree must be computed while it is still reachable, hence + /// being place before the base is removed. topics are skipped, since they'll never had been + /// indexed originally, matching . + /// + /// + /// The that owns the collection is being removed from. + /// + /// The being detached. + internal static void OnDetached(Topic parent, Topic child) { + + // Skip unless the parent's root already has a materialized index to maintain + if (!_indexes.TryGetValue(parent.GetRootTopic(), out var index)) { + return; + } + + // Remove the child and its physical subtree + foreach (var topic in child.FindAll()) { + if (topic.IsNew) { + continue; + } + index.TryRemove(topic.Id, out _); + } + + } + + /*============================================================================================================================ + | METHOD: ON ID ASSIGNED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Indexes into the live index of its own root, if that index has been materialized. + /// + /// + /// Called from 's setter after a persisted identifier is assigned (i.e., following ). The setter's guard permits re-setting the same value, so this may fire + /// repeatedly for a given topic; TryAdd covers that without an assumption that it fires once. + /// + /// The that was just assigned a persisted . + internal static void OnIdAssigned(Topic topic) { + if (_indexes.TryGetValue(topic.GetRootTopic(), out var index)) { + index.TryAdd(topic.Id, topic); + } + } + + /*============================================================================================================================ + | METHOD: INVALIDATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Discards the materialized live index for the graph rooted at , if any; the next rebuilds it from scratch. + /// + /// + /// Called from : A bulk detach, where per-item bookkeeping via isn't worth the cost, relative to just rebuilding the index in this rare scenario. + /// + /// The root of the graph whose live index should be discarded. + internal static void Invalidate(Topic rootTopic) => _indexes.Remove(rootTopic); + +} //Class \ No newline at end of file diff --git a/OnTopic/Collections/Specialized/TrackedRecordCollection{TItem,TValue,TAttribute}.cs b/OnTopic/Collections/Specialized/TrackedRecordCollection{TItem,TValue,TAttribute}.cs index 0ab54374..d37dd006 100644 --- a/OnTopic/Collections/Specialized/TrackedRecordCollection{TItem,TValue,TAttribute}.cs +++ b/OnTopic/Collections/Specialized/TrackedRecordCollection{TItem,TValue,TAttribute}.cs @@ -17,8 +17,8 @@ namespace OnTopic.Collections.Specialized; /// working with their state. /// /// -/// records represent individual instances of values associated with a particular . The class tracks these through e.g. its property. The records represent individual instances of values associated with a particular . The class tracks these through e.g. its property. The class provides a base class with methods for working with /// these records, such as , for determining if a given record has been modified, or for creating or "updating" a record. (Records are @@ -74,12 +74,12 @@ internal TrackedRecordCollection(Topic parentTopic) : base(StringComparer.Ordina /// /// /// As a performance enhancement, implementations will only save topics that are marked as - /// . If a is deleted, then it won't be marked as . If no other instances were modified, then the won't get saved, and that won't be deleted. Further more, methods like + /// . If a is deleted, then it won't be marked as . If no other instances were modified, then the won't get saved, and that won't be deleted. Further more, methods like /// the method have no way of detecting the deletion of - /// arbitrary values�i.e., attributes that were deleted which don't correspond to attributes configured on the . By tracking any deleted instances, we ensure both + /// arbitrary values�i.e., attributes that were deleted which don't correspond to attributes configured on the . By tracking any deleted instances, we ensure both /// scenarios can be accounted for. /// internal List DeletedItems { get; } = new(); @@ -98,8 +98,8 @@ internal TrackedRecordCollection(Topic parentTopic) : base(StringComparer.Ordina /// /// This method is intended primarily for data storage providers, such as , which may need /// to determine the state of a prior to saving it - /// to the data storage medium. Because is a state of the current , it does not support inheritFromParent or inheritFromBase (which otherwise default + /// to the data storage medium. Because is a state of the current , it does not support inheritFromParent or inheritFromBase (which otherwise default /// to true). /// /// The string identifier for the . @@ -127,8 +127,8 @@ public bool IsDirty(string key) { /// /// /// This method is intended primarily for data storage providers, such as , so that they can - /// mark the collection, and all instances it contains, as clean. After this, method will return false until any instances are added, modified, + /// mark the collection, and all instances it contains, as clean. After this, method will return false until any instances are added, modified, /// or removed. /// /// @@ -223,6 +223,10 @@ public void MarkClean(string key, DateTime? version) { /// Boolean indicator nothing whether to recusrively search through s in order to get the value. /// /// The number of recursions to perform when attempting to get the value. + /// + /// Indicates whether a deferred-loading subclass may trigger a load when the key is absent locally. Defaults to + /// true. + /// /// The value for the . /// /// !String.IsNullOrWhiteSpace(key) @@ -241,7 +245,13 @@ public void MarkClean(string key, DateTime? version) { /// maxHops <= 100 /// [return: NotNullIfNotNull(nameof(defaultValue))] - internal virtual TValue? GetValue(string key, TValue? defaultValue, bool inheritFromParent, int maxHops) { + internal virtual TValue? GetValue( + string key, + TValue? defaultValue, + bool inheritFromParent, + int maxHops, + bool autoLoad = true + ) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate contracts @@ -250,17 +260,17 @@ public void MarkClean(string key, DateTime? version) { Contract.Requires(maxHops >= 0, "The maximum number of hops should be a positive number."); Contract.Requires(maxHops <= 100, "The maximum number of hops should not exceed 100."); - TValue? value = null; + TValue? value = null; /*-------------------------------------------------------------------------------------------------------------------------- | Look up value from collection \-------------------------------------------------------------------------------------------------------------------------*/ if (Contains(key)) { - value = this[key].Value; + value = this[key].Value; } if (value is "") { - value = null; + value = null; } /*-------------------------------------------------------------------------------------------------------------------------- @@ -271,7 +281,7 @@ value is null && maxHops > 0 && BaseCollection is not null ) { - value = BaseCollection.GetValue(key, null, false, maxHops - 1); + value = BaseCollection.GetValue(key, null, false, maxHops - 1, autoLoad); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -282,7 +292,8 @@ value is null && inheritFromParent && ParentCollection is not null ) { - value = ParentCollection.GetValue(key, defaultValue, inheritFromParent); + // Literal 5 preserves the base-inheritance restart applied by the public overload + value = ParentCollection.GetValue(key, defaultValue, inheritFromParent, 5, autoLoad); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -325,8 +336,8 @@ ParentCollection is not null /// depending on whether that value already exists. /// /// - /// Working with records can be a bit cumbersome, and especially in determining if a value should be marked as , since that's based on a comparison with the previous value. The , since that's based on a comparison with the previous value. The method handles this logic for implementers, while simultaneously allowing /// callers to explicitly set whether the instances should be marked as dirty�via the /// parameter�and, optionally, what the should be. @@ -358,8 +369,8 @@ ParentCollection is not null public virtual void SetValue( string key, TValue? value, - bool? markDirty = null, - DateTime? version = null + bool? markDirty = null, + DateTime? version = null ) => SetValue(key, value, markDirty, true, version); @@ -404,7 +415,7 @@ internal void SetValue( TValue? value, bool? markDirty, bool enforceBusinessLogic, - DateTime? version = null + DateTime? version = null ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -415,10 +426,10 @@ internal void SetValue( /*-------------------------------------------------------------------------------------------------------------------------- | Retrieve original item \-------------------------------------------------------------------------------------------------------------------------*/ - TItem? originalItem = null; + TItem? originalItem = null; if (Contains(key)) { - originalItem = this[key]; + originalItem = this[key]; } /*-------------------------------------------------------------------------------------------------------------------------- @@ -429,7 +440,7 @@ internal void SetValue( \-------------------------------------------------------------------------------------------------------------------------*/ if (_topicPropertyDispatcher.IsRegistered(key, out var updatedItem)) { if (updatedItem.Value != value) { - updatedItem = updatedItem with { + updatedItem = updatedItem with { Value = value }; } @@ -441,15 +452,15 @@ internal void SetValue( | Because TrackedRecord is immutable, a new instance must be constructed to replace the previous version. \-------------------------------------------------------------------------------------------------------------------------*/ else if (originalItem is not null) { - var markAsDirty = originalItem.IsDirty; + var markAsDirty = originalItem.IsDirty; if (AssociatedTopic.IsNew) { - markAsDirty = true; + markAsDirty = true; } else if (markDirty.HasValue) { - markAsDirty = markDirty.Value; + markAsDirty = markDirty.Value; } else if (!originalItem.Value?.Equals(value)?? false) { - markAsDirty = true; + markAsDirty = true; } else if (!version.HasValue) { return; @@ -476,7 +487,7 @@ internal void SetValue( | Create new item \-------------------------------------------------------------------------------------------------------------------------*/ else { - updatedItem = new TItem() { + updatedItem = new() { Key = key, Value = value, IsDirty = AssociatedTopic.IsNew || (markDirty ?? true), @@ -534,8 +545,8 @@ internal void SetValue( /// The location that the should be set. /// The object which is being inserted. /// - /// An is thrown if an with the same as the already exists. + /// An is thrown if an with the same as the already exists. /// protected override void InsertItem(int index, TItem item) { Contract.Requires(item, nameof(item)); @@ -599,8 +610,8 @@ protected override void SetItem(int index, TItem item) { /// cref="TrackedRecord{T}"/>s are marked as . /// protected override void RemoveItem(int index) { - var trackedRecord = this[index] with { - Value = null + var trackedRecord = this[index] with { + Value = null }; if (_topicPropertyDispatcher.Enforce(trackedRecord.Key, trackedRecord)) { if (!AssociatedTopic.IsNew) { @@ -618,8 +629,8 @@ protected override void RemoveItem(int index) { /// it is appropriately marked as . /// /// - /// In order to ensure any business logic is enforced, loops through every in the and explicitly calls loops through every in the and explicitly calls . This is slower, but ensures that any state tracking and null /// validation that occurs in the properties is maintained. Fortunately, this is a rare use case; we typically expect /// attributes to be handled individually. @@ -638,14 +649,14 @@ protected override void ClearItems() { /// Determines if a is permitted to be marked as not . /// /// - /// If the is or the is and the is , then + /// If the is or the is and the is , then /// should never be set to false. /// /// The object which is being inserted. protected bool AllowClean(TItem item) { Contract.Requires(item, nameof(item)); - var topic = item.Value as Topic; + var topic = item.Value as Topic; if (topic is not null && topic.IsNew) { return false; } diff --git a/OnTopic/Collections/Specialized/TrackedRecord{T}.cs b/OnTopic/Collections/Specialized/TrackedRecord{T}.cs index 3547dff3..d896fd78 100644 --- a/OnTopic/Collections/Specialized/TrackedRecord{T}.cs +++ b/OnTopic/Collections/Specialized/TrackedRecord{T}.cs @@ -16,8 +16,8 @@ namespace OnTopic.Collections.Specialized; /// /// The class is comparable to the , in that it tracks the and for an item, but it additionally provides metadata related to the record, including -/// the and whether or not it . This makes it easier for e.g. implementations to make more informed decisions about whether a record needs to be saved or +/// the and whether or not it . This makes it easier for e.g. implementations to make more informed decisions about whether a record needs to be saved or /// overwritten during a or . /// public abstract record TrackedRecord { @@ -102,10 +102,11 @@ protected TrackedRecord(string key, T? value, bool isDirty = true, DateTime? las /// Gets the for the given item. /// /// - /// If loaded from a data store from e.g. , the should be set to the Version. If the is novel, however, then it should be - /// set to the current date. That won't be the same date established by for the Version, however, which is why this property is labeled . + /// If loaded from a data store from e.g. , the + /// should be set to the Version. If the is novel, however, then it + /// should be set to the current date. That won't be the same date established by for the Version, however, which is why this property is labeled . /// public DateTime LastModified { get; init; } diff --git a/OnTopic/Collections/VersionHistoryCollection.cs b/OnTopic/Collections/VersionHistoryCollection.cs new file mode 100644 index 00000000..e9c10958 --- /dev/null +++ b/OnTopic/Collections/VersionHistoryCollection.cs @@ -0,0 +1,32 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using System.Collections.ObjectModel; + +namespace OnTopic.Collections; + +/*============================================================================================================================== +| CLASS: VERSION HISTORY COLLECTION +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a collection of values representing past versions of a . +/// +public class VersionHistoryCollection: Collection { + + /*============================================================================================================================ + | PROPERTY: LOAD STATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Indicates whether the collection has been populated from the underlying , + /// allowing callers to distinguish data that is present and authoritative from data that must still be loaded. + /// + /// + /// Defaults to , reflecting that a newly constructed, in-memory topic has nothing deferred. + /// When a topic is loaded from the persistence store without its version history, the repository sets this to to indicate that it has not yet been loaded. + /// + public LoadState LoadState { get; set; } = LoadState.Loaded; + +} //Class \ No newline at end of file diff --git a/OnTopic/Internal/Diagnostics/Contract.cs b/OnTopic/Internal/Diagnostics/Contract.cs index 3ae8d691..14b6ce01 100644 --- a/OnTopic/Internal/Diagnostics/Contract.cs +++ b/OnTopic/Internal/Diagnostics/Contract.cs @@ -58,7 +58,7 @@ public static class Contract { /// public static void Requires( bool isValid, - string? errorMessage = null, + string? errorMessage = null, [CallerArgumentExpression(nameof(isValid))] string? expression = null ) => Requires(isValid, errorMessage, expression); @@ -80,7 +80,7 @@ public static void Requires( #pragma warning disable CS8777 // Parameter must have a non-null value when exiting. public static T Requires( [AllowNull, ValidatedNotNull, NotNull]T requiredObject, - string? errorMessage = null, + string? errorMessage = null, [CallerArgumentExpression(nameof(requiredObject))] string? expression = null ) { Requires(requiredObject is not null, errorMessage, expression); @@ -111,7 +111,7 @@ public static T Requires( /// public static void Requires( bool isValid, - string? errorMessage = null, + string? errorMessage = null, [CallerArgumentExpression(nameof(isValid))] string? expression = null ) where T : Exception, new() { if (isValid) return; @@ -161,7 +161,7 @@ or NotSupportedException /// public static void Assume( bool isValid, - string? errorMessage = null, + string? errorMessage = null, [CallerArgumentExpression(nameof(isValid))] string? expression = null ) => Requires(isValid, errorMessage, expression); @@ -191,7 +191,7 @@ public static void Assume( /// public static void Assume( bool isValid, - string? errorMessage = null, + string? errorMessage = null, [CallerArgumentExpression(nameof(isValid))] string? expression = null ) where T : Exception, new() => Requires(isValid, errorMessage, expression); @@ -210,7 +210,7 @@ public static void Assume( #pragma warning disable CS8777 // Parameter must have a non-null value when exiting. public static void Assume( [ValidatedNotNull, NotNull]object? requiredObject, - string? errorMessage = null, + string? errorMessage = null, [CallerArgumentExpression(nameof(requiredObject))] string? expression = null ) => Requires(requiredObject is not null, errorMessage, expression); #pragma warning restore CS8777 // Parameter must have a non-null value when exiting. diff --git a/OnTopic/Internal/Reflection/ItemMetadata.cs b/OnTopic/Internal/Reflection/ItemMetadata.cs index 6d3e6131..e0beaff5 100644 --- a/OnTopic/Internal/Reflection/ItemMetadata.cs +++ b/OnTopic/Internal/Reflection/ItemMetadata.cs @@ -82,8 +82,8 @@ internal ItemMetadata(string name, ICustomAttributeProvider attributeProvider) /// property is provided with an initter, which will automatically set , , and when it is set. If this is not done properly, dependency classes will /// not work properly, and will likely fail. Since there are only two expected derived classes— and —this shouldn't be a problem. To help avoid this scenario, a is thrown with instructions in the unexpected case that is not set. + /// /> and —this shouldn't be a problem. To help avoid this scenario, a is thrown with instructions in the unexpected case that is not set. /// public Type Type { get { @@ -115,7 +115,7 @@ bool isList() /// internal ItemConfiguration Configuration { get { - field ??= new(this); + field ??= new(this); return field; } } @@ -136,8 +136,8 @@ internal ItemConfiguration Configuration { | IS LIST? \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Determine if the member is a , , , or . + /// Determine if the member is a , , , or . /// internal bool IsList { get; init; } @@ -187,7 +187,7 @@ internal ItemConfiguration Configuration { /// internal List CustomAttributes { get { - field ??= [.. _attributeProvider.GetCustomAttributes(true).OfType()]; + field ??= [.. _attributeProvider.GetCustomAttributes(true).OfType()]; return field; } } diff --git a/OnTopic/Internal/Reflection/MemberAccessor.cs b/OnTopic/Internal/Reflection/MemberAccessor.cs index 3d3fd76e..247aa15f 100644 --- a/OnTopic/Internal/Reflection/MemberAccessor.cs +++ b/OnTopic/Internal/Reflection/MemberAccessor.cs @@ -198,7 +198,7 @@ internal void SetValue(object target, object? value, bool allowConversion = fals //Proceed with conversion } else if (allowConversion && value is string) { - valueObject = AttributeValueConverter.Convert(value as string, Type); + valueObject = AttributeValueConverter.Convert(value as string, Type); } if (valueObject is null && !IsNullable) { @@ -233,12 +233,12 @@ internal void Validate(object target) { /// Provides a list of member names automatically generated by the compiler for record types, but which aren't /// relevant to mapping and should be excluded. /// - private static List ExcludedMembers { get; } = new List() { + private static List ExcludedMembers { get; } = [ "EqualityContract", "GetHashCode", "ToString", "$" - }; + ]; /*============================================================================================================================ @@ -325,7 +325,7 @@ private Type GetType(MemberInfo memberInfo) { parameters.Length == 0, $"The '{memberInfo.Name}()' method must not expect any parameters if the return type is not void." ); - CanRead = true; + CanRead = true; return methodInfo.ReturnType; } @@ -338,7 +338,7 @@ private Type GetType(MemberInfo memberInfo) { $"will be used as the value of the setter." ); - CanWrite = true; + CanWrite = true; return parameters[0].ParameterType; } diff --git a/OnTopic/Internal/Reflection/TopicPropertyDispatcher{TItem,TValue,TAttributeType}.cs b/OnTopic/Internal/Reflection/TopicPropertyDispatcher{TItem,TValue,TAttributeType}.cs index 1cd20f5f..4622ba65 100644 --- a/OnTopic/Internal/Reflection/TopicPropertyDispatcher{TItem,TValue,TAttributeType}.cs +++ b/OnTopic/Internal/Reflection/TopicPropertyDispatcher{TItem,TValue,TAttributeType}.cs @@ -53,8 +53,8 @@ namespace OnTopic.Internal.Reflection; /// "/>. As such, by saving a reference to those as part of the process, we allow /// the source collection to retrieve the original request in order to ensure that data isn't lost. This isn't as critical /// for e.g. since the will be the same -/// that's sent to the corresponding property, and thus is expected to be the same as the value set by the property itself. +/// that's sent to the corresponding property, and thus is expected to be the same as the value set by the property itself. /// /// /// In a typical workflow, the method will end up getting once or twice. The first @@ -69,8 +69,8 @@ namespace OnTopic.Internal.Reflection; /// One caveat to this are cases where the caller attempts to set the value via the property directly, /// instead of adding the item directly to the corresponding collection—e.g., they call instead /// of e.g. the method from -/// . In that case, the business logic will already have been enforced, but the method will not have been called. To mitigate the property setter getting called twice, +/// . In that case, the business logic will already have been enforced, but the method will not have been called. To mitigate the property setter getting called twice, /// collection implementors are advised to offer an internal overload that allows an item to be added to the collection /// while bypassing the business logic. For instance, this can be done using or The whose properties should be called, when appropriate. internal TopicPropertyDispatcher(Topic associatedTopic) { - _associatedTopic = associatedTopic; + _associatedTopic = associatedTopic; } /*============================================================================================================================ @@ -160,8 +160,8 @@ internal TopicPropertyDispatcher(Topic associatedTopic) { /// .SetValue(String, TValue, Boolean?, Boolean, DateTime?)"/>. /// /// - /// It's worth noting that any calls to are invalidated the next time is called. As such, is not a way to permanently + /// It's worth noting that any calls to are invalidated the next time is called. As such, is not a way to permanently /// disable calling a property setter. (The correct way to do that is to remove the property setter, or at least its /// corresponding .) Instead, it only disables the next attempt to add an item /// corresponding to that key—which, if correctly implemented, will be when the current @@ -173,9 +173,9 @@ internal TopicPropertyDispatcher(Topic associatedTopic) { /// /// The object which is being inserted. internal bool Register(string itemKey, TItem? initialValue) { - var type = (Type?)null; + var type = (Type?)null; if (!AttributeValueConverter.IsConvertible(typeof(TValue))) { - type = typeof(TValue); + type = typeof(TValue); } if ( !PropertyCache.ContainsKey(itemKey) && @@ -251,7 +251,7 @@ internal bool Enforce(string itemKey, TItem? initialObject) { #pragma warning restore CA1853 // Unnecessary call to 'Dictionary.ContainsKey(key)' else if (Register(itemKey, initialObject)) { try { - var typeAccessor = TypeAccessorCache.GetTypeAccessor(_associatedTopic.GetType()); + var typeAccessor = TypeAccessorCache.GetTypeAccessor(_associatedTopic.GetType()); typeAccessor.SetPropertyValue(_associatedTopic, itemKey, initialObject?.Value, true); } catch (TargetInvocationException ex) { diff --git a/OnTopic/Internal/Reflection/TypeAccessor.cs b/OnTopic/Internal/Reflection/TypeAccessor.cs index ba1e777e..8f5d5511 100644 --- a/OnTopic/Internal/Reflection/TypeAccessor.cs +++ b/OnTopic/Internal/Reflection/TypeAccessor.cs @@ -24,13 +24,13 @@ namespace OnTopic.Internal.Reflection; /// For setting values, the typical workflow is for a caller to check either or , followed by or to retrieve the value. In these -/// scenarios, the will attempt to deserialize the value parameter from to the type expected by the corresponding property or method. Typically, this will be a , +/// scenarios, the will attempt to deserialize the value parameter from to the type expected by the corresponding property or method. Typically, this will be a , /// , , or . /// /// -/// Alternatively, setters can call or , in which case the final value parameter will be set the +/// Alternatively, setters can call or , in which case the final value parameter will be set the /// target property, or passed as the parameter of the method without any attempt to convert it. Obviously, this requires /// that the target type be assignable from the value object. /// @@ -179,7 +179,7 @@ internal ConstructorInfo GetPrimaryConstructor() => /// Optional, the expected. /// Optional, the expected on the property. internal bool HasGettableProperty(string propertyName, Type? targetType = null, Type? attributeFlag = null) { - var property = GetMember(propertyName); + var property = GetMember(propertyName); return ( property is not null and { CanRead: true, MemberType: MemberTypes.Property } && property.IsSettable(targetType, true) && @@ -206,7 +206,7 @@ internal bool HasGettableProperty(string propertyName, Type? targetType = nul /// Optional, the expected. /// Optional, the expected on the property. internal bool HasGettableMethod(string methodName, Type? targetType = null, Type? attributeFlag = null) { - var method = GetMember(methodName); + var method = GetMember(methodName); return ( method is not null and { CanRead: true, MemberType: MemberTypes.Method } && method.IsSettable(targetType, true) && @@ -234,7 +234,7 @@ internal bool HasGettableMethod(string name, Type? targetType = null) where T /*-------------------------------------------------------------------------------------------------------------------------- | Retrieve member \-------------------------------------------------------------------------------------------------------------------------*/ - var member = GetMember(memberName); + var member = GetMember(memberName); if (member is null) { return null; @@ -292,7 +292,7 @@ internal bool HasGettableMethod(string name, Type? targetType = null) where T /// Optional, the expected. /// Optional, the expected on the property. internal bool HasSettableProperty(string propertyName, Type? targetType = null, Type? attributeFlag = null) { - var property = GetMember(propertyName); + var property = GetMember(propertyName); return ( property is not null and { CanWrite: true, MemberType: MemberTypes.Property } && property.IsSettable(targetType, true) && @@ -320,7 +320,7 @@ internal bool HasSettableProperty(string propertyName, Type? targetType = nul /// Optional, the expected. /// Optional, the expected on the property. internal bool HasSettableMethod(string methodName, Type? targetType = null, Type? attributeFlag = null) { - var method = GetMember(methodName); + var method = GetMember(methodName); return ( method is not null and { CanWrite: true, MemberType: MemberTypes.Method } && method.IsSettable(targetType, true) && @@ -350,7 +350,7 @@ internal void SetValue(object target, string memberName, object? value, bool all /*-------------------------------------------------------------------------------------------------------------------------- | Validate dependencies \-------------------------------------------------------------------------------------------------------------------------*/ - var member = GetMember(memberName); + var member = GetMember(memberName); Contract.Assume(member, $"The {memberName} property could not be retrieved."); @@ -365,8 +365,8 @@ internal void SetValue(object target, string memberName, object? value, bool all | METHOD: SET PROPERTY VALUE \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Uses reflection to call a property, assuming that it is a) writable, and b) of type , , or , or is otherwise compatible with the type. + /// Uses reflection to call a property, assuming that it is a) writable, and b) of type , , or , or is otherwise compatible with the type. /// /// The object on which the property is defined. /// The name of the property to set, derived from . diff --git a/OnTopic/LoadState.cs b/OnTopic/LoadState.cs new file mode 100644 index 00000000..88f0580f --- /dev/null +++ b/OnTopic/LoadState.cs @@ -0,0 +1,37 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Repositories; + +namespace OnTopic; + +/*============================================================================================================================== +| ENUM: LOAD STATE +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Indicates the state to which a topic collection has been populated from the underlying , +/// allowing callers to distinguish data that is present and authoritative from data that must still be loaded. +/// +public enum LoadState { + + /*---------------------------------------------------------------------------------------------------------------------------- + | NOT LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// The collection has not been retrieved from the persistence store. Its current contents, typically empty, are not + /// authoritative, and accessing the collection should trigger an on-demand load of its immediate members. + /// + NotLoaded, + + /*---------------------------------------------------------------------------------------------------------------------------- + | LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// The collection has been fully retrieved and is authoritative. This is the default for a newly constructed, in-memory + /// topic, which has nothing deferred in the persistence store. + /// + Loaded, + +} //Enum \ No newline at end of file diff --git a/OnTopic/Lookup/CompositeTypeLookupService.cs b/OnTopic/Lookup/CompositeTypeLookupService.cs index 632c9ceb..41f57615 100644 --- a/OnTopic/Lookup/CompositeTypeLookupService.cs +++ b/OnTopic/Lookup/CompositeTypeLookupService.cs @@ -51,11 +51,11 @@ public CompositeTypeLookupService(params ITypeLookupService[] typeLookupServices \---------------------------------------------------------------------------------------------------------------------------*/ /// public Type? Lookup(params string[] typeNames) { - var type = typeof(object); + var type = typeof(object); if (typeNames is not null) { foreach (var typeName in typeNames) { foreach (var typeLookupService in _typeLookupServices) { - type = typeLookupService.Lookup(typeName); + type = typeLookupService.Lookup(typeName); if (type is not null && type.Name.Equals(typeName, StringComparison.OrdinalIgnoreCase)) { return type; } diff --git a/OnTopic/Lookup/DynamicTopicLookupService.cs b/OnTopic/Lookup/DynamicTopicLookupService.cs index 599a44c8..9385d3d5 100644 --- a/OnTopic/Lookup/DynamicTopicLookupService.cs +++ b/OnTopic/Lookup/DynamicTopicLookupService.cs @@ -3,7 +3,6 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ -using OnTopic.Metadata; namespace OnTopic.Lookup; diff --git a/OnTopic/Lookup/DynamicTypeLookupService.cs b/OnTopic/Lookup/DynamicTypeLookupService.cs index ea969305..ad3b230d 100644 --- a/OnTopic/Lookup/DynamicTypeLookupService.cs +++ b/OnTopic/Lookup/DynamicTypeLookupService.cs @@ -23,12 +23,12 @@ public class DynamicTypeLookupService : StaticTypeLookupService { /// optionally, a default object to return if none is specified. /// /// The search condition to use to identify target classes. - public DynamicTypeLookupService(Func predicate) : base() { + public DynamicTypeLookupService(Func predicate) { /*-------------------------------------------------------------------------------------------------------------------------- | Find target classes \-------------------------------------------------------------------------------------------------------------------------*/ - var matchedTypes = AppDomain + var matchedTypes = AppDomain .CurrentDomain .GetAssemblies() .Where(a => !(a.FullName?.StartsWith("Microsoft", StringComparison.Ordinal) ?? false) && !(a.FullName?.StartsWith("System", StringComparison.Ordinal) ?? false)) diff --git a/OnTopic/Lookup/StaticTypeLookupService.cs b/OnTopic/Lookup/StaticTypeLookupService.cs index 38e46c61..20942046 100644 --- a/OnTopic/Lookup/StaticTypeLookupService.cs +++ b/OnTopic/Lookup/StaticTypeLookupService.cs @@ -34,7 +34,7 @@ public class StaticTypeLookupService: ITypeLookupService { /// /// The list of instances to expose as part of this service. public StaticTypeLookupService( - IEnumerable? types = null + IEnumerable? types = null ) { /*-------------------------------------------------------------------------------------------------------------------------- diff --git a/OnTopic/Mapping/Annotations/AssociationTypes.cs b/OnTopic/Mapping/Annotations/AssociationTypes.cs index fa9b92fb..3d888d59 100644 --- a/OnTopic/Mapping/Annotations/AssociationTypes.cs +++ b/OnTopic/Mapping/Annotations/AssociationTypes.cs @@ -17,8 +17,8 @@ namespace OnTopic.Mapping.Annotations; /// /// The and use the enum to /// determine what associations should be mapped—or followed—as part of the mapping process. This helps constrain the -/// scope of the object graph to only include the data needed for a given view, or vice verse. That said, the enum can be used any place where the code needs to model multiple types of associations relevant +/// scope of the object graph to only include the data needed for a given view, or vice verse. That said, the enum can be used any place where the code needs to model multiple types of associations relevant /// to the class and its view models. /// /// @@ -80,7 +80,7 @@ public enum AssociationTypes { /// cref="AttributeKeyAttribute.Key"/>. /// /// - /// This allows mapping of custom collection, such as . + /// This allows mapping of custom collections, such as . /// MappedCollections = 1 << 4, @@ -91,8 +91,8 @@ public enum AssociationTypes { /// Map topic pointer references, such as . /// /// - /// By convention, types refer to a , , or property identifier ending in Id. + /// By convention, types refer to a , , or property identifier ending in Id. /// References = 1 << 5, @@ -102,6 +102,6 @@ public enum AssociationTypes { /// /// Map all association types. /// - All = Parents | Children | Relationships | IncomingRelationships | MappedCollections | References + All = Parents | Children | Relationships | IncomingRelationships | MappedCollections | References } //Enum \ No newline at end of file diff --git a/OnTopic/Mapping/Annotations/AttributeKeyAttribute.cs b/OnTopic/Mapping/Annotations/AttributeKeyAttribute.cs index 89de1d60..1e08426d 100644 --- a/OnTopic/Mapping/Annotations/AttributeKeyAttribute.cs +++ b/OnTopic/Mapping/Annotations/AttributeKeyAttribute.cs @@ -11,8 +11,8 @@ namespace OnTopic.Mapping.Annotations; | ATTRIBUTE: ATTRIBUTE KEY \-----------------------------------------------------------------------------------------------------------------------------*/ /// -/// Flags that a property should be mapped to a specific attributeKey in when calling . +/// Flags that a property should be mapped to a specific attributeKey in when calling . /// /// /// By default, implementations will attempt to map the property of the target data @@ -32,7 +32,7 @@ public sealed class AttributeKeyAttribute : Attribute { /// The key value of the attribute associated with the current property. public AttributeKeyAttribute(string key) { TopicFactory.ValidateKey(key, false); - Key = key; + Key = key; } /*============================================================================================================================ diff --git a/OnTopic/Mapping/Annotations/CollectionAttribute.cs b/OnTopic/Mapping/Annotations/CollectionAttribute.cs index 725054f9..56b02ea0 100644 --- a/OnTopic/Mapping/Annotations/CollectionAttribute.cs +++ b/OnTopic/Mapping/Annotations/CollectionAttribute.cs @@ -40,7 +40,7 @@ public sealed class CollectionAttribute : Attribute { /// The key value of the collection associated with the current property. public CollectionAttribute(string key) { TopicFactory.ValidateKey(key, false); - Key = key; + Key = key; } /// @@ -48,7 +48,7 @@ public CollectionAttribute(string key) { /// /// Optional. The type of collection the collection is associated with. public CollectionAttribute(CollectionType type = CollectionType.Any) { - Type = type; + Type = type; } /*============================================================================================================================ diff --git a/OnTopic/Mapping/Annotations/FilterByAttributeAttribute.cs b/OnTopic/Mapping/Annotations/FilterByAttributeAttribute.cs index b8c76e71..838b0045 100644 --- a/OnTopic/Mapping/Annotations/FilterByAttributeAttribute.cs +++ b/OnTopic/Mapping/Annotations/FilterByAttributeAttribute.cs @@ -43,8 +43,8 @@ public FilterByAttributeAttribute(string key, string value) { /*-------------------------------------------------------------------------------------------------------------------------- | Set properties \-------------------------------------------------------------------------------------------------------------------------*/ - Key = key; - Value = value; + Key = key; + Value = value; } diff --git a/OnTopic/Mapping/Annotations/FilterByContentType.cs b/OnTopic/Mapping/Annotations/FilterByContentType.cs index 5729a54f..da042567 100644 --- a/OnTopic/Mapping/Annotations/FilterByContentType.cs +++ b/OnTopic/Mapping/Annotations/FilterByContentType.cs @@ -29,7 +29,7 @@ public sealed class FilterByContentTypeAttribute : Attribute { /// The content type to filter by. public FilterByContentTypeAttribute(string contentType) { TopicFactory.ValidateKey(contentType, false); - ContentType = contentType; + ContentType = contentType; } /*============================================================================================================================ diff --git a/OnTopic/Mapping/Annotations/IncludeAttribute.cs b/OnTopic/Mapping/Annotations/IncludeAttribute.cs index fb06d3d0..eac25180 100644 --- a/OnTopic/Mapping/Annotations/IncludeAttribute.cs +++ b/OnTopic/Mapping/Annotations/IncludeAttribute.cs @@ -35,7 +35,7 @@ public sealed class IncludeAttribute : Attribute { /// /// The specific associations that should be crawled. public IncludeAttribute(AssociationTypes associations) { - Associations = associations; + Associations = associations; } /*============================================================================================================================ diff --git a/OnTopic/Mapping/Annotations/MapAsAttribute.cs b/OnTopic/Mapping/Annotations/MapAsAttribute.cs index b4794b85..077b10fb 100644 --- a/OnTopic/Mapping/Annotations/MapAsAttribute.cs +++ b/OnTopic/Mapping/Annotations/MapAsAttribute.cs @@ -41,7 +41,7 @@ public sealed class MapAsAttribute : Attribute { /// /// The view model to map the association to. public MapAsAttribute(Type type) { - Type = type; + Type = type; } /*============================================================================================================================ diff --git a/OnTopic/Mapping/Annotations/MapToParentAttribute.cs b/OnTopic/Mapping/Annotations/MapToParentAttribute.cs index 63a80404..6d28c789 100644 --- a/OnTopic/Mapping/Annotations/MapToParentAttribute.cs +++ b/OnTopic/Mapping/Annotations/MapToParentAttribute.cs @@ -52,7 +52,7 @@ public string? AttributePrefix { get => field; set { TopicFactory.ValidateKey(value, true); - field = value; + field = value; } } diff --git a/OnTopic/Mapping/Annotations/MetadataAttribute.cs b/OnTopic/Mapping/Annotations/MetadataAttribute.cs index 95e32b9a..f11eb0dc 100644 --- a/OnTopic/Mapping/Annotations/MetadataAttribute.cs +++ b/OnTopic/Mapping/Annotations/MetadataAttribute.cs @@ -30,7 +30,7 @@ public sealed class MetadataAttribute : Attribute { /// The key represents the name of the Metadata topic that should be mapped to. public MetadataAttribute(string key) { TopicFactory.ValidateKey(key, false); - Key = key; + Key = key; } /*============================================================================================================================ diff --git a/OnTopic/Mapping/CachedTopicMappingService.cs b/OnTopic/Mapping/CachedTopicMappingService.cs index 4b161207..7250416c 100644 --- a/OnTopic/Mapping/CachedTopicMappingService.cs +++ b/OnTopic/Mapping/CachedTopicMappingService.cs @@ -44,7 +44,7 @@ public class CachedTopicMappingService(ITopicMappingService topicMappingService) /*-------------------------------------------------------------------------------------------------------------------------- | Ensure cache is populated \-------------------------------------------------------------------------------------------------------------------------*/ - var cacheKey = (topic.Id, (Type?)null, associations); + var cacheKey = (topic.Id, (Type?)null, associations); if(_cache.TryGetValue(cacheKey, out var viewModel)) { return viewModel; } @@ -52,7 +52,7 @@ public class CachedTopicMappingService(ITopicMappingService topicMappingService) /*-------------------------------------------------------------------------------------------------------------------------- | Process result \-------------------------------------------------------------------------------------------------------------------------*/ - viewModel = await _topicMappingService.MapAsync(topic, associations).ConfigureAwait(false); + viewModel = await _topicMappingService.MapAsync(topic, associations).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Return (cached) result @@ -82,7 +82,7 @@ public class CachedTopicMappingService(ITopicMappingService topicMappingService) /*-------------------------------------------------------------------------------------------------------------------------- | Ensure cache is populated \-------------------------------------------------------------------------------------------------------------------------*/ - var cacheKey = (topic.Id, typeof(T), associations); + var cacheKey = (topic.Id, typeof(T), associations); if (_cache.TryGetValue(cacheKey, out var viewModel)) { return (T)viewModel; } @@ -90,7 +90,7 @@ public class CachedTopicMappingService(ITopicMappingService topicMappingService) /*-------------------------------------------------------------------------------------------------------------------------- | Process result \-------------------------------------------------------------------------------------------------------------------------*/ - viewModel = await _topicMappingService.MapAsync(topic, associations).ConfigureAwait(false); + viewModel = await _topicMappingService.MapAsync(topic, associations).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Return (cached) result @@ -125,7 +125,7 @@ public class CachedTopicMappingService(ITopicMappingService topicMappingService) /*-------------------------------------------------------------------------------------------------------------------------- | Ensure cache is populated \-------------------------------------------------------------------------------------------------------------------------*/ - var cacheKey = (topic.Id, target.GetType(), associations); + var cacheKey = (topic.Id, target.GetType(), associations); if (_cache.TryGetValue(cacheKey, out var viewModel)) { return viewModel; } @@ -133,7 +133,7 @@ public class CachedTopicMappingService(ITopicMappingService topicMappingService) /*-------------------------------------------------------------------------------------------------------------------------- | Process result \-------------------------------------------------------------------------------------------------------------------------*/ - viewModel = await _topicMappingService.MapAsync(topic, associations).ConfigureAwait(false); + viewModel = await _topicMappingService.MapAsync(topic, associations).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Return (cached) result @@ -160,8 +160,8 @@ public class CachedTopicMappingService(ITopicMappingService topicMappingService) /// The internal will potentially add two entries to the cache for every view model. /// /// - /// The first will be bound to the , view model , and the mapped. + /// The first will be bound to the , view model , and the mapped. /// /// /// The second will assume a null , and can be used for scenarios where the is @@ -192,7 +192,7 @@ public class CachedTopicMappingService(ITopicMappingService topicMappingService) _cache.TryAdd(cacheKey, viewModel); } if (cacheKey.Item2 is not null) { - cacheKey = (cacheKey.Item1, null, cacheKey.Item3); + cacheKey = (cacheKey.Item1, null, cacheKey.Item3); } if ( cacheKey.Item1 > 0 && ( diff --git a/OnTopic/Mapping/Hierarchical/CachedHierarchicalTopicMappingService{T}.cs b/OnTopic/Mapping/Hierarchical/CachedHierarchicalTopicMappingService{T}.cs index 0b62d1c4..13be551b 100644 --- a/OnTopic/Mapping/Hierarchical/CachedHierarchicalTopicMappingService{T}.cs +++ b/OnTopic/Mapping/Hierarchical/CachedHierarchicalTopicMappingService{T}.cs @@ -53,7 +53,7 @@ IHierarchicalTopicMappingService hierarchicalTopicMappingService | GET HIERARCHICAL ROOT \---------------------------------------------------------------------------------------------------------------------------*/ /// - public Topic? GetHierarchicalRoot(Topic? currentTopic, int fromRoot = 2, string defaultRoot = "Web") => + public Topic? GetHierarchicalRoot(Topic? currentTopic, int fromRoot = 2, string defaultRoot = "Root:Web") => _hierarchicalTopicMappingService.GetHierarchicalRoot(currentTopic, fromRoot, defaultRoot); /*============================================================================================================================ @@ -62,7 +62,7 @@ IHierarchicalTopicMappingService hierarchicalTopicMappingService /// public async Task GetRootViewModelAsync( Topic? sourceTopic, - int tiers = 1, + int tiers = 1, Func? validationDelegate = null ) { @@ -83,7 +83,7 @@ IHierarchicalTopicMappingService hierarchicalTopicMappingService /*-------------------------------------------------------------------------------------------------------------------------- | Cache and return new version \-------------------------------------------------------------------------------------------------------------------------*/ - var viewModel = await GetViewModelAsync(sourceTopic, tiers, validationDelegate).ConfigureAwait(false); + var viewModel = await GetViewModelAsync(sourceTopic, tiers, validationDelegate).ConfigureAwait(false); return _cache.GetOrAdd(sourceTopic.Id, viewModel); } @@ -94,7 +94,7 @@ IHierarchicalTopicMappingService hierarchicalTopicMappingService /// public async Task GetViewModelAsync( Topic? sourceTopic, - int tiers = 1, + int tiers = 1, Func? validationDelegate = null ) => await _hierarchicalTopicMappingService.GetViewModelAsync(sourceTopic, tiers, validationDelegate).ConfigureAwait(false); diff --git a/OnTopic/Mapping/Hierarchical/HierarchicalTopicMappingService{T}.cs b/OnTopic/Mapping/Hierarchical/HierarchicalTopicMappingService{T}.cs index ed66124e..c1dca371 100644 --- a/OnTopic/Mapping/Hierarchical/HierarchicalTopicMappingService{T}.cs +++ b/OnTopic/Mapping/Hierarchical/HierarchicalTopicMappingService{T}.cs @@ -65,14 +65,14 @@ public class HierarchicalTopicMappingService(ITopicRepository topicRepository /*-------------------------------------------------------------------------------------------------------------------------- | Establish variables \-------------------------------------------------------------------------------------------------------------------------*/ - var navigationRootTopic = currentTopic; + var navigationRootTopic = currentTopic; /*-------------------------------------------------------------------------------------------------------------------------- | Handle default, if necessary \-------------------------------------------------------------------------------------------------------------------------*/ if (navigationRootTopic is null) { Contract.Assume(!String.IsNullOrEmpty(defaultRoot), nameof(defaultRoot)); - navigationRootTopic = TopicRepository.Load(defaultRoot, currentTopic); + navigationRootTopic = TopicRepository.Load(defaultRoot, currentTopic).GetAwaiter().GetResult(); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -88,7 +88,7 @@ public class HierarchicalTopicMappingService(ITopicRepository topicRepository | Find navigation root \-------------------------------------------------------------------------------------------------------------------------*/ while (navigationRootTopic is not null && DistanceFromRoot(navigationRootTopic) > fromRoot) { - navigationRootTopic = navigationRootTopic.Parent; + navigationRootTopic = navigationRootTopic.Parent; } /*-------------------------------------------------------------------------------------------------------------------------- @@ -106,9 +106,9 @@ public class HierarchicalTopicMappingService(ITopicRepository topicRepository /// /// The to pull the values from. private static int DistanceFromRoot(Topic sourceTopic) { - var distance = 1; + var distance = 1; while (sourceTopic.Parent is not null) { - sourceTopic = sourceTopic.Parent; + sourceTopic = sourceTopic.Parent; distance++; } return distance; @@ -120,7 +120,7 @@ private static int DistanceFromRoot(Topic sourceTopic) { /// public virtual async Task GetRootViewModelAsync( Topic? sourceTopic, - int tiers = 1, + int tiers = 1, Func? validationDelegate = null ) => await GetViewModelAsync(sourceTopic, tiers, validationDelegate).ConfigureAwait(false); @@ -128,12 +128,48 @@ private static int DistanceFromRoot(Topic sourceTopic) { | GET VIEW MODEL (ASYNC) \---------------------------------------------------------------------------------------------------------------------------*/ /// + /// + /// Warms the requested -deep region in a single round-trip before recursing into , so there's no need to lazy load children during the recursive descent. Skipped + /// for an unsaved () since the topic's Id of -1 is + /// treated by as a request for the root of the + /// entire graph, not a itself, so this would fetch the wrong node's descendants. + /// public async Task GetViewModelAsync( Topic? sourceTopic, - int tiers = 1, + int tiers = 1, Func? validationDelegate = null ) { + /*-------------------------------------------------------------------------------------------------------------------------- + | Load the requested region + \-------------------------------------------------------------------------------------------------------------------------*/ + if (sourceTopic is not null && !sourceTopic.IsNew && tiers > 0) { + await TopicRepository.Load(sourceTopic.Id, sourceTopic, depth: tiers).ConfigureAwait(false); + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Delegate mapping once reference topic is resolved + \-------------------------------------------------------------------------------------------------------------------------*/ + return await GetHierarchicalTopicViewModelAsync(sourceTopic, tiers, validationDelegate).ConfigureAwait(false); + + } + + /*============================================================================================================================ + | GET HIERARCHICAL VIEW MODEL (ASYNC) + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Recursively maps each and its descendants, up to deep. + /// + /// The to map. + /// The number of tiers of descendants, relative to , to include. + /// An optional function to validate whether a topic should be included. + private async Task GetHierarchicalTopicViewModelAsync( + Topic? sourceTopic, + int tiers, + Func? validationDelegate + ) { + /*-------------------------------------------------------------------------------------------------------------------------- | Validate preconditions \-------------------------------------------------------------------------------------------------------------------------*/ @@ -145,19 +181,18 @@ private static int DistanceFromRoot(Topic sourceTopic) { /*-------------------------------------------------------------------------------------------------------------------------- | Establish variables \-------------------------------------------------------------------------------------------------------------------------*/ - var taskQueue = new List>(); - var children = new List(); + List children = []; var viewModel = (T?)null; /*-------------------------------------------------------------------------------------------------------------------------- | Establish default delegate \-------------------------------------------------------------------------------------------------------------------------*/ - validationDelegate ??= (Topic) => true; + validationDelegate ??= (Topic) => true; /*-------------------------------------------------------------------------------------------------------------------------- | Map object \-------------------------------------------------------------------------------------------------------------------------*/ - viewModel = await _topicMappingService.MapAsync(sourceTopic, AssociationTypes.None).ConfigureAwait(false); + viewModel = await _topicMappingService.MapAsync(sourceTopic, AssociationTypes.None).ConfigureAwait(false); Contract.Assume( viewModel, @@ -169,19 +204,10 @@ private static int DistanceFromRoot(Topic sourceTopic) { \-------------------------------------------------------------------------------------------------------------------------*/ if (tiers >= 0 && viewModel.Children.Count == 0) { foreach (var topic in sourceTopic.Children.Where(t => t.IsVisible() && validationDelegate(t))) { - taskQueue.Add(GetViewModelAsync(topic, tiers, validationDelegate)); - } - } - - /*-------------------------------------------------------------------------------------------------------------------------- - | Process children - \-------------------------------------------------------------------------------------------------------------------------*/ - while (taskQueue.Count > 0 && viewModel.Children.Count == 0) { - var dtoTask = await Task.WhenAny(taskQueue).ConfigureAwait(false); - var dto = await dtoTask.ConfigureAwait(false); - taskQueue.Remove(dtoTask); - if (dto is not null) { - children.Add(dto); + var dto = await GetHierarchicalTopicViewModelAsync(topic, tiers, validationDelegate).ConfigureAwait(false); + if (dto is not null) { + children.Add(dto); + } } } diff --git a/OnTopic/Mapping/Hierarchical/IHierarchicalTopicMappingService{T}.cs b/OnTopic/Mapping/Hierarchical/IHierarchicalTopicMappingService{T}.cs index 550f3c71..4a5eb565 100644 --- a/OnTopic/Mapping/Hierarchical/IHierarchicalTopicMappingService{T}.cs +++ b/OnTopic/Mapping/Hierarchical/IHierarchicalTopicMappingService{T}.cs @@ -48,7 +48,7 @@ namespace OnTopic.Mapping.Hierarchical; /// The to start from. /// The distance that the navigation root should be from the root of the topic graph. /// If a root cannot be identified, the default root that should be returned. - Topic? GetHierarchicalRoot(Topic? currentTopic, int fromRoot = 2, string defaultRoot = "Web"); + Topic? GetHierarchicalRoot(Topic? currentTopic, int fromRoot = 2, string defaultRoot = "Root:Web"); /*============================================================================================================================ | GET ROOT VIEW MODEL (ASYNC) @@ -73,7 +73,7 @@ namespace OnTopic.Mapping.Hierarchical; /// Task GetRootViewModelAsync( Topic? sourceTopic, - int tiers = 1, + int tiers = 1, Func? validationDelegate = null ); @@ -93,7 +93,7 @@ namespace OnTopic.Mapping.Hierarchical; /// Task GetViewModelAsync( Topic? sourceTopic, - int tiers = 1, + int tiers = 1, Func? validationDelegate = null ); diff --git a/OnTopic/Mapping/Internal/AssociationMap.cs b/OnTopic/Mapping/Internal/AssociationMap.cs index 54826e68..9b15362a 100644 --- a/OnTopic/Mapping/Internal/AssociationMap.cs +++ b/OnTopic/Mapping/Internal/AssociationMap.cs @@ -4,6 +4,7 @@ | Project Topics Library \=============================================================================================================================*/ using OnTopic.Mapping.Annotations; +using OnTopic.Repositories; namespace OnTopic.Mapping.Internal; @@ -15,8 +16,8 @@ namespace OnTopic.Mapping.Internal; /// /// /// While the and enumerations are distinct, there are times -/// when a single needs to be related to an item in the collection of . This mapping makes that feasible. +/// when a single needs to be related to an item in the collection of . This mapping makes that feasible. /// static internal class AssociationMap { @@ -25,7 +26,7 @@ static internal class AssociationMap { \---------------------------------------------------------------------------------------------------------------------------*/ static AssociationMap() { - var mappings = new Dictionary { + var mappings = new Dictionary { { CollectionType.Any, AssociationTypes.None }, { CollectionType.Children, AssociationTypes.Children }, { CollectionType.Relationship, AssociationTypes.Relationships }, @@ -34,7 +35,19 @@ static AssociationMap() { { CollectionType.IncomingRelationship, AssociationTypes.IncomingRelationships } }; - Mappings = mappings; + // Any probes Relationships first, then Children (via NestedTopics); both must be warmed before probing + // IncomingRelationship cannot be warmed for a single topic, and MappedCollection is property-based + var payloadMappings = new Dictionary { + { CollectionType.Any, TopicPayload.Children | TopicPayload.Relationships }, + { CollectionType.Children, TopicPayload.Children }, + { CollectionType.Relationship, TopicPayload.Relationships }, + { CollectionType.NestedTopics, TopicPayload.Children }, + { CollectionType.MappedCollection, TopicPayload.None }, + { CollectionType.IncomingRelationship, TopicPayload.None } + }; + + Mappings = mappings; + PayloadMappings = payloadMappings; } @@ -43,4 +56,15 @@ static AssociationMap() { \---------------------------------------------------------------------------------------------------------------------------*/ static internal Dictionary Mappings { get; } + /*============================================================================================================================ + | PROPERTY: PAYLOAD MAPPINGS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Provides a mapping of the relationship between and . + /// + /// + /// Used by to determine which lazy-load payloads to warm before probing collections. + /// + static internal Dictionary PayloadMappings { get; } + } //Class \ No newline at end of file diff --git a/OnTopic/Mapping/Internal/ItemConfiguration.cs b/OnTopic/Mapping/Internal/ItemConfiguration.cs index 06a6da48..6456caca 100644 --- a/OnTopic/Mapping/Internal/ItemConfiguration.cs +++ b/OnTopic/Mapping/Internal/ItemConfiguration.cs @@ -6,7 +6,6 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Reflection; -using OnTopic.Collections.Specialized; using OnTopic.Internal.Reflection; using OnTopic.Mapping.Annotations; @@ -86,19 +85,19 @@ internal ItemConfiguration(ItemMetadata itemMetadata) { \-------------------------------------------------------------------------------------------------------------------------*/ GetAttributeValue( a => { - CollectionKey = a.Key ?? CollectionKey; - CollectionType = a.Type; + CollectionKey = a.Key ?? CollectionKey; + CollectionType = a.Type; } ); if (CollectionKey.Equals("Children", StringComparison.OrdinalIgnoreCase)) { - CollectionType = CollectionType.Children; + CollectionType = CollectionType.Children; } /*-------------------------------------------------------------------------------------------------------------------------- | Attributes: Set attribute filters \-------------------------------------------------------------------------------------------------------------------------*/ - var filterByAttributes = CustomAttributes.OfType().ToArray(); + var filterByAttributes = CustomAttributes.OfType().ToArray(); if (filterByAttributes.Length > 0) { foreach (var filter in filterByAttributes) { AttributeFilters.Add(filter.Key, filter.Value); @@ -250,9 +249,9 @@ internal ItemConfiguration(ItemMetadata itemMetadata) { /// /// /// By default, a collection property on a model class will be mapped to a corresponding collection of the same name. - /// So, for instance, if the property on the model class is called Cousins then the will search , , , and, finally, for an object named Cousins. If + /// So, for instance, if the property on the model class is called Cousins then the will search , , , and, finally, for an object named Cousins. If /// the is set, however, then that value is used instead, thus allowing the property on the /// model to be aliased to a different collection name on the source . /// @@ -274,8 +273,8 @@ internal ItemConfiguration(ItemMetadata itemMetadata) { /// By default, a collection property on a model class will attempt to find a match from, in order, , , , and, finally, . If the is set, however, then the will only map the collection to a collection of that type. This can be valuable when the might be ambiguous between multiple collections. + /// /> will only map the collection to a collection of that type. This can be valuable when the might be ambiguous between multiple collections. /// /// /// The property corresponds to the property. It @@ -468,7 +467,7 @@ internal bool SatisfiesAttributeFilters(Topic source) => /// An type to evaluate. /// The to execute on the attribute. private void GetAttributeValue(Action action) where T : Attribute { - var attribute = GetAttribute(); + var attribute = GetAttribute(); if (attribute is not null) { action(attribute); } diff --git a/OnTopic/Mapping/Internal/MapPath.cs b/OnTopic/Mapping/Internal/MapPath.cs new file mode 100644 index 00000000..1403fabd --- /dev/null +++ b/OnTopic/Mapping/Internal/MapPath.cs @@ -0,0 +1,71 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.Mapping.Internal; + +/*============================================================================================================================== +| CLASS: MAP PATH +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Represents a single frame in the depth-first path of an in-progress mapping operation, tracking the +/// and target currently being constructed, along with a reference to the frame that preceded it. +/// +/// +/// The allows the to distinguish a genuine constructor cycle, in +/// which a topic is mapped to a type that is already being constructed higher up the same call chain, from sibling +/// concurrency, in which two independent branches happen to map the same topic to the same type at the same time. The former +/// is a true circular reference and must throw; the latter is benign, and the joiner should await the in-progress result. +/// +/// The of the topic being mapped at this frame. +/// The target being constructed at this frame. +/// The preceding frame, or null if this frame is the path root. +internal sealed class MapPath(int topicId, Type type, MapPath? parent) { + + /*============================================================================================================================ + | PROPERTY: TOPIC ID + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// The of the topic being mapped at this frame. + /// + internal int TopicId { get; } = topicId; + + /*============================================================================================================================ + | PROPERTY: TYPE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// The target being constructed at this frame. + /// + internal Type Type { get; } = type; + + /*============================================================================================================================ + | PROPERTY: PARENT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// The preceding frame, or null if this frame is the root of the path. + /// + internal MapPath? Parent { get; } = parent; + + /*============================================================================================================================ + | METHOD: CONTAINS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Determines whether the supplied and pair already appears anywhere on + /// the current path, indicating a constructor cycle. + /// + /// The to search for. + /// The target to search for. + /// Returns true if the pair is already on the path, and otherwise false. + internal bool Contains(int topicId, Type type) { + // Walk up the parent chain, comparing each frame against the requested pair + for (var frame = this; frame is not null; frame = frame.Parent) { + if (frame.TopicId == topicId && frame.Type == type) { + return true; + } + } + return false; + } + +} //Class \ No newline at end of file diff --git a/OnTopic/Mapping/Internal/MappedTopicCache.cs b/OnTopic/Mapping/Internal/MappedTopicCache.cs index 6cd48313..4481f4d0 100644 --- a/OnTopic/Mapping/Internal/MappedTopicCache.cs +++ b/OnTopic/Mapping/Internal/MappedTopicCache.cs @@ -32,13 +32,27 @@ internal sealed class MappedTopicCache { /// The associated with the cache entry. /// The that the has been mapped to. /// The containing the cached instance and metadata. + /// + /// Determines whether an entry that is still should be returned. Left + /// false by default, so callers continue to see only fully constructed entries; set true only by callers who + /// are prepared to distinguish a constructor cycle from sibling concurrency and to await an in-progress entry's completion. + /// /// Returns true if a cached entry could be found, and otherwise false. - internal bool TryGetValue(int topicId, Type type, [NotNullWhen(true)] out MappedTopicCacheEntry? cacheEntry) { - if (_cache.TryGetValue(GetCacheKey(topicId, type), out var existingCacheEntry) && !existingCacheEntry.IsInitializing) { - cacheEntry = existingCacheEntry; + internal bool TryGetValue( + int topicId, + Type type, + [NotNullWhen(true)] + out MappedTopicCacheEntry? cacheEntry, + bool includeInitializing = false + ) { + if ( + _cache.TryGetValue(GetCacheKey(topicId, type), out var existingCacheEntry) && + (includeInitializing || !existingCacheEntry.IsInitializing) + ) { + cacheEntry = existingCacheEntry; return true; }; - cacheEntry = null; + cacheEntry = null; return false; } @@ -59,21 +73,14 @@ internal void Register(int topicId, AssociationTypes associations, object viewMo \-------------------------------------------------------------------------------------------------------------------------*/ var type = viewModel.GetType(); var cacheKey = GetCacheKey(topicId, type); - var cacheEntry = new MappedTopicCacheEntry() { - MappedTopic = viewModel, - Associations = associations - }; + var cacheEntry = new MappedTopicCacheEntry(); /*-------------------------------------------------------------------------------------------------------------------------- | Get or add entry \-------------------------------------------------------------------------------------------------------------------------*/ - if (topicId > 0 && !type.Equals(typeof(object))) { - cacheEntry = _cache.GetOrAdd(cacheKey, cacheEntry); - if (cacheEntry.IsInitializing) { - cacheEntry.IsInitializing = false; - cacheEntry.MappedTopic = viewModel; - cacheEntry.Associations = associations; - } + if (topicId > 0 && type != typeof(object)) { + cacheEntry = _cache.GetOrAdd(cacheKey, cacheEntry); + cacheEntry.Complete(viewModel, associations); } } @@ -83,34 +90,31 @@ internal void Register(int topicId, AssociationTypes associations, object viewMo \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Attempts to preregister a for a that is in the process of - /// being mapped to . + /// being mapped to , returning the entry along with whether this call created it. /// + /// + /// The returned IsNew flag is true when this call established the entry, and therefore owns its construction; + /// it is false when a concurrent pass had already preregistered the same and , in which case the returned entry is that concurrent pass's entry. + /// /// The associated with the cache entry. /// The that the is being mapped to. - internal MappedTopicCacheEntry Preregister(int topicId, Type type) { + internal (MappedTopicCacheEntry Entry, bool IsNew) Preregister(int topicId, Type type) { /*-------------------------------------------------------------------------------------------------------------------------- | Construct cache entry \-------------------------------------------------------------------------------------------------------------------------*/ - var cacheKey = GetCacheKey(topicId, type); - var cacheEntry = new MappedTopicCacheEntry() { - IsInitializing = true - }; + var cacheKey = GetCacheKey(topicId, type); + var cacheEntry = new MappedTopicCacheEntry(); /*-------------------------------------------------------------------------------------------------------------------------- | Get or add entry \-------------------------------------------------------------------------------------------------------------------------*/ if (topicId > 0 && !type.Equals(typeof(object))) { var existingCacheEntry = _cache.GetOrAdd(cacheKey, cacheEntry); - if (existingCacheEntry != cacheEntry) { - throw new TopicMappingException( - $"An attempt has been made to map '{topicId}' to a {type.Name} has resulted in a circular reference during the " + - $"construction of the {type.Name} instance. This is not allowed. Circular must be be mapped as properties, not " + - $"as constructor parameters, so that cached entries can be returned." - ); - } + return (existingCacheEntry, existingCacheEntry == cacheEntry); } - return cacheEntry; + return (cacheEntry, true); } diff --git a/OnTopic/Mapping/Internal/MappedTopicCacheEntry.cs b/OnTopic/Mapping/Internal/MappedTopicCacheEntry.cs index 51f8eeb8..b9745a4c 100644 --- a/OnTopic/Mapping/Internal/MappedTopicCacheEntry.cs +++ b/OnTopic/Mapping/Internal/MappedTopicCacheEntry.cs @@ -15,21 +15,34 @@ namespace OnTopic.Mapping.Internal; /// /// /// In addition to the actual , this also includes a property for -/// tracking what associations were mapped to the . This allows the to be update the cached object with any missing associations, which can be identified using the method. In turn, the cache can then be updated to reflect those new -/// associations by using . This ensures that even if a topic has -/// already been mapped, its scope can be expanded without duplicating effort. +/// tracking what associations were mapped to the . This allows the +/// to expand the cached object with any missing associations. A caller may peek at the missing associations using the +/// method, or record them and receive the newly added subset in a +/// single atomic operation using , so that concurrent passes don't both +/// end up mapping the same associations. This ensures that even if a topic has already been mapped, its scope can be expanded +/// without duplicating effort. /// internal sealed class MappedTopicCacheEntry { + /*============================================================================================================================ + | PRIVATE VARIABLES + \---------------------------------------------------------------------------------------------------------------------------*/ + private readonly object _lock = new(); + private readonly TaskCompletionSource _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + /*============================================================================================================================ | PROPERTY: MAPPED TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Provides a reference to the mapped object. /// - internal object MappedTopic { get; set; } = null!; + /// + /// Assigned only by , which also settles the task, + /// so the mapped instance is never published outside the completion cycle. This topic is fully constructed, but may not yet + /// have all of its properties mapped; the only prevents two instances of + /// the same view model from being constructed, but doesn't guarantee that mapping is finished. + /// + internal object MappedTopic { get; private set; } = null!; /*============================================================================================================================ | PROPERTY: IS INITIALIZING @@ -40,11 +53,12 @@ internal sealed class MappedTopicCacheEntry { /// /// The property allows an entry to be pre-cached prior to the object being completed. This /// allows the to detect circular references within the object initialization sequence. - /// This is important because, unlikely property mapping where a cached reference can be returned, a circular reference - /// in constructor mapping is expected to throw an exception. By registering that an object is being initialized, the - /// is able to detect circuluar references during constructor mapping. + /// This is important because, unlike property mapping where a cached reference can be returned, a circular reference in + /// constructor mapping is expected to throw an exception. It is derived from the task rather than + /// stored, so a faulted entry remains initializing, ensuring an awaiting pass observes the fault instead of a null . /// - internal bool IsInitializing { get; set; } + internal bool IsInitializing => !_completionSource.Task.IsCompletedSuccessfully; /*============================================================================================================================ | PROPERTY: ASSOCIATIONS @@ -54,6 +68,24 @@ internal sealed class MappedTopicCacheEntry { /// internal AssociationTypes Associations { get; set; } = AssociationTypes.None; + /*============================================================================================================================ + | PROPERTY: COMPLETION + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns a that completes once the has been constructed and registered, even + /// if not all properties have yet been mapped, thus allowing a second pass to await a first pass that is still initializing + /// the same entry, instead of duplicating its work or, worse yet, failing. + /// + /// + /// The task is settled by once the target has been constructed, or by + /// if construction throws, so a second pass awaiting it never hangs. It is settled during + /// registration, after the constructor runs but before the first pass maps the target's properties, so a second pass may + /// observe an instance whose constructor parameters are set but whose properties are not yet mapped. This early publication + /// is what allows a property-level circular reference to resolve to the cached (if partially populated) instance instead of + /// recursing indefinitely, while still catching constructor-level circular references. + /// + internal Task Completion => _completionSource.Task; + /*============================================================================================================================ | METHOD: GET MISSING ASSOCIATIONS \---------------------------------------------------------------------------------------------------------------------------*/ @@ -61,15 +93,69 @@ internal sealed class MappedTopicCacheEntry { /// Given a target , identifies any associations not covered by /// and returns them as a new instance. /// + /// + /// This is intended as a quick hint to decide e.g., whether an expansion is needed at all, without any side effects. It + /// does not record the result; a caller that intends to map the missing associations should instead use , so that concurrent passes cannot both map the same associations. + /// internal AssociationTypes GetMissingAssociations(AssociationTypes associations) => Associations ^ (associations | Associations); /*============================================================================================================================ | METHOD: ADD MISSING ASSOCIATIONS \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Given a target , adds any missing to the property. + /// Given a target , adds any not already covered by and returns + /// the subset that this call just added. + /// + /// + /// This is the mutating counterpart to : It adds any associations + /// that aren't already covered, and reports which ones were added back to the caller so the caller knows which associations + /// to process. Because the delta is calculated and saved under a single lock, two concurrent passes over the same cached + /// instance receive disjoint results, ensuring each association is mapped by exactly one caller. A caller that receives + /// has nothing left to map and should return the cached instance. + /// + internal AssociationTypes AddMissingAssociations(AssociationTypes associations) { + lock (_lock) { + var missing = GetMissingAssociations(associations); + Associations = associations | Associations; + return missing; + } + } + + /*============================================================================================================================ + | METHOD: COMPLETE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Publishes the constructed and its as the entry's and , and settles the task, releasing any second pass + /// awaiting this entry. + /// + /// + /// This is the sole writer of and the initial writer of , so the + /// instance, its associations, and the completion signal are always published together under a single lock. Only the first + /// completion takes effect; a later duplicate registration is ignored, keeping the cached instance stable, while remaining + /// unobservable before the entry is completed. + /// + /// The constructed view model associated with the entry. + /// The associations that the view model was mapped with. + internal void Complete(object viewModel, AssociationTypes associations) { + lock (_lock) { + if (!_completionSource.Task.IsCompleted) { + MappedTopic = viewModel; + Associations = associations; + _completionSource.TrySetResult(); + } + } + } + + /*============================================================================================================================ + | METHOD: FAULT + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Faults the task with the supplied so that any pass awaiting the + /// entry observes the failure instead of hanging when construction of the entry throws. /// - internal void AddMissingAssociations(AssociationTypes associations) => Associations = associations | Associations; + /// The exception that occurred while constructing the entry. + internal void Fault(Exception exception) => _completionSource.TrySetException(exception); } //Class \ No newline at end of file diff --git a/OnTopic/Mapping/Reverse/BindingModelValidator.cs b/OnTopic/Mapping/Reverse/BindingModelValidator.cs index 9d124a11..93d42059 100644 --- a/OnTopic/Mapping/Reverse/BindingModelValidator.cs +++ b/OnTopic/Mapping/Reverse/BindingModelValidator.cs @@ -148,7 +148,7 @@ static internal void ValidateProperty( | Define variables \-------------------------------------------------------------------------------------------------------------------------*/ var configuration = propertyAccessor.Configuration; - var compositeAttributeKey = configuration.GetCompositeAttributeKey(attributePrefix); + var compositeAttributeKey = configuration.GetCompositeAttributeKey(attributePrefix); var attributeDescriptor = contentTypeDescriptor.AttributeDescriptors.GetValue(compositeAttributeKey); var childCollections = new[] { CollectionType.Children, CollectionType.NestedTopics }; var relationships = new[] { CollectionType.Relationship, CollectionType.IncomingRelationship }; @@ -188,7 +188,7 @@ static internal void ValidateProperty( foreach (var type in propertyAccessor.Type.GetInterfaces()) { if (type.IsGenericType && typeof(IList<>) == type.GetGenericTypeDefinition()) { //Uses last argument in case it's a KeyedCollection; in that case, we want the TItem type - listType = type.GetGenericArguments().Last(); + listType = type.GetGenericArguments().Last(); } } diff --git a/OnTopic/Mapping/Reverse/ReverseTopicMappingService.cs b/OnTopic/Mapping/Reverse/ReverseTopicMappingService.cs index 723f686b..ca892ce5 100644 --- a/OnTopic/Mapping/Reverse/ReverseTopicMappingService.cs +++ b/OnTopic/Mapping/Reverse/ReverseTopicMappingService.cs @@ -25,7 +25,6 @@ public class ReverseTopicMappingService : IReverseTopicMappingService { | PRIVATE VARIABLES \---------------------------------------------------------------------------------------------------------------------------*/ readonly ITopicRepository _topicRepository; - readonly ContentTypeDescriptorCollection _contentTypeDescriptors; /*============================================================================================================================ | CONSTRUCTOR @@ -44,16 +43,6 @@ public ReverseTopicMappingService(ITopicRepository topicRepository) { | Set dependencies \-------------------------------------------------------------------------------------------------------------------------*/ _topicRepository = topicRepository; - _contentTypeDescriptors = topicRepository.GetContentTypeDescriptors(); - - /*-------------------------------------------------------------------------------------------------------------------------- - | Validate dependencies - \-------------------------------------------------------------------------------------------------------------------------*/ - Contract.Assume( - _contentTypeDescriptors, - $"The {nameof(ITopicRepository.GetContentTypeDescriptors)}() method returned null. This could indicate a corrupt " + - $"or data source." - ); } @@ -77,7 +66,7 @@ public ReverseTopicMappingService(ITopicRepository topicRepository) { /*-------------------------------------------------------------------------------------------------------------------------- | Instantiate target \-------------------------------------------------------------------------------------------------------------------------*/ - var topic = TopicFactory.Create(source.Key, source.ContentType); + var topic = TopicFactory.Create(source.Key, source.ContentType); /*-------------------------------------------------------------------------------------------------------------------------- | Provide mapping @@ -133,7 +122,7 @@ public ReverseTopicMappingService(ITopicRepository topicRepository) { Contract.Assume(source.ContentType, nameof(source.ContentType)); //Ensure the content type is valid - if (!_contentTypeDescriptors.Contains(source.ContentType)) { + if (!GetContentTypeDescriptors().Contains(source.ContentType)) { throw new MappingModelValidationException( $"The {nameof(source)} object (with the key '{source.Key}') has a content type of '{source.ContentType}'. There " + $"are no matching content types in the ITopicRepository provided. This suggests that the binding model is invalid. " + @@ -168,6 +157,30 @@ public ReverseTopicMappingService(ITopicRepository topicRepository) { } + /*============================================================================================================================ + | PRIVATE: GET CONTENT TYPE DESCRIPTORS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Retrieves the from the . + /// + /// + /// Called per-use rather than cached into a field, since content types can be added after this service is constructed, and + /// a local cache would silently exclude any updates for the life of the service. Further, , + /// the base class for every production in this library, already caches the result after the + /// first call and maintains the live collection in place (e.g. Delete refreshes it), so the per-call access here is + /// expected to be cheap, acknowledging that's a property of that base class, not a guarantee of the interface itself. + /// + private ContentTypeDescriptorCollection GetContentTypeDescriptors() { + var contentTypeDescriptors = _topicRepository.GetContentTypeDescriptors(); + Contract.Assume( + contentTypeDescriptors, + $"The {nameof(ITopicRepository.GetContentTypeDescriptors)}() method returned null. This could indicate a corrupt " + + $"data source." + ); + return contentTypeDescriptors; + } + /*============================================================================================================================ | PRIVATE: MAP (TOPIC) \---------------------------------------------------------------------------------------------------------------------------*/ @@ -183,6 +196,13 @@ public ReverseTopicMappingService(ITopicRepository topicRepository) { /// /// An instance of provided with attributes appropriately mapped. /// + /// + /// Properties are mapped sequentially, in source order, rather than concurrently; this avoids concurrent mutation of the + /// association collections (, , etc.), which aren't thread + /// safe, and which individual property mappers write to on the shared . As a result, an exception + /// thrown while mapping one property surfaces immediately, without waiting for or aggregating exceptions from subsequent + /// properties, and any properties mapped before the failure remain applied to . + /// private async Task MapAsync(object? source, Topic target, string? attributePrefix) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -190,22 +210,28 @@ public ReverseTopicMappingService(ITopicRepository topicRepository) { \-------------------------------------------------------------------------------------------------------------------------*/ if (source is null) return target; + /*-------------------------------------------------------------------------------------------------------------------------- + | Warm extended attributes + >--------------------------------------------------------------------------------------------------------------------------- + | Without this, TrackedRecordCollection.SetValue() potentially runs against an unloaded extended attributes, and thus marks + | attributes as dirty even if they're unchanged, causing needless version rows on save. + \-------------------------------------------------------------------------------------------------------------------------*/ + await ((ITopicLazyLoadable)target).EnsureLoaded(TopicPayload.ExtendedAttributes).ConfigureAwait(false); + /*-------------------------------------------------------------------------------------------------------------------------- | Validate model \-------------------------------------------------------------------------------------------------------------------------*/ var typeAccessor = TypeAccessorCache.GetTypeAccessor(source.GetType()); - var contentTypeDescriptor = _contentTypeDescriptors.GetValue(target.ContentType); + var contentTypeDescriptor = GetContentTypeDescriptors().GetValue(target.ContentType); BindingModelValidator.ValidateModel(typeAccessor, contentTypeDescriptor, attributePrefix); /*-------------------------------------------------------------------------------------------------------------------------- | Loop through properties, mapping each one \-------------------------------------------------------------------------------------------------------------------------*/ - var taskQueue = new List(); foreach (var property in typeAccessor.GetMembers(MemberTypes.Property)) { - taskQueue.Add(SetPropertyAsync(source, target, property, attributePrefix)); + await SetPropertyAsync(source, target, property, attributePrefix).ConfigureAwait(false); } - await Task.WhenAll([.. taskQueue]).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Return result @@ -239,8 +265,8 @@ private async Task SetPropertyAsync( | Establish per-property variables \-------------------------------------------------------------------------------------------------------------------------*/ var configuration = memberAccessor.Configuration; - var contentTypeDescriptor = _contentTypeDescriptors.GetValue(target.ContentType); - var compositeAttributeKey = configuration.GetCompositeAttributeKey(attributePrefix); + var contentTypeDescriptor = GetContentTypeDescriptors().GetValue(target.ContentType); + var compositeAttributeKey = configuration.GetCompositeAttributeKey(attributePrefix); Contract.Assume(contentTypeDescriptor, nameof(contentTypeDescriptor)); @@ -273,7 +299,7 @@ await MapAsync( /*-------------------------------------------------------------------------------------------------------------------------- | Retrieve attribute descriptor \-------------------------------------------------------------------------------------------------------------------------*/ - var attributeType = contentTypeDescriptor.AttributeDescriptors.GetValue(compositeAttributeKey); + var attributeType = contentTypeDescriptor.AttributeDescriptors.GetValue(compositeAttributeKey); if (attributeType is null) { throw new MappingModelValidationException( @@ -294,13 +320,13 @@ await MapAsync( SetScalarValue(source, target, memberAccessor, attributePrefix); return; case ModelType.Relationship: - SetRelationships(source, target, memberAccessor, attributePrefix); + await SetRelationships(source, target, memberAccessor, attributePrefix).ConfigureAwait(false); return; case ModelType.NestedTopic: await SetNestedTopicsAsync(source, target, memberAccessor, attributePrefix).ConfigureAwait(false); return; case ModelType.Reference: - SetReference(source, target, memberAccessor, attributePrefix); + await SetReference(source, target, memberAccessor, attributePrefix).ConfigureAwait(false); return; } @@ -344,7 +370,7 @@ private static void SetScalarValue( | Fall back to default, if configured \-------------------------------------------------------------------------------------------------------------------------*/ if (String.IsNullOrEmpty(attributeValue) && configuration.DefaultValue is not null) { - attributeValue = configuration.DefaultValue.ToString(); + attributeValue = configuration.DefaultValue.ToString(); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -374,7 +400,7 @@ private static void SetScalarValue( /// The entity to map the data to. /// The with details about the property's attributes. /// The prefix to apply to the attributes. - private void SetRelationships( + private async Task SetRelationships( object source, Topic target, MemberAccessor memberAccessor, @@ -389,9 +415,9 @@ private void SetRelationships( /*-------------------------------------------------------------------------------------------------------------------------- | Retrieve source list \-------------------------------------------------------------------------------------------------------------------------*/ - var sourceList = (IList?)memberAccessor.GetValue(source); + var sourceList = (IList?)memberAccessor.GetValue(source); - sourceList ??= new List(); + sourceList ??= new List(); /*-------------------------------------------------------------------------------------------------------------------------- | Clear existing relationships @@ -402,7 +428,7 @@ private void SetRelationships( | Set relationships for each \-------------------------------------------------------------------------------------------------------------------------*/ foreach (IAssociatedTopicBindingModel relationship in sourceList) { - var targetTopic = _topicRepository.Load(relationship.UniqueKey, target); + var targetTopic = await _topicRepository.Load(relationship.UniqueKey, target).ConfigureAwait(false); if (targetTopic is null) { throw new MappingModelValidationException( $"The relationship '{relationship.UniqueKey}' mapped in the '{memberAccessor.Name}' property could not be " + @@ -442,17 +468,32 @@ private async Task SetNestedTopicsAsync( /*-------------------------------------------------------------------------------------------------------------------------- | Retrieve source list \-------------------------------------------------------------------------------------------------------------------------*/ - var sourceList = (IList?)memberAccessor.GetValue(source) ?? new List(); + var sourceList = (IList?)memberAccessor.GetValue(source) ?? new List(); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Warm target's children + >--------------------------------------------------------------------------------------------------------------------------- + | Replaces the Children getter's synchronous autoload with an explicit, asynchronous warm-up prior to the below probe + \-------------------------------------------------------------------------------------------------------------------------*/ + await ((ITopicLazyLoadable)target).EnsureLoaded(TopicPayload.Children).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Establish target collection to store mapped topics \-------------------------------------------------------------------------------------------------------------------------*/ - var container = target.Children.GetValue(configuration.GetCompositeAttributeKey(attributePrefix)); + var container = target.Children.GetValue(configuration.GetCompositeAttributeKey(attributePrefix)); if (container is null) { - container = TopicFactory.Create(configuration.GetCompositeAttributeKey(attributePrefix), "List", target); - container.IsHidden = true; + container = TopicFactory.Create(configuration.GetCompositeAttributeKey(attributePrefix), "List", target); + container.IsHidden = true; } + /*-------------------------------------------------------------------------------------------------------------------------- + | Warm container's children + >--------------------------------------------------------------------------------------------------------------------------- + | The container can be NotLoaded even when target is loaded; PopulateTargetCollectionAsync()'s Contains() check for existing + | children as well as it's check for orphans require the complete set + \-------------------------------------------------------------------------------------------------------------------------*/ + await ((ITopicLazyLoadable)container).EnsureLoaded(TopicPayload.Children).ConfigureAwait(false); + /*-------------------------------------------------------------------------------------------------------------------------- | Map the topics from the source collection, and add them to the target collection \-------------------------------------------------------------------------------------------------------------------------*/ @@ -473,7 +514,7 @@ private async Task SetNestedTopicsAsync( /// The entity to map the data to. /// The with details about the property's attributes. /// The prefix to apply to the attributes. - private void SetReference( + private async Task SetReference( object source, Topic target, MemberAccessor memberAccessor, @@ -488,7 +529,7 @@ private void SetReference( /*-------------------------------------------------------------------------------------------------------------------------- | Retrieve source value \-------------------------------------------------------------------------------------------------------------------------*/ - var modelReference = (IAssociatedTopicBindingModel?)memberAccessor.GetValue(source); + var modelReference = (IAssociatedTopicBindingModel?)memberAccessor.GetValue(source); /*-------------------------------------------------------------------------------------------------------------------------- | Provide error handling @@ -503,7 +544,7 @@ private void SetReference( /*-------------------------------------------------------------------------------------------------------------------------- | Identify target value \-------------------------------------------------------------------------------------------------------------------------*/ - var topicReference = _topicRepository.Load(modelReference.UniqueKey, target); + var topicReference = await _topicRepository.Load(modelReference.UniqueKey, target).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Provide error handling @@ -535,31 +576,20 @@ private void SetReference( /// /// The to pull the binding models from. /// The target to add the mapped objects to. + /// + /// Children are mapped and added sequentially, in order, rather than concurrently; this + /// avoids concurrent mutation on the shared target and guarantees 's + /// resulting order matches the binding model, instead of varying with completion order. + /// private async Task PopulateTargetCollectionAsync( IList sourceList, KeyedTopicCollection targetList ) { - /*-------------------------------------------------------------------------------------------------------------------------- - | Queue up mapping tasks - \-------------------------------------------------------------------------------------------------------------------------*/ - var taskQueue = new List>(); - - //Map child binding model to target collection on the target - foreach (ITopicBindingModel childBindingModel in sourceList) { - Contract.Assume(childBindingModel.Key); - if (targetList.Contains(childBindingModel.Key)) { - taskQueue.Add(MapAsync(childBindingModel, targetList.GetValue(childBindingModel.Key)!)); - } - else { - taskQueue.Add(MapAsync(childBindingModel)); - } - } - /*-------------------------------------------------------------------------------------------------------------------------- | Remove orphaned topics \-------------------------------------------------------------------------------------------------------------------------*/ - foreach (var childTopic in targetList.ToArray()) { + foreach (var childTopic in targetList.ToArray()) { if (sourceList.Cast().Any(model => model.Key == childTopic.Key)) { continue; } @@ -567,15 +597,23 @@ KeyedTopicCollection targetList } /*-------------------------------------------------------------------------------------------------------------------------- - | Process mapping tasks + | Map and add children in source order + >--------------------------------------------------------------------------------------------------------------------------- + | Sequential by design: concurrent MapAsync() calls would mutate non-thread-safe collections on the shared target Topic in + | parallel, and completion-order nondeterminism would make targetList's resulting order unpredictable. \-------------------------------------------------------------------------------------------------------------------------*/ - while (taskQueue.Count > 0) { - var topicTask = await Task.WhenAny(taskQueue).ConfigureAwait(false); - taskQueue.Remove(topicTask); - var topic = await topicTask.ConfigureAwait(false); - if (topic is not null && !targetList.Contains(topic.Key)) { + foreach (ITopicBindingModel childBindingModel in sourceList) { + + Contract.Assume(childBindingModel.Key); + + var topic = targetList.Contains(childBindingModel.Key) + ? await MapAsync(childBindingModel, targetList.GetValue(childBindingModel.Key)!).ConfigureAwait(false) + : await MapAsync(childBindingModel).ConfigureAwait(false); + + if (topic is not null && !targetList.Contains(topic.Key)) { targetList.Add(topic); } + } } diff --git a/OnTopic/Mapping/TopicMappingService.cs b/OnTopic/Mapping/TopicMappingService.cs index 4e10ec5a..4ae26e71 100644 --- a/OnTopic/Mapping/TopicMappingService.cs +++ b/OnTopic/Mapping/TopicMappingService.cs @@ -69,12 +69,14 @@ public class TopicMappingService(ITopicRepository topicRepository, ITypeLookupSe /// Determines what associations the mapping should include, if any. /// A cache to keep track of already-mapped object instances. /// The prefix to apply to the attributes. + /// The current mapping request's path, used to detect circular references during construction. /// An instance of the dynamically determined View Model with properties appropriately mapped. private async Task MapAsync( Topic? topic, AssociationTypes associations, MappedTopicCache cache, - string? attributePrefix = null + string? attributePrefix = null, + MapPath? mapPath = null ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -87,7 +89,7 @@ public class TopicMappingService(ITopicRepository topicRepository, ITypeLookupSe /*-------------------------------------------------------------------------------------------------------------------------- | Lookup type \-------------------------------------------------------------------------------------------------------------------------*/ - var viewModelType = _typeLookupService.Lookup($"{topic.ContentType}TopicViewModel", $"{topic.ContentType}ViewModel"); + var viewModelType = _typeLookupService.Lookup($"{topic.ContentType}TopicViewModel", $"{topic.ContentType}ViewModel"); if (viewModelType is null) { throw new InvalidTypeException( @@ -99,7 +101,7 @@ public class TopicMappingService(ITopicRepository topicRepository, ITypeLookupSe /*-------------------------------------------------------------------------------------------------------------------------- | Perform mapping \-------------------------------------------------------------------------------------------------------------------------*/ - return await MapAsync(topic, viewModelType, associations, cache, attributePrefix).ConfigureAwait(false); + return await MapAsync(topic, viewModelType, associations, cache, attributePrefix, mapPath).ConfigureAwait(false); } @@ -128,13 +130,15 @@ public class TopicMappingService(ITopicRepository topicRepository, ITypeLookupSe /// Determines what associations the mapping should include, if any. /// A cache to keep track of already-mapped object instances. /// The prefix to apply to the attributes. + /// The current mapping request's path, used to detect circular references during construction. /// An instance of the dynamically determined View Model with properties appropriately mapped. private async Task MapAsync( Topic? topic, Type type, AssociationTypes associations, MappedTopicCache cache, - string? attributePrefix = null + string? attributePrefix = null, + MapPath? mapPath = null ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -146,16 +150,14 @@ public class TopicMappingService(ITopicRepository topicRepository, ITypeLookupSe /*-------------------------------------------------------------------------------------------------------------------------- | Handle cached objects - \-------------------------------------------------------------------------------------------------------------------------*/ - var target = (object?)null; - - if (cache.TryGetValue(topic.Id, type, out var cacheEntry)) { - target = cacheEntry.MappedTopic; - if (cacheEntry.GetMissingAssociations(associations) == AssociationTypes.None) { - return target; - } - //Call MapAsync() with target object to map missing attributes - return await MapAsync(topic, target, associations, cache, attributePrefix).ConfigureAwait(false); + >--------------------------------------------------------------------------------------------------------------------------- + | Included entries that are still initializing, so a circular constructor reference (i.e., the same topic and type are + | already under construction higher up the current chain) can be distinguished from concurrent siblings (i.e., two + | independent branches mapping the same topic and type at once). The former throws; the latter awaits for the first one to + | finish construction and then uses the same cached view model. + \-------------------------------------------------------------------------------------------------------------------------*/ + if (cache.TryGetValue(topic.Id, type, out var cacheEntry, includeInitializing: true)) { + return await resolveCachedEntry(cacheEntry, mapPath).ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -171,97 +173,167 @@ public class TopicMappingService(ITopicRepository topicRepository, ITypeLookupSe /*-------------------------------------------------------------------------------------------------------------------------- | Pre-cache entry >------------------------------------------------------------------------------------------------------------------------- - | In property mapping, we deal with circular references by returning a cached reference. That isn't practical with - | circular references in constructor mapping. To help avoid these, we register a pre-cache entry as IsInitializing, but - | without a mapped object; the TopicMappingCache is expected to throw an exception if an attempt to map that topic to that - | type occurs again prior to the constructor mapping being completed. + | In property mapping, we deal with circular references by returning a cached reference. That isn't practical with circular + | references in constructor mapping. To help avoid these, we register a pre-cache entry as IsInitializing, but without a + | mapped object. If a concurrent sibling wins the race to preregister the same topic and view model type, we defer to its + | result rather than constructing a duplicate. If we're constructing the same topic and view model type as we're already in + | the middle of constructing further up the MapPath chain, however, that's a true circular construction reference, handled + | via the local resolveCachedEntry() function. \-------------------------------------------------------------------------------------------------------------------------*/ - cache.Preregister(topic.Id, type); - - /*-------------------------------------------------------------------------------------------------------------------------- - | Handle AttributeDictionary constructor - >------------------------------------------------------------------------------------------------------------------------- - | A model may optionally expose a constructor with a single parameter accepting an AttributeDictionary. In this scenario, - | the TopicMappingService may optionally pass a lightweight AttributeDictionary, allowing the model's constructor to - | populate scalar values, instead of relying on reflection. - \-------------------------------------------------------------------------------------------------------------------------*/ - if (parameters.Count is 1 && parameters[0].Type == typeof(AttributeDictionary)) { - - // This strategy is only performant if there are quite a several scalar properties and they are well-covered by the - // attributes. As a fast heuristic to evaluate this, we expect five or more attributes and three or more compatible - // properties. In practice, this should be benefitial with any more than mapped attributes, but we also expect that most - // topics will have 2-3 excluded or unmapped attributes (e.g., Title, LastModified). With models, we can be a bit more - // intelligent, by excluding any members that are likely compatible with Topic properties, thus exluding e.g., Id, Key, - // WebPath, etc. This doesn't guarantee that the attributes map to the properties, but a more accurate evaluation would - // undermine the performance benefits of this optimization. - if (topic.Attributes.Count >= 5 && properties.Count(p => !p.MaybeCompatible) >= 3) { - var attributes = topic.Attributes.AsAttributeDictionary(true); - arguments[0] = attributes; - attributeArguments = attributes; - } - else { - parameters = new(); - arguments = []; - } + var (entry, isNew) = cache.Preregister(topic.Id, type); + if (!isNew) { + return await resolveCachedEntry(entry, mapPath).ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- - | Handle other constructors - >------------------------------------------------------------------------------------------------------------------------- - | A model may optionally expose a constructor with multiple parameters, which can be defined via reflection in the same - | way as properties would be. This is especially useful for records using the positional syntax (i.e., where properties - | are defined using the constructor). This also, optionally, provides the model with more control, where needed, over how - | it's constructed. - \-------------------------------------------------------------------------------------------------------------------------*/ - else { + | Establish mapping path chain + >--------------------------------------------------------------------------------------------------------------------------- + | Now that this pass owns constructing a new view model, establish the initial topic and view model type in the MapPath, so + | any nested mapping that arrives back to the same pair while it is still initializing is recognized as a true circular + | constructor reference rather than harmless concurrency among independent siblings. + \-------------------------------------------------------------------------------------------------------------------------*/ + mapPath = new(topic.Id, type, mapPath); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Construct and register the target + >--------------------------------------------------------------------------------------------------------------------------- + | Only the construction span is guarded, so the entry's completion is always settled: cache.Register() settles it on + | success and the catch faults it on failure, so a pass awaiting this entry observes the failure instead of hanging. + | Property mapping runs afterward, outside the guard, since the entry is already settled by then. + \-------------------------------------------------------------------------------------------------------------------------*/ + object? target; + + try { + + /*------------------------------------------------------------------------------------------------------------------------ + | Handle AttributeDictionary constructor + >------------------------------------------------------------------------------------------------------------------------- + | A model may optionally expose a constructor with a single parameter accepting an AttributeDictionary. In this scenario, + | the TopicMappingService may optionally pass a lightweight AttributeDictionary, allowing the model's constructor to + | populate scalar values, instead of relying on reflection. + \-----------------------------------------------------------------------------------------------------------------------*/ + if (parameters.Count is 1 && parameters[0].Type == typeof(AttributeDictionary)) { + + // This strategy is only performant if there are quite a several scalar properties and they are well-covered by the + // attributes. As a fast heuristic to evaluate this, we expect five or more attributes and three or more compatible + // properties. In practice, this should be benefitial with any more than mapped attributes, but we also expect that most + // topics will have 2-3 excluded or unmapped attributes (e.g., Title, LastModified). With models, we can be a bit more + // intelligent, by excluding any members that are likely compatible with Topic properties, thus exluding e.g., Id, Key, + // WebPath, etc. This doesn't guarantee that the attributes map to the properties, but a more accurate evaluation would + // undermine the performance benefits of this optimization. + if (topic.Attributes.Count >= 5 && properties.Count(p => !p.MaybeCompatible) >= 3) { + var attributes = topic.Attributes.AsAttributeDictionary(true); + arguments[0] = attributes; + attributeArguments = attributes; + } + else { + parameters = new(); + arguments = []; + } - foreach (var parameter in parameters) { - parameterQueue.Add(parameter.ParameterInfo.Position, GetParameterAsync(topic, associations, parameter, cache, attributePrefix)); } - await Task.WhenAll(parameterQueue.Values).ConfigureAwait(false); + /*------------------------------------------------------------------------------------------------------------------------ + | Handle other constructors + >------------------------------------------------------------------------------------------------------------------------- + | A model may optionally expose a constructor with multiple parameters, which can be defined via reflection in the same + | way as properties would be. This is especially useful for records using the positional syntax (i.e., where properties + | are defined using the constructor). This also, optionally, provides the model with more control, where needed, over how + | it's constructed. + \-----------------------------------------------------------------------------------------------------------------------*/ + else { + + foreach (var parameter in parameters) { + parameterQueue.Add(parameter.ParameterInfo.Position, GetParameterAsync(topic, associations, parameter, cache, attributePrefix, mapPath)); + } + + await Task.WhenAll(parameterQueue.Values).ConfigureAwait(false); + + foreach (var parameter in parameterQueue) { + arguments[parameter.Key] = await parameter.Value.ConfigureAwait(false); + } - foreach (var parameter in parameterQueue) { - arguments[parameter.Key] = await parameter.Value.ConfigureAwait(false); } - } + /*------------------------------------------------------------------------------------------------------------------------ + | Initialize object + \-----------------------------------------------------------------------------------------------------------------------*/ + target = Activator.CreateInstance(type, arguments); - /*-------------------------------------------------------------------------------------------------------------------------- - | Initialize object - \-------------------------------------------------------------------------------------------------------------------------*/ - target = Activator.CreateInstance(type, arguments); + Contract.Assume( + target, + $"The target type '{type}' could not be properly constructed, as required to map the topic '{topic.GetUniqueKey()}'." + ); - Contract.Assume( - target, - $"The target type '{type}' could not be properly constructed, as required to map the topic '{topic.GetUniqueKey()}'." - ); + /*------------------------------------------------------------------------------------------------------------------------ + | Cache object + \-----------------------------------------------------------------------------------------------------------------------*/ + cache.Register(topic.Id, associations, target); - /*-------------------------------------------------------------------------------------------------------------------------- - | Cache object - \-------------------------------------------------------------------------------------------------------------------------*/ - cache.Register(topic.Id, associations, target); + } + + // Construction failed before the entry was registered, so fault it; a concurrent pass awaiting this entry's completion then + // observes the failure instead of hanging on a map that will never complete + catch (Exception exception) { + entry.Fault(exception); + throw; + } /*-------------------------------------------------------------------------------------------------------------------------- | Loop through properties, mapping each one \-------------------------------------------------------------------------------------------------------------------------*/ - var propertyQueue = new List(); + List propertyQueue = []; var mappedParameters = parameters.Select(p => p.Name).Union(attributeArguments.Select(a => a.Key)).ToArray(); foreach (var property in typeAccessor.GetMembers(MemberTypes.Property)) { if (!mappedParameters.Contains(property.Name, StringComparer.OrdinalIgnoreCase)) { - propertyQueue.Add(SetPropertyAsync(topic, target, associations, property, cache, attributePrefix, false)); + propertyQueue.Add(SetPropertyAsync(topic, target, associations, property, cache, attributePrefix, false, mapPath)); } } - await Task.WhenAll([.. propertyQueue]).ConfigureAwait(false); + await Task.WhenAll(propertyQueue).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Return target \-------------------------------------------------------------------------------------------------------------------------*/ return target; + /*-------------------------------------------------------------------------------------------------------------------------- + | Resolve cached entry + >--------------------------------------------------------------------------------------------------------------------------- + | Returns a cached instance, expanding it with any missing associations when needed. If the entry is still initializing, it + | is either a true circular constructor reference on the current path (in which we throw an exception) or it's concurrent + | mapping of two independent siblings of the same topic and view model (in which we simply await its completion, then + | return the shared instance as a normal cache hit). + \-------------------------------------------------------------------------------------------------------------------------*/ + async Task resolveCachedEntry(MappedTopicCacheEntry pendingEntry, MapPath? path) { + + // Distinguish a constructor cycle from sibling concurrency for an entry that is still being constructed + if (pendingEntry.IsInitializing) { + if (path?.Contains(topic.Id, type) == true) { + throw new TopicMappingException( + $"A circular reference was detected while constructing the '{type.Name}' instance for topic '{topic.Id}'. Circular " + + $"references must be mapped as properties, not as constructor parameters, so that a cached instance can be returned." + ); + } + // Not on the current path: a concurrent sibling owns construction, so await its completion before resolving. A mutual + // constructor cycle split across two concurrently mapped branches is the one case this cannot distinguish and would + // deadlock; such cycles are unsupported, and still throw when reached from a single branch via the path check above. + await pendingEntry.Completion.ConfigureAwait(false); + } + + // Return the cached instance as-is when it already covers the requested associations + var cachedTarget = pendingEntry.MappedTopic; + if (pendingEntry.GetMissingAssociations(associations) is AssociationTypes.None) { + return cachedTarget; + } + + // Otherwise expand the cached instance, mapping only the missing associations + return await MapAsync(topic, cachedTarget, associations, cache, attributePrefix, path).ConfigureAwait(false); + + } + } /*============================================================================================================================ @@ -292,6 +364,7 @@ public class TopicMappingService(ITopicRepository topicRepository, ITypeLookupSe /// Determines what associations the mapping should include, if any. /// A cache to keep track of already-mapped object instances. /// The prefix to apply to the attributes. + /// The current mapping request's path, used to detect circular references during construction. /// /// This internal version passes a private cache of mapped objects from this run. This helps prevent problems with /// recursion in case is referred to multiple times (e.g., a Children collection with MapAsync( object target, AssociationTypes associations, MappedTopicCache cache, - string? attributePrefix = null + string? attributePrefix = null, + MapPath? mapPath = null ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -324,17 +398,17 @@ private async Task MapAsync( /*-------------------------------------------------------------------------------------------------------------------------- | Handle cached objects - >------------------------------------------------------------------------------------------------------------------------- + >--------------------------------------------------------------------------------------------------------------------------- | If the cache contains an entry, check to make sure it includes all of the requested associations. If it does, return it. - | If it doesn't, determine the missing associations and request to have those mapped. + | Otherwise, add the missing associations to the cache entry and map only the subset this pass added, so that a concurrent + | pass doesn't remap the same associations. \-------------------------------------------------------------------------------------------------------------------------*/ if (cache.TryGetValue(topic.Id, target.GetType(), out var cacheEntry)) { - associations = cacheEntry.GetMissingAssociations(associations); + associations = cacheEntry.AddMissingAssociations(associations); target = cacheEntry.MappedTopic; if (associations is AssociationTypes.None) { return cacheEntry.MappedTopic; } - cacheEntry.AddMissingAssociations(associations); } else if (!topic.IsNew) { cache.Register( @@ -347,13 +421,13 @@ private async Task MapAsync( /*-------------------------------------------------------------------------------------------------------------------------- | Loop through properties, mapping each one \-------------------------------------------------------------------------------------------------------------------------*/ - var taskQueue = new List(); + List taskQueue = []; var typeAccessor = TypeAccessorCache.GetTypeAccessor(target.GetType()); foreach (var property in typeAccessor.GetMembers(MemberTypes.Property)) { - taskQueue.Add(SetPropertyAsync(topic, target, associations, property, cache, attributePrefix, cacheEntry is not null)); + taskQueue.Add(SetPropertyAsync(topic, target, associations, property, cache, attributePrefix, cacheEntry is not null, mapPath)); } - await Task.WhenAll([.. taskQueue]).ConfigureAwait(false); + await Task.WhenAll(taskQueue).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Return result @@ -374,12 +448,14 @@ private async Task MapAsync( /// Information related to the current parameter. /// A cache to keep track of already-mapped object instances. /// The prefix to apply to the attributes. + /// The current mapping request's path, used to detect circular references during construction. private async Task GetParameterAsync( Topic source, AssociationTypes associations, ParameterMetadata parameter, MappedTopicCache cache, - string? attributePrefix = null + string? attributePrefix = null, + MapPath? mapPath = null ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -403,14 +479,15 @@ private async Task MapAsync( parameter.Type, associations, cache, - configuration.AttributePrefix + attributePrefix + configuration.AttributePrefix + attributePrefix, + mapPath ).ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- | Determine value \-------------------------------------------------------------------------------------------------------------------------*/ - var value = await GetValue(source, parameter.Type, associations, parameter, cache, attributePrefix, false).ConfigureAwait(false); + var value = await GetValue(source, parameter.Type, associations, parameter, cache, attributePrefix, false, mapPath).ConfigureAwait(false); if (value is null && parameter.IsList) { return await getList(parameter.Type).ConfigureAwait(false); @@ -423,14 +500,14 @@ private async Task MapAsync( \-------------------------------------------------------------------------------------------------------------------------*/ async Task getList(Type targetType) { - var sourceList = GetSourceCollection(source, associations, parameter, attributePrefix); - var targetList = InitializeCollection(targetType); + var sourceList = await GetSourceCollectionAsync(source, associations, parameter, attributePrefix, false).ConfigureAwait(false); + var targetList = InitializeCollection(targetType); - if (sourceList is null || targetList is null) { - return (IList?)null; + if (targetList is null) { + return null; } - await PopulateTargetCollectionAsync(sourceList, targetList, parameter, cache).ConfigureAwait(false); + await PopulateTargetCollectionAsync(sourceList, targetList, parameter, cache, targetList, mapPath).ConfigureAwait(false); return targetList; @@ -452,6 +529,7 @@ private async Task MapAsync( /// A cache to keep track of already-mapped object instances. /// The prefix to apply to the attributes. /// Determines if properties not associated with associations should be mapped. + /// The current mapping request's path, used to detect circular references during construction. private async Task SetPropertyAsync( Topic source, object target, @@ -459,7 +537,8 @@ private async Task SetPropertyAsync( MemberAccessor propertyAccessor, MappedTopicCache cache, string? attributePrefix = null, - bool mapAssociationsOnly = false + bool mapAssociationsOnly = false, + MapPath? mapPath = null ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -478,14 +557,15 @@ private async Task SetPropertyAsync( | Handle [MapToParent] attribute \-------------------------------------------------------------------------------------------------------------------------*/ if (configuration.MapToParent) { - var targetProperty = propertyAccessor.GetValue(target); + var targetProperty = propertyAccessor.GetValue(target); if (targetProperty is not null) { await MapAsync( source, targetProperty, associations, cache, - configuration.AttributePrefix + attributePrefix + configuration.AttributePrefix + attributePrefix, + mapPath ).ConfigureAwait(false); } } @@ -494,9 +574,9 @@ await MapAsync( | Determine value \-------------------------------------------------------------------------------------------------------------------------*/ else { - var value = await GetValue(source, propertyAccessor.Type, associations, propertyAccessor, cache, attributePrefix, mapAssociationsOnly).ConfigureAwait(false); + var value = await GetValue(source, propertyAccessor.Type, associations, propertyAccessor, cache, attributePrefix, mapAssociationsOnly, mapPath).ConfigureAwait(false); if (value is null && propertyAccessor.IsList) { - await SetCollectionValueAsync(source, target, associations, propertyAccessor, cache, attributePrefix).ConfigureAwait(false); + await SetCollectionValueAsync(source, target, associations, propertyAccessor, cache, attributePrefix, mapAssociationsOnly, mapPath).ConfigureAwait(false); } else if (value != null && propertyAccessor.CanWrite) { propertyAccessor.SetValue(target, value, true); @@ -523,6 +603,7 @@ await MapAsync( /// A cache to keep track of already-mapped object instances. /// The prefix to apply to the attributes. /// Determines if properties not associated with associations should be mapped. + /// The current mapping request's path, used to detect circular references during construction. private async Task GetValue( Topic source, Type targetType, @@ -530,7 +611,8 @@ await MapAsync( ItemMetadata itemMetadata, MappedTopicCache cache, string? attributePrefix = "", - bool mapAssociationsOnly = false + bool mapAssociationsOnly = false, + MapPath? mapPath = null ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -543,18 +625,18 @@ await MapAsync( \-------------------------------------------------------------------------------------------------------------------------*/ var value = (object?)null; if (!mapAssociationsOnly && configuration.DefaultValue is not null) { - value = configuration.DefaultValue; + value = configuration.DefaultValue; } /*-------------------------------------------------------------------------------------------------------------------------- | Handle by type, attribute \-------------------------------------------------------------------------------------------------------------------------*/ - if (TryGetCompatibleProperty(source, targetType, itemMetadata, attributePrefix, out var compatibleValue)) { - value = compatibleValue; + if (!mapAssociationsOnly && TryGetCompatibleProperty(source, targetType, itemMetadata, attributePrefix, out var compatibleValue)) { + value = compatibleValue; } else if (itemMetadata.IsConvertible) { if (!mapAssociationsOnly) { - value = GetScalarValue(source, itemMetadata, attributePrefix); + value = GetScalarValue(source, itemMetadata, attributePrefix); } } else if (itemMetadata.IsList) { @@ -562,16 +644,16 @@ await MapAsync( } else if (configuration.GetCompositeAttributeKey(attributePrefix) is "Parent") { if (associations.HasFlag(AssociationTypes.Parents) && source.Parent is not null) { - value = await GetTopicReferenceAsync(source.Parent, targetType, itemMetadata, cache).ConfigureAwait(false); + value = await GetTopicReferenceAsync(source.Parent, targetType, itemMetadata, cache, mapPath).ConfigureAwait(false); } } else if (configuration.MapToParent) { return null; } else if (itemMetadata.Type.IsClass && associations.HasFlag(AssociationTypes.References)) { - var topicReference = getTopicReference(); + var topicReference = await getTopicReference().ConfigureAwait(false); if (topicReference is not null) { - value = await GetTopicReferenceAsync(topicReference, targetType, itemMetadata, cache).ConfigureAwait(false); + value = await GetTopicReferenceAsync(topicReference, targetType, itemMetadata, cache, mapPath).ConfigureAwait(false); } } @@ -583,7 +665,7 @@ await MapAsync( /*-------------------------------------------------------------------------------------------------------------------------- | Get Topic Reference \-------------------------------------------------------------------------------------------------------------------------*/ - Topic? getTopicReference() { + async Task getTopicReference() { // Check for standard topic reference var topicReference = source.References.GetValue(configuration.GetCompositeAttributeKey(attributePrefix)); @@ -599,7 +681,7 @@ await MapAsync( topicReferenceId = source.Attributes.GetInteger($"{configuration.GetCompositeAttributeKey(attributePrefix)}Id", 0); } if (topicReferenceId > 0) { - topicReference = _topicRepository.Load(topicReferenceId, source); + topicReference = await _topicRepository.Load(topicReferenceId, source).ConfigureAwait(false); } return topicReference; @@ -617,8 +699,8 @@ await MapAsync( /// /// The method will attempt to retrieve the value from the /// based on, in order, the 's Get{Property}() method, - /// {Property} property, and, finally, its collection (using ). + /// {Property} property, and, finally, its collection (using ). /// /// The source from which to pull the value. /// The with details about the property's attributes. @@ -643,20 +725,20 @@ await MapAsync( | Attempt to retrieve value from topic.{Property} \-------------------------------------------------------------------------------------------------------------------------*/ if (maybeCompatible) { - attributeValue = typeAccessor.GetMethodValue(source, $"Get{configuration.GetCompositeAttributeKey(attributePrefix)}")?.ToString(); + attributeValue = typeAccessor.GetMethodValue(source, $"Get{configuration.GetCompositeAttributeKey(attributePrefix)}")?.ToString(); } /*-------------------------------------------------------------------------------------------------------------------------- | Attempt to retrieve value from topic.{Property} \-------------------------------------------------------------------------------------------------------------------------*/ if (maybeCompatible && attributeValue is null) { - attributeValue = typeAccessor.GetPropertyValue(source, configuration.GetCompositeAttributeKey(attributePrefix))?.ToString(); + attributeValue = typeAccessor.GetPropertyValue(source, configuration.GetCompositeAttributeKey(attributePrefix))?.ToString(); } /*-------------------------------------------------------------------------------------------------------------------------- | Otherwise, attempt to retrieve value from topic.Attributes.GetValue({Property}) \-------------------------------------------------------------------------------------------------------------------------*/ - attributeValue ??= source.Attributes.GetValue( + attributeValue ??= source.Attributes.GetValue( configuration.GetCompositeAttributeKey(attributePrefix), configuration.DefaultValue?.ToString(), configuration.InheritValue @@ -729,22 +811,43 @@ await MapAsync( /// The with details about the property's attributes. /// A cache to keep track of already-mapped object instances. /// The prefix to apply to the attributes. + /// Determines if properties not associated with associations should be mapped. + /// The current mapping request's path, used to detect circular references during construction. private async Task SetCollectionValueAsync( Topic source, object target, AssociationTypes associations, MemberAccessor memberAccessor, MappedTopicCache cache, - string? attributePrefix + string? attributePrefix, + bool mapAssociationsOnly, + MapPath? mapPath = null ) { /*-------------------------------------------------------------------------------------------------------------------------- - | Ensure target list is created + | Establish per-entry lock + >--------------------------------------------------------------------------------------------------------------------------- + | Two concurrent passes expanding the same cached target with disjoint associations (see e.g., the cache-hit path in + | MapAsync) can populate the same member's target list from different sources, so its creation and population are + | synchronized on the shared cache entry. A target that isn't cached (i.e., a new topic) is never shared across passes, so + | its own instance serves as a sufficient and uncontended lock. \-------------------------------------------------------------------------------------------------------------------------*/ - var targetList = (IList?)memberAccessor.GetValue(target); - if (targetList is null) { - targetList = InitializeCollection(memberAccessor.Type); - memberAccessor.SetValue(target, targetList); + cache.TryGetValue(source.Id, target.GetType(), out var cacheEntry); + var collectionLock = (object?)cacheEntry?? target; + + /*-------------------------------------------------------------------------------------------------------------------------- + | Ensure target list is created + >--------------------------------------------------------------------------------------------------------------------------- + | Locked so two concurrent passes can't both observe a null list and instantiate competing instances, which would orphan the + | items added to whichever collection is overwritten. The lock is synchronous and never held across an await. + \-------------------------------------------------------------------------------------------------------------------------*/ + IList? targetList; + lock (collectionLock) { + targetList = (IList?)memberAccessor.GetValue(target); + if (targetList is null) { + targetList = InitializeCollection(memberAccessor.Type); + memberAccessor.SetValue(target, targetList); + } } Contract.Assume( @@ -756,7 +859,7 @@ private async Task SetCollectionValueAsync( /*-------------------------------------------------------------------------------------------------------------------------- | Establish source collection to store topics to be mapped \-------------------------------------------------------------------------------------------------------------------------*/ - var sourceList = GetSourceCollection(source, associations, memberAccessor, attributePrefix); + var sourceList = await GetSourceCollectionAsync(source, associations, memberAccessor, attributePrefix, mapAssociationsOnly).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Validate that source collection was identified @@ -766,7 +869,7 @@ private async Task SetCollectionValueAsync( /*-------------------------------------------------------------------------------------------------------------------------- | Map the topics from the source collection, and add them to the target collection \-------------------------------------------------------------------------------------------------------------------------*/ - await PopulateTargetCollectionAsync(sourceList, targetList, memberAccessor, cache).ConfigureAwait(false); + await PopulateTargetCollectionAsync(sourceList, targetList, memberAccessor, cache, collectionLock, mapPath).ConfigureAwait(false); } @@ -788,17 +891,25 @@ private async Task SetCollectionValueAsync( /// Determines what associations the mapping should include, if any. /// The with details about the property's attributes. /// The prefix to apply to the attributes. - private IList GetSourceCollection( + /// Determines if properties not associated with associations should be mapped. + private async Task> GetSourceCollectionAsync( Topic source, AssociationTypes associations, ItemMetadata itemMetadata, - string? attributePrefix + string? attributePrefix, + bool mapAssociationsOnly ) { /*-------------------------------------------------------------------------------------------------------------------------- | Establish source collection to store topics to be mapped \-------------------------------------------------------------------------------------------------------------------------*/ var configuration = itemMetadata.Configuration; + + /*-------------------------------------------------------------------------------------------------------------------------- + | Warm lazy-loaded payload before probing collections + \-------------------------------------------------------------------------------------------------------------------------*/ + await ((ITopicLazyLoadable)source).EnsureLoaded(AssociationMap.PayloadMappings[configuration.CollectionType]).ConfigureAwait(false); + var listSource = (IList)[]; var collectionKey = configuration.CollectionKey; var collectionType = configuration.CollectionType; @@ -806,7 +917,7 @@ private IList GetSourceCollection( /*-------------------------------------------------------------------------------------------------------------------------- | Handle children \-------------------------------------------------------------------------------------------------------------------------*/ - listSource = getCollection( + listSource = getCollection( CollectionType.Children, s => true, () => [.. source.Children] @@ -815,27 +926,27 @@ private IList GetSourceCollection( /*-------------------------------------------------------------------------------------------------------------------------- | Handle (outgoing) relationships \-------------------------------------------------------------------------------------------------------------------------*/ - listSource = getCollection( + listSource = getCollection( CollectionType.Relationship, - source.Relationships.Contains, + key => source.Relationships.Contains(key), () => source.Relationships.GetValues(collectionKey) ); /*-------------------------------------------------------------------------------------------------------------------------- | Handle nested topics, or children corresponding to the property name \-------------------------------------------------------------------------------------------------------------------------*/ - listSource = getCollection( + listSource = getCollection( CollectionType.NestedTopics, - source.Children.Contains, + key => source.Children.Contains(key), () => source.Children[collectionKey].Children ); /*-------------------------------------------------------------------------------------------------------------------------- | Handle (incoming) relationships \-------------------------------------------------------------------------------------------------------------------------*/ - listSource = getCollection( + listSource = getCollection( CollectionType.IncomingRelationship, - source.IncomingRelationships.Contains, + key => source.IncomingRelationships.Contains(key), () => source.IncomingRelationships.GetValues(collectionKey) ); @@ -844,16 +955,18 @@ private IList GetSourceCollection( \-------------------------------------------------------------------------------------------------------------------------*/ //The following allows a target collection to be mapped to an IList source collection. This is valuable for custom, //curated collections defined on e.g. derivatives of Topic, but which don't otherwise map to a specific collection type. - //For example, the ContentTypeDescriptor's AttributeDescriptors collection, which provides a rollup of - //AttributeDescriptors from the current ContentTypeDescriptor, as well as all of its ascendents. - if (listSource.Count == 0) { - var sourceProperty = TypeAccessorCache.GetTypeAccessor(source.GetType()).GetMember(configuration.GetCompositeAttributeKey(attributePrefix)); + //For example, the ContentTypeDescriptor's AttributeDescriptors collection, which provides a rollup of AttributeDescriptors + //from the current ContentTypeDescriptor, as well as all of its ascendants. On an expansion pass, this fallback runs only + //when MappedCollections is among the claimed associations, avoiding a redundant reflective source-property read (and its + //re-enumeration) for passes that don't claim it. + if (listSource.Count == 0 && (!mapAssociationsOnly || associations.HasFlag(AssociationTypes.MappedCollections))) { + var sourceProperty = TypeAccessorCache.GetTypeAccessor(source.GetType()).GetMember(configuration.GetCompositeAttributeKey(attributePrefix)); if ( sourceProperty?.GetValue(source) is IList sourcePropertyValue && sourcePropertyValue.Count > 0 && sourcePropertyValue[0] is Topic ) { - listSource = getCollection( + listSource = getCollection( CollectionType.MappedCollection, s => true, () => [.. sourcePropertyValue.Cast()] @@ -864,11 +977,11 @@ sourcePropertyValue[0] is Topic /*-------------------------------------------------------------------------------------------------------------------------- | Handle Metadata relationship \-------------------------------------------------------------------------------------------------------------------------*/ - if (listSource.Count == 0 && !String.IsNullOrWhiteSpace(configuration.MetadataKey)) { - var metadataKey = $"Root:Configuration:Metadata:{configuration.MetadataKey}:LookupList"; - var metadataParent = _topicRepository.Load(metadataKey, source); + if (!mapAssociationsOnly && listSource.Count == 0 && !String.IsNullOrWhiteSpace(configuration.MetadataKey)) { + var metadataKey = $"Root:Configuration:Metadata:{configuration.MetadataKey}:LookupList"; + var metadataParent = await _topicRepository.Load(metadataKey, source, TopicPayload.Children).ConfigureAwait(false); if (metadataParent is not null) { - listSource = [.. metadataParent.Children]; + listSource = [.. metadataParent.Children]; } } @@ -876,9 +989,9 @@ sourcePropertyValue[0] is Topic | Handle flattening of children \-------------------------------------------------------------------------------------------------------------------------*/ if (configuration.FlattenChildren) { - var flattenedList = new List(); + List flattenedList = []; listSource.ToList().ForEach(t => FlattenTopicGraph(t, flattenedList)); - listSource = flattenedList; + listSource = flattenedList; } return listSource; @@ -890,6 +1003,7 @@ IList getCollection(CollectionType collection, Func contain var targetAssociations = AssociationMap.Mappings[collection]; var preconditionsMet = listSource.Count == 0 && + (!mapAssociationsOnly || targetAssociations is not AssociationTypes.None) && (collectionType is CollectionType.Any || collectionType.Equals(collection)) && (collectionType is CollectionType.Children || collection is not CollectionType.Children) && (targetAssociations is AssociationTypes.None || associations.HasFlag(targetAssociations)) && @@ -909,11 +1023,19 @@ IList getCollection(CollectionType collection, Func contain /// The target to add the mapped objects to. /// The with details about the property's attributes. /// A cache to keep track of already-mapped object instances. + /// + /// The object to lock on while adding to , so concurrent passes populating a shared target's + /// list from disjoint sources don't modify it simultaneously. Callers pass the shared cache entry for a cached target, or + /// the (unshared) list itself when populating a constructor parameter. + /// + /// The current mapping request's path, used to detect circular references during construction. private async Task PopulateTargetCollectionAsync( IList sourceList, IList targetList, ItemMetadata itemMetadata, - MappedTopicCache cache + MappedTopicCache cache, + object collectionLock, + MapPath? mapPath = null ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -924,18 +1046,18 @@ MappedTopicCache cache /*-------------------------------------------------------------------------------------------------------------------------- | Determine the type of item in the list \-------------------------------------------------------------------------------------------------------------------------*/ - var listType = typeof(ITopicViewModel); + var listType = typeof(ITopicViewModel); foreach (var type in targetList.GetType().GetInterfaces()) { if (type.IsGenericType && typeof(IList<>) == type.GetGenericTypeDefinition()) { //Uses last argument in case it's a KeyedCollection; in that case, we want the TItem type - listType = type.GetGenericArguments().Last(); + listType = type.GetGenericArguments().Last(); } } /*-------------------------------------------------------------------------------------------------------------------------- | Queue up mapping tasks \-------------------------------------------------------------------------------------------------------------------------*/ - var taskQueue = new List>(); + List> taskQueue = []; foreach (var childTopic in sourceList) { @@ -968,7 +1090,7 @@ configuration.ContentTypeFilter is not null && if (!typeof(Topic).IsAssignableFrom(listType)) { var mappingType = GetValidatedMappingType(configuration.MapAs, listType)?? GetValidatedMappingType(childTopic, listType); if (mappingType is not null) { - taskQueue.Add(MapAsync(childTopic, mappingType, configuration.IncludeAssociations, cache)); + taskQueue.Add(MapAsync(childTopic, mappingType, configuration.IncludeAssociations, cache, mapPath: mapPath)); } } else { @@ -979,11 +1101,13 @@ configuration.ContentTypeFilter is not null && /*-------------------------------------------------------------------------------------------------------------------------- | Process mapping tasks + >--------------------------------------------------------------------------------------------------------------------------- + | Awaited as a batch, then added in the order the tasks were queued in, rather than completion order; this keeps sibling + | order deterministic regardless of which child mappings genuinely await \-------------------------------------------------------------------------------------------------------------------------*/ - while (taskQueue.Count > 0) { - var dtoTask = await Task.WhenAny(taskQueue).ConfigureAwait(false); - var dto = await dtoTask.ConfigureAwait(false); - taskQueue.Remove(dtoTask); + var dtos = await Task.WhenAll(taskQueue).ConfigureAwait(false); + + foreach (var dto in dtos) { if (dto is not null) { addToList(dto); } @@ -991,14 +1115,19 @@ configuration.ContentTypeFilter is not null && /*-------------------------------------------------------------------------------------------------------------------------- | Function: Add to List + >--------------------------------------------------------------------------------------------------------------------------- + | Locked so a concurrent pass populating the same shared list from a disjoint source can't add at the same time; the lock + | is synchronous and never held across an await, as child mapping happens outside of it via the above task queue. \-------------------------------------------------------------------------------------------------------------------------*/ void addToList(object dto) { - try { - targetList.Add(dto); - } - catch (ArgumentException) { - //Ignore exceptions caused by duplicate keys, in case the IList represents a keyed collection - //We would defensively check for this, except IList doesn't provide a suitable method to do so + lock (collectionLock) { + try { + targetList.Add(dto); + } + catch (ArgumentException) { + //Ignore exceptions caused by duplicate keys, in case the IList represents a keyed collection + //We would defensively check for this, except IList doesn't provide a suitable method to do so + } } } @@ -1042,11 +1171,13 @@ void addToList(object dto) { /// The expected for the mapped . /// The with details about the item's attributes. /// A cache to keep track of already-mapped object instances. + /// The current mapping request's path, used to detect circular references during construction. private async Task GetTopicReferenceAsync( Topic source, Type targetType, ItemMetadata itemMetadata, - MappedTopicCache cache + MappedTopicCache cache, + MapPath? mapPath = null ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -1070,7 +1201,7 @@ MappedTopicCache cache var mappingType = GetValidatedMappingType(configuration.MapAs, targetType)?? GetValidatedMappingType(source, targetType); if (mappingType is not null) { - topicDto = await MapAsync(source, mappingType, configuration.IncludeAssociations, cache).ConfigureAwait(false); + topicDto = await MapAsync(source, mappingType, configuration.IncludeAssociations, cache, mapPath: mapPath).ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -1132,7 +1263,7 @@ private static bool TryGetCompatibleProperty(Topic source, Type targetType, Item | Rely on MaybeCompatible to bypass known incompatible types \-------------------------------------------------------------------------------------------------------------------------*/ if (source.GetType() == typeof(Topic) && !itemMetadata.MaybeCompatible) { - value = null; + value = null; return false; }; @@ -1145,14 +1276,14 @@ private static bool TryGetCompatibleProperty(Topic source, Type targetType, Item | Escape clause if preconditions are not met \-------------------------------------------------------------------------------------------------------------------------*/ if (sourcePropertyAccessor is null || !targetType.IsAssignableFrom(sourcePropertyAccessor.Type)) { - value = null; + value = null; return false; } /*-------------------------------------------------------------------------------------------------------------------------- | Return value \-------------------------------------------------------------------------------------------------------------------------*/ - value = sourcePropertyAccessor.GetValue(source); + value = sourcePropertyAccessor.GetValue(source); return true; diff --git a/OnTopic/Mapping/_exceptions/InvalidTypeException.cs b/OnTopic/Mapping/_exceptions/InvalidTypeException.cs index 3bfaf613..a63f18cf 100644 --- a/OnTopic/Mapping/_exceptions/InvalidTypeException.cs +++ b/OnTopic/Mapping/_exceptions/InvalidTypeException.cs @@ -28,7 +28,7 @@ public class InvalidTypeException: TopicMappingException { /// /// Initializes a new instance. /// - public InvalidTypeException() : base() { } + public InvalidTypeException() { } /// /// Initializes a new instance with a specific error message. diff --git a/OnTopic/Mapping/_exceptions/MappingModelValidationException.cs b/OnTopic/Mapping/_exceptions/MappingModelValidationException.cs index aff6c2ce..bdc587f8 100644 --- a/OnTopic/Mapping/_exceptions/MappingModelValidationException.cs +++ b/OnTopic/Mapping/_exceptions/MappingModelValidationException.cs @@ -32,7 +32,7 @@ public class MappingModelValidationException: TopicMappingException { /// /// Initializes a new instance. /// - public MappingModelValidationException() : base() { } + public MappingModelValidationException() { } /// /// Initializes a new instance with a specific error message. diff --git a/OnTopic/Mapping/_exceptions/TopicMappingException.cs b/OnTopic/Mapping/_exceptions/TopicMappingException.cs index 450d16fd..230c3d76 100644 --- a/OnTopic/Mapping/_exceptions/TopicMappingException.cs +++ b/OnTopic/Mapping/_exceptions/TopicMappingException.cs @@ -26,7 +26,7 @@ public class TopicMappingException : Exception { /// /// Initializes a new instance. /// - public TopicMappingException() : base() { } + public TopicMappingException() { } /// /// Initializes a new instance with a specific error message. diff --git a/OnTopic/Metadata/AttributeDescriptor.cs b/OnTopic/Metadata/AttributeDescriptor.cs index f9a71b5e..ddb20960 100644 --- a/OnTopic/Metadata/AttributeDescriptor.cs +++ b/OnTopic/Metadata/AttributeDescriptor.cs @@ -63,8 +63,8 @@ public class AttributeDescriptor : Topic { public AttributeDescriptor( string key, string contentType, - Topic? parent = null, - int id = -1 + Topic? parent = null, + int id = -1 ) : base( key, contentType, diff --git a/OnTopic/Metadata/ContentTypeDescriptor.cs b/OnTopic/Metadata/ContentTypeDescriptor.cs index 96ee4b1e..095de773 100644 --- a/OnTopic/Metadata/ContentTypeDescriptor.cs +++ b/OnTopic/Metadata/ContentTypeDescriptor.cs @@ -64,8 +64,8 @@ public class ContentTypeDescriptor : Topic { public ContentTypeDescriptor( string key, string contentType, - Topic? parent = null, - int id = -1 + Topic? parent = null, + int id = -1 ) : base( key, contentType, diff --git a/OnTopic/Models/IAssociatedTopicBindingModel.cs b/OnTopic/Models/IAssociatedTopicBindingModel.cs index 4e2a89a2..1182511e 100644 --- a/OnTopic/Models/IAssociatedTopicBindingModel.cs +++ b/OnTopic/Models/IAssociatedTopicBindingModel.cs @@ -15,8 +15,8 @@ namespace OnTopic.Models; /// Provides a generic data transfer topic for binding an association of a binding model to an existing . /// /// -/// It is strictly required that any binding models used as associations implement the interface for the default to correctly identify +/// It is strictly required that any binding models used as associations implement the interface for the default to correctly identify /// and map an association back to a . /// public interface IAssociatedTopicBindingModel { diff --git a/OnTopic/Models/ITopicViewModel.cs b/OnTopic/Models/ITopicViewModel.cs index 2c77ec7d..ef6e6ea7 100644 --- a/OnTopic/Models/ITopicViewModel.cs +++ b/OnTopic/Models/ITopicViewModel.cs @@ -23,8 +23,8 @@ namespace OnTopic.Models; /// /// /// For instance, in the default MVC library, the TopicViewResult class requires that the and be supplied separately if they're not provided as part of a . The exact details of this will obviously vary based on the implementation of the presentation +/// ContentType"/> and be supplied separately if they're not provided as part of a . The exact details of this will obviously vary based on the implementation of the presentation /// layer and any supporting libraries. /// /// @@ -56,8 +56,8 @@ public interface ITopicViewModel: ICoreTopicViewModel, IAssociatedTopicBindingMo /// /// /// This value can be set via the query string (via the TopicViewResultExecutor class), via the Accepts header - /// (also via the TopicViewResultExecutor class), on the topic itself (via this property), or via the . By default, it will be set to the name of the . By default, it will be set to the name of the ; e.g., if the Content Type is Page, then the view will be Page. This will cause the /// TopicViewResultExecutor to look for a view at, for instance, /Views/Page/Page.cshtml. /// diff --git a/OnTopic/Obsolete/Attributes/AttributeValue.cs b/OnTopic/Obsolete/Attributes/AttributeValue.cs index 7cbe16fb..43e352ce 100644 --- a/OnTopic/Obsolete/Attributes/AttributeValue.cs +++ b/OnTopic/Obsolete/Attributes/AttributeValue.cs @@ -99,8 +99,8 @@ internal AttributeValue( string? value, bool isDirty, bool enforceBusinessLogic, - DateTime? lastModified = null, - bool? isExtendedAttribute = null + DateTime? lastModified = null, + bool? isExtendedAttribute = null ) : this( key, value, @@ -203,8 +203,8 @@ internal AttributeValue( /// cref="IsDirty"/> to determine if a value should be saved. If an attribute's value hasn't changed, but the location /// it should be stored has, that could potentially result in the attribute being deleted, as the attribute won't show /// up for when is called with isDirty set to true and - /// isExtendedAttribute is set to either true or false. By introducing , the is able to detect conflicts between the configuration and + /// isExtendedAttribute is set to either true or false. By introducing , the is able to detect conflicts between the configuration and /// the underlying data store, and ensure data is stored appropriately. /// /// diff --git a/OnTopic/Obsolete/Collections/AttributeValueCollection.cs b/OnTopic/Obsolete/Collections/AttributeValueCollection.cs index a30ee5a4..0538ca5b 100644 --- a/OnTopic/Obsolete/Collections/AttributeValueCollection.cs +++ b/OnTopic/Obsolete/Collections/AttributeValueCollection.cs @@ -214,9 +214,9 @@ internal AttributeValueCollection(Topic parentTopic) : base(StringComparer.Invar public void SetValue( string key, string? value, - bool? isDirty = null, - DateTime? version = null, - bool? isExtendedAttribute = null + bool? isDirty = null, + DateTime? version = null, + bool? isExtendedAttribute = null ) => SetValue(key, value, isDirty, true, version, isExtendedAttribute); @@ -267,8 +267,8 @@ internal void SetValue( string? value, bool? isDirty, bool enforceBusinessLogic, - DateTime? version = null, - bool? isExtendedAttribute = null + DateTime? version = null, + bool? isExtendedAttribute = null ) => throw new NotImplementedException(); /*============================================================================================================================ diff --git a/OnTopic/Obsolete/Collections/NamedTopicCollection.cs b/OnTopic/Obsolete/Collections/NamedTopicCollection.cs index 62ff941c..b46d1847 100644 --- a/OnTopic/Obsolete/Collections/NamedTopicCollection.cs +++ b/OnTopic/Obsolete/Collections/NamedTopicCollection.cs @@ -31,8 +31,8 @@ public class NamedTopicCollection: KeyedTopicCollection { /// /// Provides a name for the collection, used to identify different collections. /// Optionally seeds the collection with an optional list of topic references. - public NamedTopicCollection(string name = "", IEnumerable? topics = null) : base() { - Name = name; + public NamedTopicCollection(string name = "", IEnumerable? topics = null) { + Name = name; if (topics is not null) { CopyTo([.. topics], 0); } diff --git a/OnTopic/Obsolete/Mapping/Annotations/FollowAttribute.cs b/OnTopic/Obsolete/Mapping/Annotations/FollowAttribute.cs index ab991129..c22edda0 100644 --- a/OnTopic/Obsolete/Mapping/Annotations/FollowAttribute.cs +++ b/OnTopic/Obsolete/Mapping/Annotations/FollowAttribute.cs @@ -23,7 +23,7 @@ public sealed class FollowAttribute : Attribute { /// /// The specific relationships that should be crawled. public FollowAttribute(Relationships relationships) { - Relationships = relationships; + Relationships = relationships; } /*============================================================================================================================ diff --git a/OnTopic/Obsolete/Mapping/Annotations/RelationshipAttribute.cs b/OnTopic/Obsolete/Mapping/Annotations/RelationshipAttribute.cs index 869a470b..7e453fef 100644 --- a/OnTopic/Obsolete/Mapping/Annotations/RelationshipAttribute.cs +++ b/OnTopic/Obsolete/Mapping/Annotations/RelationshipAttribute.cs @@ -23,8 +23,8 @@ public sealed class RelationshipAttribute : Attribute { /// /// The key value of the collection associated with the current property. public RelationshipAttribute(string key) { - TopicFactory.ValidateKey(key, false); - Key = key; + TopicFactory.ValidateKey(key); + Key = key; } /// @@ -32,7 +32,7 @@ public RelationshipAttribute(string key) { /// /// Optional. The type of collection the collection is associated with. public RelationshipAttribute(RelationshipType type = RelationshipType.Any) { - Type = type; + Type = type; } /*============================================================================================================================ diff --git a/OnTopic/Obsolete/Metadata/Attributes/AttributeTypeDescriptor.cs b/OnTopic/Obsolete/Metadata/Attributes/AttributeTypeDescriptor.cs index bcb4a810..f07c8586 100644 --- a/OnTopic/Obsolete/Metadata/Attributes/AttributeTypeDescriptor.cs +++ b/OnTopic/Obsolete/Metadata/Attributes/AttributeTypeDescriptor.cs @@ -33,7 +33,7 @@ protected AttributeTypeDescriptor( string key, string contentType, Topic parent, - int id = -1 + int id = -1 ) : base( key, contentType, diff --git a/OnTopic/Obsolete/Repositories/DeleteEventArgs.cs b/OnTopic/Obsolete/Repositories/DeleteEventArgs.cs index 6ad527b8..52e7ce87 100644 --- a/OnTopic/Obsolete/Repositories/DeleteEventArgs.cs +++ b/OnTopic/Obsolete/Repositories/DeleteEventArgs.cs @@ -23,8 +23,8 @@ public class DeleteEventArgs : EventArgs { /// Initializes a new instance of the class. /// /// The topic. - public DeleteEventArgs(Topic topic) : base() { - Topic = topic; + public DeleteEventArgs(Topic topic) { + Topic = topic; } /*============================================================================================================================ diff --git a/OnTopic/Obsolete/Repositories/MoveEventArgs.cs b/OnTopic/Obsolete/Repositories/MoveEventArgs.cs index a5262b3a..db9c0bd3 100644 --- a/OnTopic/Obsolete/Repositories/MoveEventArgs.cs +++ b/OnTopic/Obsolete/Repositories/MoveEventArgs.cs @@ -43,8 +43,8 @@ public MoveEventArgs(Topic topic, Topic target) { Contract.Requires(topic, "topic"); Contract.Requires(target, "target"); Contract.Requires(topic != target, "The topic cannot be its own parent."); - Topic = topic; - Target = target; + Topic = topic; + Target = target; } /*============================================================================================================================ diff --git a/OnTopic/Obsolete/Repositories/RenameEventArgs.cs b/OnTopic/Obsolete/Repositories/RenameEventArgs.cs index c309efd8..00190fa0 100644 --- a/OnTopic/Obsolete/Repositories/RenameEventArgs.cs +++ b/OnTopic/Obsolete/Repositories/RenameEventArgs.cs @@ -25,7 +25,7 @@ public class RenameEventArgs : EventArgs { /// /// The topic object associated with the rename event. public RenameEventArgs(Topic topic) { - Topic = topic; + Topic = topic; } /*============================================================================================================================ diff --git a/OnTopic/Querying/TopicCollectionExtensions.cs b/OnTopic/Querying/TopicCollectionExtensions.cs index b930c608..315d4a5a 100644 --- a/OnTopic/Querying/TopicCollectionExtensions.cs +++ b/OnTopic/Querying/TopicCollectionExtensions.cs @@ -16,23 +16,6 @@ namespace OnTopic.Querying; /// public static class TopicCollectionExtensions { - /*============================================================================================================================ - | METHOD: ANY DIRTY? - \---------------------------------------------------------------------------------------------------------------------------*/ - /// - /// Determines whether any of the instances in the collection are marked as . - /// - /// - /// This does not determine if the collection itself is dirty—it only determines if any instances in - /// the collection are . This distinction is important. For example, if a clean is added to the collection, then the collection will be dirty—but - /// will be false. - /// - /// The collection of instances to operate against. - /// Returns true if any of the instances are . - public static bool AnyDirty(this IEnumerable topics) => topics.Any(t => t.IsDirty(true)); - /*============================================================================================================================ | METHOD: ANY NEW? \---------------------------------------------------------------------------------------------------------------------------*/ @@ -43,4 +26,4 @@ public static class TopicCollectionExtensions { /// Returns true if any of the instances are . public static bool AnyNew(this IEnumerable topics) => topics.Any(t => t.IsNew); -} +} \ No newline at end of file diff --git a/OnTopic/Querying/TopicExtensions.cs b/OnTopic/Querying/TopicExtensions.cs index 9acece90..7d4a75f8 100644 --- a/OnTopic/Querying/TopicExtensions.cs +++ b/OnTopic/Querying/TopicExtensions.cs @@ -6,6 +6,7 @@ using OnTopic.Collections; using OnTopic.Collections.Specialized; using OnTopic.Metadata; +using OnTopic.Repositories; namespace OnTopic.Querying; @@ -15,6 +16,12 @@ namespace OnTopic.Querying; /// /// Provides extensions for querying . /// +/// +/// These extensions, while powerful, were intended to be used against fully loaded, in-memory topic trees. Their usefulness +/// with lazy-loaded trees is limited and, potentially, even expensive, as innocent seeming queries may trigger lazy-loading +/// of attributes, relationships, references, children, etc. Traversal itself never triggers a load, as it reads directly, though the predicate parameter can easily call into any of these. +/// public static class TopicExtensions { /*============================================================================================================================ @@ -23,6 +30,13 @@ public static class TopicExtensions { /// /// Finds the first instance of a in the topic tree that satisfies the delegate. /// + /// + /// Traverses via , the non-triggering backing field, so this never directly + /// causes a lazy load. The is not similarly guarded, however: When using this with a + /// lazy-loaded tree, be aware that it may trigger costly on-demand loading of attributes, relationships, references, and + /// children if they're included in the predicate. It is recommended to avoid use with lazy-loaded trees, or to use extreme + /// caution. + /// /// The instance of the to operate against; populated automatically by .NET. /// The function to validate whether a should be included in the output. /// The first instance of the topic to be satisfied. @@ -44,8 +58,8 @@ public static class TopicExtensions { /*-------------------------------------------------------------------------------------------------------------------------- | Recurse over children \-------------------------------------------------------------------------------------------------------------------------*/ - foreach (var child in topic.Children) { - var nestedResult = child.FindFirst(predicate); + foreach (var child in ((ITopicBackingAccessor)topic).Children) { + var nestedResult = child.FindFirst(predicate); if (nestedResult is not null) { return nestedResult; } @@ -89,7 +103,7 @@ public static class TopicExtensions { if (predicate(topic.Parent)) { return topic.Parent; } - topic = topic.Parent; + topic = topic.Parent; } /*-------------------------------------------------------------------------------------------------------------------------- @@ -103,15 +117,23 @@ public static class TopicExtensions { | METHOD: FIND ALL \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Retrieves a collection of all topics descending from—and including—the current topic. + /// Retrieves a collection of all in-memory topics in the tree, descending from—and including—the + /// current topic. /// /// The instance of the to operate against; populated automatically by .NET. /// A collection of topics descending from the current topic. - public static ReadOnlyTopicCollection FindAll(this Topic topic) => topic.FindAll(t => true); + public static ReadOnlyTopicCollection FindAll(this Topic topic) => topic.FindAll(_ => true); /// /// Retrieves a collection of topics based on a supplied function. /// + /// + /// Traverses via , the non-triggering backing field, so this never directly + /// causes a lazy load. The is not similarly guarded, however: When using this with a + /// lazy-loaded tree, be aware that it may trigger costly on-demand loading of attributes, relationships, references, and + /// children if they're included in the predicate. It is recommended to avoid use with lazy-loaded trees, or to use extreme + /// caution. + /// /// The instance of the to operate against; populated automatically by .NET. /// The function to validate whether a should be included in the output. /// A collection of topics matching the input parameters. @@ -126,7 +148,7 @@ public static ReadOnlyTopicCollection FindAll(this Topic topic, Func /// Retrieves a collection of topics based on an attribute name and value. /// + /// + /// If querying a lazy-loaded topic tree, this will trigger a query for any extended attributes that aren't yet (fully) + /// loaded. At the same time, however, it will not trigger lazy-loading of any children that aren't yet (fully) loaded. As a + /// result, queries against lazy-loaded topic trees can be slow while also being incomplete. + /// /// The instance of the to operate against; populated automatically by .NET. /// The string identifier for the against which to be searched. /// The text value for the against which to be searched. @@ -193,12 +220,34 @@ public static ReadOnlyTopicCollection FindAllByAttribute(this Topic topic, strin | METHOD: GET TOPIC INDEX \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Retrieves all topics from the topic cache, and places them in an dictionary indexed by . + /// Retrieves all topics from the in-memory topic graph, and places them in a dictionary indexed by . /// + /// + /// This only loads topics from the in-memory topic graph. Any topics that aren't yet loaded in the in-memory topic graph + /// will not be included. This builds a throwaway snapshot on every call; it supports arbitrary subtree scoping (e.g., the + /// need not be the graph's root), which the live, incrementally maintained does not. Prefer that for hot paths that repeatedly reference the same graph's index. + /// /// The instance of the to operate against; populated automatically by .NET. /// A dictionary of topics indexed by . public static TopicIndex GetTopicIndex(this Topic topic) => new(topic.FindAll()); + /*============================================================================================================================ + | METHOD: GET LIVE TOPIC INDEX + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Retrieves the live, incrementally maintained index of all topics in 's graph, indexed by . + /// + /// + /// Root-scoped regardless of which in the graph is passed. The returned instance is shared and + /// live: Entries appear as topics are attached, detached, or assigned an , maintained internally by + /// the library as those events occur. Callers must treat it as read-only and must not add to it directly. remains available where a snapshot of an arbitrary subtree is needed instead. + /// + /// The live index of topics, indexed by , for 's graph. + public static TopicIndex GetLiveTopicIndex(this Topic topic) => TopicIndexRegistry.GetOrBuild(topic.GetRootTopic()); + /*============================================================================================================================ | METHOD: GET ROOT TOPIC \---------------------------------------------------------------------------------------------------------------------------*/ @@ -215,6 +264,11 @@ public static ReadOnlyTopicCollection FindAllByAttribute(this Topic topic, strin /// /// Retrieves a with the specified , if available. /// + /// + /// This will trigger synchronous lazy-loading calls to any topics in the chain whose children aren't yet loaded. That can + /// make initial calls to this unexpectedly expensive on a lazy-loaded topic tree, resulting in multiple calls to the + /// underlying persistence store. + /// /// The instance of the to operate against; populated automatically by .NET. /// The of the to return. /// A with the specified , if found. @@ -242,7 +296,7 @@ public static ReadOnlyTopicCollection FindAllByAttribute(this Topic topic, strin | Process keys \-------------------------------------------------------------------------------------------------------------------------*/ if (uniqueKey.StartsWith(currentTopic.Key + ":", StringComparison.OrdinalIgnoreCase)) { - uniqueKey = uniqueKey[(currentTopic.Key.Length + 1)..]; + uniqueKey = uniqueKey[(currentTopic.Key.Length + 1)..]; } var keys = uniqueKey.Split(':', StringSplitOptions.RemoveEmptyEntries); @@ -250,7 +304,7 @@ public static ReadOnlyTopicCollection FindAllByAttribute(this Topic topic, strin | Navigate to the specific path \-------------------------------------------------------------------------------------------------------------------------*/ foreach (var key in keys) { - currentTopic = currentTopic?.Children.GetValue(key); + currentTopic = currentTopic?.Children.GetValue(key); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -266,6 +320,11 @@ public static ReadOnlyTopicCollection FindAllByAttribute(this Topic topic, strin /// /// Retrieves the for the current . /// + /// + /// This assumes that the Configuration tree is fully loaded as part of the 's graph. In a standard + /// configuration, this portion of the tree should be eagerly loaded as part of the cache initialization, since it's a + /// commonly referenced dependency with a lot of internal dependencies in terms of relationships and references. + /// /// The instance of the to operate against; populated automatically by .NET. /// The associated with the . public static ContentTypeDescriptor? GetContentTypeDescriptor(this Topic topic) { @@ -283,7 +342,7 @@ public static ReadOnlyTopicCollection FindAllByAttribute(this Topic topic, strin /*-------------------------------------------------------------------------------------------------------------------------- | Find content type \-------------------------------------------------------------------------------------------------------------------------*/ - var contentTypeDescriptor = rootContentType?.FindFirst(t => + var contentTypeDescriptor = rootContentType?.FindFirst(t => t.Key.Equals(topic.ContentType, StringComparison.OrdinalIgnoreCase) && t is ContentTypeDescriptor ) as ContentTypeDescriptor; diff --git a/OnTopic/Repositories/ISitemapTopicRepository.cs b/OnTopic/Repositories/ISitemapTopicRepository.cs new file mode 100644 index 00000000..23650b8b --- /dev/null +++ b/OnTopic/Repositories/ISitemapTopicRepository.cs @@ -0,0 +1,26 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +namespace OnTopic.Repositories; + +/*============================================================================================================================== +| INTERFACE: SITEMAP TOPIC REPOSITORY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a narrow, read-only seam for retrieving the minimal graph required to render the sitemap, +/// without exposing the full read/write surface of . +/// +public interface ISitemapTopicRepository { + + /*============================================================================================================================ + | METHOD: LOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Loads a detached, lightweight graph containing only the fields the sitemap renders. The graph is + /// independent of any shared cache and is expected to be discarded once the response is rendered. + /// + Task Load(); + +} //Interface \ No newline at end of file diff --git a/OnTopic/Repositories/ITopicBackingAccessor.cs b/OnTopic/Repositories/ITopicBackingAccessor.cs new file mode 100644 index 00000000..51f6b362 --- /dev/null +++ b/OnTopic/Repositories/ITopicBackingAccessor.cs @@ -0,0 +1,91 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Associations; +using OnTopic.Collections; + +namespace OnTopic.Repositories; + +/*============================================================================================================================== +| INTERFACE: TOPIC BACKING ACCESSOR +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides direct access to the backing fields of a , bypassing the autoloading property getters. +/// +/// +/// +/// exposes , , and as autoloading getters: Accessing them can trigger a synchronous call. Repository and resolver infrastructure that reads or +/// writes these collections as part of a load or resolve operation must bypass those getters to avoid infinite loops. +/// +/// +/// implements this interface via explicit interface implementations. Callers must cast to to access the raw backing fields. +/// +/// +public interface ITopicBackingAccessor { + + /*============================================================================================================================ + | PROPERTY: CHILDREN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the raw backing field, bypassing the autoloading getter. + /// + /// + /// This is to be applied as explicit interface implementations; callers must cast to to + /// access this member. + /// + ChildTopicCollection Children { get; } + + /*============================================================================================================================ + | PROPERTY: RELATIONSHIPS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the raw backing field, bypassing the autoloading getter. + /// + /// + /// This is to be applied as explicit interface implementations; callers must cast to to + /// access this member. + /// + TopicRelationshipMultiMap Relationships { get; } + + /*============================================================================================================================ + | PROPERTY: REFERENCES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the raw backing field, bypassing the autoloading getter. + /// + /// + /// This is to be applied as explicit interface implementations; callers must cast to to + /// access this member. + /// + TopicReferenceCollection References { get; } + + /*============================================================================================================================ + | PROPERTY: ATTRIBUTES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the raw backing field, bypassing the autoloading getter. + /// + /// + /// This is to be applied as explicit interface implementations; callers must cast to to + /// access this member. + /// + AttributeCollection Attributes { get; } + + /*============================================================================================================================ + | PROPERTY: VERSION HISTORY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns the raw backing field, bypassing the autoloading getter. + /// + /// + /// This is to be applied as explicit interface implementations; callers must cast to to + /// access this member. + /// + VersionHistoryCollection VersionHistory { get; } + +} //Interface \ No newline at end of file diff --git a/OnTopic/Repositories/ITopicLazyLoadable.cs b/OnTopic/Repositories/ITopicLazyLoadable.cs new file mode 100644 index 00000000..f021cfe9 --- /dev/null +++ b/OnTopic/Repositories/ITopicLazyLoadable.cs @@ -0,0 +1,217 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +namespace OnTopic.Repositories; + +/*============================================================================================================================== +| INTERFACE: TOPIC LAZY LOADABLE +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides the required members for the class to fully support , +/// including LoadState tracking and s reference to the that allows it to populate its +/// own deferred payload on demand. +/// +/// +/// is the sole implementer of this interface, and does so via explicit interface implementations; callers +/// must cast to to access these members. This keeps the infrastructure off 's public surface while still allowing repositories, the mapping layer, tests, and other infrastructure to reach it. +/// +public interface ITopicLazyLoadable : ITopicBackingAccessor { + + /*============================================================================================================================ + | METHOD: IS LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns if every property flag in has already been fetched from the + /// underlying persistence store; if any one of them are . + /// + /// + /// Reads each collection's directly without touching any autoloading getter, making it safe to use + /// in traversal and "gating" logic that should not trigger lazy loading. + /// + /// One or more flags to test. + bool IsLoaded(TopicPayload payload) { + + // Children + if (payload.HasFlag(TopicPayload.Children) && Children.LoadState is not LoadState.Loaded) { + return false; + } + + // Extended Attributes + if (payload.HasFlag(TopicPayload.ExtendedAttributes) && Attributes.LoadState is not LoadState.Loaded) { + return false; + } + + // Relationships + if (payload.HasFlag(TopicPayload.Relationships) && Relationships.LoadState is not LoadState.Loaded) { + return false; + } + + // References + if (payload.HasFlag(TopicPayload.References) && References.LoadState is not LoadState.Loaded) { + return false; + } + + // History + if (payload.HasFlag(TopicPayload.VersionHistory) && VersionHistory.LoadState is not LoadState.Loaded) { + return false; + } + + // Unexpected + return true; + + } + + /*============================================================================================================================ + | METHOD: IS LOADED (BY DEPTH) + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns if every property flag in has already been fetched from the + /// underlying persistence store and, if is non-zero, the same is true of every descendant within + /// that depth. + /// + /// + /// Recursion is gated on being fully before + /// descending, so an unloaded branch is never mistaken for a loaded, empty one. Like , + /// this only ever reads values, never an autoloading getter, so it is safe to use for cases that + /// should not trigger a lazy load. + /// + /// One or more flags to test. + /// + /// The number of tiers of descendants that must also satisfy . -1 requires the full + /// subtree; 0 requires only this topic; N requires N tiers of descendants. + /// + bool IsLoaded(TopicPayload payload, int depth) { + + // Evaluate current topic + if (!IsLoaded(payload)) { + return false; + } + + // Return if seed-only + if (depth is 0) { + return true; + } + + // Evaluate children, without triggering a load + if (!IsLoaded(TopicPayload.Children)) { + return false; + } + + // Recurse over children + foreach (var child in Children) { + if (!((ITopicLazyLoadable)child).IsLoaded(payload, depth is -1 ? -1 : depth - 1)) { + return false; + } + } + + // Return result + return true; + + } + + /*============================================================================================================================ + | METHOD: SET LOAD STATE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Sets the for each boundary flag in to . + /// + /// + /// Mirrors : Sets each collection's directly without touching + /// any autoloading getter. Callers are responsible for passing only payload whose transition is safe. + /// + /// One or more flags identifying the payload to update. + /// The to assign to each matched boundary's collection. + void SetLoadState(TopicPayload payload, LoadState state) { + + // Children + if (payload.HasFlag(TopicPayload.Children)) { + Children.LoadState = state; + } + + // Extended Attributes + if (payload.HasFlag(TopicPayload.ExtendedAttributes)) { + Attributes.LoadState = state; + } + + // History + if (payload.HasFlag(TopicPayload.VersionHistory)) { + VersionHistory.LoadState = state; + } + + } + + /*============================================================================================================================ + | METHOD: FILTER PAYLOAD + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Returns with any already- flags cleared, so callers skip + /// redundant round trips. + /// + /// The requested flags to filter. + TopicPayload FilterPayload(TopicPayload payload) { + + // Strip already-loaded payload + foreach (var flag in Enum.GetValues()) { + if (flag is TopicPayload.None or TopicPayload.All) { + continue; + } + if (IsLoaded(flag)) { + payload &= ~flag; + } + } + + // Return filtered payload + return payload; + + } + + /*============================================================================================================================ + | METHODS: ENSURE LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Ensures each requested flag has been retrieved, while fetching and merging whichever of them + /// are not yet , and skipping those that already are. Returns immediately if the is absent. + /// + /// + /// Callers such as a mapping or navigation service can await this to prepopulate one or more payloads before accessing + /// them, thus avoiding a synchronous block on a sparse topic. The autoloading property getters (e.g., ) call this synchronously via GetAwaiter().GetResult() as an accepted sync-over-async boundary. + /// + /// + /// One or more flags identifying the payload that should be ensured to be loaded. + /// + /// An optional token that can be used to cancel the operation. + Task EnsureLoaded(TopicPayload payload, CancellationToken cancellationToken = default) { + + // Skip if the topic isn't "stamped" with the loader + if (Loader is not { } loader) { + return Task.CompletedTask; + } + + // Filter to payload that are not yet loaded + payload = FilterPayload(payload); + if (payload is TopicPayload.None) { + return Task.CompletedTask; + } + + // Ensure the appropriate payload are loaded + // (Topic)this is safe since Topic is the sole implementer of ITopicLazyLoadable + return loader.EnsureLoaded((Topic)this, payload, cancellationToken); + + } + + /*============================================================================================================================ + | PROPERTY: LOADER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Provides a reference to the used to lazy load collections on request. This is stamped + /// by with whichever most recently + /// loaded or saved this topic. + /// + ITopicLazyLoader? Loader { get; set; } + +} //Interface \ No newline at end of file diff --git a/OnTopic/Repositories/ITopicLazyLoader.cs b/OnTopic/Repositories/ITopicLazyLoader.cs new file mode 100644 index 00000000..03ab8a60 --- /dev/null +++ b/OnTopic/Repositories/ITopicLazyLoader.cs @@ -0,0 +1,34 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.Repositories; + +/*============================================================================================================================== +| INTERFACE: TOPIC LAZY LOADER +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides a narrow seam through which a can populate one or more deferred payload on demand, without +/// taking a dependency on the full . Instances are stamped onto topics by the repository as +/// they are loaded or saved; topics created in memory carry no resolver. +/// +public interface ITopicLazyLoader { + + /*============================================================================================================================ + | METHOD: ENSURE LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Ensures each requested flag has been retrieved for the supplied , + /// fetching and merging whichever of them are not yet and silently skipping those already + /// loaded. Invoked by the autoloading property getters, each with its own flag. + /// + /// The whose payload should be ensured to be loaded. + /// + /// One or more flags identifying the payload that should be ensured to be loaded. + /// + /// An optional token that can be used to cancel the operation. + Task EnsureLoaded(Topic topic, TopicPayload payload, CancellationToken cancellationToken = default); + +} //Interface \ No newline at end of file diff --git a/OnTopic/Repositories/ITopicRepository.cs b/OnTopic/Repositories/ITopicRepository.cs index bb00114b..0b5401d8 100644 --- a/OnTopic/Repositories/ITopicRepository.cs +++ b/OnTopic/Repositories/ITopicRepository.cs @@ -3,6 +3,7 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ +using OnTopic.Associations; using OnTopic.Metadata; namespace OnTopic.Repositories; @@ -20,32 +21,38 @@ public interface ITopicRepository { \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Raised after a is loaded from the as part of a operation, or one of its overloads. + /// Raised after a is loaded from the as part of a operation, or one of its overloads. /// /// - /// The event should only be raised when a new is loaded from the underlying - /// persistence store. It should not be loaded, for example, if a value is loaded from the cache, or a topicId is - /// queried from the database. Given this, this event will need to be raised in actual implementations, since it is - /// specific to the business logic of each . + /// + /// The event should only be raised when a new is loaded from the underlying + /// persistence store—not, for example, when a value is returned from a cache, or a topicId is merely queried from + /// the database. + /// + /// + /// Raising this reliably is more than a courtesy. As a concrete example, the lazy-loading resolver stamping is driven by + /// this event, so an that fails to raise it will leave loaded topics unable to lazy load + /// their own deferred payload. + /// /// event EventHandler TopicLoaded; /// - /// Raised after a is saved in the as part of a operation. + /// Raised after a is saved in the as part of a operation. /// event EventHandler TopicSaved; /// - /// Raised after a is deleted from the as part of a operation. + /// Raised after a is deleted from the as part of a operation. /// event EventHandler TopicDeleted; /// - /// Raised after a is moved within the as part of a operation. + /// Raised after a is moved within the as part of a operation. /// event EventHandler TopicMoved; @@ -83,69 +90,126 @@ public interface ITopicRepository { \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Loads the entire root topic graph, including all descendants. + /// Loads the root , using the same lazy defaults as . /// /// A topic object. - public Topic? Load() => Load(-1); + public Task Load() => Load(-1); /// - /// Loads a (and, optionally, all of its descendants) based on the specified . + /// Loads a (and, optionally, some or all of its descendants) based on the specified . /// /// The topic identifier. /// /// When loading a single topic or branch, offers a reference topic graph that can be used to ensure that topic /// associations—such as references, relationships, and —are integrated with existing entities. /// - /// Determines whether or not to recurse through and load a topic's children. + /// Specifies which data to include with each topic. + /// + /// The number of tiers of descendants to load below the seed topic. -1 loads the full subtree; 0 loads only + /// the seed topic; N loads N tiers of descendants. Ancestor topics are always loaded when needed to place + /// the seed topic within the graph. + /// + /// + /// Concurrent calls that merge into overlapping regions of the same graph are not + /// guaranteed to be thread-safe: Implementors may serialize duplicate requests for the same identity (i.e., the same + /// or uniqueKey), but a broader, cross-region lock over the graph is not part of this + /// contract. Callers performing an eager or whole-tree warm (depth: -1) against a shared graph should do so in a + /// single threaded, typically during startup, after which the warmed region is effectively read-only and safe for + /// concurrent reads. + /// /// A topic object. - Topic? Load(int topicId, Topic? referenceTopic = null, bool isRecursive = true); + Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ); /// - /// Loads a (and, optionally, all of its descendants) based on the specified . + /// Loads a (and, optionally, some or all of its descendants) based on a specified . /// /// The fully-qualified unique topic key. /// /// When loading a single topic or branch, offers a reference topic graph that can be used to ensure that topic /// associations—such as references, relationships, and —are integrated with existing entities. /// - /// Determines whether or not to recurse through and load a topic's children. + /// + /// Specifies which data to include with each topic. See for details. + /// + /// + /// The number of tiers of descendants to load. See for details. + /// + /// + /// See for the concurrency contract shared by both overloads. + /// /// A topic object. - Topic? Load(string uniqueKey, Topic? referenceTopic = null, bool isRecursive = true); + Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ); - /// + /// [ExcludeFromCodeCoverage] - [Obsolete("This overload has been removed in preference for Load(string, Topic, Boolean).")] - Topic? Load(string? uniqueKey, bool isRecursive); + [Obsolete("This overload has been removed in preference for Load(string, Topic, TopicPayload, int).")] + Task Load(string? uniqueKey, bool isRecursive); /// - /// Loads a specific version of a based on its and . + /// Loads a specific version of a based on its and + /// as a detached topic, disconnected from any graph, with no , no , and no resolved relationships or references. /// /// - /// This overload does not accept an argument for recursion; it will only load a single instance of a version. Further, - /// it will only load versions for which the unique identifier is known. + /// + /// This overload is suitable for previewing a historical version; merging one into a live and + /// persisting the result is the responsibility of . + /// + /// + /// This overload does not accept an argument for recursion; it will only load a single instance of a version. + /// + /// + /// This overload also does not accept an argument for a reference topic and, as a result, which enforces the detachment: + /// Without that, an implementation has no resident graph to resolve relationships or references against, and so every + /// association the returned carries is, by construction, left in either or rather than resolved. + /// /// /// The topic identifier. /// The version. - /// - /// When loading a single topic or branch, offers a reference topic graph that can be used to ensure that topic - /// associations—such as references, relationships, and —are integrated with existing entities. - /// - /// A topic object. - Topic? Load(int topicId, DateTime version, Topic? referenceTopic = null); + /// A detached topic object. + Task Load(int topicId, DateTime version); - /// - Topic? Load(Topic topic, DateTime version); + /// + /// A convenience overload of for callers that already have a + /// instance in hand. + /// + /// + /// Returns the same detached preview graph does; it does not merge the result into + /// , or otherwise mutate it. Callers that need to commit a historical version onto a live should use instead. + /// + /// The current version of the whose history is being requested. + /// The version. + /// A detached topic object. + Task Load(Topic topic, DateTime version); /*============================================================================================================================ | METHOD: ROLLBACK \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Rolls back the supplied to a particular point in its version history by reloading legacy - /// attributes and then saving the new version. + /// Rolls back the supplied to a particular point in its version history by merging the + /// historical version into it, and then saving the result as a new version. /// + /// + /// Unlike or , this mutates + /// in place, immediately followed by . It is not appropriate for + /// previewing a historical version; use for that, as it only load the version, without + /// incorporating it into any in-memory topic graph or committing the previous version to the persistence store. + /// /// The current version of the to rollback. /// The selected Date/Time for the version to which to roll back. /// /// !VersionHistory.Contains(version) /// - void Rollback(Topic topic, DateTime version); + Task Rollback(Topic topic, DateTime version); /*============================================================================================================================ | METHOD: REFRESH \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Updates the topic graph represented by the by loading any changes the specified . + /// Updates the topic graph represented by the by loading any changes the specified . /// /// /// The method is intended to provide basic synchronization of core attributes, /// indexed attributes, extended attributes, relationships, and topic references. It is not expected to handle deletes /// or reordering of topics. /// - void Refresh(Topic referenceTopic, DateTime since); + Task Refresh(Topic referenceTopic, DateTime since); /*============================================================================================================================ | METHOD: SAVE @@ -183,7 +247,7 @@ public interface ITopicRepository { /// topic is not null /// /// topic - void Save(Topic topic, bool isRecursive = false); + Task Save(Topic topic, bool isRecursive = false); /// [ExcludeFromCodeCoverage] @@ -207,13 +271,12 @@ public interface ITopicRepository { /// An optional object representing a sibling adjacent to which the source should /// be moved. /// - /// Boolean value representing whether the operation completed successfully. /// /// topic is not null /// - void Move(Topic topic, Topic target, Topic? sibling = null); + Task Move(Topic topic, Topic target, Topic? sibling = null); /*============================================================================================================================ | METHOD: DELETE @@ -223,13 +286,13 @@ public interface ITopicRepository { /// /// The object to delete. /// - /// Boolean indicator nothing whether to recurse through the 's descendants and delete them as well. If set to false - /// and the topic has children, including any nested topics, an exception will be thrown. The default is false. + /// Boolean indicator nothing whether to recurse through the 's descendants and delete them as well. If + /// set to false and the topic has children, including any nested topics, an exception will be thrown. The default is false. /// /// /// topic is not null /// /// topic - void Delete(Topic topic, bool isRecursive = false); + Task Delete(Topic topic, bool isRecursive = false); } //Interface \ No newline at end of file diff --git a/OnTopic/Repositories/LazyLoadingTopicRepository.cs b/OnTopic/Repositories/LazyLoadingTopicRepository.cs new file mode 100644 index 00000000..265e6d4c --- /dev/null +++ b/OnTopic/Repositories/LazyLoadingTopicRepository.cs @@ -0,0 +1,281 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ +using OnTopic.Associations; +using OnTopic.Querying; + +namespace OnTopic.Repositories; + +/*============================================================================================================================== +| CLASS: LAZY LOADING TOPIC REPOSITORY +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Provides an abstract base class for centralizing infrastructure for implementations of that +/// support lazy-loading, independent of the underlying persistence store. +/// +/// +/// This sits between , which offers only event handling, and the two families of +/// concrete base classes: , for implementations that persist +/// directly to a data store, and , for implementations that wrap another . Both need to stamp topics with an and resolve deferred +/// associations, but neither should be coupled to the other's specific concerns (e.g., 's +/// sealed Save(), Move(), and Delete() template methods, which +/// must remain free to override for delegation). +/// +public abstract class LazyLoadingTopicRepository : ObservableTopicRepository { + + /*============================================================================================================================ + | METHOD: ON TOPIC LOADED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// + /// + /// Stamps the from the , and any descendants attached to it, + /// via and before raising the event, so the + /// loader gets stamped without the loaders needing to be aware of it. + /// + /// + /// fires only for the requested topic, never individually for any descendants + /// pulled in alongside it, so this handles both. + /// + /// + /// Stamping ahead of the base call is load-bearing: subscribes to its inner in its constructor, so raising the event here synchronously re-enters this + /// method on any outer decorators before this call returns, letting the outer's stamp take precedence over inner ones. + /// + /// + protected override void OnTopicLoaded(TopicLoadEventArgs args) { + Contract.Requires(args, nameof(args)); + StampLoader(args.Topic); + StampAscendants(args.Topic.Parent); + base.OnTopicLoaded(args); + } + + /*============================================================================================================================ + | METHOD: ON TOPIC SAVED + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// + /// Stamps the from the via + /// before raising the event for the same reason and via the same method as . + /// + protected override void OnTopicSaved(TopicSaveEventArgs args) { + Contract.Requires(args, nameof(args)); + StampLoader(args.Topic); + base.OnTopicSaved(args); + } + + /*============================================================================================================================ + | METHOD: LOAD DEFERRED ASSOCIATIONS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Resolves any relationships and references that were deferred when loaded through this repository's , preferring whatever is already available in the topic's + /// graph before falling back to a fresh for any + /// that aren't. + /// + /// + /// Targets that cannot be found after this are treated as stale references to deleted topics; this is completed by clearing + /// the , resulting in the corresponding collection's becoming . + /// + /// The topic whose deferred associations should be resolved. + /// + /// The payload flags that were requested; only and are acted upon. + /// + /// An optional token that can be used to cancel the operation. + protected Task LoadDeferredAssociations(Topic topic, TopicPayload payload, CancellationToken cancellationToken) { + Contract.Requires(topic, nameof(topic)); + return ResolveAssociations(topic, payload, fallBackToLoad: true); + } + + /*============================================================================================================================ + | METHOD: RESOLVE ASSOCIATIONS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Resolves any relationship and reference targets that are already present in the 's graph, + /// without triggering a load for targets that aren't. + /// + /// + /// The counterpart to , exposed so a merge + /// that brings new descendants into a resident graph can cheaply reconnect whatever has become resolvable, without + /// discarding what's still genuinely absent, or triggering a potentially expensive roundtrip to the persistence store. + /// + /// The topic whose deferred associations should be resolved against the resident graph. + /// + /// The payload flags that were requested; only and are acted upon. + /// + protected Task ResolveAssociations(Topic topic, TopicPayload payload) { + Contract.Requires(topic, nameof(topic)); + return ResolveAssociations(topic, payload, fallBackToLoad: false); + } + + /// + /// Resolves each deferred relationship and reference entry on against its resident graph, + /// optionally falling back to for whatever the + /// graph doesn't have. + /// + /// + /// The shared core behind both ( : unresolvable targets are treated as stale and discarded) and ( : a + /// miss is left deferred for a later fallback); the two differ only in that flag. Each resolved association is marked dirty + /// according to its own , rather than a flag supplied by the caller, so + /// associations that represent an unpersisted change (see e.g., ) are + /// correctly persisted by , while those recorded during an ordinary load + /// are not. + /// + /// The topic whose deferred associations should be resolved. + /// + /// The payload flags that were requested; only and are acted upon. + /// + /// + /// Whether an association missing from the graph should be fetched via , with whatever remains unresolved afterwards cleared as + /// stale. + /// + private async Task ResolveAssociations(Topic topic, TopicPayload payload, bool fallBackToLoad) { + + // Narrow to the associations that remain deferred, skipping the graph lookup entirely if neither is + payload &= TopicPayload.Relationships | TopicPayload.References; + payload = ((ITopicLazyLoadable)topic).FilterPayload(payload); + + if (payload is TopicPayload.None) { + return; + } + + // Cast topic to safely access backing fields + var rawTopic = (ITopicBackingAccessor)topic; + + // Index the resident graph by id + var topicIndex = topic.GetLiveTopicIndex(); + + // Resolve deferred relationship targets + if (payload.HasFlag(TopicPayload.Relationships)) { + foreach (var deferred in rawTopic.Relationships.Deferred.ToArray()) { + // SetValue removes the matching Deferred entry; any left unresolved are optionally cleared below + if (await resolveTarget(deferred.TopicId).ConfigureAwait(false) is { } target) { + rawTopic.Relationships.SetValue(deferred.Key, target, markDirty: deferred.IsDirty); + } + } + if (fallBackToLoad) { + rawTopic.Relationships.Deferred.Clear(); + } + } + + // Resolve deferred reference targets + if (payload.HasFlag(TopicPayload.References)) { + foreach (var deferred in rawTopic.References.Deferred.ToArray()) { + // SetValue removes the matching Deferred entry; any left unresolved are optionally cleared below + if (await resolveTarget(deferred.TopicId).ConfigureAwait(false) is { } target) { + rawTopic.References.SetValue(deferred.Key, target, markDirty: deferred.IsDirty); + } + } + if (fallBackToLoad) { + rawTopic.References.Deferred.Clear(); + } + } + + return; + + // Resolves a deferred entry against the index, falling back to Load() only when requested and only on a miss + async Task resolveTarget(int targetId) { + if (topicIndex.TryGetValue(targetId, out var target)) { + return target; + } + return fallBackToLoad ? await Load(targetId).ConfigureAwait(false) : null; + } + + } + + /*============================================================================================================================ + | METHOD: STAMP LOADER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Stamps the supplied and its entire loaded graph with this repository as the , enabling each topic to populate deferred portions of itself on demand. + /// + /// + /// + /// Only stamps when the current repository implements . A passthrough decorator that is + /// not itself a loader leaves any existing inner stamp intact, rather than overwriting it. + /// + /// + /// Recursion is gated on so that unloaded branches are not forced + /// to load. Since is an autoloading getter, recursing into it unconditionally would trigger + /// a load for every branch just to stamp it; the gate keeps this confined to what's + /// already present. + /// + /// + /// Call this method once on the root of a recently loaded or saved graph; it stamps every present topic in one pass. + /// + /// + /// The root of the topic graph to stamp. + private void StampLoader(Topic? topic) { + + // Skip if the TopicRepository is not an ITopicLazyLoader, or if the topic doesn't exist + if (this is not ITopicLazyLoader loader || topic is null) { + return; + } + + // Stamp the loader on the topic + ((ITopicLazyLoadable)topic).Loader = loader; + + // If the children aren't yet loaded, don't bother with them yet + if (!((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.Children)) { + return; + } + + // Stamp any children (this is recursive, obviously!) + foreach (var child in topic.Children) { + StampLoader(child); + } + + } + + /*============================================================================================================================ + | METHOD: STAMP ASCENDANTS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Walks a 's chain, stamping each with this repository as the , so an ascendant that was never individually loaded can still lazy-load its own deferred + /// payload. + /// + /// + /// + /// Stops as soon as it reaches an ascendant already stamped by this exact loader instance, on the assumption that its + /// own ascendants were already walked and stamped at that time. Comparing by instance, rather than merely checking for a + /// non-null , matters when this method runs as part of a decorated stack: An outer + /// decorator's pass must not stop early just because an inner repository already stamped the chain with itself. + /// + /// + /// In practice, this only short-circuits repeat calls against the same, undecorated loader instance (e.g., a bare + /// loading many topics over its lifetime that share ascendant branches). When wrapped by a + /// , the inner and outer passes stamp with different instances on every call, so + /// neither ever finds a match from the other, and thus each pass walks the full chain to the root every time. That's + /// harmless, just not a savings there. + /// + /// + /// + /// The topic at which to start walking (typically a loaded topic's ). + /// + private void StampAscendants(Topic? topic) { + + // Skip if the current repository is not an ITopicLazyLoader + if (this is not ITopicLazyLoader loader) { + return; + } + + // Walk and stamp each ascendant, stopping once this loader has already stamped one + for (var ascendant = topic; ascendant is not null && ((ITopicLazyLoadable)ascendant).Loader != loader; ascendant = ascendant.Parent) { + ((ITopicLazyLoadable)ascendant).Loader = loader; + } + + } + +} //Class \ No newline at end of file diff --git a/OnTopic/Repositories/ObservableTopicRepository.cs b/OnTopic/Repositories/ObservableTopicRepository.cs index 950d9a72..16ce5ce5 100644 --- a/OnTopic/Repositories/ObservableTopicRepository.cs +++ b/OnTopic/Repositories/ObservableTopicRepository.cs @@ -36,32 +36,32 @@ public abstract class ObservableTopicRepository : ITopicRepository { /// public event EventHandler? TopicLoaded { - add => _topicLoaded += value; - remove => _topicLoaded -= value; + add => _topicLoaded += value; + remove => _topicLoaded -= value; } /// public event EventHandler? TopicSaved { - add => _topicSaved += value; - remove => _topicSaved -= value; + add => _topicSaved += value; + remove => _topicSaved -= value; } /// public event EventHandler? TopicDeleted { - add => _topicDeleted += value; - remove => _topicDeleted -= value; + add => _topicDeleted += value; + remove => _topicDeleted -= value; } /// public event EventHandler? TopicMoved { - add => _topicMoved += value; - remove => _topicMoved -= value; + add => _topicMoved += value; + remove => _topicMoved -= value; } /// public event EventHandler? TopicRenamed { - add => _topicRenamed += value; - remove => _topicRenamed -= value; + add => _topicRenamed += value; + remove => _topicRenamed -= value; } #pragma warning disable CS0067 // Events are never used; retained as an obsolete stub which will be removed in the next major version @@ -209,42 +209,52 @@ public event EventHandler? TopicRenamed { | METHOD: LOAD \---------------------------------------------------------------------------------------------------------------------------*/ /// - public virtual Topic? Load() => Load(-1); + public virtual Task Load() => Load(-1); /// - public abstract Topic? Load(int topicId, Topic? referenceTopic = null, bool isRecursive = true); + public abstract Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ); /// - public abstract Topic? Load(string uniqueKey, Topic? referenceTopic = null, bool isRecursive = true); - - /// + public abstract Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ); + + /// [ExcludeFromCodeCoverage] - [Obsolete("This overload has been removed in preference for Load(string, Topic, Boolean).")] - public Topic? Load(string? uniqueKey, bool isRecursive) => throw new NotImplementedException(); + [Obsolete("This overload has been removed in preference for Load(string, Topic, TopicPayload, int).")] + public Task Load(string? uniqueKey, bool isRecursive) => throw new NotImplementedException(); /// - public abstract Topic? Load(Topic topic, DateTime version); + public abstract Task Load(Topic topic, DateTime version); /// - public abstract Topic? Load(int topicId, DateTime version, Topic? referenceTopic = null); + public abstract Task Load(int topicId, DateTime version); /*============================================================================================================================ | METHOD: REFRESH \---------------------------------------------------------------------------------------------------------------------------*/ /// - public abstract void Refresh(Topic referenceTopic, DateTime since); + public abstract Task Refresh(Topic referenceTopic, DateTime since); /*============================================================================================================================ | METHOD: ROLLBACK \---------------------------------------------------------------------------------------------------------------------------*/ /// - public abstract void Rollback(Topic topic, DateTime version); + public abstract Task Rollback(Topic topic, DateTime version); /*============================================================================================================================ | METHOD: SAVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public abstract void Save(Topic topic, bool isRecursive = false); + public abstract Task Save(Topic topic, bool isRecursive = false); /// [ExcludeFromCodeCoverage] @@ -255,13 +265,13 @@ public event EventHandler? TopicRenamed { | METHOD: MOVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public abstract void Move(Topic topic, Topic target, Topic? sibling = null); + public abstract Task Move(Topic topic, Topic target, Topic? sibling = null); /*============================================================================================================================ | METHOD: DELETE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public abstract void Delete(Topic topic, bool isRecursive = false); + public abstract Task Delete(Topic topic, bool isRecursive = false); /*============================================================================================================================ | METHOD: NORMALIZE TO UTC diff --git a/OnTopic/Repositories/TopicPayload.cs b/OnTopic/Repositories/TopicPayload.cs new file mode 100644 index 00000000..f95cb10f --- /dev/null +++ b/OnTopic/Repositories/TopicPayload.cs @@ -0,0 +1,88 @@ +/*============================================================================================================================== +| Author Ignia, LLC +| Client Ignia, LLC +| Project Topics Library +\=============================================================================================================================*/ + +namespace OnTopic.Repositories; + +/*============================================================================================================================== +| ENUM: TOPIC PAYLOAD +\-----------------------------------------------------------------------------------------------------------------------------*/ +/// +/// Specifies which data ensure is loaded on a . Used as a parameter on 's +/// Load() overloads to control how much data is fetched in the first place, and on 's +/// Ensure() method to specify which previously deferred data to fill on demand. +/// +/// +/// , , , , and +/// all have lazy-loading fill paths via . +/// +/// The default value for all Load overloads is , so lazy loading +/// is the default: Each boundary is fetched on demand via its autoloading accessor or an explicit EnsureLoaded call. +/// Callers that need everything up front may still request explicitly. Indexed attributes are always +/// returned as part of the base graph and are not a separately lazy-loadable boundary. +/// +/// +[Flags] +public enum TopicPayload { + + /*---------------------------------------------------------------------------------------------------------------------------- + | NONE + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// No additional payload is requested. Indexed attributes are always returned as part of the base graph; this value + /// represents the lean baseline, with all available by specifying the additional values. + /// + None = 0, + + /*---------------------------------------------------------------------------------------------------------------------------- + | CHILDREN + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// The topic's immediate children have not been fetched. Accessing will trigger an on-demand + /// load of exactly one level. + /// + Children = 1, + + /*---------------------------------------------------------------------------------------------------------------------------- + | EXTENDED ATTRIBUTES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Extended attributes are loaded alongside the indexed attributes. + /// + ExtendedAttributes = 1 << 1, + + /*---------------------------------------------------------------------------------------------------------------------------- + | RELATIONSHIPS + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Relationship targets are included. + /// + Relationships = 1 << 2, + + /*---------------------------------------------------------------------------------------------------------------------------- + | REFERENCES + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Topic reference targets are included. + /// + References = 1 << 3, + + /*---------------------------------------------------------------------------------------------------------------------------- + | VERSION HISTORY + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Version history is included. + /// + VersionHistory = 1 << 4, + + /*---------------------------------------------------------------------------------------------------------------------------- + | ALL + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// All payload data. This ensures a comprehensive loading of all available data. + /// + All = Children | ExtendedAttributes | Relationships | References | VersionHistory, + +} //Enum \ No newline at end of file diff --git a/OnTopic/Repositories/TopicRepository.cs b/OnTopic/Repositories/TopicRepository.cs index f01380ec..25153ab1 100644 --- a/OnTopic/Repositories/TopicRepository.cs +++ b/OnTopic/Repositories/TopicRepository.cs @@ -20,20 +20,20 @@ namespace OnTopic.Repositories; /// /// /// The is a highly opinionated base implementation of . -/// In addition to validating parameters and raising events on , , and , it also provides a number of (protected) methods to -/// aid implementors in evaluating and parsing data, such as . It is recommended that all concrete implementations of that are responsible for -/// persisting data to a data store use this as a base class. +/// In addition to validating parameters and raising events on , , and , it also provides a number of (protected) +/// methods to aid implementors in evaluating and parsing data, such as . It is recommended that all concrete implementations of +/// that are responsible for persisting data to a data store use this as a base class. /// /// /// Implementations of which need to use different business logic, or do not need to -/// implement business logic (such as unit test doubles) may instead opt to derive directly from the , which handles the basic event handling, and nothing else. Implementations of decorators +/// implement business logic (such as unit test doubles) may instead opt to derive directly from the , which handles the basic event handling, and nothing else. Implementations of decorators /// should instead derive from the . /// /// -public abstract class TopicRepository : ObservableTopicRepository { +public abstract class TopicRepository : LazyLoadingTopicRepository { /*============================================================================================================================ | PRIVATE VARIABLES @@ -57,7 +57,7 @@ public override ContentTypeDescriptorCollection GetContentTypeDescriptors() { var configuration = (Topic?)null; try { - configuration = Load("Root:Configuration"); + configuration = Load("Root:Configuration").GetAwaiter().GetResult(); } catch (TopicNotFoundException) { //Swallow missing configuration, as this is an expected condition when working with a new database @@ -127,14 +127,15 @@ protected ContentTypeDescriptorCollection SetContentTypeDescriptors(Topic? sourc /// /// By default, the method will load data from the /// concrete implementation of the 's data store. There are cases, however, where it may be - /// preferrable to instead load these topics from a local, in-memory source. Namely, when first instantiating a new - /// OnTopic database, and when saving modifications to existing content types. As such, the protected method is useful to call from when the topic graph being saved includes any new s. + /// preferable to instead load these topics from a local, in-memory source. Namely, when first instantiating a new OnTopic + /// database, and when saving modifications to existing content types. As such, the protected method is useful to call from when the topic graph being saved includes any new s. /// /// - /// The root of a topic graph to merge into the collection for . The code will process not only the root topic graph to merge into the collection for . The code will process not only the root , but also any descendents. /// /// @@ -196,7 +197,7 @@ protected ContentTypeDescriptorCollection SetContentTypeDescriptors(ContentTypeD \-------------------------------------------------------------------------------------------------------------------------*/ var contentType = sourceTopic.ContentType; var contentTypes = GetContentTypeDescriptors(); - var contentTypeDescriptor = contentTypes.Contains(contentType)? contentTypes[contentType] : null; + var contentTypeDescriptor = contentTypes.Contains(contentType)? contentTypes[contentType] : null; if (contentTypeDescriptor is not null) { return contentTypeDescriptor; @@ -218,21 +219,27 @@ protected ContentTypeDescriptorCollection SetContentTypeDescriptors(ContentTypeD | METHOD: LOAD \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override Topic? Load(Topic topic, DateTime version) { + public override Task Load(Topic topic, DateTime version) { Contract.Requires(topic, nameof(topic)); Contract.Requires( !topic.IsNew, $"The version '{version}' of '{topic.GetUniqueKey()}' cannot be loaded. Topics must be saved in order to load " + $"previous versions." ); - return Load(topic.Id, version, topic); + return Load(topic.Id, version); } /*============================================================================================================================ | METHOD: ROLLBACK \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Rollback([ValidatedNotNull]Topic topic, DateTime version) { + /// + /// Merges the detached topic returned by into using , then resolves whatever relationships and references are already loaded in 's graph via , leaving + /// the rest deferred for lazy loading, before committing the result via . + /// + public override async Task Rollback([ValidatedNotNull]Topic topic, DateTime version) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -245,14 +252,32 @@ public override void Rollback([ValidatedNotNull]Topic topic, DateTime version) { ); /*-------------------------------------------------------------------------------------------------------------------------- - | Retrieve topic from database + | Retrieve historical version + \-------------------------------------------------------------------------------------------------------------------------*/ + var historicalTopic = await Load(topic, version).ConfigureAwait(false)?? throw new TopicNotFoundException(topic.Id); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Merge historical version into the live topic + >--------------------------------------------------------------------------------------------------------------------------- + | Some ITopicRepository implementations (e.g., test doubles backed by a single resident graph) may return the same instance + | from Load(Topic, DateTime); in that case, there's nothing to merge. + \-------------------------------------------------------------------------------------------------------------------------*/ + if (!ReferenceEquals(historicalTopic, topic)) { + MergeVersion(topic, historicalTopic); + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Resolve associations against the resident graph + >------------------------------------------------------------------------------------------------------------------------- + | Every entry MergeVersion() placed into Deferred is marked IsDirty via ReplaceAll(), so resolving them here also marks the + | corresponding Relationships and References dirty, ensuring Save() detects and persists them. \-------------------------------------------------------------------------------------------------------------------------*/ - Load(topic, version); + await ResolveAssociations(topic, TopicPayload.Relationships | TopicPayload.References).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Save as new version \-------------------------------------------------------------------------------------------------------------------------*/ - Save(topic, false); + await Save(topic).ConfigureAwait(false); } @@ -260,7 +285,7 @@ public override void Rollback([ValidatedNotNull]Topic topic, DateTime version) { | METHOD: SAVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override sealed void Save([ValidatedNotNull] Topic topic, bool isRecursive = false) { + public override sealed async Task Save([ValidatedNotNull] Topic topic, bool isRecursive = false) { /*-------------------------------------------------------------------------------------------------------------------------- | Establish parameters @@ -281,14 +306,14 @@ public override sealed void Save([ValidatedNotNull] Topic topic, bool isRecurs /*-------------------------------------------------------------------------------------------------------------------------- | Handle first pass \-------------------------------------------------------------------------------------------------------------------------*/ - Save(topic, isRecursive, unresolvedTopics, version); + await Save(topic, isRecursive, unresolvedTopics, version).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Attempt to resolve outstanding associations \-------------------------------------------------------------------------------------------------------------------------*/ foreach (var unresolvedTopic in unresolvedTopics.ToList()) { unresolvedTopics.Remove(unresolvedTopic); - Save(unresolvedTopic, false, unresolvedTopics, version); + await Save(unresolvedTopic, false, unresolvedTopics, version).ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -327,7 +352,7 @@ public override sealed void Save([ValidatedNotNull] Topic topic, bool isRecurs /// Determines whether or not to recursively save . /// A list of s with unresolved topic references. /// The version to assign to the updates. - private void Save([NotNull]Topic topic, bool isRecursive, TopicCollection unresolvedTopics, DateTime version) { + private async Task Save([NotNull]Topic topic, bool isRecursive, TopicCollection unresolvedTopics, DateTime version) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -338,13 +363,13 @@ private void Save([NotNull]Topic topic, bool isRecursive, TopicCollection unreso | Establish variables \-------------------------------------------------------------------------------------------------------------------------*/ var isNew = topic.IsNew; - var areRelationshipsDirty = topic.Relationships.IsDirty(); + var areRelationshipsDirty = topic.Relationships.IsDirty(); /*-------------------------------------------------------------------------------------------------------------------------- | Validate content type \-------------------------------------------------------------------------------------------------------------------------*/ - var contentTypeDescriptors= GetContentTypeDescriptors(); - var contentTypeDescriptor = GetContentTypeDescriptor(topic); + var contentTypeDescriptors = GetContentTypeDescriptors(); + var contentTypeDescriptor = GetContentTypeDescriptor(topic); if (contentTypeDescriptor is null) { throw new ReferentialIntegrityException( @@ -369,7 +394,7 @@ private void Save([NotNull]Topic topic, bool isRecursive, TopicCollection unreso /*-------------------------------------------------------------------------------------------------------------------------- | Execute core implementation \-------------------------------------------------------------------------------------------------------------------------*/ - SaveTopic(topic, version, !isRecursive || !unresolvedTopics.Contains(topic)); + await SaveTopic(topic, version, !isRecursive || !unresolvedTopics.Contains(topic)).ConfigureAwait(false); /*-------------------------------------------------------------------------------------------------------------------------- | Mark as clean @@ -392,12 +417,12 @@ private void Save([NotNull]Topic topic, bool isRecursive, TopicCollection unreso | Perform reordering and/or move \-------------------------------------------------------------------------------------------------------------------------*/ if (topic.Parent is not null && !topic.IsNew && topic.IsDirty("Parent")) { - var topicIndex = topic.Parent.Children.IndexOf(topic); + var topicIndex = topic.Parent.Children.IndexOf(topic); if (topicIndex > 0) { - Move(topic, topic.Parent, topic.Parent.Children[topicIndex - 1]); + await Move(topic, topic.Parent, topic.Parent.Children[topicIndex - 1]).ConfigureAwait(false); } else { - Move(topic, topic.Parent); + await Move(topic, topic.Parent).ConfigureAwait(false); } } @@ -442,14 +467,14 @@ _contentTypeDescriptors is not null && /*-------------------------------------------------------------------------------------------------------------------------- | Reset original key \-------------------------------------------------------------------------------------------------------------------------*/ - topic.OriginalKey = null; + topic.OriginalKey = null; /*-------------------------------------------------------------------------------------------------------------------------- | Recurse over children \-------------------------------------------------------------------------------------------------------------------------*/ - if (isRecursive) { + if (isRecursive && ((ITopicLazyLoadable)topic).IsLoaded(TopicPayload.Children)) { foreach (var childTopic in topic.Children.ToList()) { - Save(childTopic, isRecursive, unresolvedTopics, version); + await Save(childTopic, isRecursive, unresolvedTopics, version).ConfigureAwait(false); } } @@ -464,13 +489,13 @@ _contentTypeDescriptors is not null && /// /// /// The main implementation handles advanced validation of the parameters, updating the - /// , attempting to pick up any unresolved topics, updating and instances as appropriate, raising the , if needed, and recursing over children. The derived implementation of is then left to focus exclusively on the core logic of persisting the changes - /// to the individual to the underlying data store, and optionally updating its and , assuming is set to - /// true. + /// , attempting to pick up any unresolved topics, updating and instances as appropriate, raising the , if needed, and recursing over children. The derived implementation of is then left to focus exclusively on the core logic of persisting the changes + /// to the individual to the underlying data store, and optionally updating its and , assuming is set to + /// true. /// /// The source to save. /// The version to assign to the updates. @@ -480,13 +505,13 @@ _contentTypeDescriptors is not null && /// call isRecursive; in that case, will circle back and attempt to save them /// after the rest of the topic graph has been saved. /// - protected abstract void SaveTopic([NotNull] Topic topic, DateTime version, bool persistRelationships); + protected abstract Task SaveTopic([NotNull] Topic topic, DateTime version, bool persistRelationships); /*============================================================================================================================ | METHOD: MOVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override sealed void Move([ValidatedNotNull]Topic topic, [ValidatedNotNull]Topic target, Topic? sibling = null) { + public override sealed async Task Move([ValidatedNotNull]Topic topic, [ValidatedNotNull]Topic target, Topic? sibling = null) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -513,7 +538,7 @@ topic.Parent is not null && | Execute core implementation \-------------------------------------------------------------------------------------------------------------------------*/ if (!topic.IsNew && !target.IsNew && !(sibling?.IsNew?? true)) { - MoveTopic(topic, target, sibling); + await MoveTopic(topic, target, sibling).ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -567,13 +592,13 @@ topic.Parent is not null && /// The derived implementation of is then left to focus exclusively on the /// core logic of persisting the change to the underlying data store. /// - protected abstract void MoveTopic(Topic topic, Topic target, Topic? sibling = null); + protected abstract Task MoveTopic(Topic topic, Topic target, Topic? sibling = null); /*============================================================================================================================ | METHOD: DELETE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override sealed void Delete([ValidatedNotNull]Topic topic, bool isRecursive = false) { + public override sealed async Task Delete([ValidatedNotNull]Topic topic, bool isRecursive = false) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate parameters @@ -582,8 +607,16 @@ public override sealed void Delete([ValidatedNotNull]Topic topic, bool isRecur /*-------------------------------------------------------------------------------------------------------------------------- | Validate descendants + >--------------------------------------------------------------------------------------------------------------------------- + | Reads Children via ITopicLazyLoadable, not the autoloading getter, so a lazy-loaded topic isn't forced to fetch its + | children merely to be deleted. Both signals are already definitive: An ITopicRepository only ever stamps Children as + | NotLoaded when the topic genuinely has children it hasn't fully fetched, so no count check is needed for that case. When + | Loaded, the resident Children collection is complete, so its count is authoritative. \-------------------------------------------------------------------------------------------------------------------------*/ - if (!isRecursive && topic.Children.Any(t => !t.ContentType.Equals("List", StringComparison.OrdinalIgnoreCase))) { + var rawTopic = (ITopicLazyLoadable)topic; + var hasResidentChildren = rawTopic.Children.Any(t => !t.ContentType.Equals("List", StringComparison.OrdinalIgnoreCase)); + + if (!isRecursive && (hasResidentChildren || !rawTopic.IsLoaded(TopicPayload.Children))) { throw new ReferentialIntegrityException( $"The topic '{topic.GetUniqueKey()}' cannot be deleted. It has child topics, but '{nameof(isRecursive)}' is set to " + $"false. To delete '{topic.GetUniqueKey()}' and all of its descendants, set '{nameof(isRecursive)}' to true." @@ -609,7 +642,7 @@ public override sealed void Delete([ValidatedNotNull]Topic topic, bool isRecur | Execute core implementation \-------------------------------------------------------------------------------------------------------------------------*/ if (!topic.IsNew) { - DeleteTopic(topic); + await DeleteTopic(topic).ConfigureAwait(false); } /*-------------------------------------------------------------------------------------------------------------------------- @@ -676,7 +709,7 @@ public override sealed void Delete([ValidatedNotNull]Topic topic, bool isRecur /*-------------------------------------------------------------------------------------------------------------------------- | Raise event \-------------------------------------------------------------------------------------------------------------------------*/ - var args = new TopicEventArgs(topic); + var args = new TopicEventArgs(topic); OnTopicDeleted(args); } @@ -690,12 +723,12 @@ public override sealed void Delete([ValidatedNotNull]Topic topic, bool isRecur /// /// /// The main implementation handles advanced validation of the parameters, - /// removing the from the topic graph, updating and instances as appropriate, and raising the . The + /// removing the from the topic graph, updating and instances as appropriate, and raising the . The /// derived implementation of is then left to focus exclusively on the core logic of /// persisting the change to the underlying data store. /// - protected abstract void DeleteTopic(Topic topic); + protected abstract Task DeleteTopic(Topic topic); /*============================================================================================================================ | METHOD: GET ATTRIBUTES @@ -717,8 +750,8 @@ public override sealed void Delete([ValidatedNotNull]Topic topic, bool isRecur protected IEnumerable GetAttributes( Topic topic, bool? isExtendedAttribute, - bool? isDirty = null, - bool excludeLastModified = false + bool? isDirty = null, + bool excludeLastModified = false ) { /*-------------------------------------------------------------------------------------------------------------------------- @@ -739,7 +772,7 @@ protected IEnumerable GetAttributes( /*-------------------------------------------------------------------------------------------------------------------------- | Get indexed attributes \-------------------------------------------------------------------------------------------------------------------------*/ - var attributes = new List(); + List attributes = []; foreach (var attributeValue in topic.Attributes) { @@ -794,8 +827,8 @@ protected IEnumerable GetAttributes( | METHOD: GET UNMATCHED ATTRIBUTES \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Given a , identifies s that are defined based on the , but aren't defined in the . + /// Given a , identifies s that are defined based on the , but aren't defined in the . /// /// The from which to pull the attributes. protected IEnumerable GetUnmatchedAttributes(Topic topic) { @@ -898,8 +931,9 @@ private static bool IsAttributeDescriptor(Topic topic) => /// The determines where an attribute should be stored; the /// determines where an attribute was stored. If these two /// values are in conflict, that suggests the coniguration for has - /// changed since the attribute value was last saved. In that case, it should be treated as even though its value hasn't changed to ensure that its storage location is updated. + /// changed since the attribute value was last saved. In that case, it should be treated as even though its value hasn't changed to ensure that its storage location is + /// updated. /// /// /// If cannot be found then the is arbitrary attribute @@ -944,4 +978,60 @@ private static void ResetAttributeDescriptors(Topic topic) { } } + + /*============================================================================================================================ + | METHOD: MERGE VERSION + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Merges the attributes, relationships, and references of a detached , as returned by + /// , onto the live it corresponds to. + /// + /// + /// Relationships and references are replaced wholesale: Each collection's Clear() empties the resolved half + /// (including reciprocal associations on previously related topics, e.g., ), then + /// 's Deferred entries are written onto 's Deferred + /// collections, marked dirty, ready for + /// to reconnect against whatever is already present in 's graph. Only Deferred needs to be + /// read, not any resolved associations on , because + /// is contractually incapable of resolving associations: With no referenceTopic parameter, it has no graph to + /// resolve them against, so every association it returns is, by construction, Deferred. + /// + /// The live to merge the historical version into. + /// The detached historical version, as returned by . + private static void MergeVersion(Topic topic, Topic historicalTopic) { + + /*-------------------------------------------------------------------------------------------------------------------------- + | Setup + \-------------------------------------------------------------------------------------------------------------------------*/ + var rawTopic = (ITopicBackingAccessor)topic; + var rawHistoricalTopic = (ITopicBackingAccessor)historicalTopic; + + /*-------------------------------------------------------------------------------------------------------------------------- + | Merge attributes + \-------------------------------------------------------------------------------------------------------------------------*/ + foreach (var attribute in rawHistoricalTopic.Attributes) { + rawTopic.Attributes.SetValue(attribute.Key, attribute.Value, isExtendedAttribute: attribute.IsExtendedAttribute); + } + + // Remove attributes that were introduced after the requested version + foreach (var attribute in rawTopic.Attributes.ToArray()) { + if (!rawHistoricalTopic.Attributes.Contains(attribute.Key)) { + rawTopic.Attributes.Remove(attribute.Key); + } + } + + /*-------------------------------------------------------------------------------------------------------------------------- + | Merge relationships + \-------------------------------------------------------------------------------------------------------------------------*/ + rawTopic.Relationships.Clear(); + rawTopic.Relationships.Deferred.ReplaceAll(rawHistoricalTopic.Relationships.Deferred); + + /*-------------------------------------------------------------------------------------------------------------------------- + | Merge references + \-------------------------------------------------------------------------------------------------------------------------*/ + rawTopic.References.Clear(); + rawTopic.References.Deferred.ReplaceAll(rawHistoricalTopic.References.Deferred); + + } + } //Class \ No newline at end of file diff --git a/OnTopic/Repositories/TopicRepositoryDecorator.cs b/OnTopic/Repositories/TopicRepositoryDecorator.cs index 9665d056..2de3c867 100644 --- a/OnTopic/Repositories/TopicRepositoryDecorator.cs +++ b/OnTopic/Repositories/TopicRepositoryDecorator.cs @@ -23,7 +23,7 @@ namespace OnTopic.Repositories; /// can leave everything else as is. /// [ExcludeFromCodeCoverage] -public abstract class TopicRepositoryDecorator : ObservableTopicRepository { +public abstract class TopicRepositoryDecorator : LazyLoadingTopicRepository { /*============================================================================================================================ | CONSTRUCTOR @@ -36,7 +36,7 @@ public abstract class TopicRepositoryDecorator : ObservableTopicRepository { /// A concrete instance of an , which will be used for data access. /// /// A new instance of the . - protected TopicRepositoryDecorator(ITopicRepository topicRepository) : base() { + protected TopicRepositoryDecorator(ITopicRepository topicRepository) { /*-------------------------------------------------------------------------------------------------------------------------- | Validate input @@ -46,16 +46,16 @@ protected TopicRepositoryDecorator(ITopicRepository topicRepository) : base() { /*-------------------------------------------------------------------------------------------------------------------------- | Set values locally \-------------------------------------------------------------------------------------------------------------------------*/ - TopicRepository = topicRepository; + TopicRepository = topicRepository; /*-------------------------------------------------------------------------------------------------------------------------- | Subscribe to underlying events \-------------------------------------------------------------------------------------------------------------------------*/ - TopicRepository.TopicLoaded += (object? sender, TopicLoadEventArgs args) => OnTopicLoaded(args); - TopicRepository.TopicSaved += (object? sender, TopicSaveEventArgs args) => OnTopicSaved(args); - TopicRepository.TopicDeleted += (object? sender, TopicEventArgs args) => OnTopicDeleted(args); - TopicRepository.TopicMoved += (object? sender, TopicMoveEventArgs args) => OnTopicMoved(args); - TopicRepository.TopicRenamed += (object? sender, TopicRenameEventArgs args) => OnTopicRenamed(args); + TopicRepository.TopicLoaded += (_, args) => OnTopicLoaded(args); + TopicRepository.TopicSaved += (_, args) => OnTopicSaved(args); + TopicRepository.TopicDeleted += (_, args) => OnTopicDeleted(args); + TopicRepository.TopicMoved += (_, args) => OnTopicMoved(args); + TopicRepository.TopicRenamed += (_, args) => OnTopicRenamed(args); } @@ -77,52 +77,62 @@ protected TopicRepositoryDecorator(ITopicRepository topicRepository) : base() { | METHOD: LOAD \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override Topic? Load() => Load(-1); + public override Task Load() => Load(-1); /// - public override Topic? Load(int topicId, Topic? referenceTopic = null, bool isRecursive = true) => - TopicRepository.Load(topicId, referenceTopic, isRecursive); + public override Task Load( + int topicId, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) => + TopicRepository.Load(topicId, referenceTopic, payload, depth); /// - public override Topic? Load(string uniqueKey, Topic? referenceTopic = null, bool isRecursive = true) => - TopicRepository.Load(uniqueKey, referenceTopic, isRecursive); + public override Task Load( + string uniqueKey, + Topic? referenceTopic = null, + TopicPayload payload = TopicPayload.None, + int depth = 0 + ) => + TopicRepository.Load(uniqueKey, referenceTopic, payload, depth); /// - public override Topic? Load(Topic topic, DateTime version) + public override Task Load(Topic topic, DateTime version) => TopicRepository.Load(topic, version); /// - public override Topic? Load(int topicId, DateTime version, Topic? referenceTopic = null) => - TopicRepository.Load(topicId, version, referenceTopic); + public override Task Load(int topicId, DateTime version) => + TopicRepository.Load(topicId, version); /*============================================================================================================================ | METHOD: REFRESH \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Refresh(Topic referenceTopic, DateTime since) => TopicRepository.Refresh(referenceTopic, since); + public override Task Refresh(Topic referenceTopic, DateTime since) => TopicRepository.Refresh(referenceTopic, since); /*============================================================================================================================ | METHOD: ROLLBACK \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Rollback(Topic topic, DateTime version) => TopicRepository.Rollback(topic, version); + public override Task Rollback(Topic topic, DateTime version) => TopicRepository.Rollback(topic, version); /*============================================================================================================================ | METHOD: SAVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Save(Topic topic, bool isRecursive = false) => TopicRepository.Save(topic, isRecursive); + public override Task Save(Topic topic, bool isRecursive = false) => TopicRepository.Save(topic, isRecursive); /*============================================================================================================================ | METHOD: MOVE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Move(Topic topic, Topic target, Topic? sibling = null) => TopicRepository.Move(topic, target, sibling); + public override Task Move(Topic topic, Topic target, Topic? sibling = null) => TopicRepository.Move(topic, target, sibling); /*============================================================================================================================ | METHOD: DELETE \---------------------------------------------------------------------------------------------------------------------------*/ /// - public override void Delete(Topic topic, bool isRecursive = false) => TopicRepository.Delete(topic, isRecursive); + public override Task Delete(Topic topic, bool isRecursive = false) => TopicRepository.Delete(topic, isRecursive); } //Class \ No newline at end of file diff --git a/OnTopic/Repositories/_eventArgs/TopicEventArgs.cs b/OnTopic/Repositories/_eventArgs/TopicEventArgs.cs index 8179a751..90808b2c 100644 --- a/OnTopic/Repositories/_eventArgs/TopicEventArgs.cs +++ b/OnTopic/Repositories/_eventArgs/TopicEventArgs.cs @@ -29,7 +29,7 @@ public class TopicEventArgs : EventArgs { /// /// The being operated against. /// Whether or not descendants of the were also loaded. - public TopicEventArgs(Topic topic, bool isRecursive = true) : base() { + public TopicEventArgs(Topic topic, bool isRecursive = true) { /*-------------------------------------------------------------------------------------------------------------------------- | Vaidate parameters diff --git a/OnTopic/Repositories/_eventArgs/TopicLoadEventArgs.cs b/OnTopic/Repositories/_eventArgs/TopicLoadEventArgs.cs index f0b4fc93..8d92d009 100644 --- a/OnTopic/Repositories/_eventArgs/TopicLoadEventArgs.cs +++ b/OnTopic/Repositories/_eventArgs/TopicLoadEventArgs.cs @@ -19,20 +19,33 @@ public class TopicLoadEventArgs : TopicEventArgs { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// The object defines the event arguments relevant to a operation and its overloads. + /// Load(Int32, Topic?, TopicPayload, Int32)"/> operation and its overloads. /// /// The object associated with the rename event. - /// Whether or not descendants of the were also loaded. + /// The number of tiers of descendants that were also loaded. See for details. /// If a specific version was loaded, specified that version. - public TopicLoadEventArgs(Topic topic, bool isRecursive, DateTime? version = null): base(topic, isRecursive) { + public TopicLoadEventArgs(Topic topic, int depth, DateTime? version = null): base(topic, depth != 0) { /*-------------------------------------------------------------------------------------------------------------------------- | Initialize properties \-------------------------------------------------------------------------------------------------------------------------*/ + Depth = depth; Version = version; } + /*============================================================================================================================ + | PROPERTY: DEPTH + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + /// Gets or sets the number of tiers of descendants that were loaded below the . + /// + /// + /// -1 indicates the full subtree was loaded; 0 indicates only the seed + /// itself was loaded; N indicates N tiers of descendants were loaded. + /// + public int Depth { get; set; } + /*============================================================================================================================ | PROPERTY: VERSION \---------------------------------------------------------------------------------------------------------------------------*/ diff --git a/OnTopic/Repositories/_eventArgs/TopicMoveEventArgs.cs b/OnTopic/Repositories/_eventArgs/TopicMoveEventArgs.cs index 7f85faec..2114411e 100644 --- a/OnTopic/Repositories/_eventArgs/TopicMoveEventArgs.cs +++ b/OnTopic/Repositories/_eventArgs/TopicMoveEventArgs.cs @@ -40,7 +40,7 @@ public class TopicMoveEventArgs : TopicEventArgs { /// /// != /// - public TopicMoveEventArgs(Topic topic, Topic? source, Topic target, Topic? sibling = null): base(topic, true) { + public TopicMoveEventArgs(Topic topic, Topic? source, Topic target, Topic? sibling = null): base(topic) { /*-------------------------------------------------------------------------------------------------------------------------- | Vaidate parameters diff --git a/OnTopic/Repositories/_eventArgs/TopicRenameEventArgs.cs b/OnTopic/Repositories/_eventArgs/TopicRenameEventArgs.cs index f36543f5..c20aebbf 100644 --- a/OnTopic/Repositories/_eventArgs/TopicRenameEventArgs.cs +++ b/OnTopic/Repositories/_eventArgs/TopicRenameEventArgs.cs @@ -24,7 +24,7 @@ public class TopicRenameEventArgs : TopicEventArgs { /// The object associated with the rename event. /// The original key of the prior to being renamed. /// The new key of the after being renamed. - public TopicRenameEventArgs(Topic topic, string originalKey, string newKey): base(topic, true) { + public TopicRenameEventArgs(Topic topic, string originalKey, string newKey): base(topic) { /*-------------------------------------------------------------------------------------------------------------------------- | Vaidate parameters diff --git a/OnTopic/Repositories/_eventArgs/TopicSaveEventArgs.cs b/OnTopic/Repositories/_eventArgs/TopicSaveEventArgs.cs index 73f251aa..71413483 100644 --- a/OnTopic/Repositories/_eventArgs/TopicSaveEventArgs.cs +++ b/OnTopic/Repositories/_eventArgs/TopicSaveEventArgs.cs @@ -37,8 +37,8 @@ public TopicSaveEventArgs(Topic topic, bool isRecursive, bool isNew): base(topic | PROPERTY: IS NEW \---------------------------------------------------------------------------------------------------------------------------*/ /// - /// Gets or sets whether the was newly created, or if it was an existing that has been updated. + /// Gets or sets whether the was newly created, or if it was an existing that has been updated. /// public bool IsNew { get; set; } diff --git a/OnTopic/Repositories/_exceptions/ReferentialIntegrityException.cs b/OnTopic/Repositories/_exceptions/ReferentialIntegrityException.cs index accb963a..77ed5b35 100644 --- a/OnTopic/Repositories/_exceptions/ReferentialIntegrityException.cs +++ b/OnTopic/Repositories/_exceptions/ReferentialIntegrityException.cs @@ -26,7 +26,7 @@ public class ReferentialIntegrityException: TopicRepositoryException { /// /// Initializes a new instance. /// - public ReferentialIntegrityException() : base() { } + public ReferentialIntegrityException() { } /// /// Initializes a new instance based on a /// Initializes a new instance. /// - public TopicNotFoundException() : base() { } + public TopicNotFoundException() { } /// /// Initializes a new instance based on a missing topic ID. diff --git a/OnTopic/Repositories/_exceptions/TopicRepositoryException.cs b/OnTopic/Repositories/_exceptions/TopicRepositoryException.cs index 933bffba..5fd9ed43 100644 --- a/OnTopic/Repositories/_exceptions/TopicRepositoryException.cs +++ b/OnTopic/Repositories/_exceptions/TopicRepositoryException.cs @@ -30,7 +30,7 @@ public class TopicRepositoryException : DbException { /// /// Initializes a new instance. /// - public TopicRepositoryException() : base() { } + public TopicRepositoryException() { } /// /// Initializes a new instance with a specific error message. diff --git a/OnTopic/Topic.cs b/OnTopic/Topic.cs index 2bb751af..8f0da829 100644 --- a/OnTopic/Topic.cs +++ b/OnTopic/Topic.cs @@ -3,13 +3,12 @@ | Client Ignia, LLC | Project Topics Library \=============================================================================================================================*/ -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; using System.Globalization; +using OnTopic.Associations; using OnTopic.Collections; using OnTopic.Collections.Specialized; using OnTopic.Metadata; -using OnTopic.Associations; +using OnTopic.Repositories; namespace OnTopic; @@ -20,7 +19,7 @@ namespace OnTopic; /// The Topic object is a simple container for a particular node in the topic hierarchy. It contains the metadata associated /// with the particular node, a list of children, etc. /// -public class Topic: ITrackDirtyKeys { +public class Topic: ITrackDirtyKeys, ITopicLazyLoadable { /*============================================================================================================================ | PRIVATE VARIABLES @@ -29,7 +28,11 @@ public class Topic: ITrackDirtyKeys { private string _contentType; private string? _originalKey; private Topic? _parent; - readonly DirtyKeyCollection _dirtyKeys = new(); + private readonly ChildTopicCollection _children; + private readonly TopicRelationshipMultiMap _relationships; + private readonly TopicReferenceCollection _references; + private readonly VersionHistoryCollection _versionHistory = []; + readonly DirtyKeyCollection _dirtyKeys = []; /*============================================================================================================================ | CONSTRUCTOR @@ -39,8 +42,8 @@ public class Topic: ITrackDirtyKeys { /// optionally, , . /// /// - /// By default, when creating new attributes, the s for both and will be set to , which is required in order to correctly save new + /// By default, when creating new attributes, the s for both and will be set to , which is required in order to correctly save new /// topics to the database. When the parameter is set, however, the property is set to falseon and , as it is assumed these /// are being set to the same values currently used in the persistence store. @@ -55,15 +58,18 @@ public class Topic: ITrackDirtyKeys { /// A strongly-typed instance of the class based on the target content type. public Topic(string key, string contentType, Topic? parent = null, int id = -1) { + /*-------------------------------------------------------------------------------------------------------------------------- + | Set children first, since setting Id or Parent below may fire registry hooks that read this topic's children + \-------------------------------------------------------------------------------------------------------------------------*/ + _children = new(this); + /*-------------------------------------------------------------------------------------------------------------------------- | Set collections \-------------------------------------------------------------------------------------------------------------------------*/ - Children = new(); Attributes = new(this); IncomingRelationships = new(this, true); - Relationships = new(this, false); - References = new(this); - VersionHistory = new(); + _relationships = new(this); + _references = new(this); /*-------------------------------------------------------------------------------------------------------------------------- | Set entity identifier, if present @@ -115,9 +121,10 @@ public int Id { if (field > 0 && !field.Equals(value)) { throw new InvalidOperationException($"The value of this topic has already been set to {field}; it cannot be changed."); } - field = value; + field = value; + TopicIndexRegistry.OnIdAssigned(this); } - } = -1; + } = -1; /*============================================================================================================================ | PROPERTY: PARENT @@ -145,7 +152,7 @@ public Topic? Parent { set { if (_parent != value) { Contract.Requires(value, "Parent cannot be explicitly set to null."); - SetParent(value, value.Children.LastOrDefault()); + SetParent(value, value._children.LastOrDefault()); } } } @@ -159,7 +166,14 @@ public Topic? Parent { /// /// The children of the current . /// - public KeyedTopicCollection Children { get; } + public ChildTopicCollection Children { + get { + if (_children.LoadState is LoadState.NotLoaded) { + ((ITopicLazyLoadable)this).EnsureLoaded(TopicPayload.Children).GetAwaiter().GetResult(); + } + return _children; + } + } /*============================================================================================================================ | PROPERTY: CONTENT TYPE @@ -218,10 +232,10 @@ public string Key { else if (_key is not null || IsNew) { _dirtyKeys.MarkDirty("Key"); } - _originalKey ??= _key; + _originalKey ??= _key; //If an established key value is changed, the parent's index must be manually updated; this won't happen automatically. if (_originalKey is not null && !value.Equals(_key, StringComparison.OrdinalIgnoreCase) && Parent is not null) { - Parent.Children.ChangeKey(this, value); + Parent._children.ChangeKey(this, value); } _key = value; } @@ -252,7 +266,7 @@ internal string? OriginalKey { get => _originalKey; set { TopicFactory.ValidateKey(value, true); - _originalKey = value; + _originalKey = value; } } @@ -285,7 +299,7 @@ internal string? OriginalKey { [AttributeSetter] public string? View { get => - Attributes.GetValue("View", ""); + Attributes.GetValue("View", "", false, 5, autoLoad: false); set { TopicFactory.ValidateKey(value, true); SetAttributeValue("View", value); @@ -345,7 +359,7 @@ public bool IsDisabled { \---------------------------------------------------------------------------------------------------------------------------*/ /// /// Determines whether or not a topic should be visible based on IsHidden, IsDisabled, and an optional parameter - /// specifying whether or not to show disabled items (which may by triggered if, for example, a user is an administrator). + /// specifying whether or not to show disabled items (which may be triggered if, for example, a user is an administrator). /// /// /// If an item is not marked as IsVisible, then the item will not be visible independent of whether showDisabled is set. @@ -374,7 +388,7 @@ public bool IsDisabled { /// !string.IsNullOrWhiteSpace(value) /// public string Title { - get => Attributes.GetValue("Title", Key); + get => Attributes.GetValue("Title", Key, false, 5, autoLoad: false); set => SetAttributeValue("Title", value); } @@ -424,12 +438,41 @@ public string? Description { /// !string.IsNullOrWhiteSpace(value.ToString()) /// public DateTime LastModified { - get => Attributes.GetDateTime("LastModified", VersionHistory.DefaultIfEmpty(DateTime.MinValue).LastOrDefault()); + get => Attributes.GetDateTime("LastModified", _versionHistory.DefaultIfEmpty(DateTime.MinValue).LastOrDefault()); set => SetAttributeValue("LastModified", value.ToString(CultureInfo.InvariantCulture)); } #endregion + #region Lazy-Loading Infrastructure + + /*============================================================================================================================ + | PROPERTY: LOADER + \---------------------------------------------------------------------------------------------------------------------------*/ + /// + ITopicLazyLoader? ITopicLazyLoadable.Loader { get; set; } + + /*============================================================================================================================ + | INTERFACE: TOPIC BACKING ACCESSOR + \---------------------------------------------------------------------------------------------------------------------------*/ + + /// + ChildTopicCollection ITopicBackingAccessor.Children => _children; + + /// + TopicRelationshipMultiMap ITopicBackingAccessor.Relationships => _relationships; + + /// + TopicReferenceCollection ITopicBackingAccessor.References => _references; + + /// + AttributeCollection ITopicBackingAccessor.Attributes => Attributes; + + /// + VersionHistoryCollection ITopicBackingAccessor.VersionHistory => _versionHistory; + + #endregion + #region Relationship and Collection Methods /*============================================================================================================================ @@ -469,7 +512,7 @@ public void SetParent(Topic parent, Topic? sibling = null) { /*-------------------------------------------------------------------------------------------------------------------------- | Check to ensure that the topic isn't being moved to a parent with a duplicate key \-------------------------------------------------------------------------------------------------------------------------*/ - if (parent != _parent && parent.Children.Contains(Key)) { + if (parent != _parent && parent._children.Contains(Key)) { throw new InvalidKeyException( $"Duplicate key when setting Parent property: the topic with the name '{Key}' already exists in the '{parent.Key}' " + $"topic." @@ -479,16 +522,16 @@ public void SetParent(Topic parent, Topic? sibling = null) { /*-------------------------------------------------------------------------------------------------------------------------- | Move topic to new location \-------------------------------------------------------------------------------------------------------------------------*/ - _parent?.Children.Remove(Key); - var insertAt = (sibling is not null)? parent.Children.IndexOf(sibling)+1 : 0; - parent.Children.Insert(insertAt, this); + _parent?._children.Remove(Key); + var insertAt = (sibling is not null)? parent._children.IndexOf(sibling)+1 : 0; + parent._children.Insert(insertAt, this); _dirtyKeys.MarkDirty("Parent"); /*-------------------------------------------------------------------------------------------------------------------------- | Set parent values \-------------------------------------------------------------------------------------------------------------------------*/ if (_parent != parent) { - _parent = parent; + _parent = parent; } } @@ -510,13 +553,13 @@ public string GetUniqueKey() { /*-------------------------------------------------------------------------------------------------------------------------- | Crawl up tree to define uniqueKey \-------------------------------------------------------------------------------------------------------------------------*/ - var uniqueKey = ""; - var topic = (Topic?)this; + var uniqueKey = ""; + var topic = (Topic?)this; while (topic is not null) { if (uniqueKey.Length > 0) uniqueKey = $":{uniqueKey}"; - uniqueKey = topic.Key + uniqueKey; - topic = topic.Parent; + uniqueKey = topic.Key + uniqueKey; + topic = topic.Parent; } /*-------------------------------------------------------------------------------------------------------------------------- @@ -539,11 +582,11 @@ public string GetUniqueKey() { /// /// The HTTP-based path to the current . public string GetWebPath() { - var uniqueKey = GetUniqueKey() + var uniqueKey = GetUniqueKey() .Replace("Root:", "/", StringComparison.Ordinal) .Replace(":", "/", StringComparison.Ordinal) + "/"; if (!uniqueKey.StartsWith('/')) { - uniqueKey = $"/{uniqueKey}"; + uniqueKey = $"/{uniqueKey}"; } return uniqueKey; } @@ -552,112 +595,52 @@ public string GetWebPath() { | METHOD: IS DIRTY? \---------------------------------------------------------------------------------------------------------------------------*/ - /// - public bool IsDirty() => IsDirty(false, false); - /// - /// Determines if the topic is dirty, optionally checking and . + /// Determines whether the 's key attributes—i.e., its , , and + /// other tracked keys—have been modified. This does not evaluate the (overall), , or collections; query those through their own methods. /// - /// - /// Determines if , , and should be checked. - /// - /// - /// Optionally excludes s whose keys start with LastModified. This is useful for - /// excluding the byline (LastModifiedBy) and dateline (LastModified) since these values are automatically - /// generated by e.g. the OnTopic Editor and, thus, may be irrelevant updates if no other attribute values have changed. - /// - /// - /// Returns true if the , , or, optionally, any collections have been - /// modified. - /// - public bool IsDirty(bool checkCollections, bool excludeLastModified = false) { - if (IsNew || _dirtyKeys.IsDirty()) { - return true; - } - else if (!checkCollections) { - return false; - } - else if ( - Attributes.IsDirty(excludeLastModified) || - Relationships.IsDirty() || - References.IsDirty() - ) { - return true; - } - return false; - } - - /// - public bool IsDirty(string key) => IsDirty(key, false); + /// Returns true if the or have been modified. + public bool IsDirty() => IsNew || _dirtyKeys.IsDirty(); - - /// - public bool IsDirty(string key, bool checkCollections) { - if (IsNew || _dirtyKeys.IsDirty(key)) { - return true; - } - else if (!checkCollections) { - return false; - } - else if ( - Attributes.IsDirty(key) || - Relationships.IsDirty(key) || - References.IsDirty(key) - ) { - return true; - } - return false; - } + /// + /// Determines whether the on the key attributes has been modified. This does + /// not evaluate the (overall), , or + /// collections; query those through their own methods. + /// + /// The key of the attribute to check. + /// Returns true if the has been modified. + public bool IsDirty(string key) => IsNew || _dirtyKeys.IsDirty(key); /*============================================================================================================================ | METHOD: MARK CLEAN \---------------------------------------------------------------------------------------------------------------------------*/ - /// - public void MarkClean() => MarkClean(false); - /// - /// Resets the status of the —and, optionally, that of all collections, using - /// the parameter. + /// Resets the status of the 's own record, covering its key attributes. This + /// does not affect the (overall), , or + /// collections; reset those through their own methods. /// - /// - /// Determines if , , and should be included. - /// - /// - /// The value that the attributes were last saved. This corresponds to the . - /// - public void MarkClean(bool includeCollections, DateTime? version = null) { + public void MarkClean() { if (IsNew) { return; } _dirtyKeys.MarkClean(); - if (includeCollections) { - Attributes.MarkClean(version); - Relationships.MarkClean(); - References.MarkClean(); - } - } - - /// - public void MarkClean(string key) { - if (IsNew) { - return; - } - MarkClean(key, false); } - /// - public void MarkClean(string key, bool includeCollections) { + /// + /// Resets the status of the on the 's own record, + /// covering its key attributes. This does not affect the (overall), , or collections; reset those through their own methods. + /// + /// The key of the attribute to mark as clean. + public void MarkClean(string key) { if (IsNew) { return; } _dirtyKeys.MarkClean(key); - if (includeCollections) { - Attributes.MarkClean(key); - Relationships.MarkClean(key); - References.MarkClean(key); - } } #endregion @@ -678,16 +661,16 @@ public void MarkClean(string key, bool includeCollections) { /// local value for the attribute. /// /// - /// Be aware that while multiple levels of s can be configured, the method defaults to a maximum level + /// Be aware that while multiple levels of s can be configured, the method defaults to a maximum level /// of five "hops" in order to help avoid an infinite loop. /// /// /// The underlying value of the is stored as a topic reference with the of BaseTopic in . If the hasn't been /// saved, then the reference will be established, but the BaseTopic won't be persisted to the underlying - /// repository upon . That said, when is called, the will be reevaluated + /// repository upon . That said, when is called, the will be reevaluated /// and, if it has subsequently been saved, and the BaseTopic will be updated accordingly. This allows in-memory /// topic graphs to be constructed, while preventing invalid s from being persisted to the /// underlying data storage. As a result, however, a referencing an that is @@ -707,7 +690,7 @@ public Topic? BaseTopic { value != this, "A topic may not derive from itself." ); - References.SetValue("BaseTopic", value); + _references.SetValue("BaseTopic", value); } } @@ -750,7 +733,14 @@ public Topic? DerivedTopic { /// topic, thus allowing the topic hierarchy to be represented as a network graph. /// /// The current 's relationships. - public TopicRelationshipMultiMap Relationships { get; } + public TopicRelationshipMultiMap Relationships { + get { + if (_relationships.LoadState is LoadState.NotLoaded) { + ((ITopicLazyLoadable)this).EnsureLoaded(TopicPayload.Relationships).GetAwaiter().GetResult(); + } + return _relationships; + } + } /*============================================================================================================================ | PROPERTY: REFERENCES @@ -763,7 +753,14 @@ public Topic? DerivedTopic { /// BaseTopic for a ). /// /// The current 's references. - public TopicReferenceCollection References { get; } + public TopicReferenceCollection References { + get { + if (_references.LoadState is LoadState.NotLoaded) { + ((ITopicLazyLoadable)this).EnsureLoaded(TopicPayload.References).GetAwaiter().GetResult(); + } + return _references; + } + } /*============================================================================================================================ | PROPERTY: INCOMING RELATIONSHIPS @@ -791,7 +788,14 @@ public Topic? DerivedTopic { /// its derived providers). /// /// The current 's version history. - public Collection VersionHistory { get; } + public VersionHistoryCollection VersionHistory { + get { + if (_versionHistory.LoadState is LoadState.NotLoaded) { + ((ITopicLazyLoadable)this).EnsureLoaded(TopicPayload.VersionHistory).GetAwaiter().GetResult(); + } + return _versionHistory; + } + } #endregion @@ -807,8 +811,8 @@ public Topic? DerivedTopic { /// /// When an attribute value is set and a corresponding, writable property exists on the topic, that property will be /// called by the . This is intended to enforce local business logic, and prevent callers - /// from introducing invalid data.To prevent a redirect loop, however, local properties need to inform the that the business logic has already been enforced. To do that, they must either call that the business logic has already been enforced. To do that, they must either call with the /// enforceBusinessLogic flag set to false, or, if they're in a separate assembly, call this overload. ///