Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions docs/guide/handlers/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` |
| 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<T>` | [`[Queryable]`](#the-raw-iqueryable-escape-hatch) |
| The event store's write API | [`IEventStoreOperations`](#injecting-the-event-store-operations) |

## Automatically Loading Entities to Method Parameters <Badge type="tip" text="3.6" />

Expand Down Expand Up @@ -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 <Badge type="tip" text="6.28" />

Where [`[FirstOrDefault]`](#reading-the-first-of-a-type) gives you one, `[All]` gives you all of them —
the equivalent of `await session.Query<T>().ToListAsync()`, resolved through whichever provider owns the
type:

```cs
[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 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<T>` 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 <Badge type="tip" text="6.28" />

`[Queryable]` injects the persistence mechanism's own `IQueryable<T>` — Marten's `session.Query<T>()`,
EF Core's `dbContext.Set<T>()`, and so on — into a message handler, HTTP endpoint, or middleware method:

```cs
[WolverineGet("/api/alerts/recent")]
public static async Task<IReadOnlyList<Alert>> GetRecent(
[Queryable] IQueryable<Alert> 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 <Badge type="tip" text="6.28" />

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 <Badge type="tip" text="6.26" />

`[Entity]` resolves a *document* from whatever persistence your application configured. Its
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IDocumentStore>();
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<EndpointLedgerOpened>().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"));
}
}
117 changes: 117 additions & 0 deletions src/Persistence/CosmosDbTests/queryable_attribute.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// CosmosDb supports <c>[Queryable]</c> but deliberately NOT <c>[All]</c> or <c>[FirstOrDefault]</c>.
/// </summary>
/// <remarks>
/// Wolverine's CosmosDb integration writes every user document into one shared <c>wolverine</c> 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 <c>[All]</c> refuses the provider
/// outright. <c>[Queryable]</c> 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 <c>CosmosWidget</c> documents.
///
/// This suite only runs on CI.
/// </remarks>
[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<Container>();
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<PopularCosmosWidgetsFound>().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<PopularCosmosWidgetsFound> Handle(FindPopularCosmosWidgets command,
[Queryable] IQueryable<CosmosWidget> 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<string>();
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) { }
}
Loading
Loading