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
53 changes: 53 additions & 0 deletions docs/guide/handlers/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,59 @@ public enum ValueSource
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Wolverine/Attributes/ModifyChainAttribute.cs#L18-L57' title='Snippet source file'>snippet source</a> | <a href='#snippet-sample_valuesource' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

## Reading the First of a Type <Badge type="tip" text="6.28" />

`[Entity]` needs an identity to load by, which means it cannot express the *singleton document* — a type
your system stores exactly one of, looked up by nothing at all. That is what `[FirstOrDefault]` is for. It
is the equivalent of `await session.Query<T>().FirstOrDefaultAsync()`, resolved through whichever
persistence provider owns the type:

```cs
[WolverineGet("/api/alerts/config/metrics/defaults")]
public static MetricsAlertDefaults GetMetricsDefaults([FirstOrDefault] MetricsAlertDefaults? defaults)
=> defaults ?? new MetricsAlertDefaults();
```

The point is storage agnostic code: that handler is valid whether the store behind it is Marten, Polecat,
Fisher, RavenDb, or EF Core, and it replaces the hand written version that had to name a session type:

```cs
// What [FirstOrDefault] replaces -- correct, but pinned to Marten
[WolverineGet("/api/alerts/config/metrics/defaults")]
public static async Task<MetricsAlertDefaults> GetMetricsDefaults(IDocumentSession session)
{
var defaults = await session.Query<MetricsAlertDefaults>().FirstOrDefaultAsync();
return defaults ?? new MetricsAlertDefaults();
}
```

Some things to know:

* The parameter is simply `null` when nothing matches, and your handler or endpoint **runs either way**.
Unlike `[Entity]`, there is no `Required` and no `OnMissing` — a miss here is not an error condition
worth a 404, it is an ordinary answer to "is there one of these yet?". Write your own null branch,
usually a `?? new T()` fallback.
* The query is **unfiltered**. If you need a predicate, use a `Before` method, a compiled query, or
`[FromQuerySpecification]`; ordering semantics across five different LINQ providers is not something
this attribute tries to promise.
* Usable in both message handlers and HTTP endpoints, and in `Before` / `Validate` methods.
* Supported by Marten, Polecat, Fisher, RavenDb, and EF Core. **CosmosDb is not supported** — see below.

::: warning
CosmosDb cannot support `[FirstOrDefault]`. Wolverine's CosmosDb integration stores every user document
in a single shared `wolverine` container alongside Wolverine's own envelopes and node records, with no
per-type discriminator on user documents, so there is no way to ask for "the first document of type `T`"
without risking a different type entirely. A `[FirstOrDefault]` parameter on a CosmosDb-persisted type
fails at bootstrapping time with an error naming the provider, rather than returning something wrong at
runtime. Load the value explicitly in a `Before` method instead.
:::

::: tip
On Fisher, a document table is created lazily on first write, and querying a type that has never been
written throws rather than returning nothing. That applies to any Fisher query, not just this attribute,
but it is worth knowing if a brand new deployment hits a `[FirstOrDefault]` before anything is stored.
:::

## Event Sourced Models <Badge type="tip" text="6.26" />

`[Entity]` resolves a *document* from whatever persistence your application configured. Its
Expand Down
125 changes: 125 additions & 0 deletions src/Persistence/EfCoreTests/first_or_default_attribute_usage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using IntegrationTests;
using JasperFx;
using JasperFx.Resources;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SharedPersistenceModels.Items;
using Shouldly;
using Wolverine;
using Wolverine.EntityFrameworkCore;
using Wolverine.Persistence;
using Wolverine.SqlServer;
using Wolverine.Tracking;
using Xunit;

namespace EfCoreTests;

// The EF Core half of the [FirstOrDefault] storage agnostic promise. Same handler shape as the Marten,
// Polecat, Fisher and RavenDb suites -- the only difference is that EF Core resolves through a DbContext
// and Set<T>() rather than a document session and Query<T>().
[Collection("sqlserver")]
public class first_or_default_attribute_usage : IAsyncLifetime
{
private IHost _host = null!;

public async ValueTask InitializeAsync()
{
_host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(EfAlertDefaultsHandler));

opts.Services.AddDbContextWithWolverineIntegration<AlertDefaultsDbContext>(o =>
{
o.UseSqlServer(Servers.SqlServerConnectionString);
});

opts.PersistMessagesWithSqlServer(Servers.SqlServerConnectionString, "first_or_default");
opts.UseEntityFrameworkCoreTransactions();
opts.UseEntityFrameworkCoreWolverineManagedMigrations();
opts.Services.AddResourceSetupOnStartup(StartupAction.ResetState);
}).StartAsync(cancellationToken: TestContext.Current.CancellationToken);

using var scope = _host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AlertDefaultsDbContext>();
await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken);
db.AlertDefaults.RemoveRange(db.AlertDefaults);
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}

public async ValueTask DisposeAsync()
{
await _host.StopAsync();
_host.Dispose();
}

[Fact]
public async Task the_parameter_is_null_when_nothing_is_stored()
{
var tracked = await _host.InvokeMessageAndWaitAsync(new ReadEfAlertDefaults());

tracked.Sent.SingleMessage<EfAlertDefaultsRead>()
.Threshold.ShouldBe(-1);
}

[Fact]
public async Task the_first_row_is_supplied_when_one_exists()
{
using (var scope = _host.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AlertDefaultsDbContext>();
db.AlertDefaults.Add(new EfAlertDefaults { Id = Guid.NewGuid(), Threshold = 42 });
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}

var tracked = await _host.InvokeMessageAndWaitAsync(new ReadEfAlertDefaults());

tracked.Sent.SingleMessage<EfAlertDefaultsRead>()
.Threshold.ShouldBe(42);
}
}

public class EfAlertDefaults
{
public Guid Id { get; set; }
public int Threshold { get; set; }
}

public class AlertDefaultsDbContext : DbContext
{
public AlertDefaultsDbContext(DbContextOptions<AlertDefaultsDbContext> options) : base(options)
{
}

public DbSet<EfAlertDefaults> AlertDefaults { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.MapWolverineEnvelopeStorage();

modelBuilder.Entity<EfAlertDefaults>(map =>
{
map.ToTable("alert_defaults");
map.HasKey(x => x.Id);
map.Property(x => x.Threshold);
});
}
}

public record ReadEfAlertDefaults;

public record EfAlertDefaultsRead(int Threshold);

public static class EfAlertDefaultsHandler
{
public static EfAlertDefaultsRead Handle(ReadEfAlertDefaults command,
[FirstOrDefault] EfAlertDefaults? defaults)
{
return new EfAlertDefaultsRead(defaults?.Threshold ?? -1);
}

public static void Handle(EfAlertDefaultsRead msg)
{
}
}
107 changes: 107 additions & 0 deletions src/Persistence/FisherTests/first_or_default_attribute_usage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Fisher;
using JasperFx;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Wolverine;
using Wolverine.Fisher;
using Wolverine.Persistence;
using Wolverine.Tracking;

namespace FisherTests;

// The Fisher half of the [FirstOrDefault] storage agnostic promise -- the handler below is character for
// character what the Marten, Polecat, RavenDb and EF Core suites run.
public class first_or_default_attribute_usage : IAsyncLifetime
{
private FisherTestDatabase theDatabase = null!;
private IHost _host = null!;

public async ValueTask InitializeAsync()
{
theDatabase = Servers.CreateDatabase("first_or_default");

_host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(FiAlertDefaultsHandler));
opts.Durability.Mode = DurabilityMode.Solo;

opts.Services.AddFisher(m =>
{
m.Connection(theDatabase.ConnectionString);
m.AutoCreateSchemaObjects = AutoCreate.All;
})
.ApplyAllDatabaseChangesOnStartup()
.IntegrateWithWolverine();
}).StartAsync();
}

public async ValueTask DisposeAsync()
{
await _host.StopAsync();
_host.Dispose();
theDatabase.Dispose();
}

[Fact]
public async Task the_parameter_is_null_when_nothing_is_stored()
{
// Fisher creates a document table lazily on first write, and querying a type whose table was never
// created throws "no such table" rather than returning nothing -- the same Fisher characteristic
// storage_attribute_routes_to_fisher_store leans on to assert a negative. So establish the table,
// then empty it, which is the state an application is actually in once it has used the type.
await using (var session = _host.Services.GetRequiredService<IDocumentStore>().LightweightSession())
{
var seed = new FiAlertDefaults { Threshold = 1 };
session.Store(seed);
await session.SaveChangesAsync(TestContext.Current.CancellationToken);

session.Delete(seed);
await session.SaveChangesAsync(TestContext.Current.CancellationToken);
}

var tracked = await _host.InvokeMessageAndWaitAsync(new ReadFiAlertDefaults());

tracked.Sent.SingleMessage<FiAlertDefaultsRead>()
.Threshold.ShouldBe(-1);
}

[Fact]
public async Task the_first_document_is_supplied_when_one_exists()
{
await using (var session = _host.Services.GetRequiredService<IDocumentStore>().LightweightSession())
{
session.Store(new FiAlertDefaults { Threshold = 42 });
await session.SaveChangesAsync(TestContext.Current.CancellationToken);
}

var tracked = await _host.InvokeMessageAndWaitAsync(new ReadFiAlertDefaults());

tracked.Sent.SingleMessage<FiAlertDefaultsRead>()
.Threshold.ShouldBe(42);
}
}

public class FiAlertDefaults
{
public Guid Id { get; set; } = Guid.NewGuid();
public int Threshold { get; set; }
}

public record ReadFiAlertDefaults;

public record FiAlertDefaultsRead(int Threshold);

public static class FiAlertDefaultsHandler
{
public static FiAlertDefaultsRead Handle(ReadFiAlertDefaults command,
[FirstOrDefault] FiAlertDefaults? defaults)
{
return new FiAlertDefaultsRead(defaults?.Threshold ?? -1);
}

public static void Handle(FiAlertDefaultsRead msg)
{
}
}
91 changes: 91 additions & 0 deletions src/Persistence/MartenTests/first_or_default_attribute_usage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using IntegrationTests;
using Marten;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Wolverine;
using Wolverine.Marten;
using Wolverine.Persistence;
using Wolverine.Tracking;

namespace MartenTests;

// [FirstOrDefault] is deliberately storage agnostic -- the same handler is valid on Marten, Polecat, Fisher,
// RavenDb or EF Core. This is the Marten proof; the sibling suites cover the others.
public class first_or_default_attribute_usage : IAsyncLifetime
{
private IHost _host = null!;

public async ValueTask InitializeAsync()
{
_host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(AlertDefaultsHandler));
opts.Durability.Mode = DurabilityMode.Solo;
opts.Services.AddMarten(m =>
{
m.DisableNpgsqlLogging = true;
m.Connection(Servers.PostgresConnectionString);
m.DatabaseSchemaName = "first_or_default";
}).IntegrateWithWolverine().UseLightweightSessions();
}).StartAsync();

// Each test decides what is in the table, so start from empty every time
await _host.DocumentStore().Advanced.Clean.DeleteDocumentsByTypeAsync(typeof(AlertDefaults));
}

public async ValueTask DisposeAsync()
{
await _host.StopAsync();
_host.Dispose();
}

[Fact]
public async Task the_parameter_is_null_when_nothing_is_stored()
{
// The whole point of the "always optional" design: the handler still runs, and writes its own
// fallback. No 404, no exception, no silently skipped handler.
var tracked = await _host.InvokeMessageAndWaitAsync(new ReadAlertDefaults());

tracked.Sent.SingleMessage<AlertDefaultsRead>()
.Threshold.ShouldBe(-1);
}

[Fact]
public async Task the_first_document_is_supplied_when_one_exists()
{
await _host.DocumentStore().BulkInsertDocumentsAsync([new AlertDefaults { Threshold = 42 }],
cancellation: TestContext.Current.CancellationToken);

var tracked = await _host.InvokeMessageAndWaitAsync(new ReadAlertDefaults());

tracked.Sent.SingleMessage<AlertDefaultsRead>()
.Threshold.ShouldBe(42);
}
}

public class AlertDefaults
{
public Guid Id { get; set; } = Guid.NewGuid();
public int Threshold { get; set; }
}

public record ReadAlertDefaults;

public record AlertDefaultsRead(int Threshold);

public static class AlertDefaultsHandler
{
// No identity anywhere in the message -- this is exactly the singleton configuration shape that
// [Entity] cannot express, because [Entity] requires an identity value to load by.
public static AlertDefaultsRead Handle(ReadAlertDefaults command, [FirstOrDefault] AlertDefaults? defaults)
{
return new AlertDefaultsRead(defaults?.Threshold ?? -1);
}

// Gives the cascaded message a local route so the tracked session records it as Sent rather than
// dropping it with NoRoutes
public static void Handle(AlertDefaultsRead msg)
{
}
}
Loading
Loading