From 690d7ae34c507c4f76adb8bb95a4ae92022f08bd Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 10 Feb 2026 22:07:46 -0600 Subject: [PATCH 1/2] Update ISoftDeleted properties on in-memory document when calling Delete When Delete(T entity) is called on a document implementing ISoftDeleted, the in-memory Deleted and DeletedAt properties are now set immediately. Previously only the database was updated. Closes GH-2924 Co-Authored-By: Claude Opus 4.6 --- src/DocumentDbTests/Deleting/soft_deletes.cs | 18 ++++++++++++++++++ .../Sessions/DocumentSessionBase.Deletes.cs | 7 +++++++ 2 files changed, 25 insertions(+) diff --git a/src/DocumentDbTests/Deleting/soft_deletes.cs b/src/DocumentDbTests/Deleting/soft_deletes.cs index 5385ce15a7..2b6494d83c 100644 --- a/src/DocumentDbTests/Deleting/soft_deletes.cs +++ b/src/DocumentDbTests/Deleting/soft_deletes.cs @@ -720,6 +720,24 @@ public async Task should_partition_through_attribute() partitioning.Partitions.Single().ShouldBe(new ListPartition("deleted", "true")); } + + [Fact] + public async Task delete_sets_ISoftDeleted_properties_on_in_memory_document() + { + var doc1 = new SoftDeletedDocument { Id = "50" }; + theSession.Store(doc1); + await theSession.SaveChangesAsync(); + + await using var session2 = theStore.IdentitySession(); + var loadedDoc = await session2.LoadAsync(doc1.Id); + loadedDoc.ShouldNotBeNull(); + + session2.Delete(loadedDoc); + await session2.SaveChangesAsync(); + + loadedDoc.Deleted.ShouldBeTrue(); + loadedDoc.DeletedAt.ShouldNotBeNull(); + } } public class soft_deletes_with_partitioning: OneOffConfigurationsContext, IAsyncLifetime diff --git a/src/Marten/Internal/Sessions/DocumentSessionBase.Deletes.cs b/src/Marten/Internal/Sessions/DocumentSessionBase.Deletes.cs index b86aea29e6..278d9b8c78 100644 --- a/src/Marten/Internal/Sessions/DocumentSessionBase.Deletes.cs +++ b/src/Marten/Internal/Sessions/DocumentSessionBase.Deletes.cs @@ -8,6 +8,7 @@ using Marten.Exceptions; using Marten.Internal.Storage; using Marten.Linq.SqlGeneration; +using Marten.Metadata; namespace Marten.Internal.Sessions; @@ -26,6 +27,12 @@ public void Delete(T entity) where T : notnull var deletion = documentStorage.DeleteForDocument(entity, TenantId); _workTracker.Add(deletion); + if (entity is ISoftDeleted softDeleted) + { + softDeleted.Deleted = true; + softDeleted.DeletedAt = DateTimeOffset.UtcNow; + } + documentStorage.Eject(this, entity); ChangeTrackers.RemoveAll(x => x.Document is T t && documentStorage.IdentityFor(entity).Equals(documentStorage.IdentityFor(t))); From 9529f9233e07ea9f7eaf461410ea05e730009d69 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Tue, 10 Feb 2026 23:31:14 -0600 Subject: [PATCH 2/2] Add IMMUTABLE function wrappers for NodaTime type indexes Registers an IMemberSource in the NodaTime plugin that maps NodaTime types to the existing mt_immutable_* PostgreSQL functions, fixing index creation that previously failed with "functions in index expression must be marked IMMUTABLE". Closes GH-2415 Co-Authored-By: Claude Opus 4.6 --- .../Acceptance/noda_time_acceptance.cs | 27 ++++++++ src/Marten.NodaTime/NodaTimeExtensions.cs | 2 + src/Marten.NodaTime/NodaTimeMemberSource.cs | 63 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 src/Marten.NodaTime/NodaTimeMemberSource.cs diff --git a/src/Marten.NodaTime.Testing/Acceptance/noda_time_acceptance.cs b/src/Marten.NodaTime.Testing/Acceptance/noda_time_acceptance.cs index 57a57094ee..155fb15e5d 100644 --- a/src/Marten.NodaTime.Testing/Acceptance/noda_time_acceptance.cs +++ b/src/Marten.NodaTime.Testing/Acceptance/noda_time_acceptance.cs @@ -316,6 +316,33 @@ public async Task bug_1276_can_select_instant(SerializerType serializerType) } } + [Theory] + [InlineData(SerializerType.SystemTextJson)] + [InlineData(SerializerType.Newtonsoft)] + public async Task can_index_noda_time_types(SerializerType serializerType) + { + StoreOptions(opts => + { + if (serializerType == SerializerType.Newtonsoft) + { + opts.UseNewtonsoftForSerialization(); + } + opts.UseNodaTime(); + opts.DatabaseSchemaName = "NodaTime"; + opts.Schema.For() + .Duplicate(x => x.LocalDate) + .Duplicate(x => x.LocalDateTime) + .Duplicate(x => x.InstantUTC); + }, true); + + await theStore.Advanced.Clean.CompletelyRemoveAllAsync(); + + // This will apply schema changes, creating indexes on NodaTime fields. + // Previously this would fail with "functions in index expression must be marked IMMUTABLE" + await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync(); + await theStore.Storage.Database.AssertDatabaseMatchesConfigurationAsync(); + } + private class CustomJsonSerializer: ISerializer { public EnumStorage EnumStorage => throw new NotSupportedException(); diff --git a/src/Marten.NodaTime/NodaTimeExtensions.cs b/src/Marten.NodaTime/NodaTimeExtensions.cs index e86259d613..282882351d 100644 --- a/src/Marten.NodaTime/NodaTimeExtensions.cs +++ b/src/Marten.NodaTime/NodaTimeExtensions.cs @@ -26,6 +26,8 @@ public static void UseNodaTime(this StoreOptions storeOptions, bool shouldConfig SetNodaTimeTypeMappings(); NpgsqlConnection.GlobalTypeMapper.UseNodaTime(); + storeOptions.Linq.MemberSources.Insert(0, new NodaTimeMemberSource()); + if (!shouldConfigureJsonSerializer) return; storeOptions.Advanced.ModifySerializer(serializer => diff --git a/src/Marten.NodaTime/NodaTimeMemberSource.cs b/src/Marten.NodaTime/NodaTimeMemberSource.cs new file mode 100644 index 0000000000..d32faff31f --- /dev/null +++ b/src/Marten.NodaTime/NodaTimeMemberSource.cs @@ -0,0 +1,63 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Marten.Linq.Members; +using NodaTime; + +namespace Marten.NodaTimePlugin; + +internal class NodaTimeMemberSource: IMemberSource +{ + public bool TryResolve(IQueryableMember parent, StoreOptions options, MemberInfo memberInfo, Type memberType, + [NotNullWhen(true)] out IQueryableMember? member) + { + if (memberType == typeof(LocalDate) || memberType == typeof(LocalDate?)) + { + member = new DateOnlyMember(options, parent, options.Serializer().Casing, memberInfo); + return true; + } + + if (memberType == typeof(LocalDateTime) || memberType == typeof(LocalDateTime?)) + { + member = new DateTimeMember(options, parent, options.Serializer().Casing, memberInfo); + return true; + } + + if (memberType == typeof(Instant) || memberType == typeof(Instant?) + || memberType == typeof(ZonedDateTime) || memberType == typeof(ZonedDateTime?) + || memberType == typeof(OffsetDateTime) || memberType == typeof(OffsetDateTime?)) + { + member = new NodaTimeTimestampTzMember(options, parent, options.Serializer().Casing, memberInfo); + return true; + } + + if (memberType == typeof(LocalTime) || memberType == typeof(LocalTime?)) + { + member = new TimeOnlyMember(options, parent, options.Serializer().Casing, memberInfo); + return true; + } + + member = null; + return false; + } +} + +/// +/// Member class for NodaTime types that map to PostgreSQL 'timestamp with time zone'. +/// Uses the mt_immutable_timestamptz wrapper function for index compatibility +/// without the .NET DateTimeOffset-specific casting in comparisons. +/// +internal class NodaTimeTimestampTzMember: QueryableMember, IComparableMember +{ + public NodaTimeTimestampTzMember(StoreOptions options, IQueryableMember parent, Casing casing, MemberInfo member) + : base(parent, casing, member) + { + TypedLocator = $"{options.DatabaseSchemaName}.mt_immutable_timestamptz({RawLocator})"; + } + + public override string SelectorForDuplication(string pgType) + { + return TypedLocator.Replace("d.data", "data"); + } +}