From 049bb89a5080a3e3d48874f5416fb30905239b4d Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Thu, 13 Aug 2026 15:16:15 -0500 Subject: [PATCH] Add [All] and [Queryable] parameter attributes, and fix event operations transactions [All] supplies every document of its element type -- the equivalent of session.Query().ToListAsync() -- resolved through whichever persistence provider owns the type, so the same handler is valid on Marten, Polecat, Fisher, RavenDb or EF Core. The parameter must be IReadOnlyList: that is what Marten and RavenDb return from ToListAsync natively and EF Core's List converts to implicitly, so every provider assigns straight across with no copying. CosmosDb is unsupported for the same storage-model reason as [FirstOrDefault] -- one shared container, no per-type discriminator. [Queryable] injects the store's own IQueryable. It is the escape hatch and the docs say so at length: it is NOT portable in practice even though the type is, it reintroduces the coupling the other attributes remove, it makes handlers harder to test, and an unbounded query is easy to write by accident. The worked example is real -- Marten 9 refuses synchronous LINQ, so a .ToArray() that compiles everywhere and works on EF Core throws at runtime on Marten. Always use the async operators. CosmosDb IS supported here, with a warning that its shared container can surface other document types. Both validate their parameter type and name the parameter, its declaring method, what it is declared as, and what to write instead. Separately, validating that IEventStoreOperations works as a parameter turned up two defects: 1. No variable source matched the shared JasperFx.Events.IEventStoreOperations. Each store matched only its own derived spelling -- Marten.Events.IEventStoreOperations, Polecat.Events.IEventOperations, Fisher.Events.EventOperations -- so a parameter typed as the shared contract resolved on none of them. 2. CanApply did not recognize ANY event operations type, so AutoApplyTransactions skipped the chain and nothing was ever committed. The append queued into the session's unit of work and vanished with no exception. This is NOT new: it applies to each store's own event operations types too, which have been resolvable as parameters far longer than this branch. CanApply now recognizes the shared JasperFx types and each store's own. Verified on Marten for both message handlers and HTTP endpoints, and on Polecat and Fisher for handlers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC --- docs/guide/handlers/persistence.md | 96 +++++++++++ ...ent_store_operations_endpoint_parameter.cs | 86 ++++++++++ .../CosmosDbTests/queryable_attribute.cs | 117 ++++++++++++++ .../all_and_queryable_attributes.cs | 144 +++++++++++++++++ ...ll_queryable_and_event_store_operations.cs | 150 ++++++++++++++++++ .../all_and_queryable_attributes.cs | 122 ++++++++++++++ .../event_store_operations_parameter.cs | 81 ++++++++++ ...ll_queryable_and_event_store_operations.cs | 134 ++++++++++++++++ .../all_and_queryable_attributes.cs | 127 +++++++++++++++ .../CosmosDbPersistenceFrameProvider.cs | 11 ++ .../Internals/QueryableFrame.cs | 53 +++++++ .../Codegen/AllFrame.cs | 53 +++++++ .../Codegen/EFCorePersistenceFrameProvider.cs | 20 +++ .../Codegen/QueryableFrame.cs | 46 ++++++ .../Wolverine.Fisher/Codegen/AllFrame.cs | 49 ++++++ .../Codegen/QueryableFrame.cs | 41 +++++ .../Codegen/SessionVariableSource.cs | 49 ++++++ .../Wolverine.Fisher/FisherIntegration.cs | 1 + .../Sagas/FisherPersistenceFrameProvider.cs | 34 +++- .../Wolverine.Marten/Codegen/AllFrame.cs | 48 ++++++ .../Codegen/QueryableFrame.cs | 41 +++++ .../Codegen/SessionVariableSource.cs | 49 ++++++ .../Wolverine.Marten/MartenIntegration.cs | 1 + .../Sagas/MartenPersistenceFrameProvider.cs | 34 +++- .../Wolverine.Polecat/Codegen/AllFrame.cs | 49 ++++++ .../Codegen/QueryableFrame.cs | 41 +++++ .../Codegen/SessionVariableSource.cs | 49 ++++++ .../Sagas/PolecatPersistenceFrameProvider.cs | 34 +++- .../Wolverine.Polecat/PolecatIntegration.cs | 1 + .../Wolverine.RavenDb/Internals/AllFrame.cs | 53 +++++++ .../Internals/QueryableFrame.cs | 45 ++++++ .../RavenDbPersistenceFrameProvider.cs | 20 +++ .../all_and_queryable_validation.cs | 81 ++++++++++ src/Wolverine/Persistence/AllAttribute.cs | 128 +++++++++++++++ .../Persistence/IPersistenceFrameProvider.cs | 52 ++++++ .../Persistence/QueryableAttribute.cs | 128 +++++++++++++++ 36 files changed, 2262 insertions(+), 6 deletions(-) create mode 100644 src/Http/Wolverine.Http.Tests/event_store_operations_endpoint_parameter.cs create mode 100644 src/Persistence/CosmosDbTests/queryable_attribute.cs create mode 100644 src/Persistence/EfCoreTests/all_and_queryable_attributes.cs create mode 100644 src/Persistence/FisherTests/all_queryable_and_event_store_operations.cs create mode 100644 src/Persistence/MartenTests/all_and_queryable_attributes.cs create mode 100644 src/Persistence/MartenTests/event_store_operations_parameter.cs create mode 100644 src/Persistence/PolecatTests/all_queryable_and_event_store_operations.cs create mode 100644 src/Persistence/RavenDbTests/all_and_queryable_attributes.cs create mode 100644 src/Persistence/Wolverine.CosmosDb/Internals/QueryableFrame.cs create mode 100644 src/Persistence/Wolverine.EntityFrameworkCore/Codegen/AllFrame.cs create mode 100644 src/Persistence/Wolverine.EntityFrameworkCore/Codegen/QueryableFrame.cs create mode 100644 src/Persistence/Wolverine.Fisher/Codegen/AllFrame.cs create mode 100644 src/Persistence/Wolverine.Fisher/Codegen/QueryableFrame.cs create mode 100644 src/Persistence/Wolverine.Marten/Codegen/AllFrame.cs create mode 100644 src/Persistence/Wolverine.Marten/Codegen/QueryableFrame.cs create mode 100644 src/Persistence/Wolverine.Polecat/Codegen/AllFrame.cs create mode 100644 src/Persistence/Wolverine.Polecat/Codegen/QueryableFrame.cs create mode 100644 src/Persistence/Wolverine.RavenDb/Internals/AllFrame.cs create mode 100644 src/Persistence/Wolverine.RavenDb/Internals/QueryableFrame.cs create mode 100644 src/Testing/CoreTests/Persistence/all_and_queryable_validation.cs create mode 100644 src/Wolverine/Persistence/AllAttribute.cs create mode 100644 src/Wolverine/Persistence/QueryableAttribute.cs diff --git a/docs/guide/handlers/persistence.md b/docs/guide/handlers/persistence.md index a840a5cc4..6b5df7bbb 100644 --- a/docs/guide/handlers/persistence.md +++ b/docs/guide/handlers/persistence.md @@ -20,6 +20,9 @@ These all speak one vocabulary, and none of it names your database: | An event sourced model spanning several streams, matched by tag | `[DcbModel]` | | To write a document back | `Storage.Store` / `Insert` / `Update` / `Delete` / `Nothing` | | To append events to a stream | [`Storage.AppendEvents` / `Storage.StartStream`](/guide/handlers/side-effects#event-side-effects) | +| Every document of a type | [`[All]`](#reading-every-document-of-a-type) | +| The store's raw `IQueryable` | [`[Queryable]`](#the-raw-iqueryable-escape-hatch) | +| The event store's write API | [`IEventStoreOperations`](#injecting-the-event-store-operations) | ## Automatically Loading Entities to Method Parameters @@ -259,6 +262,99 @@ written throws rather than returning nothing. That applies to any Fisher query, but it is worth knowing if a brand new deployment hits a `[FirstOrDefault]` before anything is stored. ::: +## Reading Every Document of a Type + +Where [`[FirstOrDefault]`](#reading-the-first-of-a-type) gives you one, `[All]` gives you all of them — +the equivalent of `await session.Query().ToListAsync()`, resolved through whichever provider owns the +type: + +```cs +[WolverineGet("/api/alerts/config/services")] +public static IReadOnlyList GetAll([All] IReadOnlyList overrides) + => overrides; +``` + +* The parameter **must** be declared as `IReadOnlyList`. Anything else fails with a message naming the + parameter and what to change it to. That is the shape Marten and RavenDb return from `ToListAsync()` + natively, and EF Core's `List` converts to it implicitly, so every provider assigns straight across + with no copying. +* An empty table yields an empty list, never `null` — so there is no "missing" case and no `OnMissing`. +* The query is unfiltered. This is aimed at **small reference and configuration collections**; reading an + entire table into memory is a decision, not a default. +* Supported by Marten, Polecat, Fisher, RavenDb and EF Core. **CosmosDb is not supported**, for the same + reason `[FirstOrDefault]` is not — see that section's warning. + +## The Raw `IQueryable` Escape Hatch + +`[Queryable]` injects the persistence mechanism's own `IQueryable` — Marten's `session.Query()`, +EF Core's `dbContext.Set()`, and so on — into a message handler, HTTP endpoint, or middleware method: + +```cs +[WolverineGet("/api/alerts/recent")] +public static async Task> GetRecent( + [Queryable] IQueryable alerts, CancellationToken token) +{ + return await alerts + .Where(x => x.Level == "high") + .OrderByDescending(x => x.RaisedAt) + .Take(20) + .ToListAsync(token); +} +``` + +::: danger Read this before using `[Queryable]` +This is the escape hatch, and it is a sharp one. Every other attribute on this page describes *what* you +want and leaves the store to satisfy it. This one hands you a provider-specific LINQ implementation. + +**It is not portable in practice, even though the type is.** Marten, EF Core, RavenDb and CosmosDb LINQ +providers support very different subsets of LINQ. A query that compiles and runs correctly on one can +throw at *runtime* on another. The concrete example that will catch you: **Marten 9 refuses synchronous +LINQ execution outright**, so + +```cs +var names = alerts.Where(x => x.Level == "high").ToArray(); // compiles everywhere +``` + +works on EF Core and throws `NotSupportedException: As of Marten 9.0, only asynchronous data access is +supported` on Marten. **Always use the async LINQ operators** — `ToListAsync()`, `FirstOrDefaultAsync()`, +`CountAsync()` — and pass the `CancellationToken`. + +**It reintroduces the coupling everything else here exists to remove**, and makes the method meaningfully +harder to unit test — you can no longer hand it a list. + +**An unbounded query is easy to write by accident.** There is no paging, no limit, and no guard. + +**On CosmosDb especially:** Wolverine stores every user document in one shared container with no per-type +discriminator, so an unfiltered queryable can surface documents of entirely other types deserialized as +`T`. Filter on a discriminator of your own. +::: + +Prefer `[All]` for a whole small collection, `[Entity]` for a single entity by identity, or a compiled +query / `[FromQuerySpecification]` for anything filtered that you want to stay testable and portable. + +## Injecting the Event Store Operations + +A handler, HTTP endpoint, or middleware method can take `JasperFx.Events.IEventStoreOperations` (or the +narrower write-only `IEventOperations`) directly as a parameter, and it resolves to the current session's +`Events` on Marten, Polecat and Fisher alike: + +```cs +public static void Handle(RecordLedgerEntry command, IEventStoreOperations events) +{ + events.StartStream(command.Id, new LedgerEntryRecorded(command.Note)); +} +``` + +Because it is the *current session's* operations, the appended events commit with the rest of the +handler's work through the outbox — no `[Transactional]` needed. A handler marked +`[Storage(typeof(IMyStore))]` gets that ancillary store's session instead. + +::: tip +Returning [`Storage.AppendEvents()` / `Storage.StartStream()`](/guide/handlers/side-effects#event-side-effects) +is the lower ceremony option and keeps the handler a pure function. Reach for the injected operations when +you need something those two do not express. +::: + ## Event Sourced Models `[Entity]` resolves a *document* from whatever persistence your application configured. Its diff --git a/src/Http/Wolverine.Http.Tests/event_store_operations_endpoint_parameter.cs b/src/Http/Wolverine.Http.Tests/event_store_operations_endpoint_parameter.cs new file mode 100644 index 000000000..99a9b4df2 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/event_store_operations_endpoint_parameter.cs @@ -0,0 +1,86 @@ +using Alba; +using IntegrationTests; +using JasperFx.Events; +using Marten; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine.Marten; + +namespace Wolverine.Http.Tests; + +// The handler side of this is covered in MartenTests/event_store_operations_parameter. This is the HTTP +// half, because HTTP chains reach AutoApplyTransactions through HttpGraph applying the shared +// IChainPolicy list -- the same CanApply, but worth proving rather than reasoning about, since the whole +// bug being fixed here is that CanApply did not recognize the event operations types and the append then +// vanished with no error. +public class event_store_operations_endpoint_parameter : IAsyncLifetime +{ + private IAlbaHost theHost = null!; + + public async ValueTask InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + + builder.Services.AddMarten(opts => + { + opts.Connection(Servers.PostgresConnectionString); + opts.DatabaseSchemaName = "event_store_ops_endpoint"; + }).IntegrateWithWolverine().UseLightweightSessions(); + + builder.Host.UseWolverine(opts => + { + opts.Discovery.IncludeAssembly(GetType().Assembly); + opts.Policies.AutoApplyTransactions(); + }); + + builder.Services.AddWolverineHttp(); + + theHost = await AlbaHost.For(builder, app => + { + app.UseDeveloperExceptionPage(); + app.MapWolverineEndpoints(); + }); + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + if (theHost != null) + { + await theHost.StopAsync(); + theHost.Dispose(); + } + } + + [Fact] + public async Task the_endpoint_parameter_is_the_current_sessions_events() + { + var id = Guid.NewGuid(); + + await theHost.Scenario(x => + { + x.Post.Url($"/api/ledger/{id}/opened"); + x.StatusCodeShouldBe(204); + }); + + var store = theHost.Services.GetRequiredService(); + await using var session = store.LightweightSession(); + var events = await session.Events.FetchStreamAsync(id, token: TestContext.Current.CancellationToken); + + events.Count.ShouldBe(1); + events[0].Data.ShouldBeOfType().Note.ShouldBe("opened"); + } +} + +public record EndpointLedgerOpened(string Note); + +public static class LedgerEndpoint +{ + // Takes the shared JasperFx contract directly -- valid on Marten, Polecat and Fisher alike + [WolverinePost("/api/ledger/{id}/opened"), EmptyResponse] + public static void Open(Guid id, IEventStoreOperations events) + { + events.StartStream(id, new EndpointLedgerOpened("opened")); + } +} diff --git a/src/Persistence/CosmosDbTests/queryable_attribute.cs b/src/Persistence/CosmosDbTests/queryable_attribute.cs new file mode 100644 index 000000000..2f3fd5697 --- /dev/null +++ b/src/Persistence/CosmosDbTests/queryable_attribute.cs @@ -0,0 +1,117 @@ +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.CosmosDb; +using Wolverine.Persistence; +using Wolverine.Tracking; + +namespace CosmosDbTests; + +/// +/// CosmosDb supports [Queryable] but deliberately NOT [All] or [FirstOrDefault]. +/// +/// +/// Wolverine's CosmosDb integration writes every user document into one shared wolverine container +/// alongside its own envelopes and node records, with no per-type discriminator on user documents. So +/// "every document of type T" cannot be asked for safely, which is why [All] refuses the provider +/// outright. [Queryable] hands you the container's own queryable and leaves the filtering to you — +/// which is exactly why the query below filters on a discriminating property of its own rather than +/// trusting the container to hold only CosmosWidget documents. +/// +/// This suite only runs on CI. +/// +[Collection("cosmosdb")] +public class queryable_attribute : IAsyncLifetime +{ + private readonly AppFixture _fixture; + private IHost _host = null!; + + public queryable_attribute(AppFixture fixture) + { + _fixture = fixture; + } + + public async ValueTask InitializeAsync() + { + await _fixture.ClearAll(); + + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(CosmosWidgetHandler)); + opts.Durability.Mode = DurabilityMode.Solo; + opts.UseCosmosDbPersistence(AppFixture.DatabaseName); + opts.Services.AddSingleton(_fixture.Client); + }).StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + [Fact] + public async Task queryable_can_be_composed_against() + { + var container = _host.Services.GetRequiredService(); + foreach (var (name, hits) in new[] { ("red", 5), ("green", 12), ("blue", 3) }) + { + await container.UpsertItemAsync(new CosmosWidget + { + id = Guid.NewGuid().ToString(), docType = "widget", Name = name, Hits = hits + }, cancellationToken: TestContext.Current.CancellationToken); + } + + var tracked = await _host.InvokeMessageAndWaitAsync(new FindPopularCosmosWidgets(4)); + + tracked.Sent.SingleMessage().Names.ShouldBe(["green", "red"]); + } +} + +public class CosmosWidget +{ + public string id { get; set; } = null!; + + // The shared container holds Wolverine's own documents too, so user documents that intend to be queried + // as a set need a discriminator of their own. See the class remarks. + public string docType { get; set; } = null!; + + public string Name { get; set; } = null!; + public int Hits { get; set; } +} + +public record FindPopularCosmosWidgets(int Minimum); + +public record PopularCosmosWidgetsFound(string[] Names); + +[WolverineIgnore] +public static class CosmosWidgetHandler +{ + public static async Task Handle(FindPopularCosmosWidgets command, + [Queryable] IQueryable widgets, CancellationToken token) + { + // docType filter is NOT optional on Cosmos -- the container is shared + using var iterator = widgets + .Where(x => x.docType == "widget" && x.Hits >= command.Minimum) + .OrderByDescending(x => x.Hits) + .ToFeedIterator(); + + var names = new List(); + while (iterator.HasMoreResults) + { + foreach (var widget in await iterator.ReadNextAsync(token)) + { + names.Add(widget.Name); + } + } + + return new PopularCosmosWidgetsFound(names.ToArray()); + } + + public static void Handle(PopularCosmosWidgetsFound msg) { } +} diff --git a/src/Persistence/EfCoreTests/all_and_queryable_attributes.cs b/src/Persistence/EfCoreTests/all_and_queryable_attributes.cs new file mode 100644 index 000000000..7d7613af4 --- /dev/null +++ b/src/Persistence/EfCoreTests/all_and_queryable_attributes.cs @@ -0,0 +1,144 @@ +using IntegrationTests; +using JasperFx; +using JasperFx.Resources; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.EntityFrameworkCore; +using Wolverine.Persistence; +using Wolverine.SqlServer; +using Wolverine.Tracking; +using Xunit; + +namespace EfCoreTests; + +// The EF Core proof for [All] and [Queryable]. No IEventStoreOperations coverage here -- EF Core is not an +// event store, and its provider deliberately does not implement that seam. +[Collection("sqlserver")] +public class all_and_queryable_attributes : IAsyncLifetime +{ + private IHost _host = null!; + + public async ValueTask InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(EfWidgetHandler)); + + opts.Services.AddDbContextWithWolverineIntegration(o => + { + o.UseSqlServer(Servers.SqlServerConnectionString); + }); + + opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "all_queryable"); + 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.Widgets.RemoveRange(db.Widgets); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + private async Task seed() + { + using var scope = _host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Widgets.AddRangeAsync( + [ + new EfWidget { Id = Guid.NewGuid(), Name = "red", Hits = 5 }, + new EfWidget { Id = Guid.NewGuid(), Name = "green", Hits = 12 }, + new EfWidget { Id = Guid.NewGuid(), Name = "blue", Hits = 3 } + ], TestContext.Current.CancellationToken); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task all_gives_an_empty_list_when_nothing_is_stored() + { + var tracked = await _host.InvokeMessageAndWaitAsync(new CountEfWidgets()); + tracked.Sent.SingleMessage().Count.ShouldBe(0); + } + + [Fact] + public async Task all_supplies_every_row() + { + await seed(); + var tracked = await _host.InvokeMessageAndWaitAsync(new CountEfWidgets()); + tracked.Sent.SingleMessage().Count.ShouldBe(3); + } + + [Fact] + public async Task queryable_can_be_composed_against() + { + await seed(); + var tracked = await _host.InvokeMessageAndWaitAsync(new FindPopularEfWidgets(4)); + tracked.Sent.SingleMessage().Names.ShouldBe(["green", "red"]); + } +} + +public class EfWidget +{ + public Guid Id { get; set; } + public string Name { get; set; } = null!; + public int Hits { get; set; } +} + +public class EfWidgetCatalogDbContext : DbContext +{ + public EfWidgetCatalogDbContext(DbContextOptions options) : base(options) { } + + public DbSet Widgets { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.MapWolverineEnvelopeStorage(); + modelBuilder.Entity(map => + { + map.ToTable("ef_widgets"); + map.HasKey(x => x.Id); + map.Property(x => x.Name); + map.Property(x => x.Hits); + }); + } +} + +public record CountEfWidgets; +public record FindPopularEfWidgets(int Minimum); +public record EfWidgetsCounted(int Count); +public record PopularEfWidgetsFound(string[] Names); + +[WolverineIgnore] +public static class EfWidgetHandler +{ + public static EfWidgetsCounted Handle(CountEfWidgets command, [All] IReadOnlyList widgets) + => new(widgets.Count); + + // Async LINQ only, per the [Queryable] guidance -- EF Core would tolerate the sync form, Marten would not + public static async Task Handle(FindPopularEfWidgets command, + [Queryable] IQueryable widgets, CancellationToken token) + { + var names = await widgets.Where(x => x.Hits >= command.Minimum) + .OrderByDescending(x => x.Hits) + .Select(x => x.Name) + .ToListAsync(token); + + return new PopularEfWidgetsFound(names.ToArray()); + } + + public static void Handle(EfWidgetsCounted msg) { } + public static void Handle(PopularEfWidgetsFound msg) { } +} diff --git a/src/Persistence/FisherTests/all_queryable_and_event_store_operations.cs b/src/Persistence/FisherTests/all_queryable_and_event_store_operations.cs new file mode 100644 index 000000000..99f3e0e3b --- /dev/null +++ b/src/Persistence/FisherTests/all_queryable_and_event_store_operations.cs @@ -0,0 +1,150 @@ +using JasperFx; +using JasperFx.Events; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Fisher; +using Fisher.Linq; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.Persistence; +using Wolverine.Fisher; +using Wolverine.Tracking; + +namespace FisherTests; + +// The Fisher proof for [All], [Queryable] and the IEventStoreOperations parameter. Deliberately one test +// class rather than three: the Fisher suite are balanced by test-CLASS count because the per-class +// Wolverine + Fisher + SQLite fixture cost dominates, so three classes here would cost three bootstraps +// to assert what one can. +public class all_queryable_and_event_store_operations : IAsyncLifetime +{ + private FisherTestDatabase theDatabase = null!; + private IHost _host = null!; + + public async ValueTask InitializeAsync() + { + theDatabase = Servers.CreateDatabase("all_queryable"); + + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(FiCatalogHandler)); + opts.Durability.Mode = DurabilityMode.Solo; + opts.Policies.AutoApplyTransactions(); + 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(); + } + + private async Task seed() + { + await using var session = _host.Services.GetRequiredService().LightweightSession(); + session.Store(new FiWidget { Name = "red", Hits = 5 }); + session.Store(new FiWidget { Name = "green", Hits = 12 }); + session.Store(new FiWidget { Name = "blue", Hits = 3 }); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task all_gives_an_empty_list_when_nothing_is_stored() + { + // Fisher creates a document table lazily on first write, and querying a type that has never been + // written throws "no such table" rather than returning nothing. That is a general Fisher trait, not + // something [All] introduces -- so establish the table then empty it, which is the state an + // application is actually in once it has used the type at all. + await using (var session = _host.Services.GetRequiredService().LightweightSession()) + { + var seed = new FiWidget { Name = "temp", Hits = 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 CountFiWidgets()); + tracked.Sent.SingleMessage().Count.ShouldBe(0); + } + + [Fact] + public async Task all_supplies_every_document() + { + await seed(); + var tracked = await _host.InvokeMessageAndWaitAsync(new CountFiWidgets()); + tracked.Sent.SingleMessage().Count.ShouldBe(3); + } + + [Fact] + public async Task queryable_can_be_composed_against() + { + await seed(); + var tracked = await _host.InvokeMessageAndWaitAsync(new FindPopularFiWidgets(4)); + tracked.Sent.SingleMessage().Names.ShouldBe(["green", "red"]); + } + + [Fact] + public async Task event_store_operations_parameter_is_the_current_sessions_events() + { + var id = Guid.NewGuid(); + + await _host.InvokeMessageAndWaitAsync(new RecordFiLedgerEntry(id, "opening")); + + await using var session = _host.Services.GetRequiredService().LightweightSession(); + var events = await session.Events.FetchStreamAsync(id, token: TestContext.Current.CancellationToken); + + events.Count.ShouldBe(1); + events[0].Data.ShouldBeOfType().Note.ShouldBe("opening"); + } +} + +public class FiWidget +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = null!; + public int Hits { get; set; } +} + +public record CountFiWidgets; +public record FindPopularFiWidgets(int Minimum); +public record FiWidgetsCounted(int Count); +public record PopularFiWidgetsFound(string[] Names); +public record FiLedgerEntryRecorded(string Note); +public record RecordFiLedgerEntry(Guid Id, string Note); + +[WolverineIgnore] +public static class FiCatalogHandler +{ + public static FiWidgetsCounted Handle(CountFiWidgets command, [All] IReadOnlyList widgets) + => new(widgets.Count); + + // Async LINQ only -- see the [Queryable] warnings + public static async Task Handle(FindPopularFiWidgets command, + [Queryable] IQueryable widgets, CancellationToken token) + { + var names = await widgets.Where(x => x.Hits >= command.Minimum) + .OrderByDescending(x => x.Hits) + .Select(x => x.Name) + .ToListAsync(token); + + return new PopularFiWidgetsFound(names.ToArray()); + } + + public static void Handle(RecordFiLedgerEntry command, IEventStoreOperations events) + => events.StartStream(command.Id, new FiLedgerEntryRecorded(command.Note)); + + public static void Handle(FiWidgetsCounted msg) { } + public static void Handle(PopularFiWidgetsFound msg) { } +} diff --git a/src/Persistence/MartenTests/all_and_queryable_attributes.cs b/src/Persistence/MartenTests/all_and_queryable_attributes.cs new file mode 100644 index 000000000..d2b31dcc2 --- /dev/null +++ b/src/Persistence/MartenTests/all_and_queryable_attributes.cs @@ -0,0 +1,122 @@ +using IntegrationTests; +using Marten; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.Marten; +using Wolverine.Persistence; +using Wolverine.Tracking; + +namespace MartenTests; + +// [All] and [Queryable] are storage agnostic in the same way [FirstOrDefault] is -- the handlers below are +// what the Polecat, Fisher and EF Core suites run too. +public class all_and_queryable_attributes : IAsyncLifetime +{ + private IHost _host = null!; + + public async ValueTask InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(ColorHandler)); + opts.Durability.Mode = DurabilityMode.Solo; + opts.Services.AddMarten(m => + { + m.DisableNpgsqlLogging = true; + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "all_and_queryable"; + }).IntegrateWithWolverine().UseLightweightSessions(); + }).StartAsync(); + + await _host.DocumentStore().Advanced.Clean.DeleteDocumentsByTypeAsync(typeof(Color)); + } + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + private Task seed() => _host.DocumentStore().BulkInsertDocumentsAsync( + [ + new Color { Name = "red", Hits = 5 }, + new Color { Name = "green", Hits = 12 }, + new Color { Name = "blue", Hits = 3 } + ], cancellation: TestContext.Current.CancellationToken); + + [Fact] + public async Task all_gives_an_empty_list_rather_than_null_when_nothing_is_stored() + { + var tracked = await _host.InvokeMessageAndWaitAsync(new CountColors()); + + tracked.Sent.SingleMessage().Count.ShouldBe(0); + } + + [Fact] + public async Task all_supplies_every_document() + { + await seed(); + + var tracked = await _host.InvokeMessageAndWaitAsync(new CountColors()); + + tracked.Sent.SingleMessage().Count.ShouldBe(3); + } + + [Fact] + public async Task queryable_can_be_composed_against() + { + await seed(); + + var tracked = await _host.InvokeMessageAndWaitAsync(new FindPopularColors(4)); + + tracked.Sent.SingleMessage() + .Names.ShouldBe(["green", "red"]); + } +} + +public class Color +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = null!; + public int Hits { get; set; } +} + +public record CountColors; + +public record FindPopularColors(int Minimum); + +public record ColorsCounted(int Count); + +public record PopularColorsFound(string[] Names); + +// [WolverineIgnore] so conventional discovery in other hosts in this assembly never picks these up +[WolverineIgnore] +public static class ColorHandler +{ + public static ColorsCounted Handle(CountColors command, [All] IReadOnlyList colors) + => new(colors.Count); + + // The escape hatch: composing directly against the store's own LINQ provider. + // + // Note the Marten.ToListAsync() -- this handler is deliberately NOT portable, and that is the point of + // the warnings on [Queryable]. Marten 9 refuses synchronous LINQ execution outright ("only asynchronous + // data access is supported"), so the obvious .ToArray() that compiles fine and works on EF Core throws + // at RUNTIME here. + public static async Task Handle(FindPopularColors command, + [Queryable] IQueryable colors, CancellationToken token) + { + var names = await colors.Where(x => x.Hits >= command.Minimum) + .OrderByDescending(x => x.Hits) + .Select(x => x.Name) + .ToListAsync(token); + + return new PopularColorsFound(names.ToArray()); + } + + public static void Handle(ColorsCounted msg) { } + + public static void Handle(PopularColorsFound msg) { } +} diff --git a/src/Persistence/MartenTests/event_store_operations_parameter.cs b/src/Persistence/MartenTests/event_store_operations_parameter.cs new file mode 100644 index 000000000..717b05ac4 --- /dev/null +++ b/src/Persistence/MartenTests/event_store_operations_parameter.cs @@ -0,0 +1,81 @@ +using IntegrationTests; +using JasperFx.Events; +using Marten; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.Marten; +using Wolverine.Tracking; + +namespace MartenTests; + +/// +/// A handler or HTTP endpoint can take the shared straight as a +/// parameter, on Marten, Polecat and Fisher alike. In every case it resolves to +/// IDocumentSession.Events. +/// +/// +/// The assertion that matters is not "the parameter was non-null" — it is that appending through the +/// parameter lands in the database when the handler's transaction commits. That can only be true if the +/// parameter is the current session's Events rather than some other session's, which is what makes +/// this a real check on the variable source rather than a smoke test. +/// +public class event_store_operations_parameter : IAsyncLifetime +{ + private IHost _host = null!; + + public async ValueTask InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(LedgerHandler)); + opts.Durability.Mode = DurabilityMode.Solo; + opts.Policies.AutoApplyTransactions(); + opts.Services.AddMarten(m => + { + m.DisableNpgsqlLogging = true; + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "event_store_ops_param"; + }).IntegrateWithWolverine().UseLightweightSessions(); + }).StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + [Fact] + public async Task the_parameter_is_the_current_sessions_events() + { + var id = Guid.NewGuid(); + + await _host.InvokeMessageAndWaitAsync(new RecordLedgerEntry(id, "opening")); + + // Committed by the handler's own transaction, which only happens if the parameter was this + // session's Events rather than a detached one + await using var session = _host.DocumentStore().LightweightSession(); + var events = await session.Events.FetchStreamAsync(id, token: TestContext.Current.CancellationToken); + + events.Count.ShouldBe(1); + events[0].Data.ShouldBeOfType().Note.ShouldBe("opening"); + } +} + +public record LedgerEntryRecorded(string Note); + +public record RecordLedgerEntry(Guid Id, string Note); + +[WolverineIgnore] +public static class LedgerHandler +{ + // The shared JasperFx contract, not Marten's own derived IEventStoreOperations -- the same signature + // compiles and runs against Polecat and Fisher + public static void Handle(RecordLedgerEntry command, IEventStoreOperations events) + { + events.StartStream(command.Id, new LedgerEntryRecorded(command.Note)); + } +} diff --git a/src/Persistence/PolecatTests/all_queryable_and_event_store_operations.cs b/src/Persistence/PolecatTests/all_queryable_and_event_store_operations.cs new file mode 100644 index 000000000..57198bdb7 --- /dev/null +++ b/src/Persistence/PolecatTests/all_queryable_and_event_store_operations.cs @@ -0,0 +1,134 @@ +using IntegrationTests; +using JasperFx.Events; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Polecat; +using Polecat.Linq; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.Persistence; +using Wolverine.Polecat; +using Wolverine.Tracking; + +namespace PolecatTests; + +// The Polecat proof for [All], [Queryable] and the IEventStoreOperations parameter. Deliberately one test +// class rather than three: the Polecat CI shards are balanced by test-CLASS count because the per-class +// Wolverine + Polecat + SqlServer fixture cost dominates, so three classes here would cost three bootstraps +// to assert what one can. +public class all_queryable_and_event_store_operations : IAsyncLifetime +{ + private IHost _host = null!; + + public async ValueTask InitializeAsync() + { + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(PcCatalogHandler)); + opts.Durability.Mode = DurabilityMode.Solo; + opts.Policies.AutoApplyTransactions(); + opts.Services.AddPolecat(m => + { + m.ConnectionString = Servers.SqlServerConnectionString; + m.DatabaseSchemaName = "pc_all_queryable"; + }).IntegrateWithWolverine(); + }).StartAsync(); + + var store = (DocumentStore)_host.Services.GetRequiredService(); + await store.Database.ApplyAllConfiguredChangesToDatabaseAsync(); + await store.Advanced.Clean.DeleteAllDocumentsAsync(); + } + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + private async Task seed() + { + await using var session = _host.Services.GetRequiredService().LightweightSession(); + session.Store(new PcWidget { Name = "red", Hits = 5 }); + session.Store(new PcWidget { Name = "green", Hits = 12 }); + session.Store(new PcWidget { Name = "blue", Hits = 3 }); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task all_gives_an_empty_list_when_nothing_is_stored() + { + var tracked = await _host.InvokeMessageAndWaitAsync(new CountPcWidgets()); + tracked.Sent.SingleMessage().Count.ShouldBe(0); + } + + [Fact] + public async Task all_supplies_every_document() + { + await seed(); + var tracked = await _host.InvokeMessageAndWaitAsync(new CountPcWidgets()); + tracked.Sent.SingleMessage().Count.ShouldBe(3); + } + + [Fact] + public async Task queryable_can_be_composed_against() + { + await seed(); + var tracked = await _host.InvokeMessageAndWaitAsync(new FindPopularPcWidgets(4)); + tracked.Sent.SingleMessage().Names.ShouldBe(["green", "red"]); + } + + [Fact] + public async Task event_store_operations_parameter_is_the_current_sessions_events() + { + var id = Guid.NewGuid(); + + await _host.InvokeMessageAndWaitAsync(new RecordPcLedgerEntry(id, "opening")); + + await using var session = _host.Services.GetRequiredService().LightweightSession(); + var events = await session.Events.FetchStreamAsync(id, token: TestContext.Current.CancellationToken); + + events.Count.ShouldBe(1); + events[0].Data.ShouldBeOfType().Note.ShouldBe("opening"); + } +} + +public class PcWidget +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = null!; + public int Hits { get; set; } +} + +public record CountPcWidgets; +public record FindPopularPcWidgets(int Minimum); +public record PcWidgetsCounted(int Count); +public record PopularPcWidgetsFound(string[] Names); +public record PcLedgerEntryRecorded(string Note); +public record RecordPcLedgerEntry(Guid Id, string Note); + +[WolverineIgnore] +public static class PcCatalogHandler +{ + public static PcWidgetsCounted Handle(CountPcWidgets command, [All] IReadOnlyList widgets) + => new(widgets.Count); + + // Async LINQ only -- see the [Queryable] warnings + public static async Task Handle(FindPopularPcWidgets command, + [Queryable] IQueryable widgets, CancellationToken token) + { + var names = await widgets.Where(x => x.Hits >= command.Minimum) + .OrderByDescending(x => x.Hits) + .Select(x => x.Name) + .ToListAsync(token); + + return new PopularPcWidgetsFound(names.ToArray()); + } + + public static void Handle(RecordPcLedgerEntry command, IEventStoreOperations events) + => events.StartStream(command.Id, new PcLedgerEntryRecorded(command.Note)); + + public static void Handle(PcWidgetsCounted msg) { } + public static void Handle(PopularPcWidgetsFound msg) { } +} diff --git a/src/Persistence/RavenDbTests/all_and_queryable_attributes.cs b/src/Persistence/RavenDbTests/all_and_queryable_attributes.cs new file mode 100644 index 000000000..de512835a --- /dev/null +++ b/src/Persistence/RavenDbTests/all_and_queryable_attributes.cs @@ -0,0 +1,127 @@ +using JasperFx.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Raven.Client.Documents; +using Raven.Client.Documents.Linq; +using Shouldly; +using Wolverine; +using Wolverine.Attributes; +using Wolverine.Persistence; +using Wolverine.RavenDb; +using Wolverine.Tracking; + +namespace RavenDbTests; + +// The RavenDb proof for [All] and [Queryable]. RavenDb has no event store integration in Wolverine, so +// there is no IEventStoreOperations coverage here. This suite only runs on CI. +[Collection("raven")] +public class all_and_queryable_attributes : IAsyncLifetime +{ + private readonly DatabaseFixture _fixture; + private IDocumentStore _store = null!; + private IHost _host = null!; + + public all_and_queryable_attributes(DatabaseFixture fixture) + { + _fixture = fixture; + } + + public async ValueTask InitializeAsync() + { + _store = _fixture.StartRavenStore(); + + _host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(RvWidgetHandler)); + opts.Services.AddSingleton(_store); + opts.UseRavenDbPersistence(); + opts.Durability.Mode = DurabilityMode.Solo; + }).StartAsync(); + } + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + private async Task seedAndWait() + { + using (var session = _store.OpenAsyncSession()) + { + await session.StoreAsync(new RvWidget { Name = "red", Hits = 5 }, TestContext.Current.CancellationToken); + await session.StoreAsync(new RvWidget { Name = "green", Hits = 12 }, TestContext.Current.CancellationToken); + await session.StoreAsync(new RvWidget { Name = "blue", Hits = 3 }, TestContext.Current.CancellationToken); + await session.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + // RavenDb indexes asynchronously, so wait for the writes to be queryable rather than racing them + for (var i = 0; i < 20; i++) + { + using var session = _store.OpenAsyncSession(); + var count = await session.Query() + .Customize(x => x.WaitForNonStaleResults()) + .CountAsync(TestContext.Current.CancellationToken); + if (count == 3) return; + await Task.Delay(100.Milliseconds(), TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task all_gives_an_empty_list_when_nothing_is_stored() + { + var tracked = await _host.InvokeMessageAndWaitAsync(new CountRvWidgets()); + tracked.Sent.SingleMessage().Count.ShouldBe(0); + } + + [Fact] + public async Task all_supplies_every_document() + { + await seedAndWait(); + var tracked = await _host.InvokeMessageAndWaitAsync(new CountRvWidgets()); + tracked.Sent.SingleMessage().Count.ShouldBe(3); + } + + [Fact] + public async Task queryable_can_be_composed_against() + { + await seedAndWait(); + var tracked = await _host.InvokeMessageAndWaitAsync(new FindPopularRvWidgets(4)); + tracked.Sent.SingleMessage().Names.ShouldBe(["green", "red"]); + } +} + +public class RvWidget +{ + public string Id { get; set; } = null!; + public string Name { get; set; } = null!; + public int Hits { get; set; } +} + +public record CountRvWidgets; +public record FindPopularRvWidgets(int Minimum); +public record RvWidgetsCounted(int Count); +public record PopularRvWidgetsFound(string[] Names); + +[WolverineIgnore] +public static class RvWidgetHandler +{ + public static RvWidgetsCounted Handle(CountRvWidgets command, [All] IReadOnlyList widgets) + => new(widgets.Count); + + // Async LINQ only, per the [Queryable] guidance + public static async Task Handle(FindPopularRvWidgets command, + [Queryable] IQueryable widgets, CancellationToken token) + { + var names = await widgets.Where(x => x.Hits >= command.Minimum) + .OrderByDescending(x => x.Hits) + .Select(x => x.Name) + .ToListAsync(token); + + return new PopularRvWidgetsFound(names.ToArray()); + } + + public static void Handle(RvWidgetsCounted msg) { } + public static void Handle(PopularRvWidgetsFound msg) { } +} diff --git a/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbPersistenceFrameProvider.cs b/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbPersistenceFrameProvider.cs index b99f6b03a..b85971e7a 100644 --- a/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.CosmosDb/Internals/CosmosDbPersistenceFrameProvider.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JasperFx; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; @@ -65,6 +66,16 @@ public Type DetermineSagaIdType(Type sagaType, IServiceContainer container) return typeof(string); } + public bool TryBuildQueryableFrame(Type elementType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var queryable = new QueryableFrame(elementType); + frame = queryable; + result = queryable.Result; + return true; + } + public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId) { return new LoadDocumentFrame(sagaType, sagaId, PartitionsById(sagaType, container)); diff --git a/src/Persistence/Wolverine.CosmosDb/Internals/QueryableFrame.cs b/src/Persistence/Wolverine.CosmosDb/Internals/QueryableFrame.cs new file mode 100644 index 000000000..8a1ce8975 --- /dev/null +++ b/src/Persistence/Wolverine.CosmosDb/Internals/QueryableFrame.cs @@ -0,0 +1,53 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Microsoft.Azure.Cosmos; +using Microsoft.Azure.Cosmos.Linq; + +namespace Wolverine.CosmosDb.Internals; + +/// +/// Exposes CosmosDb's raw IQueryable<T> for a +/// parameter. +/// +/// +/// Read the caveat. Wolverine's CosmosDb integration writes every user document into a single +/// shared wolverine container -- the same one holding its own envelopes, node records and locks -- +/// with no per-type discriminator on user documents. A queryable obtained here is therefore scoped to that +/// container, not to T, and an unfiltered query can deserialize documents of entirely other types +/// as T. This is the same limitation that makes [FirstOrDefault] and [All] refuse to +/// support CosmosDb at all; [Queryable] supports it because the whole point of the attribute is to +/// hand you the store's own API, but you own the filtering. +/// +internal class QueryableFrame : SyncFrame +{ + private readonly Type _elementType; + private Variable? _container; + + public QueryableFrame(Type elementType) + { + _elementType = elementType; + Result = new Variable(typeof(IQueryable<>).MakeGenericType(elementType), $"queryable_{elementType.Name}", + this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"The raw CosmosDb IQueryable for {_elementType.NameInCode()}"); + writer.WriteComment( + "NOTE: this container is shared across every document type; filter accordingly"); + writer.Write( + $"{Result.VariableType.FullNameInCode()} {Result.Usage} = {_container!.Usage}.{nameof(Container.GetItemLinqQueryable)}<{_elementType.FullNameInCode()}>();"); + + Next?.GenerateCode(method, writer); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _container = chain.FindVariable(typeof(Container)); + yield return _container; + } +} diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/AllFrame.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/AllFrame.cs new file mode 100644 index 000000000..281c4a07a --- /dev/null +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/AllFrame.cs @@ -0,0 +1,53 @@ +using System.Diagnostics.CodeAnalysis; +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>().ToListAsync(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 AllFrame : AsyncFrame +{ + private readonly Type _dbContextType; + private readonly Type _entityType; + private Variable? _context; + private Variable? _cancellation; + + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "MakeGenericType closes IReadOnlyList<>/IQueryable<> over the element type at CODEGEN time only. AOT consumers run pre-generated code in TypeLoadMode.Static, so this never fires in a published app. See the AOT guide.")] + public AllFrame(Type dbContextType, Type entityType) + { + _dbContextType = dbContextType; + _entityType = entityType; + Result = new Variable(typeof(IReadOnlyList<>).MakeGenericType(entityType), $"all_{entityType.Name}", this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"Read every {_entityType.NameInCode()} in the database"); + writer.Write($"var {Result.Usage} = await {typeof(EntityFrameworkQueryableExtensions).FullNameInCode()}.{nameof(EntityFrameworkQueryableExtensions.ToListAsync)}({_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.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs index db46a12d4..9afff61c6 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EFCorePersistenceFrameProvider.cs @@ -128,6 +128,16 @@ public Type DetermineSagaIdType(Type sagaType, IServiceContainer container) $"No known primary key for {sagaType.FullNameInCode()} in DbContext {context}"); } + public bool TryBuildAllFrame(Type entityType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var all = new AllFrame(DetermineDbContextType(entityType, container), entityType); + frame = all; + result = all.Result; + return true; + } + public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container, [NotNullWhen(true)] out Frame? frame, [NotNullWhen(true)] out Variable? result) @@ -139,6 +149,16 @@ public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer conta return true; } + public bool TryBuildQueryableFrame(Type elementType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var queryable = new QueryableFrame(DetermineDbContextType(elementType, container), elementType); + frame = queryable; + result = queryable.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/QueryableFrame.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/QueryableFrame.cs new file mode 100644 index 000000000..cdcc565e3 --- /dev/null +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/QueryableFrame.cs @@ -0,0 +1,46 @@ +using System.Diagnostics.CodeAnalysis; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Microsoft.EntityFrameworkCore; + +namespace Wolverine.EntityFrameworkCore.Codegen; + +/// +/// Exposes EF Core's raw IQueryable<T> -- the DbSet<T> -- for a +/// parameter. +/// +internal class QueryableFrame : SyncFrame +{ + private readonly Type _dbContextType; + private readonly Type _elementType; + private Variable? _context; + + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "MakeGenericType closes IReadOnlyList<>/IQueryable<> over the element type at CODEGEN time only. AOT consumers run pre-generated code in TypeLoadMode.Static, so this never fires in a published app. See the AOT guide.")] + public QueryableFrame(Type dbContextType, Type elementType) + { + _dbContextType = dbContextType; + _elementType = elementType; + Result = new Variable(typeof(IQueryable<>).MakeGenericType(elementType), $"queryable_{elementType.Name}", + this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"The raw EF Core IQueryable for {_elementType.NameInCode()}"); + writer.Write( + $"{Result.VariableType.FullNameInCode()} {Result.Usage} = {_context!.Usage}.{nameof(DbContext.Set)}<{_elementType.FullNameInCode()}>();"); + + Next?.GenerateCode(method, writer); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _context = chain.FindVariable(_dbContextType); + yield return _context; + } +} diff --git a/src/Persistence/Wolverine.Fisher/Codegen/AllFrame.cs b/src/Persistence/Wolverine.Fisher/Codegen/AllFrame.cs new file mode 100644 index 000000000..9dbbf05d7 --- /dev/null +++ b/src/Persistence/Wolverine.Fisher/Codegen/AllFrame.cs @@ -0,0 +1,49 @@ +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>().ToListAsync(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 AllFrame : AsyncFrame +{ + private readonly Type _entityType; + private Variable? _session; + private Variable? _cancellation; + + public AllFrame(Type entityType) + { + _entityType = entityType; + Result = new Variable(typeof(IReadOnlyList<>).MakeGenericType(entityType), $"all_{entityType.Name}", this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"Read every {_entityType.NameInCode()} in the database"); + writer.Write($"var {Result.Usage} = await {typeof(QueryableExtensions).FullNameInCode()}.{nameof(QueryableExtensions.ToListAsync)}({_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/Codegen/QueryableFrame.cs b/src/Persistence/Wolverine.Fisher/Codegen/QueryableFrame.cs new file mode 100644 index 000000000..708554ed3 --- /dev/null +++ b/src/Persistence/Wolverine.Fisher/Codegen/QueryableFrame.cs @@ -0,0 +1,41 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Fisher; + +namespace Wolverine.Fisher.Codegen; + +/// +/// Exposes Fisher's raw IQueryable<T> for a +/// parameter. +/// +internal class QueryableFrame : SyncFrame +{ + private readonly Type _elementType; + private Variable? _source; + + public QueryableFrame(Type elementType) + { + _elementType = elementType; + Result = new Variable(typeof(IQueryable<>).MakeGenericType(elementType), $"queryable_{elementType.Name}", + this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"The raw Fisher IQueryable for {_elementType.NameInCode()}"); + writer.Write( + $"{Result.VariableType.FullNameInCode()} {Result.Usage} = {_source!.Usage}.{nameof(IQuerySession.Query)}<{_elementType.FullNameInCode()}>();"); + + Next?.GenerateCode(method, writer); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _source = chain.FindVariable(typeof(IDocumentSession)); + yield return _source; + } +} diff --git a/src/Persistence/Wolverine.Fisher/Codegen/SessionVariableSource.cs b/src/Persistence/Wolverine.Fisher/Codegen/SessionVariableSource.cs index cb1794f00..79fa60a15 100644 --- a/src/Persistence/Wolverine.Fisher/Codegen/SessionVariableSource.cs +++ b/src/Persistence/Wolverine.Fisher/Codegen/SessionVariableSource.cs @@ -140,3 +140,52 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } } + +/// +/// Supplies the shared contract — the full +/// read + write session-level event API — rather than Fisher's own derived spelling, so a message handler +/// or HTTP endpoint can take it as a parameter and stay valid on Marten, Polecat and Fisher alike. +/// +/// +/// Sibling of , which supplies the narrower write-only +/// IEventOperations. Both resolve to exactly the same thing — session.Events — and both go +/// through IDocumentSession rather than a store, so an ancillary store's [Storage] frame has +/// already swapped the session and these follow it. +/// +internal class SharedEventStoreOperationsSource : IVariableSource +{ + public bool Matches(Type type) + { + return type == typeof(JasperFx.Events.IEventStoreOperations); + } + + public Variable Create(Type type) + { + return new SharedEventStoreOperationsFrame().Variable; + } +} + +internal class SharedEventStoreOperationsFrame : SyncFrame +{ + private Variable _session = null!; + + public SharedEventStoreOperationsFrame() + { + Variable = new Variable(typeof(JasperFx.Events.IEventStoreOperations), this); + } + + public Variable Variable { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write( + $"{typeof(JasperFx.Events.IEventStoreOperations)} {Variable.Usage} = {_session.Usage}.{nameof(IDocumentSession.Events)};"); + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Fisher/FisherIntegration.cs b/src/Persistence/Wolverine.Fisher/FisherIntegration.cs index fd8838bfe..9cfeeb6f3 100644 --- a/src/Persistence/Wolverine.Fisher/FisherIntegration.cs +++ b/src/Persistence/Wolverine.Fisher/FisherIntegration.cs @@ -51,6 +51,7 @@ public void Configure(WolverineOptions options) options.CodeGeneration.Sources.Add(new DocumentOperationsSource()); options.CodeGeneration.Sources.Add(new EventOperationsSource()); options.CodeGeneration.Sources.Add(new SharedEventOperationsSource()); + options.CodeGeneration.Sources.Add(new SharedEventStoreOperationsSource()); options.Policies.Add(); diff --git a/src/Persistence/Wolverine.Fisher/Persistence/Sagas/FisherPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Fisher/Persistence/Sagas/FisherPersistenceFrameProvider.cs index e5357d319..08964523c 100644 --- a/src/Persistence/Wolverine.Fisher/Persistence/Sagas/FisherPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.Fisher/Persistence/Sagas/FisherPersistenceFrameProvider.cs @@ -82,8 +82,18 @@ public bool CanApply(IChain chain, IServiceContainer container) if (ChainHasFisherSessionAttributes(chain)) return true; var serviceDependencies = chain - .ServiceDependencies(container, new[] { typeof(IDocumentSession), typeof(IQuerySession), typeof(IDocumentOperations) }).ToArray(); - return serviceDependencies.Any(x => x == typeof(IDocumentSession) || x == typeof(IDocumentOperations) || x.Closes(typeof(IEventStream<>))); + .ServiceDependencies(container, new[] { typeof(IDocumentSession), typeof(IQuerySession), typeof(IDocumentOperations), typeof(global::JasperFx.Events.IEventOperations), typeof(global::JasperFx.Events.IEventStoreOperations), typeof(global::Fisher.Events.EventOperations) }).ToArray(); + // A handler that takes the event operations straight as a parameter -- the shared + // JasperFx.Events.IEventOperations / IEventStoreOperations, or Fisher's own EventOperations -- is + // unambiguously using this store, but none of those types appeared here, so + // AutoApplyTransactions skipped the chain and nothing was ever committed. Appending + // through the parameter queued into the session's unit of work and then silently + // vanished, with no exception. + return serviceDependencies.Any(x => x == typeof(IDocumentSession) || x == typeof(IDocumentOperations) + || x.Closes(typeof(IEventStream<>)) + || x == typeof(global::JasperFx.Events.IEventOperations) + || x == typeof(global::JasperFx.Events.IEventStoreOperations) + || x == typeof(global::Fisher.Events.EventOperations)); } private static bool ChainHasFisherSessionAttributes(IChain chain) @@ -119,6 +129,16 @@ private static bool IsDocumentExistsAttribute(Attribute attribute) return def == typeof(DocumentExistsAttribute<>) || def == typeof(DocumentDoesNotExistAttribute<>); } + public bool TryBuildAllFrame(Type entityType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var all = new AllFrame(entityType); + frame = all; + result = all.Result; + return true; + } + public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container, [NotNullWhen(true)] out Frame? frame, [NotNullWhen(true)] out Variable? result) @@ -129,6 +149,16 @@ public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer conta return true; } + public bool TryBuildQueryableFrame(Type elementType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var queryable = new QueryableFrame(elementType); + frame = queryable; + result = queryable.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/AllFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/AllFrame.cs new file mode 100644 index 000000000..7f1c2a978 --- /dev/null +++ b/src/Persistence/Wolverine.Marten/Codegen/AllFrame.cs @@ -0,0 +1,48 @@ +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>().ToListAsync(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 AllFrame : AsyncFrame +{ + private readonly Type _entityType; + private Variable? _session; + private Variable? _cancellation; + + public AllFrame(Type entityType) + { + _entityType = entityType; + Result = new Variable(typeof(IReadOnlyList<>).MakeGenericType(entityType), $"all_{entityType.Name}", this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"Read every {_entityType.NameInCode()} in the database"); + writer.Write($"var {Result.Usage} = await {typeof(QueryableExtensions).FullNameInCode()}.{nameof(QueryableExtensions.ToListAsync)}({_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/Codegen/QueryableFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/QueryableFrame.cs new file mode 100644 index 000000000..8d3364092 --- /dev/null +++ b/src/Persistence/Wolverine.Marten/Codegen/QueryableFrame.cs @@ -0,0 +1,41 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Marten; + +namespace Wolverine.Marten.Codegen; + +/// +/// Exposes Marten's raw IQueryable<T> for a +/// parameter. +/// +internal class QueryableFrame : SyncFrame +{ + private readonly Type _elementType; + private Variable? _source; + + public QueryableFrame(Type elementType) + { + _elementType = elementType; + Result = new Variable(typeof(IQueryable<>).MakeGenericType(elementType), $"queryable_{elementType.Name}", + this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"The raw Marten IQueryable for {_elementType.NameInCode()}"); + writer.Write( + $"{Result.VariableType.FullNameInCode()} {Result.Usage} = {_source!.Usage}.{nameof(IQuerySession.Query)}<{_elementType.FullNameInCode()}>();"); + + Next?.GenerateCode(method, writer); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _source = chain.FindVariable(typeof(IQuerySession)); + yield return _source; + } +} diff --git a/src/Persistence/Wolverine.Marten/Codegen/SessionVariableSource.cs b/src/Persistence/Wolverine.Marten/Codegen/SessionVariableSource.cs index 3930a8dd9..4f21200e5 100644 --- a/src/Persistence/Wolverine.Marten/Codegen/SessionVariableSource.cs +++ b/src/Persistence/Wolverine.Marten/Codegen/SessionVariableSource.cs @@ -144,3 +144,52 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } } + +/// +/// Supplies the shared contract — the full +/// read + write session-level event API — rather than Marten's own derived spelling, so a message handler +/// or HTTP endpoint can take it as a parameter and stay valid on Marten, Polecat and Fisher alike. +/// +/// +/// Sibling of , which supplies the narrower write-only +/// IEventOperations. Both resolve to exactly the same thing — session.Events — and both go +/// through IDocumentSession rather than a store, so an ancillary store's [Storage] frame has +/// already swapped the session and these follow it. +/// +internal class SharedEventStoreOperationsSource : IVariableSource +{ + public bool Matches(Type type) + { + return type == typeof(JasperFx.Events.IEventStoreOperations); + } + + public Variable Create(Type type) + { + return new SharedEventStoreOperationsFrame().Variable; + } +} + +internal class SharedEventStoreOperationsFrame : SyncFrame +{ + private Variable _session = null!; + + public SharedEventStoreOperationsFrame() + { + Variable = new Variable(typeof(JasperFx.Events.IEventStoreOperations), this); + } + + public Variable Variable { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write( + $"{typeof(JasperFx.Events.IEventStoreOperations)} {Variable.Usage} = {_session.Usage}.{nameof(IDocumentSession.Events)};"); + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Marten/MartenIntegration.cs b/src/Persistence/Wolverine.Marten/MartenIntegration.cs index 67e128c84..8d37a0742 100644 --- a/src/Persistence/Wolverine.Marten/MartenIntegration.cs +++ b/src/Persistence/Wolverine.Marten/MartenIntegration.cs @@ -74,6 +74,7 @@ public void Configure(WolverineOptions options) options.CodeGeneration.Sources.Add(new DocumentOperationsSource()); options.CodeGeneration.Sources.Add(new EventStoreOperationsSource()); options.CodeGeneration.Sources.Add(new SharedEventOperationsSource()); + options.CodeGeneration.Sources.Add(new SharedEventStoreOperationsSource()); options.Policies.Add(); diff --git a/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs index 79df11881..068a0ece7 100644 --- a/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.Marten/Persistence/Sagas/MartenPersistenceFrameProvider.cs @@ -108,8 +108,18 @@ public bool CanApply(IChain chain, IServiceContainer container) if (ChainHasMartenSessionAttributes(chain)) return true; var serviceDependencies = chain - .ServiceDependencies(container, new []{typeof(IDocumentSession), typeof(IQuerySession), typeof(IDocumentOperations)}).ToArray(); - return serviceDependencies.Any(x => x == typeof(IDocumentSession) || x == typeof(IDocumentOperations) || x.Closes(typeof(IEventStream<>))); + .ServiceDependencies(container, new[] { typeof(IDocumentSession), typeof(IQuerySession), typeof(IDocumentOperations), typeof(global::JasperFx.Events.IEventOperations), typeof(global::JasperFx.Events.IEventStoreOperations), typeof(global::Marten.Events.IEventStoreOperations) }).ToArray(); + // A handler that takes the event operations straight as a parameter -- the shared + // JasperFx.Events.IEventOperations / IEventStoreOperations, or Marten's own IEventStoreOperations -- is + // unambiguously using this store, but none of those types appeared here, so + // AutoApplyTransactions skipped the chain and nothing was ever committed. Appending + // through the parameter queued into the session's unit of work and then silently + // vanished, with no exception. + return serviceDependencies.Any(x => x == typeof(IDocumentSession) || x == typeof(IDocumentOperations) + || x.Closes(typeof(IEventStream<>)) + || x == typeof(global::JasperFx.Events.IEventOperations) + || x == typeof(global::JasperFx.Events.IEventStoreOperations) + || x == typeof(global::Marten.Events.IEventStoreOperations)); } private static bool ChainHasMartenSessionAttributes(IChain chain) @@ -147,6 +157,16 @@ private static bool IsDocumentExistsAttribute(Attribute attribute) return def == typeof(DocumentExistsAttribute<>) || def == typeof(DocumentDoesNotExistAttribute<>); } + public bool TryBuildQueryableFrame(Type elementType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var queryable = new QueryableFrame(elementType); + frame = queryable; + result = queryable.Result; + return true; + } + public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId) { return new LoadDocumentFrame(sagaType, sagaId); @@ -203,6 +223,16 @@ public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity) return [new SetVariableToNullIfSoftDeletedFrame(entity)]; } + public bool TryBuildAllFrame(Type entityType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var all = new AllFrame(entityType); + frame = all; + result = all.Result; + return true; + } + public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container, [NotNullWhen(true)] out Frame? frame, [NotNullWhen(true)] out Variable? result) diff --git a/src/Persistence/Wolverine.Polecat/Codegen/AllFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/AllFrame.cs new file mode 100644 index 000000000..4ed02e707 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/AllFrame.cs @@ -0,0 +1,49 @@ +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>().ToListAsync(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 AllFrame : AsyncFrame +{ + private readonly Type _entityType; + private Variable? _session; + private Variable? _cancellation; + + public AllFrame(Type entityType) + { + _entityType = entityType; + Result = new Variable(typeof(IReadOnlyList<>).MakeGenericType(entityType), $"all_{entityType.Name}", this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"Read every {_entityType.NameInCode()} in the database"); + writer.Write($"var {Result.Usage} = await {typeof(PolecatQueryableExtensions).FullNameInCode()}.{nameof(PolecatQueryableExtensions.ToListAsync)}({_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/Codegen/QueryableFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/QueryableFrame.cs new file mode 100644 index 000000000..b99059154 --- /dev/null +++ b/src/Persistence/Wolverine.Polecat/Codegen/QueryableFrame.cs @@ -0,0 +1,41 @@ +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Frames; +using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; +using Polecat; + +namespace Wolverine.Polecat.Codegen; + +/// +/// Exposes Polecat's raw IQueryable<T> for a +/// parameter. +/// +internal class QueryableFrame : SyncFrame +{ + private readonly Type _elementType; + private Variable? _source; + + public QueryableFrame(Type elementType) + { + _elementType = elementType; + Result = new Variable(typeof(IQueryable<>).MakeGenericType(elementType), $"queryable_{elementType.Name}", + this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"The raw Polecat IQueryable for {_elementType.NameInCode()}"); + writer.Write( + $"{Result.VariableType.FullNameInCode()} {Result.Usage} = {_source!.Usage}.{nameof(IQuerySession.Query)}<{_elementType.FullNameInCode()}>();"); + + Next?.GenerateCode(method, writer); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _source = chain.FindVariable(typeof(IDocumentSession)); + yield return _source; + } +} diff --git a/src/Persistence/Wolverine.Polecat/Codegen/SessionVariableSource.cs b/src/Persistence/Wolverine.Polecat/Codegen/SessionVariableSource.cs index 349f088ef..ef9ab9c21 100644 --- a/src/Persistence/Wolverine.Polecat/Codegen/SessionVariableSource.cs +++ b/src/Persistence/Wolverine.Polecat/Codegen/SessionVariableSource.cs @@ -140,3 +140,52 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } } + +/// +/// Supplies the shared contract — the full +/// read + write session-level event API — rather than Polecat's own derived spelling, so a message handler +/// or HTTP endpoint can take it as a parameter and stay valid on Marten, Polecat and Fisher alike. +/// +/// +/// Sibling of , which supplies the narrower write-only +/// IEventOperations. Both resolve to exactly the same thing — session.Events — and both go +/// through IDocumentSession rather than a store, so an ancillary store's [Storage] frame has +/// already swapped the session and these follow it. +/// +internal class SharedEventStoreOperationsSource : IVariableSource +{ + public bool Matches(Type type) + { + return type == typeof(JasperFx.Events.IEventStoreOperations); + } + + public Variable Create(Type type) + { + return new SharedEventStoreOperationsFrame().Variable; + } +} + +internal class SharedEventStoreOperationsFrame : SyncFrame +{ + private Variable _session = null!; + + public SharedEventStoreOperationsFrame() + { + Variable = new Variable(typeof(JasperFx.Events.IEventStoreOperations), this); + } + + public Variable Variable { get; } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _session = chain.FindVariable(typeof(IDocumentSession)); + yield return _session; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.Write( + $"{typeof(JasperFx.Events.IEventStoreOperations)} {Variable.Usage} = {_session.Usage}.{nameof(IDocumentSession.Events)};"); + Next?.GenerateCode(method, writer); + } +} diff --git a/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs index 9be7ec38a..6e8202c04 100644 --- a/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs +++ b/src/Persistence/Wolverine.Polecat/Persistence/Sagas/PolecatPersistenceFrameProvider.cs @@ -81,8 +81,18 @@ public bool CanApply(IChain chain, IServiceContainer container) if (ChainHasPolecatSessionAttributes(chain)) return true; var serviceDependencies = chain - .ServiceDependencies(container, new[] { typeof(IDocumentSession), typeof(IQuerySession), typeof(IDocumentOperations) }).ToArray(); - return serviceDependencies.Any(x => x == typeof(IDocumentSession) || x == typeof(IDocumentOperations) || x.Closes(typeof(IEventStream<>))); + .ServiceDependencies(container, new[] { typeof(IDocumentSession), typeof(IQuerySession), typeof(IDocumentOperations), typeof(global::JasperFx.Events.IEventOperations), typeof(global::JasperFx.Events.IEventStoreOperations), typeof(global::Polecat.Events.IEventOperations) }).ToArray(); + // A handler that takes the event operations straight as a parameter -- the shared + // JasperFx.Events.IEventOperations / IEventStoreOperations, or Polecat's own IEventOperations -- is + // unambiguously using this store, but none of those types appeared here, so + // AutoApplyTransactions skipped the chain and nothing was ever committed. Appending + // through the parameter queued into the session's unit of work and then silently + // vanished, with no exception. + return serviceDependencies.Any(x => x == typeof(IDocumentSession) || x == typeof(IDocumentOperations) + || x.Closes(typeof(IEventStream<>)) + || x == typeof(global::JasperFx.Events.IEventOperations) + || x == typeof(global::JasperFx.Events.IEventStoreOperations) + || x == typeof(global::Polecat.Events.IEventOperations)); } private static bool ChainHasPolecatSessionAttributes(IChain chain) @@ -118,6 +128,16 @@ private static bool IsDocumentExistsAttribute(Attribute attribute) return def == typeof(DocumentExistsAttribute<>) || def == typeof(DocumentDoesNotExistAttribute<>); } + public bool TryBuildAllFrame(Type entityType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var all = new AllFrame(entityType); + frame = all; + result = all.Result; + return true; + } + public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container, [NotNullWhen(true)] out Frame? frame, [NotNullWhen(true)] out Variable? result) @@ -128,6 +148,16 @@ public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer conta return true; } + public bool TryBuildQueryableFrame(Type elementType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var queryable = new QueryableFrame(elementType); + frame = queryable; + result = queryable.Result; + return true; + } + public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId) { return new LoadDocumentFrame(sagaType, sagaId); diff --git a/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs b/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs index 45dd5898f..b12cdec48 100644 --- a/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs +++ b/src/Persistence/Wolverine.Polecat/PolecatIntegration.cs @@ -56,6 +56,7 @@ public void Configure(WolverineOptions options) options.CodeGeneration.Sources.Add(new DocumentOperationsSource()); options.CodeGeneration.Sources.Add(new EventOperationsSource()); options.CodeGeneration.Sources.Add(new SharedEventOperationsSource()); + options.CodeGeneration.Sources.Add(new SharedEventStoreOperationsSource()); options.Policies.Add(); diff --git a/src/Persistence/Wolverine.RavenDb/Internals/AllFrame.cs b/src/Persistence/Wolverine.RavenDb/Internals/AllFrame.cs new file mode 100644 index 000000000..2cac3e633 --- /dev/null +++ b/src/Persistence/Wolverine.RavenDb/Internals/AllFrame.cs @@ -0,0 +1,53 @@ +using System.Diagnostics.CodeAnalysis; +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>().ToListAsync(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 AllFrame : AsyncFrame +{ + private readonly Type _entityType; + private Variable? _session; + private Variable? _cancellation; + + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "MakeGenericType closes IReadOnlyList<>/IQueryable<> over the element type at CODEGEN time only. AOT consumers run pre-generated code in TypeLoadMode.Static, so this never fires in a published app. See the AOT guide.")] + public AllFrame(Type entityType) + { + _entityType = entityType; + Result = new Variable(typeof(IReadOnlyList<>).MakeGenericType(entityType), $"all_{entityType.Name}", this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"Read every {_entityType.NameInCode()} in the database"); + writer.Write($"var {Result.Usage} = await {typeof(LinqExtensions).FullNameInCode()}.{nameof(LinqExtensions.ToListAsync)}({_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/QueryableFrame.cs b/src/Persistence/Wolverine.RavenDb/Internals/QueryableFrame.cs new file mode 100644 index 000000000..719a4b816 --- /dev/null +++ b/src/Persistence/Wolverine.RavenDb/Internals/QueryableFrame.cs @@ -0,0 +1,45 @@ +using System.Diagnostics.CodeAnalysis; +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; + +/// +/// Exposes RavenDb's raw IQueryable<T> for a +/// parameter. +/// +internal class QueryableFrame : SyncFrame +{ + private readonly Type _elementType; + private Variable? _source; + + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "MakeGenericType closes IReadOnlyList<>/IQueryable<> over the element type at CODEGEN time only. AOT consumers run pre-generated code in TypeLoadMode.Static, so this never fires in a published app. See the AOT guide.")] + public QueryableFrame(Type elementType) + { + _elementType = elementType; + Result = new Variable(typeof(IQueryable<>).MakeGenericType(elementType), $"queryable_{elementType.Name}", + this); + } + + public Variable Result { get; } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment($"The raw RavenDb IQueryable for {_elementType.NameInCode()}"); + writer.Write( + $"{Result.VariableType.FullNameInCode()} {Result.Usage} = {_source!.Usage}.Query<{_elementType.FullNameInCode()}>();"); + + Next?.GenerateCode(method, writer); + } + + public override IEnumerable FindVariables(IMethodVariables chain) + { + _source = chain.FindVariable(typeof(IAsyncDocumentSession)); + yield return _source; + } +} diff --git a/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs b/src/Persistence/Wolverine.RavenDb/Internals/RavenDbPersistenceFrameProvider.cs index 2c892817f..ffe9208a9 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 TryBuildAllFrame(Type entityType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var all = new AllFrame(entityType); + frame = all; + result = all.Result; + return true; + } + public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer container, [NotNullWhen(true)] out Frame? frame, [NotNullWhen(true)] out Variable? result) @@ -79,6 +89,16 @@ public bool TryBuildFirstOrDefaultFrame(Type entityType, IServiceContainer conta return true; } + public bool TryBuildQueryableFrame(Type elementType, IServiceContainer container, + [NotNullWhen(true)] out Frame? frame, + [NotNullWhen(true)] out Variable? result) + { + var queryable = new QueryableFrame(elementType); + frame = queryable; + result = queryable.Result; + return true; + } + public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId) { return new LoadDocumentFrame(sagaType, sagaId); diff --git a/src/Testing/CoreTests/Persistence/all_and_queryable_validation.cs b/src/Testing/CoreTests/Persistence/all_and_queryable_validation.cs new file mode 100644 index 000000000..2225a9fd2 --- /dev/null +++ b/src/Testing/CoreTests/Persistence/all_and_queryable_validation.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine.Attributes; +using Wolverine; +using Wolverine.Persistence; +using Xunit; + +namespace CoreTests.Persistence; + +// [All] and [Queryable] are strict about the parameter type they will accept. The point of these is that the +// message says what was wrong and what to write instead, rather than failing somewhere in codegen. +public class all_and_queryable_validation +{ + // The type check lives in the parameter attribute's Modify(), which runs during code generation for + // the chain rather than at host startup -- the same timing [Entity]'s own errors have. So the message + // surfaces on first invocation, which is what these assert. + private static async Task shouldFail(Type handlerType) + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => opts.Discovery.DisableConventionalDiscovery().IncludeType(handlerType)) + .StartAsync(); + + return await Should.ThrowAsync( + () => host.InvokeAsync(new CountValidationColors())); + } + + [Fact] + public async Task all_rejects_a_bare_ienumerable() + { + var ex = await shouldFail(typeof(AllOnEnumerableHandler)); + + ex.Message.ShouldContain("[All] attribute can only be applied to a parameter of type IReadOnlyList"); + ex.Message.ShouldContain("colors"); // names the parameter + ex.Message.ShouldContain(nameof(AllOnEnumerableHandler)); // names the declaring method + ex.Message.ShouldContain("IReadOnlyList"); // suggests the concrete fix + } + + [Fact] + public async Task all_rejects_a_single_entity() + { + var ex = await shouldFail(typeof(AllOnSingleHandler)); + + ex.Message.ShouldContain("IReadOnlyList"); + } + + [Fact] + public async Task queryable_rejects_a_non_queryable_parameter() + { + var ex = await shouldFail(typeof(QueryableOnListHandler)); + + ex.Message.ShouldContain("[Queryable] attribute can only be applied to a parameter of type IQueryable"); + ex.Message.ShouldContain("colors"); + ex.Message.ShouldContain("IQueryable"); + } +} + +public class ValidationColor +{ + public Guid Id { get; set; } +} + +public record CountValidationColors; + +// [WolverineIgnore] -- these are deliberately invalid and would break every other host in this assembly +[WolverineIgnore] +public static class AllOnEnumerableHandler +{ + public static void Handle(CountValidationColors command, [All] IEnumerable colors) { } +} + +[WolverineIgnore] +public static class AllOnSingleHandler +{ + public static void Handle(CountValidationColors command, [All] ValidationColor colors) { } +} + +[WolverineIgnore] +public static class QueryableOnListHandler +{ + public static void Handle(CountValidationColors command, [Queryable] IReadOnlyList colors) { } +} diff --git a/src/Wolverine/Persistence/AllAttribute.cs b/src/Wolverine/Persistence/AllAttribute.cs new file mode 100644 index 000000000..53b83e06e --- /dev/null +++ b/src/Wolverine/Persistence/AllAttribute.cs @@ -0,0 +1,128 @@ +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 every document of its element type in the +/// configured persistence — the equivalent of await session.Query<T>().ToListAsync(), resolved +/// through whichever persistence provider owns the type. Like , the point +/// is storage agnostic code: the same handler is valid whether the store behind it is Marten, Polecat, Fisher, +/// RavenDb, or EF Core. +/// +/// +/// +/// [WolverineGet("/api/alerts/config/services")] +/// public static IReadOnlyList<ServiceAlertOverrides> GetAll([All] IReadOnlyList<ServiceAlertOverrides> overrides) +/// => overrides; +/// +/// +/// +/// +/// The parameter must be declared as IReadOnlyList<T>. Anything else throws with a message naming +/// the parameter and what it should have been. +/// +/// +/// An empty table yields an empty list, never null, so there is no "missing" case to configure and no +/// Required / OnMissing here. +/// +/// +/// The query is unfiltered on purpose. If you need a predicate, that is what a Before method, a compiled +/// query, or are for. Reading an entire table into memory is also +/// something to do deliberately — this is aimed at small reference and configuration collections. +/// +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class AllAttribute : WolverineParameterAttribute +{ + public AllAttribute() + { + ValueSource = ValueSource.Anything; + } + + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, + GenerationRules rules) + { + var elementType = DetermineElementType(parameter); + + if (!rules.TryFindPersistenceFrameProvider(container, elementType, out var provider)) + { + throw new InvalidOperationException( + $"Could not determine a matching persistence service for [All] parameter '{parameter.Name}' of " + + $"element type {elementType.FullNameInCode()}. Check that the persistence integration for this " + + "type has been registered, i.e. IntegrateWithWolverine() for Marten."); + } + + if (!provider.TryBuildAllFrame(elementType, container, out var frame, out var result)) + { + throw new InvalidOperationException( + $"The {provider.GetType().FullNameInCode()} persistence provider does not support [All], so " + + $"parameter '{parameter.Name}' of element type {elementType.FullNameInCode()} cannot be " + + "resolved. Load the values 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] and [FirstOrDefault] do. + EntityAttribute.StoreDeferredMiddlewareVariable(chain, parameter.Name!, result); + + return result; + } + + /// + /// The element type behind an IReadOnlyList<T> parameter. + /// + /// + /// Deliberately one accepted shape rather than any IEnumerable<T>. IReadOnlyList<T> + /// is what Marten and RavenDb hand back from ToListAsync() natively, and EF Core's + /// List<T> converts to it implicitly, so every provider assigns straight across with no + /// copying. A lazy IEnumerable<T> would also leave the reader guessing whether the query had + /// already run. + /// + internal static Type DetermineElementType(ParameterInfo parameter) + { + var type = parameter.ParameterType; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IReadOnlyList<>)) + { + return type.GetGenericArguments()[0]; + } + + throw new InvalidOperationException( + $"The [All] attribute can only be applied to a parameter of type IReadOnlyList, but " + + $"'{parameter.Name}' on {describeMember(parameter)} is declared as " + + $"{type.FullNameInCode()}. Change it to IReadOnlyList<{elementNameHint(type)}>."); + } + + private static string describeMember(ParameterInfo parameter) + { + var method = parameter.Member; + return $"{method.DeclaringType?.FullNameInCode()}.{method.Name}"; + } + + // Best effort so the message can suggest the concrete fix rather than a bare "List". Deliberately + // avoids walking the interface graph -- that needs a DynamicallyAccessedMembers annotation the caller + // cannot satisfy, and this is only a hint inside an exception message. + private static string elementNameHint(Type type) + { + if (type.IsArray) + { + return type.GetElementType()!.NameInCode(); + } + + if (type.IsGenericType && type.GetGenericArguments().Length == 1) + { + return type.GetGenericArguments()[0].NameInCode(); + } + + return type.NameInCode(); + } +} diff --git a/src/Wolverine/Persistence/IPersistenceFrameProvider.cs b/src/Wolverine/Persistence/IPersistenceFrameProvider.cs index 4caa3397b..e0859c6e6 100644 --- a/src/Wolverine/Persistence/IPersistenceFrameProvider.cs +++ b/src/Wolverine/Persistence/IPersistenceFrameProvider.cs @@ -124,6 +124,58 @@ bool TryBuildFirstOrDefaultFrame( result = null; return false; } + + /// + /// Attempt to build a codegen that executes the equivalent of + /// session.Query<T>().ToListAsync() for against this provider's + /// own session, producing a List<T> as a new variable for downstream frames. + /// + /// + /// Return true if the provider can express an unfiltered "every row of this type" read. The + /// default implementation returns false, which turns into a + /// bootstrapping time error naming the provider rather than silently doing nothing. + /// + /// + /// The element type to read every instance of. + /// Active codegen service container. + /// The built frame, when the provider supports this. + /// The List<T> variable produced by the frame, when built. + bool TryBuildAllFrame( + 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; + } + + /// + /// Attempt to build a codegen that exposes this provider's raw + /// IQueryable<T> for — Marten's session.Query<T>(), + /// EF Core's dbContext.Set<T>(), and so on — as a new variable for the endpoint or handler to + /// compose a query against directly. + /// + /// + /// Return true if the provider can hand out a queryable. The default returns false, which + /// turns into a bootstrapping time error naming the provider. + /// + /// + /// The element type of the queryable. + /// Active codegen service container. + /// The built frame, when the provider supports this. + /// The IQueryable<T> variable produced by the frame, when built. + bool TryBuildQueryableFrame( + Type elementType, + IServiceContainer container, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Frame? frame, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Variable? result) + { + frame = null; + result = null; + return false; + } } diff --git a/src/Wolverine/Persistence/QueryableAttribute.cs b/src/Wolverine/Persistence/QueryableAttribute.cs new file mode 100644 index 000000000..b8615083f --- /dev/null +++ b/src/Wolverine/Persistence/QueryableAttribute.cs @@ -0,0 +1,128 @@ +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; + +/// +/// Injects the persistence mechanism's raw — Marten's +/// session.Query<T>(), EF Core's dbContext.Set<T>(), and so on — into a message +/// handler, HTTP endpoint, or middleware method parameter. +/// +/// +/// +/// [WolverineGet("/api/alerts/recent")] +/// public static Task<List<Alert>> GetRecent([Queryable] IQueryable<Alert> alerts, CancellationToken token) +/// => alerts.Where(x => x.Level == "high").OrderByDescending(x => x.RaisedAt).Take(20).ToListAsync(token); +/// +/// +/// +/// +/// This is the escape hatch, and it is a sharp one. Unlike , +/// and , which describe *what* you want and +/// leave the store to satisfy it, this hands you a provider-specific LINQ implementation. Read the warnings in +/// the documentation before reaching for it: +/// +/// +/// +/// It is not portable in practice. Marten, EF Core, RavenDb and CosmosDb LINQ providers support very +/// different subsets of LINQ; a query that compiles and runs on one can throw at *runtime* on another. The +/// type is storage agnostic, the query you write against it is not. +/// +/// +/// It reintroduces exactly the persistence coupling the other attributes exist to remove, and makes the method +/// meaningfully harder to unit test. +/// +/// +/// An unbounded query is easy to write by accident. There is no paging, no limit, and no guard. +/// +/// +/// On CosmosDb in particular, Wolverine stores every user document in one shared container with no +/// per-type discriminator, so an unfiltered queryable can surface documents of other types entirely. +/// +/// +/// +/// Prefer for a whole small collection, for a single +/// entity by identity, or / a compiled query for anything +/// filtered that you want to stay testable and portable. +/// +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class QueryableAttribute : WolverineParameterAttribute +{ + public QueryableAttribute() + { + ValueSource = ValueSource.Anything; + } + + public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, + GenerationRules rules) + { + var elementType = DetermineElementType(parameter); + + if (!rules.TryFindPersistenceFrameProvider(container, elementType, out var provider)) + { + throw new InvalidOperationException( + $"Could not determine a matching persistence service for [Queryable] parameter " + + $"'{parameter.Name}' of element type {elementType.FullNameInCode()}. Check that the persistence " + + "integration for this type has been registered, i.e. IntegrateWithWolverine() for Marten."); + } + + if (!provider.TryBuildQueryableFrame(elementType, container, out var frame, out var result)) + { + throw new InvalidOperationException( + $"The {provider.GetType().FullNameInCode()} persistence provider does not support [Queryable], " + + $"so parameter '{parameter.Name}' of element type {elementType.FullNameInCode()} cannot be " + + "resolved."); + } + + chain.Middleware.Add(frame); + result.OverrideName(parameter.Name!); + + EntityAttribute.StoreDeferredMiddlewareVariable(chain, parameter.Name!, result); + + return result; + } + + /// + /// The element type behind an IQueryable<T> parameter. + /// + internal static Type DetermineElementType(ParameterInfo parameter) + { + var type = parameter.ParameterType; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IQueryable<>)) + { + return type.GetGenericArguments()[0]; + } + + var member = parameter.Member; + + throw new InvalidOperationException( + $"The [Queryable] attribute can only be applied to a parameter of type IQueryable, but " + + $"'{parameter.Name}' on {member.DeclaringType?.FullNameInCode()}.{member.Name} is declared as " + + $"{type.FullNameInCode()}. Change it to IQueryable<{elementNameHint(type)}>."); + } + + // Mirrors AllAttribute.elementNameHint -- deliberately avoids walking the interface graph, which would + // need a DynamicallyAccessedMembers annotation the caller cannot satisfy for an exception message hint. + private static string elementNameHint(Type type) + { + if (type.IsArray) + { + return type.GetElementType()!.NameInCode(); + } + + if (type.IsGenericType && type.GetGenericArguments().Length == 1) + { + return type.GetGenericArguments()[0].NameInCode(); + } + + return type.NameInCode(); + } +}