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
54 changes: 54 additions & 0 deletions src/DocumentDbTests/Metadata/flexible_document_metadata.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class MetadataTarget

public Dictionary<string, object> Headers { get; set; }
public DateTimeOffset LastModified { get; set; }
public DateTimeOffset CreatedOn { get; set; }
}


Expand Down Expand Up @@ -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<MetadataTarget>.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<MetadataTarget>(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<MetadataTarget>(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<MetadataTarget>(doc.Id);
afterUpdate.CreatedOn.ShouldBe(firstCreated);
}
}

public class when_turning_off_all_optional_metadata: FlexibleDocumentMetadataContext
{
protected override void MetadataIs(MartenRegistry.DocumentMappingExpression<MetadataTarget>.MetadataConfig metadata)
Expand Down
70 changes: 70 additions & 0 deletions src/Marten/Internal/ClosedShape/DocumentCreatedAtBinder.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Read-only <see cref="IDocumentMetadataBinder{TDoc}"/> for the
/// <c>mt_created_at</c> column. Issue #4575: in v8 the codegen storage path
/// projected the column back onto a <c>[CreatedAt]</c>-annotated /
/// <c>Metadata(m =&gt; m.CreatedAt.MapTo(...))</c> member; the v9 closed-shape
/// rewrite (#4498) ported every other metadata column but missed this one,
/// so the .NET member stayed at <c>default(DateTimeOffset)</c> after a
/// reload.
/// <para>
/// Unlike <see cref="DocumentLastModifiedBinder{TDoc}"/>, this binder is
/// added to <c>readBinders</c> only — never <c>writeBinders</c>. The
/// underlying <see cref="Marten.Storage.Metadata.CreatedAtColumn"/> carries
/// a <c>transaction_timestamp()</c> DEFAULT, so PostgreSQL fills the value
/// on insert; participating in <c>writeBinders</c> would put the column
/// into the UPDATE SET list too and clobber the original creation time on
/// every subsequent save.
/// </para>
/// </summary>
internal sealed class DocumentCreatedAtBinder<TDoc>: IDocumentMetadataBinder<TDoc>
where TDoc : notnull
{
private readonly Action<TDoc, DateTimeOffset>? _setter;

public DocumentCreatedAtBinder(MemberInfo? createdAtMember)
{
if (createdAtMember is not null)
{
_setter = LambdaBuilder.Setter<TDoc, DateTimeOffset>(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<DateTimeOffset>(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.");
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ public static DocumentStorageDescriptor<TDoc, TId> Build<TDoc, TId>(
}
}

// #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<TDoc>(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,
Expand Down
Loading