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; + } }