diff --git a/src/Marten/Internal/Sessions/DocumentSessionBase.cs b/src/Marten/Internal/Sessions/DocumentSessionBase.cs index 536a4e1f99..a3d5c9dc72 100644 --- a/src/Marten/Internal/Sessions/DocumentSessionBase.cs +++ b/src/Marten/Internal/Sessions/DocumentSessionBase.cs @@ -399,9 +399,16 @@ public void EjectPatchedTypes(IUnitOfWork changes) internal void StoreDocumentInItemMap(TId id, TDoc document) where TDoc : class where TId : notnull { - if (ItemMap.ContainsKey(typeof(TDoc))) + if (ItemMap.TryGetValue(typeof(TDoc), out var existing)) { - ItemMap[typeof(TDoc)].As>()[id] = document; + if (existing is Dictionary typedDict) + { + typedDict[id] = document; + } + // else: The identity map was created with a different key type (e.g., a strong-typed ID + // like PaymentId while TId is Guid). The document is already stored by the inline + // projection under the strong-typed key, so we skip storing it again to avoid + // replacing the dictionary with an incompatible key type. } else { diff --git a/src/ValueTypeTests/Bugs/Bug_4214_identity_map_strong_typed_ids.cs b/src/ValueTypeTests/Bugs/Bug_4214_identity_map_strong_typed_ids.cs new file mode 100644 index 0000000000..e1752573f5 --- /dev/null +++ b/src/ValueTypeTests/Bugs/Bug_4214_identity_map_strong_typed_ids.cs @@ -0,0 +1,109 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using JasperFx.Events; +using JasperFx.Events.Projections; +using Marten; +using Marten.Events.Aggregation; +using Marten.Events.Projections; +using Marten.Testing.Harness; +using Shouldly; +using StronglyTypedIds; +using Xunit; + +namespace ValueTypeTests.Bugs; + +public class Bug_4214_identity_map_strong_typed_ids : BugIntegrationContext +{ + [Theory] + [InlineData(ProjectionLifecycle.Inline)] + [InlineData(ProjectionLifecycle.Live)] + public async Task fetch_for_writing_with_identity_map_and_strong_typed_guid_id(ProjectionLifecycle lifecycle) + { + StoreOptions(opts => + { + opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true }); + opts.Projections.Add(new SingleStreamProjection(), lifecycle); + opts.Events.UseIdentityMapForAggregates = true; + }); + + await using var session = theStore.LightweightSession(); + + var id = session.Events.StartStream( + new Bug4214PaymentCreated(DateTimeOffset.UtcNow), + new Bug4214PaymentVerified(DateTimeOffset.UtcNow)).Id; + + await session.SaveChangesAsync(); + + // This threw InvalidCastException before the fix: + // "Unable to cast object of type 'Dictionary`2[Bug4214PaymentId,Bug4214Payment]' + // to type 'Dictionary`2[Guid,Bug4214Payment]'" + var stream = await session.Events.FetchForWriting(id); + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.State.ShouldBe(Bug4214PaymentState.Verified); + } + + [Theory] + [InlineData(ProjectionLifecycle.Inline)] + [InlineData(ProjectionLifecycle.Live)] + public async Task fetch_for_writing_twice_with_identity_map_and_strong_typed_guid_id( + ProjectionLifecycle lifecycle) + { + StoreOptions(opts => + { + opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true }); + opts.Projections.Add(new SingleStreamProjection(), lifecycle); + opts.Events.UseIdentityMapForAggregates = true; + }); + + await using var session = theStore.LightweightSession(); + + var id = session.Events.StartStream( + new Bug4214PaymentCreated(DateTimeOffset.UtcNow), + new Bug4214PaymentVerified(DateTimeOffset.UtcNow)).Id; + + await session.SaveChangesAsync(); + + // First fetch stores in identity map + var stream1 = await session.Events.FetchForWriting(id); + stream1.Aggregate.ShouldNotBeNull(); + + stream1.AppendOne(new Bug4214PaymentCanceled(DateTimeOffset.UtcNow)); + await session.SaveChangesAsync(); + + // Second fetch should retrieve from identity map without cast error + var stream2 = await session.Events.FetchForWriting(id); + stream2.Aggregate.ShouldNotBeNull(); + stream2.Aggregate.State.ShouldBe(Bug4214PaymentState.Canceled); + } +} + +[StronglyTypedId(Template.Guid)] +public readonly partial struct Bug4214PaymentId; + +public class Bug4214Payment +{ + [JsonInclude] public Bug4214PaymentId? Id { get; private set; } + [JsonInclude] public DateTimeOffset CreatedAt { get; private set; } + [JsonInclude] public Bug4214PaymentState State { get; private set; } + + public static Bug4214Payment Create(IEvent @event) + { + return new Bug4214Payment + { + Id = new Bug4214PaymentId(@event.StreamId), + CreatedAt = @event.Data.CreatedAt, + State = Bug4214PaymentState.Created + }; + } + + public void Apply(Bug4214PaymentVerified _) => State = Bug4214PaymentState.Verified; + public void Apply(Bug4214PaymentCanceled _) => State = Bug4214PaymentState.Canceled; +} + +public enum Bug4214PaymentState { Created, Verified, Canceled } + +public record Bug4214PaymentCreated(DateTimeOffset CreatedAt); +public record Bug4214PaymentVerified(DateTimeOffset VerifiedAt); +public record Bug4214PaymentCanceled(DateTimeOffset CanceledAt);