diff --git a/Documentation/backend/chronicle/read-models/injecting-into-commands.md b/Documentation/backend/chronicle/read-models/injecting-into-commands.md index a07ee9d17..1435069e1 100644 --- a/Documentation/backend/chronicle/read-models/injecting-into-commands.md +++ b/Documentation/backend/chronicle/read-models/injecting-into-commands.md @@ -179,6 +179,38 @@ public record AddItemToCart([Key] Guid CartId, Guid ProductId, int Quantity) Read models never emit events. If the decision must hold under concurrency, drive it from the aggregate or from a Chronicle [constraint](/chronicle/constraints/) rather than from projected state — read models are eventually consistent. +## Read models from other providers + +Injection is not Chronicle-only. Any provider that owns a read model's storage can make its `[ReadModel]` types injectable into a command, resolved by the same key, so a validator, `Provide()`, or `Handle()` takes the read model exactly as it would a Chronicle-backed one. + +The Entity Framework Core integration does this for you. A `[ReadModel]` entity carried by a `ReadOnlyDbContext` becomes injectable once the context is registered — there is nothing extra to wire up: + +```csharp +[ReadModel] +public class Customer +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class CustomerDbContext(DbContextOptions options) : ReadOnlyDbContext(options) +{ + public DbSet Customers => Set(); +} +``` + +```csharp +[Command] +public record RenameCustomer([Key] Guid CustomerId, string NewName) +{ + public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName); +} +``` + +`WithEntityFrameworkCore()` discovers the `ReadOnlyDbContext`, and the command's resolved key (here the `[Key]` on `CustomerId`) loads the entity by its primary key. The primary key may be a `Guid`, `int`, `long`, `string`, or a `ConceptAs` wrapping one of those. + +The nullable rules are identical: a nullable `Customer?` receives `null` when no row exists, and a non-nullable `Customer` fails the command with [`ReadModelDoesNotExistForCommand`](./failures.md#readmodeldoesnotexistforcommand). Chronicle-backed and EF-backed read models coexist in the same application and never claim each other's types. + ## Testing Seed the state the command should see with the `Given` builder — either the events behind it or a pinned instance — and execute through the real pipeline. See [Testing with Chronicle](../../testing/chronicle.md#testing-commands-that-take-read-model-dependencies). diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/a_read_model_resolver.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/a_read_model_resolver.cs new file mode 100644 index 000000000..4017da98e --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/a_read_model_resolver.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions; + +/// +/// A test that resolves pinned instances for the read model types it owns. +/// +/// The read model types this resolver owns. +/// The pinned instances to resolve, keyed by read model type; a type with no entry resolves to null. +public class a_read_model_resolver(IEnumerable readModelTypes, IReadOnlyDictionary? instances = null) : ICanResolveReadModelForCommand +{ + readonly IReadOnlyDictionary _instances = instances ?? new Dictionary(); + + public IEnumerable ReadModelTypes { get; } = [.. readModelTypes]; + + public Task Resolve(Type readModelType, CommandContext commandContext) => + Task.FromResult(_instances.TryGetValue(readModelType, out var instance) ? instance : null); +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/read_models.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/read_models.cs new file mode 100644 index 000000000..f673f8c3c --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/read_models.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions; + +#pragma warning disable SA1402, SA1649 // File may only contain a single type, File name should match first type name + +public record FirstReadModel(string Value); + +public record SecondReadModel(string Value); + +#pragma warning restore SA1402, SA1649 diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_for_command.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_for_command.cs new file mode 100644 index 000000000..83539b925 --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_for_command.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions; + +public class when_adding_read_models_for_command : Specification +{ + IServiceCollection _services; + RegisteredReadModelTypes _registeredReadModelTypes; + + void Establish() => _services = new ServiceCollection(); + + void Because() + { + _services.AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)])); + _registeredReadModelTypes = _services + .First(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes)) + .ImplementationInstance as RegisteredReadModelTypes; + } + + [Fact] void should_register_a_scoped_resolver_for_the_read_model() => + _services.ShouldContain(descriptor => descriptor.ServiceType == typeof(FirstReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped); + + [Fact] void should_register_the_read_model_type_as_a_known_read_model() => _registeredReadModelTypes.Contains(typeof(FirstReadModel)).ShouldBeTrue(); +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_two_providers.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_two_providers.cs new file mode 100644 index 000000000..31ed71c31 --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_two_providers.cs @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions; + +public class when_adding_read_models_from_two_providers : Specification +{ + IServiceCollection _services; + RegisteredReadModelTypes _registeredReadModelTypes; + + void Establish() => _services = new ServiceCollection(); + + void Because() + { + _services.AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)])); + _services.AddReadModelsForCommand(new a_read_model_resolver([typeof(SecondReadModel)])); + _registeredReadModelTypes = _services + .First(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes)) + .ImplementationInstance as RegisteredReadModelTypes; + } + + [Fact] void should_register_a_scoped_resolver_for_the_first_provider_read_model() => + _services.ShouldContain(descriptor => descriptor.ServiceType == typeof(FirstReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped); + + [Fact] void should_register_a_scoped_resolver_for_the_second_provider_read_model() => + _services.ShouldContain(descriptor => descriptor.ServiceType == typeof(SecondReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped); + + [Fact] void should_keep_the_first_provider_read_model_in_the_known_set() => _registeredReadModelTypes.Contains(typeof(FirstReadModel)).ShouldBeTrue(); + [Fact] void should_add_the_second_provider_read_model_to_the_known_set() => _registeredReadModelTypes.Contains(typeof(SecondReadModel)).ShouldBeTrue(); +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_absent.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_absent.cs new file mode 100644 index 000000000..4a0a16d54 --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_absent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_resolving_through_di; + +public class and_the_instance_is_absent : Specification +{ + ServiceProvider _rootProvider; + IServiceScope _scope; + object? _resolved; + + void Establish() + { + var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], new CommandContextValues()); + + _rootProvider = new ServiceCollection() + .AddScoped(_ => commandContext) + .AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)])) + .BuildServiceProvider(); + _scope = _rootProvider.CreateScope(); + } + + void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(FirstReadModel)); + + [Fact] void should_resolve_to_null() => _resolved.ShouldBeNull(); + + void Destroy() + { + _scope.Dispose(); + _rootProvider.Dispose(); + } +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_present.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_present.cs new file mode 100644 index 000000000..f993e8234 --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_present.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_resolving_through_di; + +public class and_the_instance_is_present : Specification +{ + ServiceProvider _rootProvider; + IServiceScope _scope; + FirstReadModel _instance; + object? _resolved; + + void Establish() + { + _instance = new FirstReadModel("materialized"); + var instances = new Dictionary { [typeof(FirstReadModel)] = _instance }; + var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], new CommandContextValues()); + + _rootProvider = new ServiceCollection() + .AddScoped(_ => commandContext) + .AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)], instances)) + .BuildServiceProvider(); + _scope = _rootProvider.CreateScope(); + } + + void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(FirstReadModel)); + + [Fact] void should_resolve_the_pinned_instance() => _resolved.ShouldEqual(_instance); + + void Destroy() + { + _scope.Dispose(); + _rootProvider.Dispose(); + } +} diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs similarity index 79% rename from Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs rename to Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs index 6f1cb43fb..9d06908d9 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs @@ -2,13 +2,11 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Reflection; -using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands; -using Cratis.Chronicle.Events; using Cratis.Execution; using Microsoft.Extensions.DependencyInjection; -namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.given; +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.given; public class a_classifier : Specification { @@ -23,13 +21,15 @@ void Establish() _parameter = typeof(Consumer).GetMethod(nameof(Consumer.Method))!.GetParameters()[0]; } - protected IServiceProvider ServiceProviderWith(bool registerReadModel, EventSourceId eventSourceId) + protected IServiceProvider ServiceProviderWith(bool registerReadModel, string? resolvedKey) { - var commandContextValues = new CommandContextValues + var commandContextValues = new CommandContextValues(); + if (resolvedKey is not null) { - { WellKnownCommandContextKeys.EventSourceId, eventSourceId } - }; - var commandContext = new CommandContext(CorrelationId.New(), typeof(TestCommand), new TestCommand(), [], commandContextValues, null); + commandContextValues[CommandContextKeys.ResolvedKey] = resolvedKey; + } + + var commandContext = new CommandContext(CorrelationId.New(), typeof(TestCommand), new TestCommand(), [], commandContextValues); var services = new ServiceCollection(); services.AddScoped(_ => commandContext); diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_id.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_key.cs similarity index 82% rename from Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_id.cs rename to Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_key.cs index c9564c865..1783f8c82 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_id.cs +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_key.cs @@ -2,15 +2,14 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Arc.Validation; -using Cratis.Chronicle.Events; -namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.when_classifying; +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying; -public class and_the_dependency_is_a_registered_read_model_with_a_valid_id : given.a_classifier +public class and_the_dependency_is_a_registered_read_model_with_a_valid_key : given.a_classifier { IServiceProvider _serviceProvider; - void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, EventSourceId.New()); + void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, resolvedKey: "some-key"); void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure); diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs similarity index 76% rename from Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs rename to Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs index d0a264469..f07241a8e 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs @@ -1,15 +1,13 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using Cratis.Chronicle.Events; - -namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.when_classifying; +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying; public class and_the_dependency_is_not_a_registered_read_model : given.a_classifier { IServiceProvider _serviceProvider; - void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: false, EventSourceId.New()); + void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: false, resolvedKey: "some-key"); void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure); diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_event_source_id_is_unspecified.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_absent.cs similarity index 67% rename from Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_event_source_id_is_unspecified.cs rename to Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_absent.cs index 32cdbf0f6..2ac0fd4b7 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_event_source_id_is_unspecified.cs +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_absent.cs @@ -1,15 +1,13 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using Cratis.Chronicle.Events; +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying; -namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.when_classifying; - -public class and_the_event_source_id_is_unspecified : given.a_classifier +public class and_the_resolved_key_is_absent : given.a_classifier { IServiceProvider _serviceProvider; - void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, EventSourceId.Unspecified); + void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, resolvedKey: null); void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure); diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_empty.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_empty.cs new file mode 100644 index 000000000..28e168b7e --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_empty.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying; + +public class and_the_resolved_key_is_empty : given.a_classifier +{ + IServiceProvider _serviceProvider; + + void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, resolvedKey: string.Empty); + + void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure); + + [Fact] void should_not_classify_as_client_input() => _result.ShouldBeFalse(); + [Fact] void should_not_produce_a_failure() => _failure.ShouldBeNull(); +} diff --git a/Source/DotNET/Arc.Core/Commands/CommandContextExtensions.cs b/Source/DotNET/Arc.Core/Commands/CommandContextExtensions.cs new file mode 100644 index 000000000..84de0058b --- /dev/null +++ b/Source/DotNET/Arc.Core/Commands/CommandContextExtensions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Commands; + +/// +/// Provides provider-neutral extension methods for the . +/// +public static class CommandContextExtensions +{ + /// + /// Gets the provider-neutral resolved key from the command context values, if present. + /// + /// The to get the resolved key from. + /// The resolved key, or null when the command carried no usable key. + public static string? GetResolvedKey(this CommandContext commandContext) => + commandContext.Values.TryGetValue(CommandContextKeys.ResolvedKey, out var value) && value is string resolvedKey + ? resolvedKey + : null; +} diff --git a/Source/DotNET/Arc.Core/Commands/CommandContextKeys.cs b/Source/DotNET/Arc.Core/Commands/CommandContextKeys.cs new file mode 100644 index 000000000..44c0cb724 --- /dev/null +++ b/Source/DotNET/Arc.Core/Commands/CommandContextKeys.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Commands; + +/// +/// Provider-neutral keys for values held on a . +/// +public static class CommandContextKeys +{ + /// + /// The key for the provider-neutral resolved key in the command context values. + /// + /// + /// The resolved key is the command's key expressed as a plain string, independent of any backing store. A provider + /// that owns the key resolution (for example the Chronicle integration, which resolves an event source id) writes it + /// so that a read model backing provider — which need not depend on the resolving provider — can load a read model + /// by the same key. + /// + public const string ResolvedKey = "resolvedKey"; +} diff --git a/Source/DotNET/Arc.Core/Queries/ICanResolveReadModelForCommand.cs b/Source/DotNET/Arc.Core/Queries/ICanResolveReadModelForCommand.cs new file mode 100644 index 000000000..d73f94f05 --- /dev/null +++ b/Source/DotNET/Arc.Core/Queries/ICanResolveReadModelForCommand.cs @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; + +namespace Cratis.Arc.Queries; + +/// +/// Defines a provider that can resolve read models by key for a command, so a read model backed by any store — Chronicle, +/// Entity Framework Core, or another provider — can be injected into command-scoped code. +/// +/// +/// A read model is injectable into command-scoped code (a CommandValidator<>, Provide(), or +/// Handle()) because it is resolvable by the command's resolved key. The command pipeline resolves each read model +/// dependency from DI by type, and a provider contributes a command-scoped, by-key resolver for the read model types it +/// owns through this abstraction. Providers coexist without claiming each other's read model types — each reports only +/// the types it can resolve through . +/// +public interface ICanResolveReadModelForCommand +{ + /// + /// Gets the read model types this provider can resolve by key for a command. + /// + IEnumerable ReadModelTypes { get; } + + /// + /// Resolves the read model of the given type for the current command context, keyed by the command's resolved key. + /// + /// The type of read model to resolve. + /// The to resolve from. + /// The resolved read model instance, or null when no instance exists for the command's key. + Task Resolve(Type readModelType, CommandContext commandContext); +} diff --git a/Source/DotNET/Chronicle/ReadModels/ReadModelDoesNotExistForCommand.cs b/Source/DotNET/Arc.Core/Queries/ReadModelDoesNotExistForCommand.cs similarity index 60% rename from Source/DotNET/Chronicle/ReadModels/ReadModelDoesNotExistForCommand.cs rename to Source/DotNET/Arc.Core/Queries/ReadModelDoesNotExistForCommand.cs index 91c091b8c..24e171fb6 100644 --- a/Source/DotNET/Chronicle/ReadModels/ReadModelDoesNotExistForCommand.cs +++ b/Source/DotNET/Arc.Core/Queries/ReadModelDoesNotExistForCommand.cs @@ -3,21 +3,21 @@ using Cratis.Arc.Validation; -namespace Cratis.Arc.Chronicle.ReadModels; +namespace Cratis.Arc.Queries; /// /// Exception that gets thrown when a command requires a non-nullable read model that does not exist for the command's -/// event source id. +/// resolved key. /// /// -/// The command carried a usable event source id, but no read model exists for it. A nullable read model dependency -/// tolerates this by receiving null; a non-nullable ("must exist") dependency cannot, and this would otherwise surface -/// as a server error (HTTP 500) leaking the read model type. It implements , so the -/// pipeline surfaces it as a validation failure (HTTP 400) with a message that does not reveal the read model type. +/// The command carried a usable key, but no read model exists for it. A nullable read model dependency tolerates this by +/// receiving null; a non-nullable ("must exist") dependency cannot, and this would otherwise surface as a server error +/// (HTTP 500) leaking the read model type. It implements , so the pipeline surfaces it as +/// a validation failure (HTTP 400) with a message that does not reveal the read model type. /// /// The type of read model that does not exist. public class ReadModelDoesNotExistForCommand(Type readModelType) - : Exception($"Read model of type '{readModelType.FullName}' does not exist for the command's event source id."), + : Exception($"Read model of type '{readModelType.FullName}' does not exist for the command's resolved key."), IValidationFailure { /// diff --git a/Source/DotNET/Arc.Core/Queries/ReadModelForCommandServiceCollectionExtensions.cs b/Source/DotNET/Arc.Core/Queries/ReadModelForCommandServiceCollectionExtensions.cs new file mode 100644 index 000000000..8d394e33a --- /dev/null +++ b/Source/DotNET/Arc.Core/Queries/ReadModelForCommandServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Arc.Queries; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for for registering command-scoped read model resolution. +/// +public static class ReadModelForCommandServiceCollectionExtensions +{ + /// + /// Registers a provider's read model types for command-scoped, by-key resolution. + /// + /// The to add to. + /// The that owns and resolves the read model types. + /// The service collection for continuation. + /// + /// For each read model type the resolver owns, a scoped factory is registered that delegates to the resolver, so the + /// command pipeline resolves the read model from DI by type like any other dependency. The set of registered read + /// model types is additive: multiple providers can contribute their own types and the classification of a missing + /// read model as invalid client input sees the union across all of them. + /// + public static IServiceCollection AddReadModelsForCommand(this IServiceCollection services, ICanResolveReadModelForCommand resolver) + { + foreach (var readModelType in resolver.ReadModelTypes) + { + services.RemoveAll(readModelType); + services.AddScoped(readModelType, serviceProvider => + { + // Resolve the read model from the same scope the dependency is being resolved in, so the resolver can + // reach scoped collaborators (the Chronicle IReadModels, the EF Core DbContext) through the context. + var commandContext = serviceProvider.GetRequiredService() with { ServiceProvider = serviceProvider }; + return resolver.Resolve(readModelType, commandContext).GetAwaiter().GetResult()!; + }); + } + + var existing = services + .FirstOrDefault(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes))? + .ImplementationInstance as RegisteredReadModelTypes; + + var union = (existing?.Types ?? []) + .Concat(resolver.ReadModelTypes) + .Distinct() + .ToArray(); + + services.RemoveAll(); + services.AddSingleton(new RegisteredReadModelTypes(union)); + + return services; + } +} diff --git a/Source/DotNET/Chronicle/ReadModels/ReadModelUnresolvableDependencyClassifier.cs b/Source/DotNET/Arc.Core/Queries/ReadModelUnresolvableDependencyClassifier.cs similarity index 66% rename from Source/DotNET/Chronicle/ReadModels/ReadModelUnresolvableDependencyClassifier.cs rename to Source/DotNET/Arc.Core/Queries/ReadModelUnresolvableDependencyClassifier.cs index aa57f3822..cd947a0d3 100644 --- a/Source/DotNET/Chronicle/ReadModels/ReadModelUnresolvableDependencyClassifier.cs +++ b/Source/DotNET/Arc.Core/Queries/ReadModelUnresolvableDependencyClassifier.cs @@ -3,26 +3,24 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; -using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands; using Cratis.Arc.DependencyInjection; -using Cratis.Chronicle.Events; using Cratis.DependencyInjection; using Microsoft.Extensions.DependencyInjection; -namespace Cratis.Arc.Chronicle.ReadModels; +namespace Cratis.Arc.Queries; /// /// Represents an that recognizes a non-nullable read model dependency -/// which does not exist for the command's event source id as invalid client input rather than a server fault. +/// which does not exist for the command's resolved key as invalid client input rather than a server fault. /// /// -/// The classifier only runs when a non-nullable dependency resolved to null. For a read model that is invoked through -/// , a null result can only mean the entity does not -/// exist for a valid event source id: an unspecified id throws -/// before this point. It therefore classifies a registered read model dependency with a valid event source id as a +/// The classifier only runs when a non-nullable dependency resolved to null. For a read model resolved through an +/// , a null result can only mean the entity does not exist for a usable key: +/// a command carrying no usable key throws inside the resolver +/// before this point. It therefore classifies a registered read model dependency with a usable resolved key as a /// (HTTP 400). Any dependency that is not a registered read model, or where -/// no event source id is available, is left for the default server-error behavior so misconfigurations are not masked. +/// no usable key is available, is left for the default server-error behavior so misconfigurations are not masked. /// [Singleton] public class ReadModelUnresolvableDependencyClassifier : IUnresolvableDependencyClassifier @@ -39,7 +37,7 @@ public bool TryClassifyAsClientInput(ParameterInfo parameter, IServiceProvider s } var commandContext = serviceProvider.GetService(); - if (commandContext is null || commandContext.GetEventSourceId() == EventSourceId.Unspecified) + if (commandContext is null || string.IsNullOrEmpty(commandContext.GetResolvedKey())) { return false; } diff --git a/Source/DotNET/Chronicle/ReadModels/RegisteredReadModelTypes.cs b/Source/DotNET/Arc.Core/Queries/RegisteredReadModelTypes.cs similarity index 60% rename from Source/DotNET/Chronicle/ReadModels/RegisteredReadModelTypes.cs rename to Source/DotNET/Arc.Core/Queries/RegisteredReadModelTypes.cs index 6cb353756..21e5adc81 100644 --- a/Source/DotNET/Chronicle/ReadModels/RegisteredReadModelTypes.cs +++ b/Source/DotNET/Arc.Core/Queries/RegisteredReadModelTypes.cs @@ -1,17 +1,28 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -namespace Cratis.Arc.Chronicle.ReadModels; +namespace Cratis.Arc.Queries; /// /// Holds the set of read model types that were registered for command-scoped resolution, so consumers can tell a read /// model dependency apart from an unrelated one without re-resolving it. /// +/// +/// The set is additive across every provider that contributes a command-scoped resolver: a read model backed by +/// Chronicle and one backed by another provider (for example Entity Framework Core) are both registered here, so the +/// classification of a missing read model as invalid client input works uniformly regardless of where the read model is +/// materialized. +/// /// The read model types registered for command-scoped resolution. public class RegisteredReadModelTypes(IEnumerable types) { readonly HashSet _types = [.. types]; + /// + /// Gets the read model types registered for command-scoped resolution. + /// + public IEnumerable Types => _types; + /// /// Determines whether the given type is a registered read model. /// diff --git a/Source/DotNET/Chronicle/ReadModels/UnableToResolveReadModelFromCommandContext.cs b/Source/DotNET/Arc.Core/Queries/UnableToResolveReadModelFromCommandContext.cs similarity index 68% rename from Source/DotNET/Chronicle/ReadModels/UnableToResolveReadModelFromCommandContext.cs rename to Source/DotNET/Arc.Core/Queries/UnableToResolveReadModelFromCommandContext.cs index 283411ed1..443ed5961 100644 --- a/Source/DotNET/Chronicle/ReadModels/UnableToResolveReadModelFromCommandContext.cs +++ b/Source/DotNET/Arc.Core/Queries/UnableToResolveReadModelFromCommandContext.cs @@ -3,17 +3,17 @@ using Cratis.Arc.Validation; -namespace Cratis.Arc.Chronicle.ReadModels; +namespace Cratis.Arc.Queries; /// /// Exception that gets thrown when not being able to resolve a read model from command context. /// /// Type of read model that could not be resolved. /// -/// A read model is keyed by the command's event source id, so a command that carries no usable identifier can never -/// resolve one — that is invalid client input, not a server fault. This implements so -/// the command pipeline surfaces it as a validation failure (HTTP 400). The detailed message (naming the read model -/// type) is for server logs only; the client sees the generic validation message. +/// A read model is keyed by the command's resolved key, so a command that carries no usable identifier can never resolve +/// one — that is invalid client input, not a server fault. This implements so the +/// command pipeline surfaces it as a validation failure (HTTP 400). The detailed message (naming the read model type) is +/// for server logs only; the client sees the generic validation message. /// public class UnableToResolveReadModelFromCommandContext(Type readModelType) : Exception($"Unable to resolve read model of type '{readModelType.FullName}' from command context. Make sure the command has a property that is assignable to EventSourceId or marked with [Key] attribute"), diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelServiceCollectionExtensions/when_resolving_read_model/without_event_source_id.cs b/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelServiceCollectionExtensions/when_resolving_read_model/without_event_source_id.cs index 3ec50cbb4..5f67c92c9 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelServiceCollectionExtensions/when_resolving_read_model/without_event_source_id.cs +++ b/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelServiceCollectionExtensions/when_resolving_read_model/without_event_source_id.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.Arc.Queries; using Cratis.Arc.Validation; namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelServiceCollectionExtensions.when_resolving_read_model; diff --git a/Source/DotNET/Chronicle/Commands/EventSourceValuesProvider.cs b/Source/DotNET/Chronicle/Commands/EventSourceValuesProvider.cs index ad2d086fc..a84f5b064 100644 --- a/Source/DotNET/Chronicle/Commands/EventSourceValuesProvider.cs +++ b/Source/DotNET/Chronicle/Commands/EventSourceValuesProvider.cs @@ -15,13 +15,37 @@ public class EventSourceValuesProvider(ILogger logger { /// public CommandContextValues Provide(object command) + { + var eventSourceId = ResolveEventSourceId(command); + + // Also expose the id as the provider-neutral resolved key so a read model backing provider that does not depend + // on Chronicle (for example Entity Framework Core) can load a read model by the same key. An unspecified id + // carries no usable key, so the neutral key is empty in that case. + return new CommandContextValues + { + { WellKnownCommandContextKeys.EventSourceId, eventSourceId }, + { Cratis.Arc.Commands.CommandContextKeys.ResolvedKey, NeutralKeyFrom(eventSourceId) } + }; + } + + /// + /// Converts an event source id into the provider-neutral resolved key string. + /// + /// The event source id to convert. + /// The id value as a string, or an empty string when the id is unspecified. + static string NeutralKeyFrom(EventSourceId eventSourceId) => + eventSourceId == EventSourceId.Unspecified ? string.Empty : eventSourceId.Value; + + /// + /// Resolves the event source id for a command, from a self-composing command or from a key property. + /// + /// The command to resolve the event source id for. + /// The resolved event source id, or when none could be composed. + EventSourceId ResolveEventSourceId(object command) { if (command is ICanProvideEventSourceId provider) { - return new CommandContextValues - { - { WellKnownCommandContextKeys.EventSourceId, ProvidedEventSourceIdOrUnspecified(provider) } - }; + return ProvidedEventSourceIdOrUnspecified(provider); } var eventSourceId = EventSourceId.New(); @@ -30,10 +54,7 @@ public CommandContextValues Provide(object command) eventSourceId = command.GetEventSourceId(); } - return new CommandContextValues - { - { WellKnownCommandContextKeys.EventSourceId, eventSourceId } - }; + return eventSourceId; } /// diff --git a/Source/DotNET/Chronicle/ReadModels/ChronicleReadModelForCommandResolver.cs b/Source/DotNET/Chronicle/ReadModels/ChronicleReadModelForCommandResolver.cs new file mode 100644 index 000000000..34456e873 --- /dev/null +++ b/Source/DotNET/Chronicle/ReadModels/ChronicleReadModelForCommandResolver.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Arc.Queries; +using Cratis.Chronicle.ReadModels; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Chronicle.ReadModels; + +/// +/// Represents an that resolves read models backed by Chronicle through +/// , keyed by the command's resolved key (the event source id). +/// +/// The read model types Chronicle can resolve by key. +public class ChronicleReadModelForCommandResolver(IEnumerable readModelTypes) : ICanResolveReadModelForCommand +{ + /// + public IEnumerable ReadModelTypes { get; } = readModelTypes; + + /// + public Task Resolve(Type readModelType, CommandContext commandContext) + { + var readModels = commandContext.ServiceProvider!.GetRequiredService(); + return Task.FromResult(ReadModelServiceCollectionExtensions.ResolveReadModel(readModelType, commandContext, readModels)); + } +} diff --git a/Source/DotNET/Chronicle/ReadModels/ReadModelServiceCollectionExtensions.cs b/Source/DotNET/Chronicle/ReadModels/ReadModelServiceCollectionExtensions.cs index 4d3a79afa..60e1652b9 100644 --- a/Source/DotNET/Chronicle/ReadModels/ReadModelServiceCollectionExtensions.cs +++ b/Source/DotNET/Chronicle/ReadModels/ReadModelServiceCollectionExtensions.cs @@ -62,19 +62,11 @@ public static IServiceCollection AddReadModels(this IServiceCollection services, .Concat(ReadModelTargetsFrom(clientArtifactsProvider.Reducers, typeof(IReducerFor<>))) .Distinct() .ToArray(); - foreach (var readModelType in readModelTypes) - { - services.RemoveAll(readModelType); - services.AddScoped(readModelType, serviceProvider => ResolveReadModel( - readModelType, - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService())!); - } - // Register the set of read model types so a command-scoped resolution failure for a non-nullable read model - // can be told apart from an unrelated missing dependency and surfaced as a validation failure (HTTP 400). - services.RemoveAll(); - services.AddSingleton(new RegisteredReadModelTypes(readModelTypes)); + // Contribute the Chronicle-backed read model types to the provider-neutral command-scope resolution. This + // registers a scoped, by-key resolver for each type and adds them to the additive set that lets a missing + // non-nullable read model be surfaced as a validation failure (HTTP 400), coexisting with any other provider. + services.AddReadModelsForCommand(new ChronicleReadModelForCommandResolver(readModelTypes)); return services; } diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/read_models.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/read_models.cs new file mode 100644 index 000000000..0d4b78007 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/read_models.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Queries.ModelBound; +using Microsoft.EntityFrameworkCore; + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver; + +#pragma warning disable SA1402, SA1649 // File may only contain a single type, File name should match first type name + +[ReadModel] +public class CustomerReadModel +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class PlainEntity +{ + public Guid Id { get; set; } + public string Description { get; set; } = string.Empty; +} + +/// +/// A read-only DbContext carrying a read model entity and a plain entity, used to verify discovery. +/// +/// The options to be used by the DbContext. +public class CustomerReadModelDbContext(DbContextOptions options) : ReadOnlyDbContext(options) +{ + public DbSet Customers => Set(); + public DbSet Plain => Set(); +} + +/// +/// A writable DbContext over the same read model entity, used to seed data the resolver reads back. +/// +/// The options to be used by the DbContext. +public class SeedableCustomerDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); +} + +#pragma warning restore SA1402, SA1649 diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_discovering_read_model_types/and_a_context_has_read_model_and_plain_entities.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_discovering_read_model_types/and_a_context_has_read_model_and_plain_entities.cs new file mode 100644 index 000000000..7b2891524 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_discovering_read_model_types/and_a_context_has_read_model_and_plain_entities.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_discovering_read_model_types; + +public class and_a_context_has_read_model_and_plain_entities : Specification +{ + IReadOnlyDictionary _result; + + void Because() => _result = EntityFrameworkReadModelForCommandResolver.DiscoverReadModelTypes([typeof(CustomerReadModelDbContext)]); + + [Fact] void should_map_the_read_model_entity_to_its_context() => _result[typeof(CustomerReadModel)].ShouldEqual(typeof(CustomerReadModelDbContext)); + [Fact] void should_not_map_the_plain_entity() => _result.ContainsKey(typeof(PlainEntity)).ShouldBeFalse(); +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_injecting_through_di/and_the_read_model_exists.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_injecting_through_di/and_the_read_model_exists.cs new file mode 100644 index 000000000..09aa26246 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_injecting_through_di/and_the_read_model_exists.cs @@ -0,0 +1,63 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_injecting_through_di; + +/// +/// Exercises the exact seam the command pipeline uses to inject a read model: a scoped resolution by the read model type +/// through the service provider, keyed by the command's resolved key. +/// +public class and_the_read_model_exists : Specification +{ + SeedableCustomerDbContext _dbContext; + ServiceProvider _rootProvider; + IServiceScope _scope; + Guid _customerId; + object? _resolved; + + void Establish() + { + _customerId = Guid.NewGuid(); + + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + + _dbContext = new SeedableCustomerDbContext(options); + _dbContext.Database.OpenConnection(); + _dbContext.Database.EnsureCreated(); + _dbContext.Customers.Add(new CustomerReadModel { Id = _customerId, Name = "Test" }); + _dbContext.SaveChanges(); + + var values = new CommandContextValues + { + [CommandContextKeys.ResolvedKey] = _customerId.ToString() + }; + var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], values); + + _rootProvider = new ServiceCollection() + .AddSingleton(_dbContext) + .AddScoped(_ => commandContext) + .AddReadModelsForCommand(new EntityFrameworkReadModelForCommandResolver( + new Dictionary { [typeof(CustomerReadModel)] = typeof(SeedableCustomerDbContext) })) + .BuildServiceProvider(); + _scope = _rootProvider.CreateScope(); + } + + void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(CustomerReadModel)); + + [Fact] void should_inject_the_read_model() => _resolved.ShouldNotBeNull(); + [Fact] void should_inject_the_read_model_with_the_matching_key() => ((CustomerReadModel)_resolved!).Id.ShouldEqual(_customerId); + + void Destroy() + { + _dbContext.Database.CloseConnection(); + _scope.Dispose(); + _rootProvider.Dispose(); + } +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_absent.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_absent.cs new file mode 100644 index 000000000..2b4a3435f --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_absent.cs @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_resolving; + +public class and_the_instance_is_absent : given.a_seeded_resolver +{ + void Because() => ResolveCustomerWithKey(_customerId.ToString()); + + [Fact] void should_resolve_to_null() => _resolved.ShouldBeNull(); +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_present.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_present.cs new file mode 100644 index 000000000..658f1f9ad --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_present.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_resolving; + +public class and_the_instance_is_present : given.a_seeded_resolver +{ + void Establish() => SeedCustomer(); + + void Because() => ResolveCustomerWithKey(_customerId.ToString()); + + [Fact] void should_resolve_the_read_model() => _resolved.ShouldNotBeNull(); + [Fact] void should_resolve_the_read_model_with_the_matching_key() => ((CustomerReadModel)_resolved!).Id.ShouldEqual(_customerId); +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_key_is_absent.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_key_is_absent.cs new file mode 100644 index 000000000..0591f093a --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_key_is_absent.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Queries; +using Cratis.Arc.Validation; + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_resolving; + +public class and_the_key_is_absent : given.a_seeded_resolver +{ + void Establish() => SeedCustomer(); + + void Because() => CatchResolveCustomerWithKey(resolvedKey: null); + + [Fact] void should_throw_unable_to_resolve_read_model_from_command_context() => _exception.ShouldBeOfExactType(); + [Fact] void should_be_a_validation_failure() => (_exception is IValidationFailure).ShouldBeTrue(); +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/given/a_seeded_resolver.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/given/a_seeded_resolver.cs new file mode 100644 index 000000000..3de11e7c9 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/given/a_seeded_resolver.cs @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_resolving.given; + +public class a_seeded_resolver : Specification +{ + protected SeedableCustomerDbContext _dbContext; + protected EntityFrameworkReadModelForCommandResolver _resolver; + protected IServiceProvider _serviceProvider; + protected Guid _customerId; + protected object? _resolved; + protected Exception _exception; + + void Establish() + { + _customerId = Guid.NewGuid(); + + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + + _dbContext = new SeedableCustomerDbContext(options); + _dbContext.Database.OpenConnection(); + _dbContext.Database.EnsureCreated(); + + _serviceProvider = new ServiceCollection() + .AddSingleton(_dbContext) + .BuildServiceProvider(); + + _resolver = new EntityFrameworkReadModelForCommandResolver( + new Dictionary { [typeof(CustomerReadModel)] = typeof(SeedableCustomerDbContext) }); + } + + void Destroy() + { + _dbContext.Database.CloseConnection(); + _dbContext.Dispose(); + } + + protected void SeedCustomer() + { + _dbContext.Customers.Add(new CustomerReadModel { Id = _customerId, Name = "Test" }); + _dbContext.SaveChanges(); + } + + protected void ResolveCustomerWithKey(string? resolvedKey) => + _resolved = _resolver.Resolve(typeof(CustomerReadModel), CommandContextWithKey(resolvedKey)).GetAwaiter().GetResult(); + + protected void CatchResolveCustomerWithKey(string? resolvedKey) => + _exception = Catch.Exception(() => _resolver.Resolve(typeof(CustomerReadModel), CommandContextWithKey(resolvedKey)).GetAwaiter().GetResult()); + + CommandContext CommandContextWithKey(string? resolvedKey) + { + var values = new CommandContextValues(); + if (resolvedKey is not null) + { + values[CommandContextKeys.ResolvedKey] = resolvedKey; + } + + return new CommandContext(CorrelationId.New(), typeof(object), new object(), [], values) with { ServiceProvider = _serviceProvider }; + } +} diff --git a/Source/DotNET/EntityFrameworkCore/DbContextServiceCollectionExtensions.cs b/Source/DotNET/EntityFrameworkCore/DbContextServiceCollectionExtensions.cs index 7e286d681..923b44c6d 100644 --- a/Source/DotNET/EntityFrameworkCore/DbContextServiceCollectionExtensions.cs +++ b/Source/DotNET/EntityFrameworkCore/DbContextServiceCollectionExtensions.cs @@ -73,10 +73,13 @@ public static IServiceCollection AddReadModelDbContextsFromAssemblies(this IServ { var addDbContextMethod = typeof(ReadOnlyDbContextExtensions).GetMethod(nameof(ReadOnlyDbContextExtensions.AddReadOnlyDbContext), BindingFlags.Static | BindingFlags.Public)!; - foreach (var dbContext in DiscoverAndFilterDbContextTypes(assemblies)) + var dbContextTypes = DiscoverAndFilterDbContextTypes(assemblies).ToArray(); + foreach (var dbContext in dbContextTypes) { addDbContextMethod.MakeGenericMethod(dbContext).Invoke(null, [services, optionsAction]); } + + AddReadModelCommandResolution(services, dbContextTypes); return services; } @@ -94,13 +97,32 @@ public static IServiceCollection AddReadModelDbContextsWithConnectionStringFromA { var addDbContextMethod = typeof(ReadOnlyDbContextExtensions).GetMethod(nameof(ReadOnlyDbContextExtensions.AddReadOnlyDbContextWithConnectionString), BindingFlags.Static | BindingFlags.Public)!; - foreach (var dbContext in DiscoverAndFilterDbContextTypes(assemblies)) + var dbContextTypes = DiscoverAndFilterDbContextTypes(assemblies).ToArray(); + foreach (var dbContext in dbContextTypes) { addDbContextMethod.MakeGenericMethod(dbContext).Invoke(null, [services, connectionString, optionsAction]); } + + AddReadModelCommandResolution(services, dbContextTypes); return services; } + /// + /// Registers command-scoped, by-key resolution for the read model entities owned by the given DbContext types. + /// + /// The service collection to register with. + /// The read model DbContext types to discover read model entities from. + internal static void AddReadModelCommandResolution(IServiceCollection services, IEnumerable dbContextTypes) + { + var readModelToDbContext = EntityFrameworkReadModelForCommandResolver.DiscoverReadModelTypes(dbContextTypes); + if (readModelToDbContext.Count == 0) + { + return; + } + + services.AddReadModelsForCommand(new EntityFrameworkReadModelForCommandResolver(readModelToDbContext)); + } + /// /// Discovers and filters DbContext types from the specified assemblies, excluding those marked with IgnoreAutoRegistrationAttribute. /// diff --git a/Source/DotNET/EntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs b/Source/DotNET/EntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs index 07626c53a..de80b0369 100644 --- a/Source/DotNET/EntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs +++ b/Source/DotNET/EntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs @@ -97,6 +97,10 @@ public static IEntityFrameworkCoreBuilder DiscoverAndRegisterDbContexts(this IEn .Invoke(null, [builder.Services, connectionString, null]); } + // Contribute the EF Core read model entities to command-scoped, by-key resolution so they can be injected into + // commands the same way Chronicle-backed read models are. + DbContextServiceCollectionExtensions.AddReadModelCommandResolution(builder.Services, readOnlyTypes); + return builder; } } diff --git a/Source/DotNET/EntityFrameworkCore/EntityFrameworkReadModelForCommandResolver.cs b/Source/DotNET/EntityFrameworkCore/EntityFrameworkReadModelForCommandResolver.cs new file mode 100644 index 000000000..f2e691ab8 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore/EntityFrameworkReadModelForCommandResolver.cs @@ -0,0 +1,78 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Arc.Queries; +using Cratis.Arc.Queries.ModelBound; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.EntityFrameworkCore; + +/// +/// Represents an that resolves read models backed by Entity Framework Core +/// through their owning , keyed by the command's resolved key. +/// +/// A map from each read model entity type to the DbContext type that owns it. +public class EntityFrameworkReadModelForCommandResolver(IReadOnlyDictionary readModelToDbContext) : ICanResolveReadModelForCommand +{ + /// + public IEnumerable ReadModelTypes { get; } = [.. readModelToDbContext.Keys]; + + /// + /// Discovers the read model entity types carried by the given DbContext types and maps each to its owning DbContext. + /// + /// The types to inspect. + /// A map from each read model entity type to the DbContext type that owns it. + /// + /// A read model entity is one carried by a whose entity type is marked with + /// [ReadModel]. When the same entity type appears in more than one DbContext the first one discovered owns it. + /// + public static IReadOnlyDictionary DiscoverReadModelTypes(IEnumerable dbContextTypes) + { + var map = new Dictionary(); + foreach (var dbContextType in dbContextTypes) + { + var entityTypes = dbContextType.GetProperties() + .Where(property => property.PropertyType.IsGenericType && property.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + .Select(property => property.PropertyType.GetGenericArguments()[0]) + .Where(entityType => entityType.IsReadModel()); + + foreach (var entityType in entityTypes) + { + map.TryAdd(entityType, dbContextType); + } + } + + return map; + } + + /// + /// Thrown when the command carries no usable key to resolve the read model by; it surfaces as a validation failure (HTTP 400). + /// Thrown when the read model entity has no single-property primary key to resolve by. + public async Task Resolve(Type readModelType, CommandContext commandContext) + { + var resolvedKey = commandContext.GetResolvedKey(); + if (string.IsNullOrEmpty(resolvedKey)) + { + // A read model is keyed by the command's resolved key, so an absent key can never resolve one — for a + // nullable and a non-nullable dependency alike. That is invalid client input, so it surfaces as a + // validation failure (HTTP 400) rather than null or an unhandled server error. A valid-but-not-found read + // model still resolves to null below. + throw new UnableToResolveReadModelFromCommandContext(readModelType); + } + + var dbContext = (DbContext)commandContext.ServiceProvider!.GetRequiredService(readModelToDbContext[readModelType]); + var primaryKey = dbContext.Model.GetEntityTypes().FirstOrDefault(entityType => entityType.ClrType == readModelType)?.FindPrimaryKey(); + if (primaryKey is null || primaryKey.Properties.Count != 1) + { + throw new EntityDoesNotHavePrimaryKey(readModelType); + } + + var key = resolvedKey.ConvertTo(primaryKey.Properties[0].ClrType); + + // A never-materialized read model resolves to null; command-side code can inject a nullable read model and + // treat null as "does not exist", while a non-nullable dependency is surfaced as a validation failure (HTTP 400). + return await dbContext.FindAsync(readModelType, key); + } +}