From c789b70e9c125b4e0afe269639b51d61a030480a Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Thu, 13 Aug 2026 13:18:30 -0500 Subject: [PATCH] Add a storage agnostic [FirstOrDefault] attribute [Entity] needs an identity to load by, so it cannot express the singleton document -- a type a system stores exactly one of, looked up by nothing at all. Today that forces handler code to name a session type and hand-write the query, which pins the code to one store. [FirstOrDefault] resolves the equivalent of session.Query().FirstOrDefaultAsync() through whichever persistence provider owns the type, so the same handler is valid on Marten, Polecat, Fisher, RavenDb or EF Core. The seam is a new optional IPersistenceFrameProvider.TryBuildFirstOrDefaultFrame, a default interface method returning false in the same shape as the existing TryBuildFetchSpecificationFrame. Each provider supplies its own frame, because every one of them spells the async terminal operator differently. Those extension classes and methods are referenced through typeof/nameof rather than as literal strings in the generated source, so a rename in any client library breaks the build instead of shipping a codegen failure that only surfaces at runtime -- which matters most for RavenDb, whose suite only runs on CI. Deliberately standalone rather than an IDataRequirement: no Required, no OnMissing. The parameter is null when nothing matches and the handler runs anyway, because a miss here is an ordinary answer rather than an error worth a 404. The query is unfiltered; a predicate is what Before methods, compiled queries and [FromQuerySpecification] are for. CosmosDb is NOT supported, and this is a storage model limitation rather than an omission: Wolverine's CosmosDb integration upserts every user document into one shared `wolverine` container alongside its own envelopes and node records, with no per-type discriminator on user documents, so "the first document of type T" cannot be asked for without risking a different type entirely. It therefore does not implement the new method, and the default false turns into a bootstrapping time error naming the provider rather than a wrong answer at runtime. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC --- docs/guide/handlers/persistence.md | 53 ++++++++ .../first_or_default_attribute_usage.cs | 125 ++++++++++++++++++ .../first_or_default_attribute_usage.cs | 107 +++++++++++++++ .../first_or_default_attribute_usage.cs | 91 +++++++++++++ .../first_or_default_attribute_usage.cs | 91 +++++++++++++ .../first_or_default_attribute_usage.cs | 113 ++++++++++++++++ .../Codegen/EFCorePersistenceFrameProvider.cs | 11 ++ .../Codegen/FirstOrDefaultFrame.cs | 51 +++++++ .../Codegen/FirstOrDefaultFrame.cs | 50 +++++++ .../Sagas/FisherPersistenceFrameProvider.cs | 11 ++ .../Codegen/FirstOrDefaultFrame.cs | 49 +++++++ .../Sagas/MartenPersistenceFrameProvider.cs | 10 ++ .../Codegen/FirstOrDefaultFrame.cs | 50 +++++++ .../Sagas/PolecatPersistenceFrameProvider.cs | 11 ++ .../Internals/FirstOrDefaultFrame.cs | 51 +++++++ .../RavenDbPersistenceFrameProvider.cs | 10 ++ .../Persistence/FirstOrDefaultAttribute.cs | 84 ++++++++++++ .../Persistence/IPersistenceFrameProvider.cs | 33 +++++ 18 files changed, 1001 insertions(+) create mode 100644 src/Persistence/EfCoreTests/first_or_default_attribute_usage.cs create mode 100644 src/Persistence/FisherTests/first_or_default_attribute_usage.cs create mode 100644 src/Persistence/MartenTests/first_or_default_attribute_usage.cs create mode 100644 src/Persistence/PolecatTests/first_or_default_attribute_usage.cs create mode 100644 src/Persistence/RavenDbTests/first_or_default_attribute_usage.cs create mode 100644 src/Persistence/Wolverine.EntityFrameworkCore/Codegen/FirstOrDefaultFrame.cs create mode 100644 src/Persistence/Wolverine.Fisher/Codegen/FirstOrDefaultFrame.cs create mode 100644 src/Persistence/Wolverine.Marten/Codegen/FirstOrDefaultFrame.cs create mode 100644 src/Persistence/Wolverine.Polecat/Codegen/FirstOrDefaultFrame.cs create mode 100644 src/Persistence/Wolverine.RavenDb/Internals/FirstOrDefaultFrame.cs create mode 100644 src/Wolverine/Persistence/FirstOrDefaultAttribute.cs 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; + } }