diff --git a/docs/guide/durability/marten/operations.md b/docs/guide/durability/marten/operations.md index 2688a6bd7..e66b6d05c 100644 --- a/docs/guide/durability/marten/operations.md +++ b/docs/guide/durability/marten/operations.md @@ -104,5 +104,93 @@ type of `IMartenOp` is a side effect. Like any other "side effect", you could technically return this as the main return type of a method or as part of a tuple. +## Data Requirements + +Wolverine provides declarative data requirement checks that verify whether a Marten document exists (or does not +exist) before a handler or HTTP endpoint executes. If the check fails, a `RequiredDataMissingException` is thrown, +preventing the handler from running. + +### Using Attributes + +The simplest way to declare data requirements is with the `[DocumentExists]` and `[DocumentDoesNotExist]` +attributes on handler methods: + +```csharp +// Convention: looks for a property named "UserId" or "Id" on the command +[DocumentExists] +public void Handle(PromoteUser command) +{ + // Only runs if a User document with the matching identity exists +} + +// Explicit property name for the identity +[DocumentDoesNotExist(nameof(AddUser.UserId))] +public void Handle(AddUser command) +{ + // Only runs if no User document with the matching identity exists +} +``` + +The identity property is resolved from the message/request type by convention: +1. If a property name is specified explicitly in the attribute constructor, that is used +2. Otherwise, Wolverine looks for a property named `{DocumentTypeName}Id` (e.g., `UserId` for `User`) +3. As a fallback, Wolverine looks for a property named `Id` + +You can apply multiple attributes to a single handler method to check multiple documents: + +```csharp +[DocumentExists(nameof(TransferEmployee.TargetDepartmentId))] +[DocumentExists] +public void Handle(TransferEmployee command) +{ + // Only runs if both the employee and target department exist +} +``` + +### Using the Before Method Pattern + +For more complex requirements, or when you need access to the command properties at runtime to construct +the check, use the `Before` method pattern with `MartenOps.Document()`: + +```csharp +public static class CreateThingHandler +{ + // Single requirement + public static IMartenDataRequirement Before(CreateThing command) + => MartenOps.Document().MustExist(command.Category); + + public static IMartenOp Handle(CreateThing command) + { + return MartenOps.Store(new Thing + { + Id = command.Name, + CategoryId = command.Category + }); + } +} + +public static class CreateThing2Handler +{ + // Multiple requirements + public static IEnumerable Before(CreateThing2 command) + { + yield return MartenOps.Document().MustExist(command.Category); + yield return MartenOps.Document().MustNotExist(command.Name); + } + + public static IMartenOp Handle(CreateThing2 command) + { + return MartenOps.Store(new Thing + { + Id = command.Name, + CategoryId = command.Category + }); + } +} +``` + +When multiple data requirements are present in the same handler (whether from attributes or `Before` methods), +Wolverine will automatically batch the existence checks into a single Marten batch query for efficiency. + diff --git a/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs b/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs index d919a9fea..0b9713b24 100644 --- a/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs +++ b/src/Persistence/MartenTests/Dcb/University/ChangeCourseCapacity.cs @@ -23,6 +23,7 @@ public static async Task Handle(ChangeCourseCapacity command, IDocumentSession s var query = new EventTagQuery() .Or(command.CourseId) .Or(command.CourseId); + var boundary = await session.Events.FetchForWritingByTags(query); var state = boundary.Aggregate; @@ -44,15 +45,19 @@ public class State public bool Created { get; private set; } public int Capacity { get; private set; } - public void Apply(CourseCreated e) + public void Evolve(IEvent e) { - Created = true; - Capacity = e.Capacity; - } - - public void Apply(CourseCapacityChanged e) - { - Capacity = e.Capacity; + switch (e.Data) + { + case CourseCreated c: + Created = true; + Capacity = c.Capacity; + break; + + case CourseCapacityChanged changed: + Capacity = changed.Capacity; + break; + } } } @@ -75,7 +80,7 @@ public static HandlerContinuation Validate( return HandlerContinuation.Continue; } - public static CourseCapacityChanged? Handle(ChangeCourseCapacity command, State state) + public static CourseCapacityChanged? Handle(ChangeCourseCapacity command, [BoundaryModel]State state) { return command.Capacity != state.Capacity ? new CourseCapacityChanged(FacultyId.Default, command.CourseId, command.Capacity) @@ -125,7 +130,6 @@ public static HandlerContinuation Validate( public static CourseCapacityChanged? Handle(ChangeCourseCapacity command, - // TODO -- see if we could auto-register this with Marten? [WriteAggregate] Course state) { diff --git a/src/Persistence/MartenTests/Requirements/using_data_requirements.cs b/src/Persistence/MartenTests/Requirements/using_data_requirements.cs new file mode 100644 index 000000000..244824208 --- /dev/null +++ b/src/Persistence/MartenTests/Requirements/using_data_requirements.cs @@ -0,0 +1,366 @@ +using IntegrationTests; +using Marten; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Marten; +using Wolverine.Marten.Requirements; +using Wolverine.Persistence; +using Wolverine.Tracking; + +namespace MartenTests.Requirements; + +public class using_data_requirements : IAsyncLifetime +{ + private IHost _host; + private IDocumentStore _store; + + public async Task InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services + .AddMarten(Servers.PostgresConnectionString) + .IntegrateWithWolverine(); + + opts.Policies.AutoApplyTransactions(); + }).StartAsync(); + + _store = _host.Services.GetRequiredService(); + + await _store.Advanced.Clean.DeleteDocumentsByTypeAsync(typeof(ThingCategory)); + await _store.Advanced.Clean.DeleteDocumentsByTypeAsync(typeof(Thing)); + } + + public async Task DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + #region Single IMartenDataRequirement - MustExist + + [Fact] + public async Task single_requirement_must_exist_happy_path() + { + // Arrange: category exists + using (var session = _store.LightweightSession()) + { + session.Store(new ThingCategory { Id = "widgets" }); + await session.SaveChangesAsync(); + } + + // Act + await _host.InvokeMessageAndWaitAsync(new CreateThing("widget-1", "widgets")); + + // Assert: Thing was created + using (var session = _store.LightweightSession()) + { + var thing = await session.LoadAsync("widget-1"); + thing.ShouldNotBeNull(); + thing.CategoryId.ShouldBe("widgets"); + } + } + + [Fact] + public async Task single_requirement_must_exist_sad_path() + { + // Category does not exist - should throw + await Should.ThrowAsync(async () => + { + await _host.InvokeAsync(new CreateThing("widget-2", "nonexistent")); + }); + } + + #endregion + + #region IEnumerable - MustExist + MustNotExist + + [Fact] + public async Task enumerable_requirements_happy_path() + { + // Arrange: category exists, thing does not + using (var session = _store.LightweightSession()) + { + session.Store(new ThingCategory { Id = "gadgets" }); + await session.SaveChangesAsync(); + } + + // Act + await _host.InvokeMessageAndWaitAsync(new CreateThing2("gadget-1", "gadgets")); + + // Assert: Thing was created + using (var session = _store.LightweightSession()) + { + var thing = await session.LoadAsync("gadget-1"); + thing.ShouldNotBeNull(); + thing.CategoryId.ShouldBe("gadgets"); + } + } + + [Fact] + public async Task enumerable_requirements_sad_path_category_missing() + { + // Category doesn't exist + await Should.ThrowAsync(async () => + { + await _host.InvokeAsync(new CreateThing2("gadget-2", "nonexistent")); + }); + } + + [Fact] + public async Task enumerable_requirements_sad_path_thing_already_exists() + { + // Arrange: category exists AND thing already exists + using (var session = _store.LightweightSession()) + { + session.Store(new ThingCategory { Id = "dupes" }); + session.Store(new Thing { Id = "existing-thing", CategoryId = "dupes" }); + await session.SaveChangesAsync(); + } + + // MustNotExist should fail because thing already exists + await Should.ThrowAsync(async () => + { + await _host.InvokeAsync(new CreateThing2("existing-thing", "dupes")); + }); + } + + #endregion + + #region Single requirement + [Entity(Required = true)] + + [Fact] + public async Task requirement_with_entity_attribute_happy_path() + { + // Arrange: category exists + using (var session = _store.LightweightSession()) + { + session.Store(new ThingCategory { Id = "tools" }); + await session.SaveChangesAsync(); + } + + // Act + await _host.InvokeMessageAndWaitAsync(new CreateThing3("tool-1", "tools")); + + // Assert: Thing was created + using (var session = _store.LightweightSession()) + { + var thing = await session.LoadAsync("tool-1"); + thing.ShouldNotBeNull(); + thing.CategoryId.ShouldBe("tools"); + } + } + + [Fact] + public async Task requirement_with_entity_attribute_sad_path() + { + // Category doesn't exist - requirement check should fail + await Should.ThrowAsync(async () => + { + await _host.InvokeAsync(new CreateThing3("tool-2", "nonexistent")); + }); + } + + #endregion + + #region [DocumentExists] attribute - convention + + [Fact] + public async Task document_exists_attribute_happy_path() + { + using (var session = _store.LightweightSession()) + { + session.Store(new ThingCategory { Id = "attr-cat" }); + await session.SaveChangesAsync(); + } + + await _host.InvokeMessageAndWaitAsync(new CreateThingByAttribute("attr-thing", "attr-cat")); + + using (var session = _store.LightweightSession()) + { + var thing = await session.LoadAsync("attr-thing"); + thing.ShouldNotBeNull(); + thing.CategoryId.ShouldBe("attr-cat"); + } + } + + [Fact] + public async Task document_exists_attribute_sad_path() + { + await Should.ThrowAsync(async () => + { + await _host.InvokeAsync(new CreateThingByAttribute("attr-thing-2", "nonexistent")); + }); + } + + #endregion + + #region [DocumentExists] attribute - explicit property name + + [Fact] + public async Task document_exists_attribute_explicit_happy_path() + { + using (var session = _store.LightweightSession()) + { + session.Store(new ThingCategory { Id = "explicit-cat" }); + await session.SaveChangesAsync(); + } + + await _host.InvokeMessageAndWaitAsync(new CreateThingByAttributeExplicit("explicit-thing", "explicit-cat")); + + using (var session = _store.LightweightSession()) + { + var thing = await session.LoadAsync("explicit-thing"); + thing.ShouldNotBeNull(); + thing.CategoryId.ShouldBe("explicit-cat"); + } + } + + [Fact] + public async Task document_exists_attribute_explicit_sad_path() + { + await Should.ThrowAsync(async () => + { + await _host.InvokeAsync(new CreateThingByAttributeExplicit("explicit-thing-2", "nonexistent")); + }); + } + + #endregion + + #region [DocumentDoesNotExist] attribute + + [Fact] + public async Task document_does_not_exist_attribute_happy_path() + { + // Thing does not exist - should succeed + await _host.InvokeMessageAndWaitAsync(new EnsureNoDuplicateThing("brand-new-thing")); + } + + [Fact] + public async Task document_does_not_exist_attribute_sad_path() + { + using (var session = _store.LightweightSession()) + { + session.Store(new Thing { Id = "already-here", CategoryId = "whatever" }); + await session.SaveChangesAsync(); + } + + await Should.ThrowAsync(async () => + { + await _host.InvokeAsync(new EnsureNoDuplicateThing("already-here")); + }); + } + + #endregion +} + +public record CreateThing(string Name, string Category); +public record CreateThing2(string Name, string Category); +public record CreateThing3(string Name, string Category); +public record CreateThingByAttribute(string Name, string ThingCategoryId); +public record CreateThingByAttributeExplicit(string Name, string CategoryKey); +public record EnsureNoDuplicateThing(string ThingId); + +public class ThingCategory +{ + public string Id { get; set; } +} + +public class Thing +{ + public string Id { get; set; } + public string CategoryId { get; set; } +} + +public static class CreateThingHandler +{ + public static IMartenDataRequirement Before(CreateThing command) + => MartenOps.Document().MustExist(command.Category); + + public static IMartenOp Handle(CreateThing command) + { + return MartenOps.Store(new Thing + { + Id = command.Name, + CategoryId = command.Category + }); + } + +} + +public static class CreateThing2Handler +{ + public static IEnumerable Before(CreateThing2 command) + { + yield return MartenOps.Document().MustExist(command.Category); + yield return MartenOps.Document().MustNotExist(command.Name); + } + + + public static IMartenOp Handle(CreateThing2 command) + { + return MartenOps.Store(new Thing + { + Id = command.Name, + CategoryId = command.Category + }); + } +} + +public static class CreateThingByAttributeHandler +{ + // Convention: looks for ThingCategoryId on the command + [DocumentExists] + public static IMartenOp Handle(CreateThingByAttribute command) + { + return MartenOps.Store(new Thing + { + Id = command.Name, + CategoryId = command.ThingCategoryId + }); + } +} + +public static class CreateThingByAttributeExplicitHandler +{ + // Explicit property name + [DocumentExists(nameof(CreateThingByAttributeExplicit.CategoryKey))] + public static IMartenOp Handle(CreateThingByAttributeExplicit command) + { + return MartenOps.Store(new Thing + { + Id = command.Name, + CategoryId = command.CategoryKey + }); + } +} + +public static class EnsureNoDuplicateThingHandler +{ + [DocumentDoesNotExist] + public static void Handle(EnsureNoDuplicateThing command) + { + // No-op - just verifying the check + } +} + +public static class CreateThing3Handler +{ + public static IMartenDataRequirement Before(CreateThing3 command) + => MartenOps.Document().MustExist(command.Category); + + public static IMartenOp Handle(CreateThing3 command, + + [Entity(nameof(CreateThing3.Category), Required = true)] ThingCategory category) + { + return MartenOps.Store(new Thing + { + Id = command.Name, + CategoryId = command.Category + }); + } + +} diff --git a/src/Persistence/Wolverine.Marten/IMartenOp.cs b/src/Persistence/Wolverine.Marten/IMartenOp.cs index a1d58b1c6..99c67a05d 100644 --- a/src/Persistence/Wolverine.Marten/IMartenOp.cs +++ b/src/Persistence/Wolverine.Marten/IMartenOp.cs @@ -10,6 +10,7 @@ using Marten.Internal.Sessions; using Wolverine.Configuration; using Wolverine.Marten.Persistence.Sagas; +using Wolverine.Marten.Requirements; using Wolverine.Runtime; using Wolverine.Runtime.Handlers; @@ -292,6 +293,21 @@ public static IStartStream StartStream(string streamKey, params object[] even /// /// public static NoOp Nothing() => new NoOp(); + + public static CheckDocument Document() where T : class => new(); +} + +public class CheckDocument where TDoc : class +{ + public IMartenDataRequirement MustExist(TId id) + { + return new DocumentExists(id); + } + + public IMartenDataRequirement MustNotExist(TId id) + { + return new DocumentDoesNotExist(id); + } } /// diff --git a/src/Persistence/Wolverine.Marten/MartenIntegration.cs b/src/Persistence/Wolverine.Marten/MartenIntegration.cs index 0c0928c0b..8719ddffc 100644 --- a/src/Persistence/Wolverine.Marten/MartenIntegration.cs +++ b/src/Persistence/Wolverine.Marten/MartenIntegration.cs @@ -12,8 +12,10 @@ using Weasel.Core; using Wolverine.ErrorHandling; using Wolverine.Marten.Codegen; +using Wolverine.Middleware; using Wolverine.Marten.Persistence.Sagas; using Wolverine.Marten.Publishing; +using Wolverine.Marten.Requirements; using Wolverine.Persistence.Sagas; using Wolverine.Postgresql.Transport; using Wolverine.RDBMS; @@ -82,6 +84,8 @@ public void Configure(WolverineOptions options) transport.MessageStorageSchemaName = MessageStorageSchemaName ?? "public"; options.Policies.Add(); + + options.CodeGeneration.AddContinuationStrategy(); } /// diff --git a/src/Persistence/Wolverine.Marten/Requirements/DocumentExistsAttribute.cs b/src/Persistence/Wolverine.Marten/Requirements/DocumentExistsAttribute.cs new file mode 100644 index 000000000..b5d2d675a --- /dev/null +++ b/src/Persistence/Wolverine.Marten/Requirements/DocumentExistsAttribute.cs @@ -0,0 +1,231 @@ +using JasperFx; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Marten; +using Microsoft.Extensions.Logging; +using Wolverine.Attributes; +using Wolverine.Configuration; +using Wolverine.Persistence; + +namespace Wolverine.Marten.Requirements; + +/// +/// Apply to a handler or HTTP endpoint method to declaratively check that a Marten document +/// of type exists before the handler executes. Throws +/// if the document is not found. +/// +/// The identity is resolved from the message/request by looking for a property named +/// {DocTypeName}Id or Id on the input type. You can override this by +/// specifying the property name explicitly. +/// +/// +/// +/// // Convention: looks for UserId or Id on the command +/// [DocumentExists<User>] +/// public void Handle(PromoteUser command) { } +/// +/// // Explicit property name +/// [DocumentExists<User>(nameof(AddUser.UserId))] +/// public void Handle(AddUser command) { } +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class DocumentExistsAttribute : ModifyChainAttribute where TDoc : class +{ + private readonly string? _argumentName; + + public DocumentExistsAttribute() + { + } + + public DocumentExistsAttribute(string argumentName) + { + _argumentName = argumentName; + } + + public override void Modify(IChain chain, GenerationRules rules, IServiceContainer container) + { + var store = container.GetInstance(); + var documentType = store.Options.FindOrResolveDocumentType(typeof(TDoc)); + var idType = documentType.IdType; + + if (!TryFindIdentityVariable(chain, _argumentName, typeof(TDoc), idType, out var identity)) + { + throw new InvalidOperationException( + $"Could not find an identity variable for {typeof(TDoc).FullNameInCode()} on chain {chain}. " + + $"Expected a property named '{typeof(TDoc).Name}Id' or 'Id' on the input type, " + + $"or specify the property name explicitly in the attribute constructor."); + } + + if (identity.Creator != null) + { + chain.Middleware.Add(identity.Creator); + } + + chain.Middleware.Add(new DocumentExistenceCheckFrame(typeof(TDoc), idType, identity, mustExist: true)); + } + + internal static bool TryFindIdentityVariable(IChain chain, string? argumentName, Type docType, Type idType, + out Variable variable) + { + if (argumentName.IsNotEmpty()) + { + if (chain.TryFindVariable(argumentName, ValueSource.Anything, idType, out variable)) + { + return true; + } + } + + if (chain.TryFindVariable(docType.Name + "Id", ValueSource.Anything, idType, out variable)) + { + return true; + } + + if (chain.TryFindVariable("Id", ValueSource.Anything, idType, out variable)) + { + return true; + } + + variable = default!; + return false; + } +} + +/// +/// Apply to a handler or HTTP endpoint method to declaratively check that a Marten document +/// of type does NOT exist before the handler executes. Throws +/// if the document already exists. +/// +/// The identity is resolved from the message/request by looking for a property named +/// {DocTypeName}Id or Id on the input type. You can override this by +/// specifying the property name explicitly. +/// +/// +/// +/// // Convention: looks for UserId or Id on the command +/// [DocumentDoesNotExist<User>] +/// public void Handle(CreateUser command) { } +/// +/// // Explicit property name +/// [DocumentDoesNotExist<User>(nameof(CreateUser.Email))] +/// public void Handle(CreateUser command) { } +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class DocumentDoesNotExistAttribute : ModifyChainAttribute where TDoc : class +{ + private readonly string? _argumentName; + + public DocumentDoesNotExistAttribute() + { + } + + public DocumentDoesNotExistAttribute(string argumentName) + { + _argumentName = argumentName; + } + + public override void Modify(IChain chain, GenerationRules rules, IServiceContainer container) + { + var store = container.GetInstance(); + var documentType = store.Options.FindOrResolveDocumentType(typeof(TDoc)); + var idType = documentType.IdType; + + if (!DocumentExistsAttribute.TryFindIdentityVariable(chain, _argumentName, typeof(TDoc), idType, + out var identity)) + { + throw new InvalidOperationException( + $"Could not find an identity variable for {typeof(TDoc).FullNameInCode()} on chain {chain}. " + + $"Expected a property named '{typeof(TDoc).Name}Id' or 'Id' on the input type, " + + $"or specify the property name explicitly in the attribute constructor."); + } + + if (identity.Creator != null) + { + chain.Middleware.Add(identity.Creator); + } + + chain.Middleware.Add(new DocumentExistenceCheckFrame(typeof(TDoc), idType, identity, mustExist: false)); + } +} + +/// +/// Code generation frame that checks whether a Marten document exists (or does not exist) +/// and throws on failure. +/// +internal class DocumentExistenceCheckFrame : AsyncFrame +{ + private static int _count; + + private readonly Type _docType; + private readonly Type _idType; + private readonly Variable _identity; + private readonly bool _mustExist; + private readonly int _id; + + private Variable? _session; + private Variable? _logger; + private Variable? _cancellation; + + public DocumentExistenceCheckFrame(Type docType, Type idType, Variable identity, bool mustExist) + { + _docType = docType; + _idType = idType; + _identity = identity; + _mustExist = mustExist; + _id = ++_count; + uses.Add(identity); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + + _logger = chain.FindVariable(typeof(ILogger)); + yield return _logger; + + _cancellation = chain.FindVariable(typeof(CancellationToken)); + yield return _cancellation; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + var docTypeName = _docType.FullNameInCode(); + var idTypeName = _idType.FullNameInCode(); + var existsVar = $"docExists{_id}"; + + writer.WriteComment(_mustExist + ? $"Verify that {docTypeName} exists" + : $"Verify that {docTypeName} does not exist"); + + writer.WriteLine( + $"var {existsVar} = await {_session!.Usage}.CheckExistsAsync<{docTypeName}>({_identity.Usage}, {_cancellation!.Usage}).ConfigureAwait(false);"); + + if (_mustExist) + { + writer.Write($"BLOCK:if (!{existsVar})"); + var msg = $"No {_docType.Name} with the specified identity exists"; + writer.WriteLine( + $"{_logger!.Usage}.LogWarning(\"Marten data requirement failure: {{Message}}\", \"{msg}\");"); + writer.WriteLine( + $"throw new {typeof(RequiredDataMissingException).FullNameInCode()}(\"{msg}\");"); + writer.FinishBlock(); + } + else + { + writer.Write($"BLOCK:if ({existsVar})"); + var msg = $"A {_docType.Name} with the specified identity already exists"; + writer.WriteLine( + $"{_logger!.Usage}.LogWarning(\"Marten data requirement failure: {{Message}}\", \"{msg}\");"); + writer.WriteLine( + $"throw new {typeof(RequiredDataMissingException).FullNameInCode()}(\"{msg}\");"); + writer.FinishBlock(); + } + + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Marten/Requirements/IDataRequirement.cs b/src/Persistence/Wolverine.Marten/Requirements/IDataRequirement.cs deleted file mode 100644 index e3220b5f2..000000000 --- a/src/Persistence/Wolverine.Marten/Requirements/IDataRequirement.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Marten; -using Marten.Services.BatchQuerying; -using Microsoft.Extensions.Logging; - -namespace Wolverine.Marten.Requirements; - -public interface IDataRequirement -{ - Task CheckAsync(IDocumentSession session, ILogger logger, CancellationToken cancellation); - Task CheckFromBatch(ILogger logger); - void RegisterInBatch(IBatchedQuery query); -} \ No newline at end of file diff --git a/src/Persistence/Wolverine.Marten/Requirements/IMartenDataRequirement.cs b/src/Persistence/Wolverine.Marten/Requirements/IMartenDataRequirement.cs new file mode 100644 index 000000000..d8738ebaf --- /dev/null +++ b/src/Persistence/Wolverine.Marten/Requirements/IMartenDataRequirement.cs @@ -0,0 +1,120 @@ +using JasperFx.Core.Reflection; +using Marten; +using Marten.Services.BatchQuerying; +using Microsoft.Extensions.Logging; +using Wolverine.Persistence; + +namespace Wolverine.Marten.Requirements; + +public interface IMartenDataRequirement +{ + Task CheckAsync(IDocumentSession session, ILogger logger, CancellationToken cancellation); + Task CheckFromBatch(ILogger logger); + void RegisterInBatch(IBatchedQuery query); +} + +/// +/// Returning this from a "Before/Validate" method in a handler or HTTP endpoint will opt into a declarative +/// check that the designated document exists in Marten +/// +/// +/// +public class DocumentExists : IMartenDataRequirement where TDoc : class +{ + private readonly TId _identity; + private readonly string _missingMessage; + private Task? _query; + + public DocumentExists(TId identity) : this(identity, $"No {typeof(TDoc).NameInCode()} with identity {identity} exists") + { + } + + public DocumentExists(TId identity, string missingMessage) + { + _identity = identity; + _missingMessage = missingMessage; + } + + public async Task CheckAsync(IDocumentSession session, ILogger logger, CancellationToken cancellation) + { + var exists = await session.CheckExistsAsync(_identity, cancellation); + if (!exists) + { + logger.LogWarning("Marten data requirement failure: {Message}", _missingMessage); + throw new RequiredDataMissingException(_missingMessage); + } + } + + public async Task CheckFromBatch(ILogger logger) + { + if (_query == null) + { + throw new InvalidOperationException("This method was called before registering in a batch query"); + } + + var exists = await _query; + if (!exists) + { + logger.LogWarning("Marten data requirement failure: {Message}", _missingMessage); + throw new RequiredDataMissingException(_missingMessage); + } + } + + public void RegisterInBatch(IBatchedQuery query) + { + _query = query.CheckExists(_identity); + } +} + +/// +/// Returning this from a "Before/Validate" method in a handler or HTTP endpoint will opt into a declarative +/// check that the designated document does not already exist in Marten +/// +/// +/// +public class DocumentDoesNotExist : IMartenDataRequirement where TDoc : class +{ + private readonly TId _identity; + private readonly string _existsMessage; + private Task? _query; + + public DocumentDoesNotExist(TId identity) : this(identity, $"A {typeof(TDoc).NameInCode()} with identity {identity} already exists") + { + } + + public DocumentDoesNotExist(TId identity, string existsMessage) + { + _identity = identity; + _existsMessage = existsMessage; + } + + public async Task CheckAsync(IDocumentSession session, ILogger logger, CancellationToken cancellation) + { + var exists = await session.CheckExistsAsync(_identity, cancellation); + if (exists) + { + logger.LogWarning("Marten data requirement failure: {Message}", _existsMessage); + throw new RequiredDataMissingException(_existsMessage); + } + } + + public async Task CheckFromBatch(ILogger logger) + { + if (_query == null) + { + throw new InvalidOperationException("This method was called before registering in a batch query"); + } + + var exists = await _query; + if (exists) + { + logger.LogWarning("Marten data requirement failure: {Message}", _existsMessage); + throw new RequiredDataMissingException(_existsMessage); + } + } + + public void RegisterInBatch(IBatchedQuery query) + { + _query = query.CheckExists(_identity); + } +} diff --git a/src/Persistence/Wolverine.Marten/Requirements/MartenDataRequirementFrame.cs b/src/Persistence/Wolverine.Marten/Requirements/MartenDataRequirementFrame.cs new file mode 100644 index 000000000..a2c705c8d --- /dev/null +++ b/src/Persistence/Wolverine.Marten/Requirements/MartenDataRequirementFrame.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Marten; +using Microsoft.Extensions.Logging; +using Wolverine.Configuration; +using Wolverine.Marten.Codegen; +using Wolverine.Middleware; + +namespace Wolverine.Marten.Requirements; + +internal class MartenDataRequirementFrame : AsyncFrame, IBatchableFrame +{ + private static int _count; + private readonly Variable _requirementVariable; + private readonly bool _isEnumerable; + private readonly int _id; + private Variable? _session; + private Variable? _logger; + private Variable? _cancellation; + private Variable? _batchQuery; + + public MartenDataRequirementFrame(Variable requirementVariable, bool isEnumerable) + { + _requirementVariable = requirementVariable; + _isEnumerable = isEnumerable; + _id = ++_count; + uses.Add(requirementVariable); + } + + private string MaterializedVarName => $"materializedDataReqs{_id}"; + + public void WriteCodeToEnlistInBatchQuery(GeneratedMethod method, ISourceWriter writer) + { + if (_isEnumerable) + { + writer.WriteLine( + $"var {MaterializedVarName} = new {typeof(List).FullNameInCode()}();"); + writer.Write($"BLOCK:foreach (var dataReq{_id} in {_requirementVariable.Usage})"); + writer.WriteLine( + $"dataReq{_id}.{nameof(IMartenDataRequirement.RegisterInBatch)}({_batchQuery!.Usage});"); + writer.WriteLine($"{MaterializedVarName}.Add(dataReq{_id});"); + writer.FinishBlock(); + } + else + { + writer.WriteLine( + $"{_requirementVariable.Usage}.{nameof(IMartenDataRequirement.RegisterInBatch)}({_batchQuery!.Usage});"); + } + } + + public void EnlistInBatchQuery(Variable batchQuery) + { + _batchQuery = batchQuery; + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + + _logger = chain.FindVariable(typeof(ILogger)); + yield return _logger; + + _cancellation = chain.FindVariable(typeof(CancellationToken)); + yield return _cancellation; + + if (_batchQuery != null) + { + yield return _batchQuery; + } + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment("Evaluate Marten data requirement(s)"); + + if (_batchQuery != null) + { + // Batched mode - use CheckFromBatch after batch execution + if (_isEnumerable) + { + writer.Write($"BLOCK:foreach (var dataReqCheck{_id} in {MaterializedVarName})"); + writer.WriteLine( + $"await dataReqCheck{_id}.{nameof(IMartenDataRequirement.CheckFromBatch)}({_logger!.Usage});"); + writer.FinishBlock(); + } + else + { + writer.WriteLine( + $"await {_requirementVariable.Usage}.{nameof(IMartenDataRequirement.CheckFromBatch)}({_logger!.Usage});"); + } + } + else + { + // Non-batched mode - use CheckAsync + if (_isEnumerable) + { + writer.Write($"BLOCK:foreach (var dataReq{_id} in {_requirementVariable.Usage})"); + writer.WriteLine( + $"await dataReq{_id}.{nameof(IMartenDataRequirement.CheckAsync)}({_session!.Usage}, {_logger!.Usage}, {_cancellation!.Usage});"); + writer.FinishBlock(); + } + else + { + writer.WriteLine( + $"await {_requirementVariable.Usage}.{nameof(IMartenDataRequirement.CheckAsync)}({_session!.Usage}, {_logger!.Usage}, {_cancellation!.Usage});"); + } + } + + Next?.GenerateCode(method, writer); + } +} + +public class MartenDataRequirementContinuationStrategy : IContinuationStrategy +{ + public bool TryFindContinuationHandler(IChain chain, MethodCall call, out Frame? frame) + { + // Check for single IMartenDataRequirement return + var singleVar = + call.Creates.FirstOrDefault(v => v.VariableType == typeof(IMartenDataRequirement)); + if (singleVar != null) + { + frame = new MartenDataRequirementFrame(singleVar, isEnumerable: false); + return true; + } + + // Check for IEnumerable return + var enumerableType = typeof(IEnumerable); + var enumerableVar = call.Creates.FirstOrDefault(v => + v.VariableType == enumerableType || v.VariableType.CanBeCastTo(enumerableType)); + if (enumerableVar != null) + { + frame = new MartenDataRequirementFrame(enumerableVar, isEnumerable: true); + return true; + } + + frame = null; + return false; + } +} diff --git a/src/Wolverine/Middleware/RequirementResultContinuationPolicy.cs b/src/Wolverine/Middleware/RequirementResultContinuationPolicy.cs index 11a098782..a0ca27517 100644 --- a/src/Wolverine/Middleware/RequirementResultContinuationPolicy.cs +++ b/src/Wolverine/Middleware/RequirementResultContinuationPolicy.cs @@ -40,7 +40,7 @@ public static bool ShouldStop(ILogger logger, RequirementResult result) public bool TryFindContinuationHandler(IChain chain, MethodCall call, out Frame? frame) { - if (call.Method.Name != "Validate" && call.Method.Name != "ValidateAsync") + if (!MiddlewarePolicy.BeforeMethodNames.Contains(call.Method.Name)) { frame = null; return false; diff --git a/src/Wolverine/RequirementResult.cs b/src/Wolverine/RequirementResult.cs index ed8d92ccb..7034a0287 100644 --- a/src/Wolverine/RequirementResult.cs +++ b/src/Wolverine/RequirementResult.cs @@ -5,4 +5,8 @@ namespace Wolverine; /// /// /// -public record RequirementResult(HandlerContinuation Branch, string[] Messages); \ No newline at end of file +public record RequirementResult(HandlerContinuation Branch, string[] Messages) +{ + public static RequirementResult AllGood() => + new RequirementResult(HandlerContinuation.Continue, []); +} \ No newline at end of file