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
12 changes: 12 additions & 0 deletions docs/guide/durability/efcore/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ builder.UseWolverine(opts =>

Right now, we've tested Wolverine with EF Core using both [SQL Server](/guide/durability/sqlserver) and [PostgreSQL](/guide/durability/postgresql) persistence.

## Combining EF Core with Marten <Badge type="tip" text="6.18" />

It's completely valid to use both the EF Core and [Marten](/guide/durability/marten) integrations in
the same application — say, Marten for event sourcing and EF Core for flat relational tables. When
Wolverine needs a persistence provider for an entity (for [storage side effects](/guide/handlers/side-effects),
`[Entity]` loading, or [saga](/guide/durability/sagas.html) persistence), the EF Core integration is
always consulted **before** Marten, no matter which integration was registered first in your
`Program.cs`: an entity mapped in one of your registered `DbContext` models deterministically
resolves to EF Core, and every other document falls through to Marten. Marten can genuinely persist
*any* document, so it acts as the catch-all; EF Core only claims the types its `DbContext` models
actually map.

## Development-time usage <Badge type="tip" text="5.32" />

Wolverine + EF Core is designed to keep the dev loop short: fast schema iteration, cheap per-test database resets, declarative seed data. The three pillars below all work together — and all come for free the moment you call `UseEntityFrameworkCoreTransactions()`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using IntegrationTests;
using JasperFx;
using JasperFx.Resources;
using Marten;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Wolverine;
using Wolverine.EntityFrameworkCore;
using Wolverine.EntityFrameworkCore.Codegen;
using Wolverine.Marten;
using Wolverine.Marten.Persistence.Sagas;
using Wolverine.Persistence.Sagas;
using Wolverine.Postgresql;
using Wolverine.Runtime;
using Xunit;

namespace PersistenceTests;

// GH-3359: in a mixed Marten + EF Core application, an entity mapped in a registered
// DbContext must resolve to the EF Core persistence provider no matter which integration
// the application registered first. MartenPersistenceFrameProvider.CanPersist claims every
// type (Marten genuinely can persist any document), so before the IsCatchAll ordering rule
// the winner was whichever integration happened to register last (InsertFirstPersistenceStrategy
// puts the last-applied extension at index 0). Both permutations below must behave identically.
public class persistence_provider_precedence_permutations
{
// Deliberately NOT mapped in SampleDbContext, so only Marten can claim it
public class MartenOnlyDocument
{
public Guid Id { get; set; }
}

private static async Task assertResolutionIsOrderIndependent(IHost host)
{
var runtime = host.Services.GetRequiredService<IWolverineRuntime>();
var rules = runtime.Options.CodeGeneration;
var container = host.Services.GetRequiredService<IServiceContainer>();

// Item is mapped in SampleDbContext, so the selective EF Core provider owns it
rules.TryFindPersistenceFrameProvider(container, typeof(Item), out var forItem)
.ShouldBeTrue();
forItem.ShouldBeOfType<EFCorePersistenceFrameProvider>();

// Anything the DbContext model does not map still falls through to Marten
rules.TryFindPersistenceFrameProvider(container, typeof(MartenOnlyDocument), out var forDocument)
.ShouldBeTrue();
forDocument.ShouldBeOfType<MartenPersistenceFrameProvider>();

await host.StopAsync();
}

private static IHostBuilder builderWith(Action<WolverineOptions> registrations)
{
return Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.Durability.Mode = DurabilityMode.Solo;
opts.Durability.DurabilityAgentEnabled = false;
opts.Discovery.DisableConventionalDiscovery();

registrations(opts);

opts.Services.AddResourceSetupOnStartup();
});
}

[Fact]
public async Task efcore_owns_its_mapped_entity_when_marten_registers_last()
{
// Marten last = Marten's extension applies last = MartenPersistenceFrameProvider at
// index 0. This is the registration order that used to hand EF-mapped entities to Marten.
using var host = await builderWith(opts =>
{
opts.Services.AddDbContextWithWolverineIntegration<SampleDbContext>(x =>
x.UseSqlServer(Servers.SqlServerConnectionString));

opts.Services.AddMarten(m =>
{
m.Connection(Servers.PostgresConnectionString);
m.DatabaseSchemaName = "provider_precedence";
})
.IntegrateWithWolverine(x => x.MessageStorageSchemaName = "provider_precedence");
}).StartAsync();

await assertResolutionIsOrderIndependent(host);
}

[Fact]
public async Task efcore_owns_its_mapped_entity_when_marten_registers_first()
{
using var host = await builderWith(opts =>
{
opts.Services.AddMarten(m =>
{
m.Connection(Servers.PostgresConnectionString);
m.DatabaseSchemaName = "provider_precedence";
})
.IntegrateWithWolverine(x => x.MessageStorageSchemaName = "provider_precedence");

opts.Services.AddDbContextWithWolverineIntegration<SampleDbContext>(x =>
x.UseSqlServer(Servers.SqlServerConnectionString));
}).StartAsync();

await assertResolutionIsOrderIndependent(host);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public bool CanApply(IChain chain, IServiceContainer container)
return serviceDependencies.Any(x => x == typeof(Container));
}

// CosmosDb can persist any document, so CanPersist claims every type. Yield to selective
// providers (EF Core) for the entity types they actually map, regardless of the order the
// integrations were registered in
public bool IsCatchAll => true;

public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService)
{
persistenceService = typeof(Container);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,14 @@ public static IServiceCollection AddDbContextWithWolverineManagedMultiTenancyByD
b.ReplaceService<IModelCustomizer, WolverineModelCustomizer>();
}, ServiceLifetime.Scoped, ServiceLifetime.Singleton);

services.TryAddSingleton<IWolverineExtension, EntityFrameworkCoreBackedPersistence>();
// TryAddEnumerable, NOT TryAddSingleton: TryAddSingleton gates on the service type alone,
// so any other integration that had already registered an IWolverineExtension (e.g. Marten's
// IntegrateWithWolverine) would silently swallow this registration and the EF Core codegen
// integration - persistence provider, batching, query-spec policies - would never apply.
// TryAddEnumerable dedupes on the (service, implementation) pair, which is exactly the
// idempotency wanted across repeated AddDbContextWithWolverineIntegration calls. See GH-3359.
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IWolverineExtension, EntityFrameworkCoreBackedPersistence>());

services.TryAddScoped(typeof(IDbContextOutbox<>), typeof(DbContextOutbox<>));
services.TryAddScoped<IDbContextOutbox, DbContextOutbox>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ namespace Wolverine.Marten.Persistence.Sagas;

internal class MartenPersistenceFrameProvider : IPersistenceFrameProvider
{
// Marten can persist any document, so CanPersist claims every type. Yield to selective
// providers (EF Core) for the entity types they actually map, regardless of the order the
// integrations were registered in
public bool IsCatchAll => true;

public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService)
{
persistenceService = typeof(IDocumentSession);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ namespace Wolverine.Polecat.Persistence.Sagas;

internal class PolecatPersistenceFrameProvider : IPersistenceFrameProvider
{
// Polecat can persist any document, so CanPersist claims every type. Yield to selective
// providers (EF Core) for the entity types they actually map, regardless of the order the
// integrations were registered in
public bool IsCatchAll => true;

public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService)
{
persistenceService = typeof(IDocumentSession);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public bool CanApply(IChain chain, IServiceContainer container)
return serviceDependencies.Any(x => x == typeof(IAsyncDocumentSession));
}

// RavenDb can persist any document, so CanPersist claims every type. Yield to selective
// providers (EF Core) for the entity types they actually map, regardless of the order the
// integrations were registered in
public bool IsCatchAll => true;

public bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService)
{
persistenceService = typeof(IAsyncDocumentSession);
Expand Down
180 changes: 180 additions & 0 deletions src/Testing/CoreTests/Persistence/persistence_provider_precedence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using JasperFx;
using JasperFx.CodeGeneration;
using JasperFx.CodeGeneration.Frames;
using JasperFx.CodeGeneration.Model;
using Shouldly;
using Wolverine.Configuration;
using Wolverine.Persistence;
using Wolverine.Persistence.Sagas;
using Xunit;

namespace CoreTests.Persistence;

// Unit tests for the GH-3359 consultation-order rule: selective persistence providers
// (whose CanPersist checks the entity type against their own model, like EF Core) are
// consulted ahead of catch-all document stores (whose CanPersist claims any type, like
// Marten) regardless of the order the integrations were registered in. Within each
// group, registration order is preserved.
public class persistence_provider_precedence
{
private static GenerationRules rulesWith(params IPersistenceFrameProvider[] providers)
{
var rules = new GenerationRules();
rules.Properties[GenerationRulesExtensions.PersistenceKey] = providers.ToList();
return rules;
}

[Fact]
public void selective_provider_wins_for_its_entity_even_when_the_catch_all_registered_after_it()
{
var selective = new SelectiveProvider(typeof(MappedEntity));
var catchAll = new CatchAllProvider();

// The catch-all integration registered last, so InsertFirstPersistenceStrategy put it
// at index 0 - the registration order that used to steal the entity from the selective
// provider
var rules = rulesWith(catchAll, selective);

rules.TryFindPersistenceFrameProvider(null!, typeof(MappedEntity), out var provider)
.ShouldBeTrue();

provider.ShouldBeSameAs(selective);
}

[Fact]
public void selective_provider_wins_for_its_entity_when_it_registered_last_too()
{
var selective = new SelectiveProvider(typeof(MappedEntity));
var catchAll = new CatchAllProvider();

var rules = rulesWith(selective, catchAll);

rules.TryFindPersistenceFrameProvider(null!, typeof(MappedEntity), out var provider)
.ShouldBeTrue();

provider.ShouldBeSameAs(selective);
}

[Fact]
public void unmapped_entity_still_falls_through_to_the_catch_all()
{
var selective = new SelectiveProvider(typeof(MappedEntity));
var catchAll = new CatchAllProvider();

var rules = rulesWith(catchAll, selective);

// Nothing the selective provider maps, so the catch-all keeps everything else
rules.TryFindPersistenceFrameProvider(null!, typeof(UnmappedDocument), out var provider)
.ShouldBeTrue();

provider.ShouldBeSameAs(catchAll);
}

[Fact]
public void registration_order_is_preserved_within_each_group()
{
var selective1 = new SelectiveProvider(typeof(MappedEntity));
var selective2 = new SelectiveProvider(typeof(MappedEntity));
var catchAll1 = new CatchAllProvider();
var catchAll2 = new CatchAllProvider();

var rules = rulesWith(catchAll1, selective1, catchAll2, selective2);

var ordered = rules.OrderedPersistenceProviders();

// OrderBy is a stable sort: selective providers first in their original relative
// order, catch-alls after in theirs
ordered.ShouldBe([selective1, selective2, catchAll1, catchAll2]);
}

[Fact]
public void a_lone_catch_all_is_still_found()
{
var catchAll = new CatchAllProvider();
var rules = rulesWith(catchAll);

rules.TryFindPersistenceFrameProvider(null!, typeof(MappedEntity), out var provider)
.ShouldBeTrue();

provider.ShouldBeSameAs(catchAll);
}

public class MappedEntity;

public class UnmappedDocument;

// Claims only the single entity type it was constructed with, like EF Core claiming
// only the types mapped in a registered DbContext model
private class SelectiveProvider : StubProviderBase
{
private readonly Type _entityType;

public SelectiveProvider(Type entityType)
{
_entityType = entityType;
}

public override bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService)
{
persistenceService = GetType();
return entityType == _entityType;
}
}

// Claims every type it is asked about, like Marten
private class CatchAllProvider : StubProviderBase
{
public override bool IsCatchAll => true;

public override bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService)
{
persistenceService = GetType();
return true;
}
}

private abstract class StubProviderBase : IPersistenceFrameProvider
{
public virtual bool IsCatchAll => false;

public abstract bool CanPersist(Type entityType, IServiceContainer container, out Type persistenceService);

public void ApplyTransactionSupport(IChain chain, IServiceContainer container) =>
throw new NotSupportedException();

public void ApplyTransactionSupport(IChain chain, IServiceContainer container, Type entityType) =>
throw new NotSupportedException();

public bool CanApply(IChain chain, IServiceContainer container) => false;

public Type DetermineSagaIdType(Type sagaType, IServiceContainer container) =>
throw new NotSupportedException();

public Frame DetermineLoadFrame(IServiceContainer container, Type sagaType, Variable sagaId) =>
throw new NotSupportedException();

public Frame DetermineInsertFrame(Variable saga, IServiceContainer container) =>
throw new NotSupportedException();

public Frame CommitUnitOfWorkFrame(Variable saga, IServiceContainer container) =>
throw new NotSupportedException();

public Frame DetermineUpdateFrame(Variable saga, IServiceContainer container) =>
throw new NotSupportedException();

public Frame DetermineDeleteFrame(Variable sagaId, Variable saga, IServiceContainer container) =>
throw new NotSupportedException();

public Frame DetermineStoreFrame(Variable saga, IServiceContainer container) =>
throw new NotSupportedException();

public Frame DetermineDeleteFrame(Variable variable, IServiceContainer container) =>
throw new NotSupportedException();

public Frame DetermineStorageActionFrame(Type entityType, Variable action, IServiceContainer container) =>
throw new NotSupportedException();

public Frame[] DetermineFrameToNullOutMaybeSoftDeleted(Variable entity) =>
throw new NotSupportedException();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ private static void readSagas(IWolverineRuntime runtime, ServiceCapabilities cap
.ToArray();
if (sagaTypes.Length == 0) return;

var providers = runtime.Options.CodeGeneration.PersistenceProviders();
var providers = runtime.Options.CodeGeneration.OrderedPersistenceProviders();
var container = runtime.Options.HandlerGraph.Container;

foreach (var sagaType in sagaTypes)
Expand Down
4 changes: 2 additions & 2 deletions src/Wolverine/Configuration/SagaPersistenceChainPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ internal class SagaPersistenceChainPolicy : IChainPolicy
{
public void Apply(IReadOnlyList<IChain> chains, GenerationRules rules, IServiceContainer container)
{
var providers = rules.PersistenceProviders();
var providers = rules.OrderedPersistenceProviders();

foreach (var chain in chains)
{
Expand All @@ -30,7 +30,7 @@ public void Apply(IReadOnlyList<IChain> chains, GenerationRules rules, IServiceC
}
}

private static bool attachSagaPersistenceFrame(IServiceContainer container, List<IPersistenceFrameProvider> providers,
private static bool attachSagaPersistenceFrame(IServiceContainer container, IReadOnlyList<IPersistenceFrameProvider> providers,
Variable saga, IChain chain)
{
foreach (var provider in providers)
Expand Down
Loading
Loading