From 6c7ecbac6f7ad7bc600c5a31eabdcec019904aec Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 24 Sep 2024 10:30:32 -0500 Subject: [PATCH 1/2] Supporting strong typed identifiers for aggregated projections Docs for strong typed identifiers and aggregates Assertion that the aggregate id needs to be nullable for Marten Can use string based strong typed identifiers with single stream aggregations wip: past async single stream projections, had to add LoadManyAsync support to document storage --- docs/documents/identity.md | 4 +- .../projections/aggregate-projections.md | 9 + ...aggregation_projection_validation_rules.cs | 4 +- ...ntifiers_on_aggregates_must_be_nullable.cs | 50 +++++ ..._strong_typed_id_for_aggregate_identity.cs | 196 ++++++++++++++++++ ..._strong_typed_id_for_aggregate_identity.cs | 142 +++++++++++++ .../EventSourcingTests.csproj | 1 + src/Marten/Events/Aggregation/ByStreamId.cs | 48 +++++ src/Marten/Events/Aggregation/ByStreamKey.cs | 50 +++++ .../GeneratedAggregateProjectionBase.cs | 2 +- .../Aggregation/SingleStreamProjection.cs | 75 +++---- .../Events/Aggregation/TenantSliceGroup.cs | 6 + .../CodeGeneration/BulkLoaderBuilder.cs | 10 +- .../CodeGeneration/DocumentStorageBuilder.cs | 51 ++++- .../Internal/Storage/DocumentStorage.cs | 8 +- src/Marten/StoreOptions.Identity.cs | 57 +++++ src/Marten/StoreOptions.MemberFactory.cs | 48 ----- .../guid_based_document_operations.cs | 15 +- 18 files changed, 682 insertions(+), 94 deletions(-) create mode 100644 src/EventSourcingTests/Aggregation/strong_typed_identifiers_on_aggregates_must_be_nullable.cs create mode 100644 src/EventSourcingTests/Aggregation/using_guid_based_strong_typed_id_for_aggregate_identity.cs create mode 100644 src/EventSourcingTests/Aggregation/using_string_based_strong_typed_id_for_aggregate_identity.cs diff --git a/docs/documents/identity.md b/docs/documents/identity.md index 90aa18e68c..d8b624fdfa 100644 --- a/docs/documents/identity.md +++ b/docs/documents/identity.md @@ -309,7 +309,9 @@ Vogen or StronglyTypedID for now. ::: ::: info -There is not yet any direct support for strong typed identifiers for the event store +As of Marten 7.29.0, the event sourcing features support strong typed identifiers for the aggregated +document types, but there is still no direct support for supplying strong typed identifiers for event streams yet. +This may change in Marten 8.0. ::: Marten can now support [strong typed identifiers](https://en.wikipedia.org/wiki/Strongly_typed_identifie) using a couple different strategies. diff --git a/docs/events/projections/aggregate-projections.md b/docs/events/projections/aggregate-projections.md index 4081495a64..cede14f2ce 100644 --- a/docs/events/projections/aggregate-projections.md +++ b/docs/events/projections/aggregate-projections.md @@ -129,6 +129,15 @@ Alternatively, if your aggregate will never be deleted you can use a stream aggr To create aggregate projections that include events in multiple streams, see [Multi-Stream Projections](/events/projections/multi-stream-projections). +## Strong Typed Identifiers + +Marten supports using strong-typed identifiers as the document identity for aggregated documents. Here's an example: + +snippet: sample_using_strong_typed_identifier_for_aggregate_projections + +Just note that for single stream aggregations, your strong typed identifier types will need to wrap either a `Guid` or +`string` depending on your application's `StreamIdentity`. + ## Aggregate Creation ::: tip diff --git a/src/EventSourcingTests/Aggregation/aggregation_projection_validation_rules.cs b/src/EventSourcingTests/Aggregation/aggregation_projection_validation_rules.cs index f7e8ea2dec..d9706e7c5f 100644 --- a/src/EventSourcingTests/Aggregation/aggregation_projection_validation_rules.cs +++ b/src/EventSourcingTests/Aggregation/aggregation_projection_validation_rules.cs @@ -54,7 +54,7 @@ public void aggregate_id_is_wrong_type_1() }); message.ShouldContain( - $"Id type mismatch. The stream identity type is System.Guid, but the aggregate document {typeof(StringIdentifiedAggregate).FullNameInCode()} id type is string", + $"Id type mismatch", StringComparisonOption.Default); } @@ -67,7 +67,7 @@ public void aggregate_id_is_wrong_type_2() x.Events.StreamIdentity = StreamIdentity.AsString; x.Projections.Snapshot(SnapshotLifecycle.Async); }).ShouldContain( - $"Id type mismatch. The stream identity type is string, but the aggregate document {typeof(GuidIdentifiedAggregate).FullNameInCode()} id type is Guid", + $"Id type mismatch", StringComparisonOption.Default ); } diff --git a/src/EventSourcingTests/Aggregation/strong_typed_identifiers_on_aggregates_must_be_nullable.cs b/src/EventSourcingTests/Aggregation/strong_typed_identifiers_on_aggregates_must_be_nullable.cs new file mode 100644 index 0000000000..ec1fd086d1 --- /dev/null +++ b/src/EventSourcingTests/Aggregation/strong_typed_identifiers_on_aggregates_must_be_nullable.cs @@ -0,0 +1,50 @@ +using System; +using System.Text.Json.Serialization; +using Marten.Events; +using Marten.Events.Projections; +using Marten.Exceptions; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.Aggregation; + +public class strong_typed_identifiers_on_aggregates_must_be_nullable : OneOffConfigurationsContext +{ + [Fact] + public void should_warn_if_the_id_is_not_nullable() + { + Should.Throw(() => + { + StoreOptions(opts => opts.Projections.Snapshot(SnapshotLifecycle.Inline)); + }); + + } +} + +public class Payment3 +{ + [JsonInclude] public PaymentId Id { get; private set; } + + [JsonInclude] public DateTimeOffset CreatedAt { get; private set; } + + [JsonInclude] public PaymentState State { get; private set; } + + public static Payment3 Create(IEvent @event) + { + return new Payment3 + { + Id = new PaymentId(@event.StreamId), CreatedAt = @event.Data.CreatedAt, State = PaymentState.Created + }; + } + + public void Apply(PaymentCanceled @event) + { + State = PaymentState.Canceled; + } + + public void Apply(PaymentVerified @event) + { + State = PaymentState.Verified; + } +} diff --git a/src/EventSourcingTests/Aggregation/using_guid_based_strong_typed_id_for_aggregate_identity.cs b/src/EventSourcingTests/Aggregation/using_guid_based_strong_typed_id_for_aggregate_identity.cs new file mode 100644 index 0000000000..4a714008b8 --- /dev/null +++ b/src/EventSourcingTests/Aggregation/using_guid_based_strong_typed_id_for_aggregate_identity.cs @@ -0,0 +1,196 @@ +using System; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Marten.Events; +using Marten.Events.Projections; +using Marten.Testing.Harness; +using Microsoft.Extensions.Logging; +using Shouldly; +using StronglyTypedIds; +using Xunit; +using Xunit.Abstractions; + +namespace EventSourcingTests.Aggregation; + +// Sample code taken from https://github.com/JasperFx/marten/issues/3306 +public class using_guid_based_strong_typed_id_for_aggregate_identity: OneOffConfigurationsContext +{ + private readonly ITestOutputHelper _output; + + public using_guid_based_strong_typed_id_for_aggregate_identity(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task can_utilize_strong_typed_id_in_aggregate_stream() + { + StoreOptions(opts => + { + opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true }); + }); + + var id = theSession.Events.StartStream(new PaymentCreated(DateTimeOffset.UtcNow), + new PaymentVerified(DateTimeOffset.UtcNow)).Id; + + await theSession.SaveChangesAsync(); + + var payment = await theSession.Events.AggregateStreamAsync(id); + + payment.Id.Value.Value.ShouldBe(id); + } + + [Fact] + public async Task can_utilize_strong_typed_id_in_with_inline_aggregations() + { + StoreOptions(opts => + { + opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true }); + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var id = theSession.Events.StartStream(new PaymentCreated(DateTimeOffset.UtcNow), + new PaymentVerified(DateTimeOffset.UtcNow)).Id; + + await theSession.SaveChangesAsync(); + + var payment = await theSession.LoadAsync(new PaymentId(id)); + + payment.State.ShouldBe(PaymentState.Verified); + } + + [Fact] + public async Task can_utilize_strong_typed_id_with_async_aggregation() + { + var testLogger = new TestLogger(_output); + StoreOptions(opts => + { + opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true }); + opts.Projections.Snapshot(SnapshotLifecycle.Async); + opts.DotNetLogger = testLogger; + }); + + var id = theSession.Events.StartStream(new PaymentCreated(DateTimeOffset.UtcNow), + new PaymentVerified(DateTimeOffset.UtcNow)).Id; + + await theSession.SaveChangesAsync(); + + + using var daemon = await theStore.BuildProjectionDaemonAsync(logger: testLogger); + await daemon.StartAllAsync(); + await daemon.WaitForNonStaleData(1.Minutes()); + + var payment = await theSession.LoadAsync(new PaymentId(id)); + + payment.State.ShouldBe(PaymentState.Verified); + + + // Do it again so you catch existing aggregates + theSession.Events.Append(id, new PaymentCanceled(DateTimeOffset.UtcNow)); + await theSession.SaveChangesAsync(); + + await daemon.WaitForNonStaleData(1.Minutes()); + + payment = await theSession.LoadAsync(new PaymentId(id)); + + payment.State.ShouldBe(PaymentState.Canceled); + } +} + +#region sample_using_strong_typed_identifier_for_aggregate_projections + +[StronglyTypedId(Template.Guid)] +public readonly partial struct PaymentId; + +public class Payment +{ + [JsonInclude] public PaymentId? Id { get; private set; } + + [JsonInclude] public DateTimeOffset CreatedAt { get; private set; } + + [JsonInclude] public PaymentState State { get; private set; } + + public static Payment Create(IEvent @event) + { + return new Payment + { + Id = new PaymentId(@event.StreamId), CreatedAt = @event.Data.CreatedAt, State = PaymentState.Created + }; + } + + public void Apply(PaymentCanceled @event) + { + State = PaymentState.Canceled; + } + + public void Apply(PaymentVerified @event) + { + State = PaymentState.Verified; + } +} + +#endregion + +public enum PaymentState +{ + Created, + Initialized, + Canceled, + Verified +} + +public record PaymentCreated( + DateTimeOffset CreatedAt +); + +public record PaymentCanceled( + DateTimeOffset CanceledAt +); + +public record PaymentVerified( + DateTimeOffset VerifiedAt +); + +public class TestLogger: ILogger, IDisposable +{ + private readonly ITestOutputHelper _output; + + public TestLogger(ITestOutputHelper output) + { + _output = output; + } + + + public void Dispose() + { + // Nothing + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + var message = $"{typeof(T).NameInCode()}/{logLevel}: {formatter(state, exception)}"; + Debug.WriteLine(message); + _output.WriteLine(message); + + if (exception != null) + { + Debug.WriteLine(exception); + _output.WriteLine(exception.ToString()); + } + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public IDisposable BeginScope(TState state) + { + return this; + } +} diff --git a/src/EventSourcingTests/Aggregation/using_string_based_strong_typed_id_for_aggregate_identity.cs b/src/EventSourcingTests/Aggregation/using_string_based_strong_typed_id_for_aggregate_identity.cs new file mode 100644 index 0000000000..d1ef36e617 --- /dev/null +++ b/src/EventSourcingTests/Aggregation/using_string_based_strong_typed_id_for_aggregate_identity.cs @@ -0,0 +1,142 @@ +using System; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using JasperFx.Core; +using JasperFx.Core.Reflection; +using Marten.Events; +using Marten.Events.Projections; +using Marten.Testing.Harness; +using Microsoft.Extensions.Logging; +using Shouldly; +using StronglyTypedIds; +using Xunit; +using Xunit.Abstractions; + +namespace EventSourcingTests.Aggregation; + +// Sample code taken from https://github.com/JasperFx/marten/issues/3306 +public class using_string_based_strong_typed_id_for_aggregate_identity: OneOffConfigurationsContext +{ + private readonly ITestOutputHelper _output; + + public using_string_based_strong_typed_id_for_aggregate_identity(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public async Task can_utilize_strong_typed_id_in_aggregate_stream() + { + StoreOptions(opts => + { + opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true }); + opts.Events.StreamIdentity = StreamIdentity.AsString; + }); + + var id = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(id, new PaymentCreated(DateTimeOffset.UtcNow), + new PaymentVerified(DateTimeOffset.UtcNow)); + + await theSession.SaveChangesAsync(); + + var payment = await theSession.Events.AggregateStreamAsync(id); + + payment.Id.Value.Value.ShouldBe(id); + } + + [Fact] + public async Task can_utilize_strong_typed_id_in_with_inline_aggregations() + { + StoreOptions(opts => + { + opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true }); + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + opts.Events.StreamIdentity = StreamIdentity.AsString; + }); + + var id = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(id, new PaymentCreated(DateTimeOffset.UtcNow), + new PaymentVerified(DateTimeOffset.UtcNow)); + + await theSession.SaveChangesAsync(); + + var payment = await theSession.LoadAsync(new Payment2Id(id)); + + payment.State.ShouldBe(PaymentState.Verified); + } + + [Fact] + public async Task can_utilize_strong_typed_id_with_async_aggregation() + { + var testLogger = new TestLogger(_output); + StoreOptions(opts => + { + opts.Events.StreamIdentity = StreamIdentity.AsString; + opts.UseSystemTextJsonForSerialization(new JsonSerializerOptions { IncludeFields = true }); + opts.Projections.Snapshot(SnapshotLifecycle.Async); + opts.DotNetLogger = testLogger; + }); + + var id = Guid.NewGuid().ToString(); + + theSession.Events.StartStream(id, new PaymentCreated(DateTimeOffset.UtcNow), + new PaymentVerified(DateTimeOffset.UtcNow)); + + await theSession.SaveChangesAsync(); + + + using var daemon = await theStore.BuildProjectionDaemonAsync(logger: testLogger); + await daemon.StartAllAsync(); + await daemon.WaitForNonStaleData(1.Minutes()); + + var payment = await theSession.LoadAsync(new Payment2Id(id)); + + payment.State.ShouldBe(PaymentState.Verified); + + + // Do it again so you catch existing aggregates + theSession.Events.Append(id, new PaymentCanceled(DateTimeOffset.UtcNow)); + await theSession.SaveChangesAsync(); + + await daemon.WaitForNonStaleData(1.Minutes()); + + payment = await theSession.LoadAsync(new Payment2Id(id)); + + payment.State.ShouldBe(PaymentState.Canceled); + } +} + +[StronglyTypedId(Template.String)] +public readonly partial struct Payment2Id; + +public class Payment2 +{ + [JsonInclude] public Payment2Id? Id { get; private set; } + + [JsonInclude] public DateTimeOffset CreatedAt { get; private set; } + + [JsonInclude] public PaymentState State { get; private set; } + + public static Payment2 Create(IEvent @event) + { + return new Payment2 + { + Id = new Payment2Id(@event.StreamKey), CreatedAt = @event.Data.CreatedAt, State = PaymentState.Created + }; + } + + public void Apply(PaymentCanceled @event) + { + State = PaymentState.Canceled; + } + + public void Apply(PaymentVerified @event) + { + State = PaymentState.Verified; + } +} + diff --git a/src/EventSourcingTests/EventSourcingTests.csproj b/src/EventSourcingTests/EventSourcingTests.csproj index cf2184eea7..3ff393576f 100644 --- a/src/EventSourcingTests/EventSourcingTests.csproj +++ b/src/EventSourcingTests/EventSourcingTests.csproj @@ -19,6 +19,7 @@ + all runtime; build; native; contentfiles; analyzers diff --git a/src/Marten/Events/Aggregation/ByStreamId.cs b/src/Marten/Events/Aggregation/ByStreamId.cs index 92884e7720..b3d5c05822 100644 --- a/src/Marten/Events/Aggregation/ByStreamId.cs +++ b/src/Marten/Events/Aggregation/ByStreamId.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Marten.Events.Projections; +using Marten.Internal; using Marten.Storage; namespace Marten.Events.Aggregation; @@ -49,3 +50,50 @@ public ValueTask>> SliceAsyncEvents(I return new ValueTask>>(list); } } + +/// +/// Slicer strategy by stream id (Guid identified streams) and a custom value type +/// +/// +public class ByStreamId: IEventSlicer, ISingleStreamSlicer +{ + private readonly Func _converter; + + public ByStreamId(ValueTypeInfo valueType) + { + _converter = valueType.CreateConverter(); + } + + public ValueTask>> SliceInlineActions(IQuerySession querySession, + IEnumerable streams) + { + return new ValueTask>>(streams.Select(s => + { + var tenant = new Tenant(s.TenantId, querySession.Database); + return new EventSlice(_converter(s.Id), tenant, s.Events){ActionType = s.ActionType}; + }).ToList()); + } + + public ValueTask>> SliceAsyncEvents(IQuerySession querySession, + List events) + { + var list = new List>(); + var byTenant = events.GroupBy(x => x.TenantId); + + foreach (var tenantGroup in byTenant) + { + var tenant = new Tenant(tenantGroup.Key, querySession.Database); + + var slices = tenantGroup + .GroupBy(x => x.StreamId) + .Select(x => new EventSlice( _converter(x.Key), tenant, x)); + + var group = new TenantSliceGroup(tenant, slices); + + list.Add(group); + } + + return new ValueTask>>(list); + } +} + diff --git a/src/Marten/Events/Aggregation/ByStreamKey.cs b/src/Marten/Events/Aggregation/ByStreamKey.cs index 4136f7f768..66fa7f064e 100644 --- a/src/Marten/Events/Aggregation/ByStreamKey.cs +++ b/src/Marten/Events/Aggregation/ByStreamKey.cs @@ -1,8 +1,10 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Marten.Events.Projections; +using Marten.Internal; using Marten.Storage; namespace Marten.Events.Aggregation; @@ -46,3 +48,51 @@ public ValueTask>> SliceAsyncEvents return new ValueTask>>(list); } } + +/// +/// Slicer strategy by stream key (string identified streams) for strong typed identifiers +/// +/// +public class ByStreamKey: IEventSlicer, ISingleStreamSlicer +{ + private readonly Func _converter; + + public ByStreamKey(ValueTypeInfo valueType) + { + _converter = valueType.CreateConverter(); + } + + public ValueTask>> SliceInlineActions(IQuerySession querySession, + IEnumerable streams) + { + return new ValueTask>>(streams.Select(s => + { + var tenant = new Tenant(s.TenantId, querySession.Database); + return new EventSlice(_converter(s.Key!), tenant, s.Events){ActionType = s.ActionType}; + }).ToList()); + } + + public ValueTask>> SliceAsyncEvents( + IQuerySession querySession, + List events) + { + var list = new List>(); + var byTenant = events.GroupBy(x => x.TenantId); + + foreach (var tenantGroup in byTenant) + { + var tenant = new Tenant(tenantGroup.Key, querySession.Database); + + var slices = tenantGroup + .GroupBy(x => x.StreamKey) + .Select(x => new EventSlice(_converter(x.Key!), tenant, x)); + + var group = new TenantSliceGroup(tenant, slices); + + list.Add(group); + } + + return new ValueTask>>(list); + } +} + diff --git a/src/Marten/Events/Aggregation/GeneratedAggregateProjectionBase.cs b/src/Marten/Events/Aggregation/GeneratedAggregateProjectionBase.cs index ee254fa16a..0f5b112b24 100644 --- a/src/Marten/Events/Aggregation/GeneratedAggregateProjectionBase.cs +++ b/src/Marten/Events/Aggregation/GeneratedAggregateProjectionBase.cs @@ -135,7 +135,7 @@ public void VersionIdentity(Expression> expression) _versioning.Override(expression); } - protected abstract object buildEventSlicer(StoreOptions documentMapping); + protected abstract object buildEventSlicer(StoreOptions options); protected abstract Type baseTypeForAggregationRuntime(); /// diff --git a/src/Marten/Events/Aggregation/SingleStreamProjection.cs b/src/Marten/Events/Aggregation/SingleStreamProjection.cs index 622e5f26fc..40bcb62040 100644 --- a/src/Marten/Events/Aggregation/SingleStreamProjection.cs +++ b/src/Marten/Events/Aggregation/SingleStreamProjection.cs @@ -1,11 +1,9 @@ #nullable enable using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Threading.Tasks; -using JasperFx.CodeGeneration; using JasperFx.Core.Reflection; using Marten.Events.Projections; +using Marten.Internal; using Marten.Schema; namespace Marten.Events.Aggregation; @@ -25,29 +23,37 @@ public override bool IsSingleStream() return true; } - protected sealed override object buildEventSlicer(StoreOptions documentMapping) + protected sealed override object buildEventSlicer(StoreOptions options) { - Type slicerType = null; - if (_aggregateMapping.IdType == typeof(Guid)) - { - slicerType = typeof(ByStreamId<>).MakeGenericType(_aggregateMapping.DocumentType); - } - else if (_aggregateMapping.IdType != typeof(string)) + var isValidIdentity = IsIdTypeValidForStream(_aggregateMapping.IdType, options, out var idType, out var valueType); + if (!isValidIdentity) { throw new ArgumentOutOfRangeException( $"{_aggregateMapping.IdType.FullNameInCode()} is not a supported stream id type for aggregate {_aggregateMapping.DocumentType.FullNameInCode()}"); } - else + + if (valueType != null) { - slicerType = typeof(ByStreamKey<>).MakeGenericType(_aggregateMapping.DocumentType); + var slicerType = idType == typeof(Guid) + ? typeof(ByStreamId<,>).MakeGenericType(_aggregateMapping.DocumentType, valueType.OuterType) + : typeof(ByStreamKey<,>).MakeGenericType(_aggregateMapping.DocumentType, valueType.OuterType); + + return Activator.CreateInstance(slicerType, valueType)!; } + else + { + var slicerType = idType == typeof(Guid) + ? typeof(ByStreamId<>).MakeGenericType(_aggregateMapping.DocumentType) + : typeof(ByStreamKey<>).MakeGenericType(_aggregateMapping.DocumentType); - return Activator.CreateInstance(slicerType); + return Activator.CreateInstance(slicerType)!; + } } public override void ConfigureAggregateMapping(DocumentMapping mapping, StoreOptions storeOptions) { - mapping.UseVersionFromMatchingStream = Lifecycle == ProjectionLifecycle.Inline && storeOptions.Events.AppendMode == EventAppendMode.Quick; + mapping.UseVersionFromMatchingStream = Lifecycle == ProjectionLifecycle.Inline && + storeOptions.Events.AppendMode == EventAppendMode.Quick; } protected sealed override Type baseTypeForAggregationRuntime() @@ -55,33 +61,32 @@ protected sealed override Type baseTypeForAggregationRuntime() return typeof(AggregationRuntime<,>).MakeGenericType(typeof(T), _aggregateMapping.IdType); } + internal bool IsIdTypeValidForStream(Type idType, StoreOptions options, out Type expectedType, out ValueTypeInfo? valueType) + { + valueType = default; + expectedType = options.Events.StreamIdentity == StreamIdentity.AsGuid ? typeof(Guid) : typeof(string); + if (idType == expectedType) return true; + + valueType = options.TryFindValueType(idType); + if (valueType == null) return false; + + return valueType.SimpleType == expectedType; + } protected sealed override IEnumerable validateDocumentIdentity(StoreOptions options, DocumentMapping mapping) { - switch (options.Events.StreamIdentity) + var matches = IsIdTypeValidForStream(mapping.IdType, options, out var expectedType, out var valueTypeInfo); + if (!matches) { - case StreamIdentity.AsGuid: - { - if (mapping.IdType != typeof(Guid)) - { - yield return - $"Id type mismatch. The stream identity type is System.Guid, but the aggregate document {typeof(T).FullNameInCode()} id type is {mapping.IdType.NameInCode()}"; - } - - break; - } - case StreamIdentity.AsString: - { - if (mapping.IdType != typeof(string)) - { - yield return - $"Id type mismatch. The stream identity type is string, but the aggregate document {typeof(T).FullNameInCode()} id type is {mapping.IdType.NameInCode()}"; - } + yield return + $"Id type mismatch. The stream identity type is {expectedType.NameInCode()} (or a strong typed identifier type that is convertible to {expectedType.NameInCode()}), but the aggregate document {typeof(T).FullNameInCode()} id type is {mapping.IdType.NameInCode()}"; + } - break; - } + if (valueTypeInfo != null && !mapping.IdMember.GetRawMemberType().IsNullable()) + { + yield return + $"At this point, Marten requires that identity members for strong typed identifiers be Nullable. Change {mapping.DocumentType.FullNameInCode()}.{mapping.IdMember.Name} to a Nullable for Marten compliance"; } } } - diff --git a/src/Marten/Events/Aggregation/TenantSliceGroup.cs b/src/Marten/Events/Aggregation/TenantSliceGroup.cs index 962fa6bd16..f714068407 100644 --- a/src/Marten/Events/Aggregation/TenantSliceGroup.cs +++ b/src/Marten/Events/Aggregation/TenantSliceGroup.cs @@ -171,6 +171,12 @@ private async Task processEventSlices(IAggregationRuntime runtime, return; } + // Minor optimization + if (!beingFetched.Any()) + { + return; + } + var ids = beingFetched.Select(x => x.Id).ToArray(); var options = new SessionOptions { Tenant = Tenant, AllowAnyTenant = true }; diff --git a/src/Marten/Internal/CodeGeneration/BulkLoaderBuilder.cs b/src/Marten/Internal/CodeGeneration/BulkLoaderBuilder.cs index 89943e9234..563a0ae393 100644 --- a/src/Marten/Internal/CodeGeneration/BulkLoaderBuilder.cs +++ b/src/Marten/Internal/CodeGeneration/BulkLoaderBuilder.cs @@ -61,11 +61,17 @@ public GeneratedType BuildType(GeneratedAssembly assembly) var load = type.MethodFor("LoadRow"); - foreach (var argument in arguments) argument.GenerateBulkWriterCode(type, load, _mapping); + foreach (var argument in arguments) + { + argument.GenerateBulkWriterCode(type, load, _mapping); + } var loadAsync = type.MethodFor("LoadRowAsync"); - foreach (var argument in arguments) argument.GenerateBulkWriterCodeAsync(type, loadAsync, _mapping); + foreach (var argument in arguments) + { + argument.GenerateBulkWriterCodeAsync(type, loadAsync, _mapping); + } return type; } diff --git a/src/Marten/Internal/CodeGeneration/DocumentStorageBuilder.cs b/src/Marten/Internal/CodeGeneration/DocumentStorageBuilder.cs index d9a77aec53..ceddb793a5 100644 --- a/src/Marten/Internal/CodeGeneration/DocumentStorageBuilder.cs +++ b/src/Marten/Internal/CodeGeneration/DocumentStorageBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; @@ -11,6 +12,8 @@ using Marten.Services; using Marten.Storage; using Npgsql; +using NpgsqlTypes; +using Weasel.Postgresql; namespace Marten.Internal.CodeGeneration; @@ -78,17 +81,63 @@ private GeneratedType buildDocumentStorageType(GeneratedAssembly assembly, Docum Use.Type(), Use.Type()); writeParameterForId(type); + writeParameterForIdArray(type); writeNotImplementedStubs(type); return type; } + internal class BuildArrayParameterFrame: SyncFrame + { + private readonly ValueTypeIdGeneration _idGeneration; + private readonly NpgsqlDbType _dbType; + private readonly string _memberName; + + public BuildArrayParameterFrame(ValueTypeIdGeneration idGeneration) + { + _dbType = PostgresqlProvider.Instance.ToParameterType(idGeneration.SimpleType); + _memberName = idGeneration.ValueProperty.Name; + } + + public BuildArrayParameterFrame(FSharpDiscriminatedUnionIdGeneration idGeneration) + { + _dbType = PostgresqlProvider.Instance.ToParameterType(idGeneration.SimpleType); + _memberName = idGeneration.ValueProperty.Name; + } + + public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) + { + var dbTypeCode = $"{typeof(NpgsqlDbType).FullNameInCode()}.Array | {typeof(NpgsqlDbType).FullNameInCode()}.{_dbType}"; + + var code = $"return new(){{Value = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Select(ids, x => x.{_memberName})), {nameof(NpgsqlParameter.NpgsqlDbType)} = {dbTypeCode}}};"; + writer.WriteLine(code); + Next?.GenerateCode(method, writer); + } + } + + private void writeParameterForIdArray(GeneratedType type) + { + var method = type.MethodFor(nameof(DocumentStorage.BuildManyIdParameter)); + if (_mapping.IdStrategy is ValueTypeIdGeneration st) + { + method.Frames.Add(new BuildArrayParameterFrame(st)); + } + else if (_mapping.IdStrategy is FSharpDiscriminatedUnionIdGeneration fst) + { + method.Frames.Add(new BuildArrayParameterFrame(fst)); + } + else + { + method.Frames.Code($"return base.{method.MethodName}(ids);"); + } + } + private void writeParameterForId(GeneratedType type) { var method = type.MethodFor(nameof(DocumentStorage.RawIdentityValue)); if (_mapping.IdStrategy is ValueTypeIdGeneration st) { - method.Frames.Code($"return id.{st.ValueProperty.Name};"); + method.Frames.Code($"return id.{st.ValueProperty.Name};"); } else if (_mapping.IdStrategy is FSharpDiscriminatedUnionIdGeneration fst) { diff --git a/src/Marten/Internal/Storage/DocumentStorage.cs b/src/Marten/Internal/Storage/DocumentStorage.cs index d4af30af11..a4d7f22659 100644 --- a/src/Marten/Internal/Storage/DocumentStorage.cs +++ b/src/Marten/Internal/Storage/DocumentStorage.cs @@ -360,7 +360,7 @@ public NpgsqlCommand BuildLoadCommand(TId id, string tenant) ? new NpgsqlCommand(_loaderSql) { Parameters = { ParameterForId(id), - new() { Value = tenant } + new() { Value = tenant } } } : new NpgsqlCommand(_loaderSql) @@ -372,12 +372,14 @@ public NpgsqlCommand BuildLoadCommand(TId id, string tenant) }; } + public virtual NpgsqlParameter BuildManyIdParameter(TId[] ids) => new() { Value = ids }; + public NpgsqlCommand BuildLoadManyCommand(TId[] ids, string tenant) { return _mapping.TenancyStyle == TenancyStyle.Conjoined ? new NpgsqlCommand(_loadArraySql) { Parameters = { - new() { Value = ids }, + BuildManyIdParameter(ids), new() { Value = tenant } } } @@ -385,7 +387,7 @@ public NpgsqlCommand BuildLoadManyCommand(TId[] ids, string tenant) { Parameters = { - new() { Value = ids } + BuildManyIdParameter(ids) } }; } diff --git a/src/Marten/StoreOptions.Identity.cs b/src/Marten/StoreOptions.Identity.cs index 8f9d508447..025c4d71e1 100644 --- a/src/Marten/StoreOptions.Identity.cs +++ b/src/Marten/StoreOptions.Identity.cs @@ -1,7 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Reflection; using JasperFx.Core; using JasperFx.Core.Reflection; +using Marten.Exceptions; +using Marten.Internal; using Marten.Linq.Members; using Marten.Schema.Identity; using Marten.Schema.Identity.Sequences; @@ -58,4 +62,57 @@ private bool idMemberIsSettable(MemberInfo idMember) return false; } + + internal ValueTypeInfo? TryFindValueType(Type idType) + { + return ValueTypes.FirstOrDefault(x => x.OuterType == idType); + } + + /// + /// Register a custom value type with Marten. Doing this enables Marten + /// to use this type correctly within LINQ expressions. The "value type" + /// should wrap a single, primitive value with a single public get-able + /// property + /// + /// + /// + public ValueTypeInfo RegisterValueType(Type type) + { + PropertyInfo? valueProperty; + if (FSharpDiscriminatedUnionIdGeneration.IsFSharpSingleCaseDiscriminatedUnion(type)) + { + valueProperty = type.GetProperties().Where(x => x.Name != "Tag").SingleOrDefaultIfMany(); + } + else + { + valueProperty = type.GetProperties().SingleOrDefaultIfMany(); + } + + if (valueProperty == null || !valueProperty.CanRead) throw new InvalidValueTypeException(type, "Must be only a single public, 'gettable' property"); + + var ctor = type.GetConstructors() + .FirstOrDefault(x => x.GetParameters().Length == 1 && x.GetParameters()[0].ParameterType == valueProperty.PropertyType); + + if (ctor != null) + { + var valueType = new Internal.ValueTypeInfo(type, valueProperty.PropertyType, valueProperty, ctor); + ValueTypes.Add(valueType); + return valueType; + } + + var builder = type.GetMethods(BindingFlags.Static | BindingFlags.Public).FirstOrDefault(x => + x.GetParameters().Length == 1 && x.GetParameters()[0].ParameterType == valueProperty.PropertyType); + + if (builder != null) + { + var valueType = new ValueTypeInfo(type, valueProperty.PropertyType, valueProperty, builder); + ValueTypes.Add(valueType); + return valueType; + } + + throw new InvalidValueTypeException(type, + "Unable to determine either a builder static method or a constructor to use"); + } + + internal List ValueTypes { get; } = new(); } diff --git a/src/Marten/StoreOptions.MemberFactory.cs b/src/Marten/StoreOptions.MemberFactory.cs index 0af1d9936f..39aa4656bf 100644 --- a/src/Marten/StoreOptions.MemberFactory.cs +++ b/src/Marten/StoreOptions.MemberFactory.cs @@ -130,54 +130,6 @@ private static bool isEnumerable(Type fieldType) { return fieldType.IsArray || fieldType.Closes(typeof(IEnumerable<>)); } - - /// - /// Register a custom value type with Marten. Doing this enables Marten - /// to use this type correctly within LINQ expressions. The "value type" - /// should wrap a single, primitive value with a single public get-able - /// property - /// - /// - /// - public ValueTypeInfo RegisterValueType(Type type) - { - PropertyInfo? valueProperty; - if (FSharpDiscriminatedUnionIdGeneration.IsFSharpSingleCaseDiscriminatedUnion(type)) - { - valueProperty = type.GetProperties().Where(x => x.Name != "Tag").SingleOrDefaultIfMany(); - } - else - { - valueProperty = type.GetProperties().SingleOrDefaultIfMany(); - } - - if (valueProperty == null || !valueProperty.CanRead) throw new InvalidValueTypeException(type, "Must be only a single public, 'gettable' property"); - - var ctor = type.GetConstructors() - .FirstOrDefault(x => x.GetParameters().Length == 1 && x.GetParameters()[0].ParameterType == valueProperty.PropertyType); - - if (ctor != null) - { - var valueType = new Internal.ValueTypeInfo(type, valueProperty.PropertyType, valueProperty, ctor); - ValueTypes.Add(valueType); - return valueType; - } - - var builder = type.GetMethods(BindingFlags.Static | BindingFlags.Public).FirstOrDefault(x => - x.GetParameters().Length == 1 && x.GetParameters()[0].ParameterType == valueProperty.PropertyType); - - if (builder != null) - { - var valueType = new ValueTypeInfo(type, valueProperty.PropertyType, valueProperty, builder); - ValueTypes.Add(valueType); - return valueType; - } - - throw new InvalidValueTypeException(type, - "Unable to determine either a builder static method or a constructor to use"); - } - - internal List ValueTypes { get; } = new(); } internal class ValueTypeMemberSource: IMemberSource diff --git a/src/ValueTypeTests/StrongTypedId/guid_based_document_operations.cs b/src/ValueTypeTests/StrongTypedId/guid_based_document_operations.cs index cb85818af2..71e30e2017 100644 --- a/src/ValueTypeTests/StrongTypedId/guid_based_document_operations.cs +++ b/src/ValueTypeTests/StrongTypedId/guid_based_document_operations.cs @@ -141,7 +141,7 @@ public async Task load_document() } [Fact] - public async Task load_many() + public async Task load_many_through_linq() { var invoice1 = new Invoice2{Name = Guid.NewGuid().ToString()}; var invoice2 = new Invoice2{Name = Guid.NewGuid().ToString()}; @@ -158,6 +158,19 @@ public async Task load_many() results.Count.ShouldBe(3); } + [Fact] + public async Task load_many() + { + var invoice1 = new Invoice2{Name = Guid.NewGuid().ToString()}; + var invoice2 = new Invoice2{Name = Guid.NewGuid().ToString()}; + var invoice3 = new Invoice2{Name = Guid.NewGuid().ToString()}; + theSession.Store(invoice1, invoice2, invoice3); + + await theSession.SaveChangesAsync(); + throw new NotImplementedException(); + //var invoices = await theSession.LoadManyAsync() + } + [Fact] public async Task delete_by_id() { From d9ff596404392a7a05c30917ebb92d936a9b2347 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 24 Sep 2024 13:26:21 -0500 Subject: [PATCH 2/2] Kind of deprecating the unstable connection & transaction tests --- ...ability_to_use_an_existing_connection_and_transaction.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename src/{CoreTests => StressTests}/ability_to_use_an_existing_connection_and_transaction.cs (98%) diff --git a/src/CoreTests/ability_to_use_an_existing_connection_and_transaction.cs b/src/StressTests/ability_to_use_an_existing_connection_and_transaction.cs similarity index 98% rename from src/CoreTests/ability_to_use_an_existing_connection_and_transaction.cs rename to src/StressTests/ability_to_use_an_existing_connection_and_transaction.cs index a56d4d759f..6c9a84ff4f 100644 --- a/src/CoreTests/ability_to_use_an_existing_connection_and_transaction.cs +++ b/src/StressTests/ability_to_use_an_existing_connection_and_transaction.cs @@ -13,7 +13,7 @@ using Xunit.Abstractions; using IsolationLevel = System.Data.IsolationLevel; -namespace CoreTests; +namespace StressTests; public class ability_to_use_an_existing_connection_and_transaction: IntegrationContext { @@ -24,9 +24,9 @@ public ability_to_use_an_existing_connection_and_transaction(DefaultStoreFixture { } - protected override Task fixtureSetup() + protected override async Task fixtureSetup() { - return theStore.BulkInsertDocumentsAsync(targets); + await theStore.BulkInsertDocumentsAsync(targets); }