diff --git a/docs/guide/handlers/persistence.md b/docs/guide/handlers/persistence.md
index 263093a13..e8a716ca1 100644
--- a/docs/guide/handlers/persistence.md
+++ b/docs/guide/handlers/persistence.md
@@ -166,6 +166,59 @@ public enum ValueSource
snippet source | anchor
+## Reading the First of a Type
+
+`[Entity]` needs an identity to load by, which means it cannot express the *singleton document* — a type
+your system stores exactly one of, looked up by nothing at all. That is what `[FirstOrDefault]` is for. It
+is the equivalent of `await session.Query().FirstOrDefaultAsync()`, resolved through whichever
+persistence provider owns the type:
+
+```cs
+[WolverineGet("/api/alerts/config/metrics/defaults")]
+public static MetricsAlertDefaults GetMetricsDefaults([FirstOrDefault] MetricsAlertDefaults? defaults)
+ => defaults ?? new MetricsAlertDefaults();
+```
+
+The point is storage agnostic code: that handler is valid whether the store behind it is Marten, Polecat,
+Fisher, RavenDb, or EF Core, and it replaces the hand written version that had to name a session type:
+
+```cs
+// What [FirstOrDefault] replaces -- correct, but pinned to Marten
+[WolverineGet("/api/alerts/config/metrics/defaults")]
+public static async Task GetMetricsDefaults(IDocumentSession session)
+{
+ var defaults = await session.Query().FirstOrDefaultAsync();
+ return defaults ?? new MetricsAlertDefaults();
+}
+```
+
+Some things to know:
+
+* The parameter is simply `null` when nothing matches, and your handler or endpoint **runs either way**.
+ Unlike `[Entity]`, there is no `Required` and no `OnMissing` — a miss here is not an error condition
+ worth a 404, it is an ordinary answer to "is there one of these yet?". Write your own null branch,
+ usually a `?? new T()` fallback.
+* The query is **unfiltered**. If you need a predicate, use a `Before` method, a compiled query, or
+ `[FromQuerySpecification]`; ordering semantics across five different LINQ providers is not something
+ this attribute tries to promise.
+* Usable in both message handlers and HTTP endpoints, and in `Before` / `Validate` methods.
+* Supported by Marten, Polecat, Fisher, RavenDb, and EF Core. **CosmosDb is not supported** — see below.
+
+::: warning
+CosmosDb cannot support `[FirstOrDefault]`. Wolverine's CosmosDb integration stores every user document
+in a single shared `wolverine` container alongside Wolverine's own envelopes and node records, with no
+per-type discriminator on user documents, so there is no way to ask for "the first document of type `T`"
+without risking a different type entirely. A `[FirstOrDefault]` parameter on a CosmosDb-persisted type
+fails at bootstrapping time with an error naming the provider, rather than returning something wrong at
+runtime. Load the value explicitly in a `Before` method instead.
+:::
+
+::: tip
+On Fisher, a document table is created lazily on first write, and querying a type that has never been
+written throws rather than returning nothing. That applies to any Fisher query, not just this attribute,
+but it is worth knowing if a brand new deployment hits a `[FirstOrDefault]` before anything is stored.
+:::
+
## Event Sourced Models
`[Entity]` resolves a *document* from whatever persistence your application configured. Its
diff --git a/src/Persistence/EfCoreTests/first_or_default_attribute_usage.cs b/src/Persistence/EfCoreTests/first_or_default_attribute_usage.cs
new file mode 100644
index 000000000..552836089
--- /dev/null
+++ b/src/Persistence/EfCoreTests/first_or_default_attribute_usage.cs
@@ -0,0 +1,125 @@
+using IntegrationTests;
+using JasperFx;
+using JasperFx.Resources;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using SharedPersistenceModels.Items;
+using Shouldly;
+using Wolverine;
+using Wolverine.EntityFrameworkCore;
+using Wolverine.Persistence;
+using Wolverine.SqlServer;
+using Wolverine.Tracking;
+using Xunit;
+
+namespace EfCoreTests;
+
+// The EF Core half of the [FirstOrDefault] storage agnostic promise. Same handler shape as the Marten,
+// Polecat, Fisher and RavenDb suites -- the only difference is that EF Core resolves through a DbContext
+// and Set() rather than a document session and Query().
+[Collection("sqlserver")]
+public class first_or_default_attribute_usage : IAsyncLifetime
+{
+ private IHost _host = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ _host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(EfAlertDefaultsHandler));
+
+ opts.Services.AddDbContextWithWolverineIntegration(o =>
+ {
+ o.UseSqlServer(Servers.SqlServerConnectionString);
+ });
+
+ opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "first_or_default");
+ opts.UseEntityFrameworkCoreTransactions();
+ opts.UseEntityFrameworkCoreWolverineManagedMigrations();
+ opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState);
+ }).StartAsync(cancellationToken: TestContext.Current.CancellationToken);
+
+ using var scope = _host.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken);
+ db.AlertDefaults.RemoveRange(db.AlertDefaults);
+ await db.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ }
+
+ [Fact]
+ public async Task the_parameter_is_null_when_nothing_is_stored()
+ {
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadEfAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(-1);
+ }
+
+ [Fact]
+ public async Task the_first_row_is_supplied_when_one_exists()
+ {
+ using (var scope = _host.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.AlertDefaults.Add(new EfAlertDefaults { Id = Guid.NewGuid(), Threshold = 42 });
+ await db.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadEfAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(42);
+ }
+}
+
+public class EfAlertDefaults
+{
+ public Guid Id { get; set; }
+ public int Threshold { get; set; }
+}
+
+public class AlertDefaultsDbContext : DbContext
+{
+ public AlertDefaultsDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ public DbSet AlertDefaults { get; set; } = null!;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.MapWolverineEnvelopeStorage();
+
+ modelBuilder.Entity(map =>
+ {
+ map.ToTable("alert_defaults");
+ map.HasKey(x => x.Id);
+ map.Property(x => x.Threshold);
+ });
+ }
+}
+
+public record ReadEfAlertDefaults;
+
+public record EfAlertDefaultsRead(int Threshold);
+
+public static class EfAlertDefaultsHandler
+{
+ public static EfAlertDefaultsRead Handle(ReadEfAlertDefaults command,
+ [FirstOrDefault] EfAlertDefaults? defaults)
+ {
+ return new EfAlertDefaultsRead(defaults?.Threshold ?? -1);
+ }
+
+ public static void Handle(EfAlertDefaultsRead msg)
+ {
+ }
+}
diff --git a/src/Persistence/FisherTests/first_or_default_attribute_usage.cs b/src/Persistence/FisherTests/first_or_default_attribute_usage.cs
new file mode 100644
index 000000000..931a54519
--- /dev/null
+++ b/src/Persistence/FisherTests/first_or_default_attribute_usage.cs
@@ -0,0 +1,107 @@
+using Fisher;
+using JasperFx;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Wolverine;
+using Wolverine.Fisher;
+using Wolverine.Persistence;
+using Wolverine.Tracking;
+
+namespace FisherTests;
+
+// The Fisher half of the [FirstOrDefault] storage agnostic promise -- the handler below is character for
+// character what the Marten, Polecat, RavenDb and EF Core suites run.
+public class first_or_default_attribute_usage : IAsyncLifetime
+{
+ private FisherTestDatabase theDatabase = null!;
+ private IHost _host = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ theDatabase = Servers.CreateDatabase("first_or_default");
+
+ _host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(FiAlertDefaultsHandler));
+ opts.Durability.Mode = DurabilityMode.Solo;
+
+ opts.Services.AddFisher(m =>
+ {
+ m.Connection(theDatabase.ConnectionString);
+ m.AutoCreateSchemaObjects = AutoCreate.All;
+ })
+ .ApplyAllDatabaseChangesOnStartup()
+ .IntegrateWithWolverine();
+ }).StartAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ theDatabase.Dispose();
+ }
+
+ [Fact]
+ public async Task the_parameter_is_null_when_nothing_is_stored()
+ {
+ // Fisher creates a document table lazily on first write, and querying a type whose table was never
+ // created throws "no such table" rather than returning nothing -- the same Fisher characteristic
+ // storage_attribute_routes_to_fisher_store leans on to assert a negative. So establish the table,
+ // then empty it, which is the state an application is actually in once it has used the type.
+ await using (var session = _host.Services.GetRequiredService().LightweightSession())
+ {
+ var seed = new FiAlertDefaults { Threshold = 1 };
+ session.Store(seed);
+ await session.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ session.Delete(seed);
+ await session.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadFiAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(-1);
+ }
+
+ [Fact]
+ public async Task the_first_document_is_supplied_when_one_exists()
+ {
+ await using (var session = _host.Services.GetRequiredService().LightweightSession())
+ {
+ session.Store(new FiAlertDefaults { Threshold = 42 });
+ await session.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadFiAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(42);
+ }
+}
+
+public class FiAlertDefaults
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public int Threshold { get; set; }
+}
+
+public record ReadFiAlertDefaults;
+
+public record FiAlertDefaultsRead(int Threshold);
+
+public static class FiAlertDefaultsHandler
+{
+ public static FiAlertDefaultsRead Handle(ReadFiAlertDefaults command,
+ [FirstOrDefault] FiAlertDefaults? defaults)
+ {
+ return new FiAlertDefaultsRead(defaults?.Threshold ?? -1);
+ }
+
+ public static void Handle(FiAlertDefaultsRead msg)
+ {
+ }
+}
diff --git a/src/Persistence/MartenTests/first_or_default_attribute_usage.cs b/src/Persistence/MartenTests/first_or_default_attribute_usage.cs
new file mode 100644
index 000000000..35ededb16
--- /dev/null
+++ b/src/Persistence/MartenTests/first_or_default_attribute_usage.cs
@@ -0,0 +1,91 @@
+using IntegrationTests;
+using Marten;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Wolverine;
+using Wolverine.Marten;
+using Wolverine.Persistence;
+using Wolverine.Tracking;
+
+namespace MartenTests;
+
+// [FirstOrDefault] is deliberately storage agnostic -- the same handler is valid on Marten, Polecat, Fisher,
+// RavenDb or EF Core. This is the Marten proof; the sibling suites cover the others.
+public class first_or_default_attribute_usage : IAsyncLifetime
+{
+ private IHost _host = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ _host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(AlertDefaultsHandler));
+ opts.Durability.Mode = DurabilityMode.Solo;
+ opts.Services.AddMarten(m =>
+ {
+ m.DisableNpgsqlLogging = true;
+ m.Connection(Servers.PostgresConnectionString);
+ m.DatabaseSchemaName = "first_or_default";
+ }).IntegrateWithWolverine().UseLightweightSessions();
+ }).StartAsync();
+
+ // Each test decides what is in the table, so start from empty every time
+ await _host.DocumentStore().Advanced.Clean.DeleteDocumentsByTypeAsync(typeof(AlertDefaults));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ }
+
+ [Fact]
+ public async Task the_parameter_is_null_when_nothing_is_stored()
+ {
+ // The whole point of the "always optional" design: the handler still runs, and writes its own
+ // fallback. No 404, no exception, no silently skipped handler.
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(-1);
+ }
+
+ [Fact]
+ public async Task the_first_document_is_supplied_when_one_exists()
+ {
+ await _host.DocumentStore().BulkInsertDocumentsAsync([new AlertDefaults { Threshold = 42 }],
+ cancellation: TestContext.Current.CancellationToken);
+
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(42);
+ }
+}
+
+public class AlertDefaults
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public int Threshold { get; set; }
+}
+
+public record ReadAlertDefaults;
+
+public record AlertDefaultsRead(int Threshold);
+
+public static class AlertDefaultsHandler
+{
+ // No identity anywhere in the message -- this is exactly the singleton configuration shape that
+ // [Entity] cannot express, because [Entity] requires an identity value to load by.
+ public static AlertDefaultsRead Handle(ReadAlertDefaults command, [FirstOrDefault] AlertDefaults? defaults)
+ {
+ return new AlertDefaultsRead(defaults?.Threshold ?? -1);
+ }
+
+ // Gives the cascaded message a local route so the tracked session records it as Sent rather than
+ // dropping it with NoRoutes
+ public static void Handle(AlertDefaultsRead msg)
+ {
+ }
+}
diff --git a/src/Persistence/PolecatTests/first_or_default_attribute_usage.cs b/src/Persistence/PolecatTests/first_or_default_attribute_usage.cs
new file mode 100644
index 000000000..0f53d3920
--- /dev/null
+++ b/src/Persistence/PolecatTests/first_or_default_attribute_usage.cs
@@ -0,0 +1,91 @@
+using IntegrationTests;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Polecat;
+using Shouldly;
+using Wolverine;
+using Wolverine.Persistence;
+using Wolverine.Polecat;
+using Wolverine.Tracking;
+
+namespace PolecatTests;
+
+// The Polecat half of the [FirstOrDefault] storage agnostic promise -- the handler below is character for
+// character what the Marten, Fisher, RavenDb and EF Core suites run.
+public class first_or_default_attribute_usage : IAsyncLifetime
+{
+ private IHost _host = null!;
+
+ public async ValueTask InitializeAsync()
+ {
+ _host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(PcAlertDefaultsHandler));
+ opts.Services.AddPolecat(m =>
+ {
+ m.ConnectionString = Servers.SqlServerConnectionString;
+ m.DatabaseSchemaName = "first_or_default";
+ }).IntegrateWithWolverine();
+ }).StartAsync();
+
+ var store = (DocumentStore)_host.Services.GetRequiredService();
+ await store.Database.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ // Polecat's cleaner has no per-type delete; this schema is dedicated to these tests anyway
+ await store.Advanced.Clean.DeleteAllDocumentsAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ }
+
+ [Fact]
+ public async Task the_parameter_is_null_when_nothing_is_stored()
+ {
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadPcAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(-1);
+ }
+
+ [Fact]
+ public async Task the_first_document_is_supplied_when_one_exists()
+ {
+ await using (var session = _host.Services.GetRequiredService().LightweightSession())
+ {
+ session.Store(new PcAlertDefaults { Threshold = 42 });
+ await session.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadPcAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(42);
+ }
+}
+
+public class PcAlertDefaults
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public int Threshold { get; set; }
+}
+
+public record ReadPcAlertDefaults;
+
+public record PcAlertDefaultsRead(int Threshold);
+
+public static class PcAlertDefaultsHandler
+{
+ public static PcAlertDefaultsRead Handle(ReadPcAlertDefaults command,
+ [FirstOrDefault] PcAlertDefaults? defaults)
+ {
+ return new PcAlertDefaultsRead(defaults?.Threshold ?? -1);
+ }
+
+ public static void Handle(PcAlertDefaultsRead msg)
+ {
+ }
+}
diff --git a/src/Persistence/RavenDbTests/first_or_default_attribute_usage.cs b/src/Persistence/RavenDbTests/first_or_default_attribute_usage.cs
new file mode 100644
index 000000000..7929ea44a
--- /dev/null
+++ b/src/Persistence/RavenDbTests/first_or_default_attribute_usage.cs
@@ -0,0 +1,113 @@
+using JasperFx.Core;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Raven.Client.Documents;
+using Shouldly;
+using Wolverine;
+using Wolverine.Persistence;
+using Wolverine.RavenDb;
+using Wolverine.Tracking;
+
+namespace RavenDbTests;
+
+// The RavenDb half of the [FirstOrDefault] storage agnostic promise -- the handler below is character for
+// character what the Marten, Polecat, Fisher and EF Core suites run.
+[Collection("raven")]
+public class first_or_default_attribute_usage : IAsyncLifetime
+{
+ private readonly DatabaseFixture _fixture;
+ private IDocumentStore _store = null!;
+ private IHost _host = null!;
+
+ public first_or_default_attribute_usage(DatabaseFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ public async ValueTask InitializeAsync()
+ {
+ _store = _fixture.StartRavenStore();
+
+ _host = await Host.CreateDefaultBuilder()
+ .UseWolverine(opts =>
+ {
+ opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(RvAlertDefaultsHandler));
+ opts.Services.AddSingleton(_store);
+ opts.UseRavenDbPersistence();
+ opts.Durability.Mode = DurabilityMode.Solo;
+ }).StartAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _host.StopAsync();
+ _host.Dispose();
+ }
+
+ [Fact]
+ public async Task the_parameter_is_null_when_nothing_is_stored()
+ {
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadRvAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(-1);
+ }
+
+ [Fact]
+ public async Task the_first_document_is_supplied_when_one_exists()
+ {
+ using (var session = _store.OpenAsyncSession())
+ {
+ await session.StoreAsync(new RvAlertDefaults { Threshold = 42 },
+ TestContext.Current.CancellationToken);
+ await session.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ // RavenDb indexes asynchronously, so a query issued immediately after the write can legitimately
+ // miss it. Wait for the write to be queryable rather than making this a timing flake.
+ await waitForQueryable();
+
+ var tracked = await _host.InvokeMessageAndWaitAsync(new ReadRvAlertDefaults());
+
+ tracked.Sent.SingleMessage()
+ .Threshold.ShouldBe(42);
+ }
+
+ private async Task waitForQueryable()
+ {
+ for (var i = 0; i < 20; i++)
+ {
+ using var session = _store.OpenAsyncSession();
+ var found = await session.Query()
+ .Customize(x => x.WaitForNonStaleResults())
+ .CountAsync(TestContext.Current.CancellationToken);
+
+ if (found > 0) return;
+
+ await Task.Delay(100.Milliseconds(), TestContext.Current.CancellationToken);
+ }
+ }
+}
+
+public class RvAlertDefaults
+{
+ public string Id { get; set; } = null!;
+ public int Threshold { get; set; }
+}
+
+public record ReadRvAlertDefaults;
+
+public record RvAlertDefaultsRead(int Threshold);
+
+public static class RvAlertDefaultsHandler
+{
+ public static RvAlertDefaultsRead Handle(ReadRvAlertDefaults command,
+ [FirstOrDefault] RvAlertDefaults? defaults)
+ {
+ return new RvAlertDefaultsRead(defaults?.Threshold ?? -1);
+ }
+
+ public static void Handle(RvAlertDefaultsRead msg)
+ {
+ }
+}
diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs
index d78657765..db46a12d4 100644
--- a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs
+++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs
@@ -128,6 +128,17 @@ public Type DetermineSagaIdType(Type sagaType, IServiceContainer container)
$"No known primary key for {sagaType.FullNameInCode()} in DbContext {context}");
}
+ public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container,
+ [NotNullWhen(true)] out Frame? frame,
+ [NotNullWhen(true)] out Variable? result)
+ {
+ var dbContextType = DetermineDbContextType(entityType, container);
+ var first = new FirstOrDefaultFrame(dbContextType, entityType);
+ frame = first;
+ result = first.Result;
+ return true;
+ }
+
public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId)
{
var dbContextType = DetermineDbContextType(sagaType, container);
diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/FirstOrDefaultFrame.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/FirstOrDefaultFrame.cs
new file mode 100644
index 000000000..1d888ebf6
--- /dev/null
+++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/FirstOrDefaultFrame.cs
@@ -0,0 +1,51 @@
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Frames;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
+using Microsoft.EntityFrameworkCore;
+
+namespace Wolverine.EntityFrameworkCore.Codegen;
+
+///
+/// Emits await dbContext.Set<T>().FirstOrDefaultAsync(token) for a
+/// parameter.
+///
+///
+/// The extension class and method are referenced through typeof / nameof rather than as literal
+/// strings so that a rename in EF Core breaks this build instead of shipping a codegen failure that only
+/// surfaces the first time an endpoint using the attribute is compiled at runtime.
+///
+internal class FirstOrDefaultFrame : AsyncFrame
+{
+ private readonly Type _dbContextType;
+ private readonly Type _entityType;
+ private Variable? _context;
+ private Variable? _cancellation;
+
+ public FirstOrDefaultFrame(Type dbContextType, Type entityType)
+ {
+ _dbContextType = dbContextType;
+ _entityType = entityType;
+ Result = new Variable(entityType, $"firstOrDefault_{entityType.Name}", this);
+ }
+
+ public Variable Result { get; }
+
+ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ writer.WriteComment($"Read the first {_entityType.NameInCode()} in the database, if any");
+ writer.Write(
+ $"var {Result.Usage} = await {typeof(EntityFrameworkQueryableExtensions).FullNameInCode()}.{nameof(EntityFrameworkQueryableExtensions.FirstOrDefaultAsync)}({_context!.Usage}.{nameof(DbContext.Set)}<{_entityType.FullNameInCode()}>(), {_cancellation!.Usage}).ConfigureAwait(false);");
+
+ Next?.GenerateCode(method, writer);
+ }
+
+ public override IEnumerable FindVariables(IMethodVariables chain)
+ {
+ _context = chain.FindVariable(_dbContextType);
+ yield return _context;
+
+ _cancellation = chain.FindVariable(typeof(CancellationToken));
+ yield return _cancellation;
+ }
+}
diff --git a/src/Persistence/Wolverine.Fisher/Codegen/FirstOrDefaultFrame.cs b/src/Persistence/Wolverine.Fisher/Codegen/FirstOrDefaultFrame.cs
new file mode 100644
index 000000000..339db26f3
--- /dev/null
+++ b/src/Persistence/Wolverine.Fisher/Codegen/FirstOrDefaultFrame.cs
@@ -0,0 +1,50 @@
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Frames;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
+using Fisher;
+using Fisher.Linq;
+
+namespace Wolverine.Fisher.Codegen;
+
+///
+/// Emits await session.Query<T>().FirstOrDefaultAsync(token) for a
+/// parameter.
+///
+///
+/// The extension class and method are referenced through typeof / nameof rather than as literal
+/// strings so that a rename in Fisher breaks this build instead of shipping a codegen failure that only
+/// surfaces the first time an endpoint using the attribute is compiled at runtime.
+///
+internal class FirstOrDefaultFrame : AsyncFrame
+{
+ private readonly Type _entityType;
+ private Variable? _session;
+ private Variable? _cancellation;
+
+ public FirstOrDefaultFrame(Type entityType)
+ {
+ _entityType = entityType;
+ Result = new Variable(entityType, $"firstOrDefault_{entityType.Name}", this);
+ }
+
+ public Variable Result { get; }
+
+ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ writer.WriteComment($"Read the first {_entityType.NameInCode()} in the database, if any");
+ writer.Write(
+ $"var {Result.Usage} = await {typeof(QueryableExtensions).FullNameInCode()}.{nameof(QueryableExtensions.FirstOrDefaultAsync)}({_session!.Usage}.{nameof(IQuerySession.Query)}<{_entityType.FullNameInCode()}>(), {_cancellation!.Usage}).ConfigureAwait(false);");
+
+ Next?.GenerateCode(method, writer);
+ }
+
+ public override IEnumerable FindVariables(IMethodVariables chain)
+ {
+ _session = chain.FindVariable(typeof(IDocumentSession));
+ yield return _session;
+
+ _cancellation = chain.FindVariable(typeof(CancellationToken));
+ yield return _cancellation;
+ }
+}
diff --git a/src/Persistence/Wolverine.Fisher/Persistence/Sagas/FisherPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Fisher/Persistence/Sagas/FisherPersistenceFrameProvider.cs
index a3c16f59c..e5357d319 100644
--- a/src/Persistence/Wolverine.Fisher/Persistence/Sagas/FisherPersistenceFrameProvider.cs
+++ b/src/Persistence/Wolverine.Fisher/Persistence/Sagas/FisherPersistenceFrameProvider.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using JasperFx;
using JasperFx.CodeGeneration;
@@ -118,6 +119,16 @@ private static bool IsDocumentExistsAttribute(Attribute attribute)
return def == typeof(DocumentExistsAttribute<>) || def == typeof(DocumentDoesNotExistAttribute<>);
}
+ public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container,
+ [NotNullWhen(true)] out Frame? frame,
+ [NotNullWhen(true)] out Variable? result)
+ {
+ var first = new FirstOrDefaultFrame(entityType);
+ frame = first;
+ result = first.Result;
+ return true;
+ }
+
public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId)
{
return new LoadDocumentFrame(sagaType, sagaId);
diff --git a/src/Persistence/Wolverine.Marten/Codegen/FirstOrDefaultFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/FirstOrDefaultFrame.cs
new file mode 100644
index 000000000..1ab396e0b
--- /dev/null
+++ b/src/Persistence/Wolverine.Marten/Codegen/FirstOrDefaultFrame.cs
@@ -0,0 +1,49 @@
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Frames;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
+using Marten;
+
+namespace Wolverine.Marten.Codegen;
+
+///
+/// Emits await session.Query<T>().FirstOrDefaultAsync(token) for a
+/// parameter.
+///
+///
+/// The extension class and method are referenced through typeof / nameof rather than as literal
+/// strings so that a rename in Marten breaks this build instead of shipping a codegen failure that only
+/// surfaces the first time an endpoint using the attribute is compiled at runtime.
+///
+internal class FirstOrDefaultFrame : AsyncFrame
+{
+ private readonly Type _entityType;
+ private Variable? _session;
+ private Variable? _cancellation;
+
+ public FirstOrDefaultFrame(Type entityType)
+ {
+ _entityType = entityType;
+ Result = new Variable(entityType, $"firstOrDefault_{entityType.Name}", this);
+ }
+
+ public Variable Result { get; }
+
+ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ writer.WriteComment($"Read the first {_entityType.NameInCode()} in the database, if any");
+ writer.Write(
+ $"var {Result.Usage} = await {typeof(QueryableExtensions).FullNameInCode()}.{nameof(QueryableExtensions.FirstOrDefaultAsync)}({_session!.Usage}.{nameof(IQuerySession.Query)}<{_entityType.FullNameInCode()}>(), {_cancellation!.Usage}).ConfigureAwait(false);");
+
+ Next?.GenerateCode(method, writer);
+ }
+
+ public override IEnumerable FindVariables(IMethodVariables chain)
+ {
+ _session = chain.FindVariable(typeof(IQuerySession));
+ yield return _session;
+
+ _cancellation = chain.FindVariable(typeof(CancellationToken));
+ yield return _cancellation;
+ }
+}
diff --git a/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs
index 4c158c1e1..79df11881 100644
--- a/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs
+++ b/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs
@@ -203,6 +203,16 @@ public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity)
return [new SetVariableToNullIfSoftDeletedFrame(entity)];
}
+ public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container,
+ [NotNullWhen(true)] out Frame? frame,
+ [NotNullWhen(true)] out Variable? result)
+ {
+ var first = new FirstOrDefaultFrame(entityType);
+ frame = first;
+ result = first.Result;
+ return true;
+ }
+
public bool TryBuildFetchSpecificationFrame(
Variable specVariable,
IServiceContainer container,
diff --git a/src/Persistence/Wolverine.Polecat/Codegen/FirstOrDefaultFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/FirstOrDefaultFrame.cs
new file mode 100644
index 000000000..3ce3eed3c
--- /dev/null
+++ b/src/Persistence/Wolverine.Polecat/Codegen/FirstOrDefaultFrame.cs
@@ -0,0 +1,50 @@
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Frames;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
+using Polecat;
+using Polecat.Linq;
+
+namespace Wolverine.Polecat.Codegen;
+
+///
+/// Emits await session.Query<T>().FirstOrDefaultAsync(token) for a
+/// parameter.
+///
+///
+/// The extension class and method are referenced through typeof / nameof rather than as literal
+/// strings so that a rename in Polecat breaks this build instead of shipping a codegen failure that only
+/// surfaces the first time an endpoint using the attribute is compiled at runtime.
+///
+internal class FirstOrDefaultFrame : AsyncFrame
+{
+ private readonly Type _entityType;
+ private Variable? _session;
+ private Variable? _cancellation;
+
+ public FirstOrDefaultFrame(Type entityType)
+ {
+ _entityType = entityType;
+ Result = new Variable(entityType, $"firstOrDefault_{entityType.Name}", this);
+ }
+
+ public Variable Result { get; }
+
+ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ writer.WriteComment($"Read the first {_entityType.NameInCode()} in the database, if any");
+ writer.Write(
+ $"var {Result.Usage} = await {typeof(PolecatQueryableExtensions).FullNameInCode()}.{nameof(PolecatQueryableExtensions.FirstOrDefaultAsync)}({_session!.Usage}.{nameof(IQuerySession.Query)}<{_entityType.FullNameInCode()}>(), {_cancellation!.Usage}).ConfigureAwait(false);");
+
+ Next?.GenerateCode(method, writer);
+ }
+
+ public override IEnumerable FindVariables(IMethodVariables chain)
+ {
+ _session = chain.FindVariable(typeof(IDocumentSession));
+ yield return _session;
+
+ _cancellation = chain.FindVariable(typeof(CancellationToken));
+ yield return _cancellation;
+ }
+}
diff --git a/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs
index db91054c9..9be7ec38a 100644
--- a/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs
+++ b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using JasperFx;
using JasperFx.CodeGeneration;
@@ -117,6 +118,16 @@ private static bool IsDocumentExistsAttribute(Attribute attribute)
return def == typeof(DocumentExistsAttribute<>) || def == typeof(DocumentDoesNotExistAttribute<>);
}
+ public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container,
+ [NotNullWhen(true)] out Frame? frame,
+ [NotNullWhen(true)] out Variable? result)
+ {
+ var first = new FirstOrDefaultFrame(entityType);
+ frame = first;
+ result = first.Result;
+ return true;
+ }
+
public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId)
{
return new LoadDocumentFrame(sagaType, sagaId);
diff --git a/src/Persistence/Wolverine.RavenDb/Internals/FirstOrDefaultFrame.cs b/src/Persistence/Wolverine.RavenDb/Internals/FirstOrDefaultFrame.cs
new file mode 100644
index 000000000..175424939
--- /dev/null
+++ b/src/Persistence/Wolverine.RavenDb/Internals/FirstOrDefaultFrame.cs
@@ -0,0 +1,51 @@
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Frames;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
+using Raven.Client.Documents;
+using Raven.Client.Documents.Session;
+
+namespace Wolverine.RavenDb.Internals;
+
+///
+/// Emits await session.Query<T>().FirstOrDefaultAsync(token) for a
+/// parameter.
+///
+///
+/// The extension class and method are referenced through typeof / nameof rather than as literal
+/// strings so that a rename in the RavenDb client breaks this build instead of shipping a codegen failure that
+/// only surfaces the first time an endpoint using the attribute is compiled at runtime. That matters more here
+/// than for the other providers, since the RavenDb suite only runs on CI.
+///
+internal class FirstOrDefaultFrame : AsyncFrame
+{
+ private readonly Type _entityType;
+ private Variable? _session;
+ private Variable? _cancellation;
+
+ public FirstOrDefaultFrame(Type entityType)
+ {
+ _entityType = entityType;
+ Result = new Variable(entityType, $"firstOrDefault_{entityType.Name}", this);
+ }
+
+ public Variable Result { get; }
+
+ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+ {
+ writer.WriteComment($"Read the first {_entityType.NameInCode()} in the database, if any");
+ writer.Write(
+ $"var {Result.Usage} = await {typeof(LinqExtensions).FullNameInCode()}.{nameof(LinqExtensions.FirstOrDefaultAsync)}({_session!.Usage}.Query<{_entityType.FullNameInCode()}>(), {_cancellation!.Usage}).ConfigureAwait(false);");
+
+ Next?.GenerateCode(method, writer);
+ }
+
+ public override IEnumerable FindVariables(IMethodVariables chain)
+ {
+ _session = chain.FindVariable(typeof(IAsyncDocumentSession));
+ yield return _session;
+
+ _cancellation = chain.FindVariable(typeof(CancellationToken));
+ yield return _cancellation;
+ }
+}
diff --git a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs
index 9aa982b07..2c892817f 100644
--- a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs
+++ b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs
@@ -69,6 +69,16 @@ public Type DetermineSagaIdType(Type sagaType, IServiceContainer container)
return typeof(string);
}
+ public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container,
+ [NotNullWhen(true)] out Frame? frame,
+ [NotNullWhen(true)] out Variable? result)
+ {
+ var first = new FirstOrDefaultFrame(entityType);
+ frame = first;
+ result = first.Result;
+ return true;
+ }
+
public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId)
{
return new LoadDocumentFrame(sagaType, sagaId);
diff --git a/src/Wolverine/Persistence/FirstOrDefaultAttribute.cs b/src/Wolverine/Persistence/FirstOrDefaultAttribute.cs
new file mode 100644
index 000000000..6e270aa1e
--- /dev/null
+++ b/src/Wolverine/Persistence/FirstOrDefaultAttribute.cs
@@ -0,0 +1,84 @@
+using System.Reflection;
+using JasperFx;
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
+using Wolverine.Attributes;
+using Wolverine.Configuration;
+using Wolverine.Persistence.Sagas;
+
+namespace Wolverine.Persistence;
+
+///
+/// Marks a message handler or HTTP endpoint parameter as the first row of its type in the configured
+/// persistence — the equivalent of await session.Query<T>().FirstOrDefaultAsync(), resolved through
+/// whichever persistence provider owns the type. The point is storage agnostic code: the same handler is valid
+/// whether the store behind it is Marten, Polecat, Fisher, RavenDb, EF Core, or CosmosDb.
+///
+///
+/// The canonical use is a singleton configuration document — a type your system stores exactly one of, with no
+/// meaningful identity to look it up by, which is precisely the case cannot express
+/// because it requires an identity value.
+///
+///
+///
+///
+/// [WolverineGet("/api/alerts/config/metrics/defaults")]
+/// public static MetricsAlertDefaults GetMetricsDefaults([FirstOrDefault] MetricsAlertDefaults? defaults)
+/// => defaults ?? new MetricsAlertDefaults();
+///
+///
+///
+///
+/// The parameter is simply null when nothing matches, and the handler or endpoint runs either way. There is
+/// deliberately no Required / OnMissing here: unlike , a miss is not an
+/// error condition worth a 404, it is an ordinary answer to "is there one of these yet?". Write your own null
+/// branch — usually a ?? new T() fallback.
+///
+///
+/// The query is unfiltered on purpose. If you need a predicate, that is what a Before method, a compiled
+/// query, or are for; ordering across six different LINQ providers
+/// is not something this attribute tries to promise.
+///
+///
+[AttributeUsage(AttributeTargets.Parameter)]
+public class FirstOrDefaultAttribute : WolverineParameterAttribute
+{
+ public FirstOrDefaultAttribute()
+ {
+ ValueSource = ValueSource.Anything;
+ }
+
+ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container,
+ GenerationRules rules)
+ {
+ // Nullable reference annotations do not change the CLR type, but an explicitly nullable value type
+ // parameter would, and reading the first row of a Nullable is meaningless.
+ var entityType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType;
+
+ if (!rules.TryFindPersistenceFrameProvider(container, entityType, out var provider))
+ {
+ throw new InvalidOperationException(
+ $"Could not determine a matching persistence service for [FirstOrDefault] parameter " +
+ $"'{parameter.Name}' of type {entityType.FullNameInCode()}. Check that the persistence " +
+ "integration for this type has been registered, i.e. IntegrateWithWolverine() for Marten.");
+ }
+
+ if (!provider.TryBuildFirstOrDefaultFrame(entityType, container, out var frame, out var result))
+ {
+ throw new InvalidOperationException(
+ $"The {provider.GetType().FullNameInCode()} persistence provider does not support " +
+ $"[FirstOrDefault], so parameter '{parameter.Name}' of type {entityType.FullNameInCode()} " +
+ "cannot be resolved. Load the value explicitly in a Before method instead.");
+ }
+
+ chain.Middleware.Add(frame);
+ result.OverrideName(parameter.Name!);
+
+ // Keeps the value reachable from Before/After middleware methods added later, the same way
+ // [Entity] does.
+ EntityAttribute.StoreDeferredMiddlewareVariable(chain, parameter.Name!, result);
+
+ return result;
+ }
+}
diff --git a/src/Wolverine/Persistence/IPersistenceFrameProvider.cs b/src/Wolverine/Persistence/IPersistenceFrameProvider.cs
index 138979dfe..4caa3397b 100644
--- a/src/Wolverine/Persistence/IPersistenceFrameProvider.cs
+++ b/src/Wolverine/Persistence/IPersistenceFrameProvider.cs
@@ -91,6 +91,39 @@ bool TryBuildFetchSpecificationFrame(
result = null;
return false;
}
+
+ ///
+ /// Attempt to build a codegen that executes the equivalent of
+ /// session.Query<T>().FirstOrDefaultAsync() for against this
+ /// provider's own session, producing the entity (or null) as a new variable for downstream frames.
+ ///
+ ///
+ /// Return true if the provider can express an unfiltered "first row of this type" read. The default
+ /// implementation returns false, signaling "this provider does not support it" — which
+ /// turns into a bootstrapping time error naming the provider, rather
+ /// than silently doing nothing.
+ ///
+ ///
+ /// Every provider spells the async terminal operator differently — Marten's QueryableExtensions,
+ /// EF Core's EntityFrameworkQueryableExtensions, RavenDb's own async LINQ extensions, and CosmosDb
+ /// with no such extension at all — which is exactly why this is provider supplied rather than a shared
+ /// expression built in core.
+ ///
+ ///
+ /// The entity type to read the first instance of.
+ /// Active codegen service container.
+ /// The built frame, when the provider supports this.
+ /// The result variable produced by the frame, when built.
+ bool TryBuildFirstOrDefaultFrame(
+ Type entityType,
+ IServiceContainer container,
+ [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Frame? frame,
+ [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Variable? result)
+ {
+ frame = null;
+ result = null;
+ return false;
+ }
}