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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<JasperFxVersion>2.37.1</JasperFxVersion>
<JasperFxVersion>2.37.2</JasperFxVersion>
<LangVersion>13</LangVersion>
<NoWarn>1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618</NoWarn>
<Authors>Jeremy D. Miller;Jaedyn Tonee</Authors>
Expand Down
32 changes: 32 additions & 0 deletions src/JasperFx.Events.ComplianceTests/ComplianceStoreConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ public sealed class ComplianceStoreConfig
/// </summary>
public string? SchemaName { get; set; }

/// <summary>
/// Optional explicit value for the per-database rebuild concurrency cap. Null leaves the store
/// on its derived default; zero or negative disables the cap.
/// </summary>
/// <remarks>
/// Not routed through <see cref="IComplianceStoreRegistrar"/> because the products hang the knob
/// off different option objects (Marten <c>Projections</c>, Polecat <c>DaemonSettings</c>) and
/// the fixture is already the place that knows which.
/// </remarks>
public int? MaxConcurrentRebuildsPerDatabase { get; set; }

/// <summary>
/// Optional connection pool ceiling, folded into the connection string by the fixture. Exists so
/// the rebuild-cap suite can exercise the pool-size-derived default without caring whether the
/// store speaks Npgsql or SqlClient.
/// </summary>
public int? MaxPoolSize { get; set; }

public List<Type> EventTypes { get; } = new();

public List<(Type Tag, string Suffix, Type? Aggregate)> TagTypes { get; } = new();
Expand All @@ -31,6 +49,8 @@ public sealed class ComplianceStoreConfig

public List<Type> LiveAggregations { get; } = new();

public List<(ProjectionBase Projection, ProjectionLifecycle Lifecycle)> Projections { get; } = new();

public ComplianceStoreConfig AddEventType<T>()
{
EventTypes.Add(typeof(T));
Expand Down Expand Up @@ -68,6 +88,18 @@ public ComplianceStoreConfig LiveAggregation<TDoc>() where TDoc : notnull
return this;
}

/// <summary>
/// Register an already-constructed projection instance. Used where the projection carries test
/// state (the enrichment suite's call-order recorder) or where the point of the test is what the
/// source generator emitted onto a concrete projection type.
/// </summary>
public ComplianceStoreConfig AddProjection(ProjectionBase projection, ProjectionLifecycle lifecycle)
{
Projections.Add((projection, lifecycle));
_registrations.Add(registrar => registrar.AddProjection(projection, lifecycle));
return this;
}

public void ApplyTo(IComplianceStoreRegistrar registrar)
{
foreach (var registration in _registrations)
Expand Down
24 changes: 24 additions & 0 deletions src/JasperFx.Events.ComplianceTests/EventStoreComplianceFixture.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using JasperFx.Events.Daemon;
Expand Down Expand Up @@ -79,11 +80,34 @@ public async Task ConfigureAsync(Action<ComplianceStoreConfig> configure)
public abstract Task<T?> LoadDocumentAsync<T>(TQuerySession session, object id, CancellationToken token)
where T : class;

/// <summary>
/// Store a plain document — not an event. Only needed where a suite has to seed state the event
/// store itself did not produce, such as the lookup document an enrichment projection reads.
/// </summary>
public abstract void StoreDocument<T>(TOperations session, T document) where T : notnull;

/// <summary>
/// The payoff member — everything portable in the suites runs off the shared JasperFx surface.
/// </summary>
public abstract IEventStoreOperations EventsFor(TOperations session);

/// <summary>
/// The store itself, as the shared <see cref="IEventStore"/> surface. Suites reach for this on
/// store-level contracts — the rebuild concurrency cap, usage descriptors — never for anything
/// session-scoped.
/// </summary>
public abstract IEventStore EventStore { get; }

/// <summary>
/// Aggregate types the store knows about, including ones discovered from source-generated
/// evolvers rather than explicit registration.
/// </summary>
/// <remarks>
/// <c>ProjectionGraph.AllAggregateTypes()</c> is shared, but the graph hangs off each product's
/// own options type, so reaching it costs one line of fixture code.
/// </remarks>
public abstract IEnumerable<Type> AllAggregateTypes();

public abstract IComplianceBatch CreateBatch(TQuerySession session);

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public virtual async ValueTask InitializeAsync()
protected Task<T?> LoadDocumentAsync<T>(TQuerySession session, object id) where T : class
=> theFixture.LoadDocumentAsync<T>(session, id, Cancellation);

protected void StoreDocument<T>(TOperations session, T document) where T : notnull
=> theFixture.StoreDocument(session, document);

protected IEventStore EventStore => theFixture.EventStore;

protected IComplianceBatch CreateBatch(TQuerySession session) => theFixture.CreateBatch(session);

/// <summary>
Expand Down
12 changes: 12 additions & 0 deletions src/JasperFx.Events.ComplianceTests/IComplianceStoreRegistrar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,16 @@ public interface IComplianceStoreRegistrar
/// build live aggregators automatically.
/// </summary>
void LiveAggregation<TDoc>() where TDoc : notnull;

/// <summary>
/// Register an already-constructed projection instance.
/// </summary>
/// <remarks>
/// Typed as the shared <see cref="ProjectionBase"/> rather than
/// <c>IProjectionSource&lt;TOperations, TQuerySession&gt;</c> because this interface is not
/// generic over the session pair; the implementing fixture casts down to its own closure. Every
/// projection a suite can build derives from the product's own EventProjection base, so the cast
/// is total in practice.
/// </remarks>
void AddProjection(ProjectionBase projection, ProjectionLifecycle lifecycle);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// NOT PACKAGED. See ComplianceQuerySessionPlaceholder.cs for why Local/ exists.
//
// The EventProjection suites declare projection types at file scope, so they cannot reach the
// <TOperations, TQuerySession> pair that the suite classes are generic over. Two more per-consumer
// global aliases close that gap, exactly like ComplianceQuerySession does for the self-aggregating
// fixtures:
//
// global using ComplianceOperations = Marten.IDocumentOperations;
// global using ComplianceEventProjection = Marten.Events.Projections.EventProjection;
//
// Aliases (rather than generic base classes) because both products' EventProjection base carries
// store-specific members -- Marten's IProjectionSchemaSource/IMartenRegistrable, Polecat's sealed
// storeEntity override -- so the shared sources want the product's own base type, whatever it is.

global using ComplianceOperations = JasperFx.Events.ComplianceTests.Local.IPlaceholderOperations;
global using ComplianceEventProjection = JasperFx.Events.ComplianceTests.Local.PlaceholderEventProjection;

using JasperFx.Events.Projections;

namespace JasperFx.Events.ComplianceTests.Local;

public interface IPlaceholderOperations: IPlaceholderQuerySession, IStorageOperations
{
/// <summary>
/// Called by the registration suite's explicit <c>ApplyAsync</c> override, which is the whole
/// point of that test -- the source generator has to see the <c>Store&lt;T&gt;</c> call.
/// </summary>
void Store<T>(T entity) where T : notnull;
}

public abstract class PlaceholderEventProjection: JasperFxEventProjectionBase<IPlaceholderOperations,
IPlaceholderQuerySession>
{
protected override void storeEntity<T>(IPlaceholderOperations ops, T entity) => ops.Store(entity);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,24 @@
//
// global using ComplianceQuerySession = Marten.IQuerySession;
//
// This placeholder stands in for that alias here and never leaves the repo.
// This placeholder stands in for that alias here and never leaves the repo. Members are declared
// only where a shared suite actually calls them, and their shapes are the intersection of what
// Marten and Polecat already expose -- binding against the real session types in the consumers is
// what validates them for real.

global using ComplianceQuerySession = JasperFx.Events.ComplianceTests.Local.IPlaceholderQuerySession;

using System;
using System.Threading;
using System.Threading.Tasks;

namespace JasperFx.Events.ComplianceTests.Local;

public interface IPlaceholderQuerySession;
public interface IPlaceholderQuerySession
{
/// <summary>
/// Called by the enrichment suite's database-lookup projection, which reads a document from
/// inside <c>EnrichEventsAsync</c>.
/// </summary>
Task<T?> LoadAsync<T>(Guid id, CancellationToken token = default) where T : class;
}
12 changes: 9 additions & 3 deletions src/JasperFx.Events.ComplianceTests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@ differences between the repos.
Reference the package from a test project that already has xunit v3 and Shouldly, then supply two
things.

**1. A global alias naming your store's read session.** The shared self-aggregating fixtures declare
`EvolveAsync(IEvent, ComplianceQuerySession)`; the source generator resolves the parameter by type
name, so an alias is enough:
**1. Three global aliases naming your store's own types.** The shared suites declare aggregates and
projections at file scope, so they cannot reach the `<TOperations, TQuerySession>` pair the suite
classes are generic over. The source generator resolves these by type name, so aliases are enough:

```csharp
global using ComplianceQuerySession = Marten.IQuerySession;
global using ComplianceOperations = Marten.IDocumentOperations;
global using ComplianceEventProjection = Marten.Events.Projections.EventProjection;
```

`ComplianceQuerySession` binds the `EvolveAsync(IEvent, …)` convention on the self-aggregating
fixtures; the other two bind the EventProjection suites to your product's own projection base and
writable session.

**2. A concrete fixture** closing `EventStoreComplianceFixture<TOperations, TQuerySession>` over your
store's session pair. Everything portable in the suites runs through the shared JasperFx surfaces
(`IEventStoreOperations`, `IEventRegistry`, `IProjectionDaemon`); the fixture only has to supply what
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Shouldly;
using Xunit;

namespace JasperFx.Events.ComplianceTests;

/// <summary>
/// Self-aggregating types whose evolvers were emitted by the source generator have to be usable
/// without ever being registered — no <c>Snapshot&lt;T&gt;()</c>, no explicit projection. The store
/// finds them by walking loaded assemblies for <c>[GeneratedEvolver]</c> at construction time.
/// </summary>
/// <remarks>
/// Deliberately configures a store with nothing registered at all, which is what separates this from
/// <see cref="SelfAggregatingEvolveCompliance{TFixture,TOperations,TQuerySession}"/> — there the same
/// aggregates are registered as inline snapshots, so discovery is never exercised.
/// </remarks>
public abstract class AutoDiscoveredAggregateCompliance<TFixture, TOperations, TQuerySession>
: EventStoreComplianceSuite<TFixture, TOperations, TQuerySession>
where TFixture : EventStoreComplianceFixture<TOperations, TQuerySession>, new()
where TOperations : TQuerySession, IStorageOperations
{
private static readonly Action<ComplianceStoreConfig> _configuration = config =>
{
config.SchemaName = "compliance_auto_discover";
};

protected override Action<ComplianceStoreConfig> Configuration => _configuration;

[Fact]
public void self_aggregating_types_are_auto_discovered()
{
var aggregateTypes = theFixture.AllAggregateTypes().ToArray();

aggregateTypes.ShouldContain(typeof(MutableIEventEvolveAggregate),
"MutableIEventEvolveAggregate has a source-generated evolver and was never registered, " +
"so it can only be here by assembly discovery");
}

[Fact]
public async Task auto_discovered_type_works_for_live_aggregation()
{
await using var session = OpenSession();

var streamId = Guid.NewGuid();
EventsFor(session).StartStream(streamId, new EvolveAEvent(), new EvolveBEvent(), new EvolveCEvent());
await SaveChangesAsync(session);

var aggregate =
await EventsFor(session).AggregateStreamAsync<MutableIEventEvolveAggregate>(streamId, token: Cancellation);

aggregate.ShouldNotBeNull();
aggregate.ACount.ShouldBe(1);
aggregate.BCount.ShouldBe(1);
aggregate.CCount.ShouldBe(1);
}
}
Loading
Loading