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
10 changes: 5 additions & 5 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
<PackageVersion Include="FluentAssertions" Version="6.12.0" />
<PackageVersion Include="FSharp.Core" Version="9.0.100" />
<PackageVersion Include="FSharp.SystemTextJson" Version="1.3.13" />
<PackageVersion Include="JasperFx" Version="1.21.4" />
<PackageVersion Include="JasperFx.Events" Version="1.24.1" />
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="1.3.0" />
<PackageVersion Include="JasperFx" Version="1.21.5" />
<PackageVersion Include="JasperFx.Events" Version="1.24.2" />
<PackageVersion Include="JasperFx.Events.SourceGenerator" Version="1.4.0" />
<PackageVersion Include="JasperFx.RuntimeCompiler" Version="4.4.0" />
<PackageVersion Include="Jil" Version="3.0.0-alpha2" />
<PackageVersion Include="Lamar" Version="7.1.1" />
Expand Down Expand Up @@ -79,8 +79,8 @@
</ItemGroup>
<!-- Framework-specific package versions -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.2" />
Expand Down
106 changes: 106 additions & 0 deletions src/EventSourcingTests/Bugs/Bug_4199_natural_key_table_not_found.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System;
using System.Threading.Tasks;
using JasperFx.Events.Aggregation;
using Marten;
using Marten.Events.Projections;
using Marten.Testing.Harness;
using Shouldly;
using Xunit;

namespace EventSourcingTests.Bugs;

public class Bug_4199_natural_key_table_not_found : OneOffConfigurationsContext
{
public sealed record OrderNumber(string Value);

public sealed record OrderPlaced(Guid OrderId, string OrderNumber);

public sealed record OrderShipped(Guid OrderId, string TrackingNumber);

public sealed class OrderAggregate
{
public Guid Id { get; set; }

[NaturalKey]
public OrderNumber Number { get; set; }

public string? TrackingNumber { get; set; }

[NaturalKeySource]
public void Apply(OrderPlaced e)
{
Id = e.OrderId;
Number = new OrderNumber(e.OrderNumber);
}

public void Apply(OrderShipped e)
{
TrackingNumber = e.TrackingNumber;
}
}

[Fact]
public async Task should_auto_create_natural_key_table_on_fetch_for_writing()
{
// This is the exact scenario from issue #4199:
// No explicit projection registration, no ApplyAllConfiguredChangesToDatabaseAsync(),
// just FetchForWriting with a natural key type on a self-aggregating aggregate.
// The natural key table should be auto-created.
StoreOptions(opts =>
{
// Deliberately no projection registration - relying on auto-discovery
});

var orderId = Guid.NewGuid();
var orderNumber = new OrderNumber("ORD-12345");

// Trigger auto-discovery of the natural key projection by calling FetchForWriting.
// This is the pattern the user would follow: first attempt triggers registration,
// then subsequent writes include the inline projection.
await using var session0 = theStore.LightweightSession();
var preCheck = await session0.Events.FetchForWriting<OrderAggregate, OrderNumber>(
new OrderNumber("nonexistent"));
preCheck.Aggregate.ShouldBeNull(); // No stream exists yet, that's fine

// Now start a stream — the inline projection is registered, so the natural key
// mapping will be written alongside the events
await using var session1 = theStore.LightweightSession();
session1.Events.StartStream<OrderAggregate>(orderId,
new OrderPlaced(orderId, orderNumber.Value));
await session1.SaveChangesAsync();

// Fetch by natural key — should find the aggregate
await using var session2 = theStore.LightweightSession();
var stream = await session2.Events.FetchForWriting<OrderAggregate, OrderNumber>(orderNumber);

stream.ShouldNotBeNull();
stream.Aggregate.ShouldNotBeNull();
stream.Aggregate.Number.ShouldBe(orderNumber);
stream.Aggregate.Id.ShouldBe(orderId);
}

[Fact]
public async Task should_work_with_explicit_inline_projection()
{
// Verify the explicit registration path still works
StoreOptions(opts =>
{
opts.Projections.Snapshot<OrderAggregate>(SnapshotLifecycle.Inline);
});

var orderId = Guid.NewGuid();
var orderNumber = new OrderNumber("ORD-67890");

await using var session1 = theStore.LightweightSession();
session1.Events.StartStream<OrderAggregate>(orderId,
new OrderPlaced(orderId, orderNumber.Value));
await session1.SaveChangesAsync();

await using var session2 = theStore.LightweightSession();
var stream = await session2.Events.FetchForWriting<OrderAggregate, OrderNumber>(orderNumber);

stream.ShouldNotBeNull();
stream.Aggregate.ShouldNotBeNull();
stream.Aggregate.Number.ShouldBe(orderNumber);
}
}
41 changes: 41 additions & 0 deletions src/Marten/DocumentStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Transactions;
Expand All @@ -11,12 +12,14 @@
using JasperFx.Core.Reflection;
using JasperFx.Descriptors;
using JasperFx.Events;
using JasperFx.Events.Aggregation;
using JasperFx.Events.Daemon;
using JasperFx.Events.Descriptors;
using JasperFx.Events.Projections;
using JasperFx.MultiTenancy;
using Marten.Events;
using Marten.Events.Daemon;
using Marten.Events.Projections;
using Marten.Events.Daemon.HighWater;
using Marten.Exceptions;
using Marten.Internal.Sessions;
Expand Down Expand Up @@ -62,6 +65,7 @@ public DocumentStore(StoreOptions options)
StorageFeatures.PostProcessConfiguration();
Events.Initialize(this);
Options.Projections.DiscoverGeneratedEvolvers(AppDomain.CurrentDomain.GetAssemblies());
DiscoverNaturalKeyAggregates(AppDomain.CurrentDomain.GetAssemblies());
Options.Projections.AssertValidity(Options);

if (Options.LogFactory != null)
Expand Down Expand Up @@ -471,6 +475,43 @@ public IProjectionDaemon BuildProjectionDaemon(
return new ProjectionDaemon(this, (MartenDatabase)database, logger, detector);
}

/// <summary>
/// Scan loaded assemblies for types marked with [NaturalKeyAggregate] by the source generator
/// and auto-register Inline snapshot projections for any that don't already have a projection.
/// </summary>
private void DiscoverNaturalKeyAggregates(Assembly[] assemblies)
{
foreach (var assembly in assemblies)
{
IEnumerable<NaturalKeyAggregateAttribute> attrs;
try
{
attrs = assembly.GetCustomAttributes<NaturalKeyAggregateAttribute>();
}
catch
{
continue;
}

foreach (var attr in attrs)
{
if (!Options.Projections.TryFindAggregate(attr.AggregateType, out _))
{
// Register Inline snapshot via reflection — this activates the full natural key pipeline:
// NaturalKeyDefinition discovery, NaturalKeyTable creation, NaturalKeyProjection
var snapshotMethod = typeof(ProjectionOptions)
.GetMethods()
.First(m => m.Name == "Snapshot" && m.IsGenericMethod &&
m.GetParameters().Length == 2 &&
m.GetParameters()[0].ParameterType == typeof(SnapshotLifecycle));

snapshotMethod.MakeGenericMethod(attr.AggregateType)
.Invoke(Options.Projections, new object?[] { SnapshotLifecycle.Inline, null });
}
}
}
}

/// <summary>
/// Quick way to stand up a DocumentStore to the given database connection
/// in the "development" mode for auto-creating schema objects as needed
Expand Down
21 changes: 17 additions & 4 deletions src/Marten/Events/EventStore.FetchForWriting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
using Marten.Internal;
using Marten.Internal.Sessions;
using Marten.Internal.Storage;
using Marten.Storage;
using Marten.Linq.QueryHandlers;
using Weasel.Postgresql;
using Weasel.Postgresql.SqlGeneration;
Expand Down Expand Up @@ -252,7 +253,17 @@ private IAggregateFetchPlan<TDoc, TId> determineFetchPlan<TDoc, TId>(StoreOption
{
// Auto-discover natural key from [NaturalKey] attribute on the aggregate type
// BEFORE iterating planners, so the projection is registered and available
tryAutoRegisterNaturalKeyProjection<TDoc, TId>(options);
if (tryAutoRegisterNaturalKeyProjection<TDoc, TId>(options))
{
// The projection was just auto-registered, which adds a NaturalKeyTable
// to the IEvent feature schema. Reset the schema existence check so
// EnsureStorageExistsAsync(typeof(IEvent)) will re-evaluate and create
// the natural key table.
if (_session.Database is MartenDatabase martenDb)
{
martenDb.ResetSchemaExistenceChecks();
}
}

foreach (var planner in options.Projections.allPlanners())
{
Expand Down Expand Up @@ -284,26 +295,28 @@ private IAggregateFetchPlan<TDoc, TId> determineFetchPlan<TDoc, TId>(StoreOption
/// This enables FetchForWriting with natural keys on self-aggregating types
/// without requiring explicit projection registration.
/// </summary>
private static void tryAutoRegisterNaturalKeyProjection<TDoc, TId>(StoreOptions options)
/// <returns>True if a projection was newly registered</returns>
private static bool tryAutoRegisterNaturalKeyProjection<TDoc, TId>(StoreOptions options)
where TDoc : class where TId : notnull
{
// Skip if a projection is already registered for this aggregate type
if (options.Projections.TryFindAggregate(typeof(TDoc), out _))
{
return;
return false;
}

var naturalKeyProp = typeof(TDoc).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.FirstOrDefault(p => p.GetCustomAttribute<NaturalKeyAttribute>() != null);

if (naturalKeyProp == null || naturalKeyProp.PropertyType != typeof(TId))
{
return;
return false;
}

// Register an Inline snapshot projection so the natural key infrastructure
// (natural key table, inline projection, NaturalKeyFetchPlanner) all activate
options.Projections.Snapshot<TDoc>(SnapshotLifecycle.Inline);
return true;
}
}

Expand Down
Loading