diff --git a/src/DocumentDbTests/Metadata/flexible_document_metadata.cs b/src/DocumentDbTests/Metadata/flexible_document_metadata.cs index 016b89bbd5..5db2dbdbce 100644 --- a/src/DocumentDbTests/Metadata/flexible_document_metadata.cs +++ b/src/DocumentDbTests/Metadata/flexible_document_metadata.cs @@ -22,6 +22,7 @@ public class MetadataTarget public Dictionary Headers { get; set; } public DateTimeOffset LastModified { get; set; } + public DateTimeOffset CreatedOn { get; set; } } @@ -173,6 +174,59 @@ public async Task save_and_load_metadata_last_modified_by() } } +public class when_mapping_to_the_created_at_metadata: FlexibleDocumentMetadataContext +{ + protected override void MetadataIs(MartenRegistry.DocumentMappingExpression.MetadataConfig metadata) + { + metadata.CreatedAt.MapTo(x => x.CreatedOn); + } + + // #4575: in v8 the codegen storage path projected mt_created_at back + // onto the mapped member; the v9 ClosedShape rewrite missed wiring it + // (every other metadata column was ported) so CreatedOn stayed at + // default(DateTimeOffset) after reload. + [Fact] + public async Task created_at_is_populated_on_query_only() + { + var doc = new MetadataTarget(); + var before = DateTimeOffset.UtcNow.AddSeconds(-1); + + theSession.Store(doc); + await theSession.SaveChangesAsync(); + + await using var query = theStore.QuerySession(); + var loaded = await query.LoadAsync(doc.Id); + + loaded.CreatedOn.ShouldNotBe(default); + loaded.CreatedOn.ShouldBeGreaterThanOrEqualTo(before); + } + + [Fact] + public async Task created_at_is_immutable_across_updates() + { + var doc = new MetadataTarget(); + theSession.Store(doc); + await theSession.SaveChangesAsync(); + + DateTimeOffset firstCreated; + await using (var query = theStore.QuerySession()) + { + var loaded = await query.LoadAsync(doc.Id); + firstCreated = loaded.CreatedOn; + } + + // Force an update and confirm CreatedOn doesn't get clobbered by the new write. + await Task.Delay(50); + doc.Name = "renamed"; + theSession.Store(doc); + await theSession.SaveChangesAsync(); + + await using var query2 = theStore.QuerySession(); + var afterUpdate = await query2.LoadAsync(doc.Id); + afterUpdate.CreatedOn.ShouldBe(firstCreated); + } +} + public class when_turning_off_all_optional_metadata: FlexibleDocumentMetadataContext { protected override void MetadataIs(MartenRegistry.DocumentMappingExpression.MetadataConfig metadata) diff --git a/src/Marten/Internal/ClosedShape/DocumentCreatedAtBinder.cs b/src/Marten/Internal/ClosedShape/DocumentCreatedAtBinder.cs new file mode 100644 index 0000000000..adae13bf02 --- /dev/null +++ b/src/Marten/Internal/ClosedShape/DocumentCreatedAtBinder.cs @@ -0,0 +1,70 @@ +#nullable enable +using System; +using System.Data.Common; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using JasperFx.Core.Reflection; +using Marten.Internal; +using Npgsql; + +namespace Marten.Internal.ClosedShape; + +/// +/// Read-only for the +/// mt_created_at column. Issue #4575: in v8 the codegen storage path +/// projected the column back onto a [CreatedAt]-annotated / +/// Metadata(m => m.CreatedAt.MapTo(...)) member; the v9 closed-shape +/// rewrite (#4498) ported every other metadata column but missed this one, +/// so the .NET member stayed at default(DateTimeOffset) after a +/// reload. +/// +/// Unlike , this binder is +/// added to readBinders only — never writeBinders. The +/// underlying carries +/// a transaction_timestamp() DEFAULT, so PostgreSQL fills the value +/// on insert; participating in writeBinders would put the column +/// into the UPDATE SET list too and clobber the original creation time on +/// every subsequent save. +/// +/// +internal sealed class DocumentCreatedAtBinder: IDocumentMetadataBinder + where TDoc : notnull +{ + private readonly Action? _setter; + + public DocumentCreatedAtBinder(MemberInfo? createdAtMember) + { + if (createdAtMember is not null) + { + _setter = LambdaBuilder.Setter(createdAtMember); + } + } + + public string ColumnName => Marten.Schema.SchemaConstants.CreatedAtColumn; + + // No write path — the column's transaction_timestamp() DEFAULT does + // the work on insert, and CreatedAt is immutable after that. ValueSql + // is non-"?" so IsServerSide is true; even if a future caller adds + // this binder to writeBinders, BindParameter throwing keeps the + // "never written client-side" invariant honest. + public string ValueSql => "transaction_timestamp()"; + + public void BindParameter(NpgsqlParameter parameter, TDoc document, IMartenSession session) + => throw new NotSupportedException( + "mt_created_at has a server-side DEFAULT and is never written through this binder."); + + public void Apply(DbDataReader reader, int columnOrdinal, TDoc document, IMartenSession session) + { + if (_setter is null) return; + if (reader.IsDBNull(columnOrdinal)) return; + + var ts = reader.GetFieldValue(columnOrdinal); + _setter(document, ts); + } + + public Task WriteToBulkAsync(NpgsqlBinaryImporter writer, TDoc document, + ISerializer serializer, CancellationToken cancellation) + => throw new NotSupportedException( + "mt_created_at is filled by the column DEFAULT during bulk load; this binder is read-only."); +} diff --git a/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs b/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs index 9a636b454a..7d27e0865d 100644 --- a/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs +++ b/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs @@ -114,6 +114,19 @@ public static DocumentStorageDescriptor Build( } } + // #4575: mt_created_at sits in the table between mt_last_modified + // and mt_dotnet_type (DocumentTable line 51), and DotNetTypeColumn + // is NOT ISelectableColumn, so in the SELECT projection it lands + // immediately after mt_last_modified. CreatedAtColumn.ShouldSelect + // is Member != null — read-only: the column has a + // transaction_timestamp() DEFAULT for insert and is never updated + // afterwards, so we deliberately keep it out of writeBinders to + // avoid clobbering the creation time on subsequent saves. + if (mapping.Metadata.CreatedAt.Enabled && mapping.Metadata.CreatedAt.Member is not null) + { + readBinders.Add(new DocumentCreatedAtBinder(mapping.Metadata.CreatedAt.Member)); + } + // Session-derived metadata columns: correlation_id, causation_id, // last_modified_by, headers. Each column gets a write slot when // enabled (the value comes from IMartenSession on every write,