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