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
82 changes: 66 additions & 16 deletions src/Marten.NodaTime.Testing/Acceptance/noda_time_acceptance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,59 @@ public async Task can_append_and_query_events(SerializerType serializerType)
[InlineData(SerializerType.Newtonsoft)]
public async Task bug_1276_can_select_instant(SerializerType serializerType)
{
return; // TODO -- FIX THIS
// NOTE: the cast/rounding scenario described by this comment won't happen on MacOS
// since it doesn't provide nanosecond precision since High Sierra
// https://github.com/golang/go/issues/22037
//
//
// .NET date/time types have tick precision (100ns)
// Postgres date/time types have microsecond precision
//
//
// When an Instant is saved as a property in a document, it's converted to a string
// using NodaTime.Text.InstantPattern.ExtendedIso, and it contains the full tick precision
// e.g. "2025-11-23T20:36:25.9226214Z"
//
// When this document is queried, there are two scenarios:
// 1. The full document is queried directly using LINQ, the Instant property is deserialized using
// the string value with the full tick precision.
//
// 2. The document is queried using LINQ and projected with Select with the Instant property being
// selected - the property string from the database is cast to a 'timestamp with time zone' postgresql
// type.
// This is where the value is truncated to microseconds and round half up is used on the
// tick remainder. It also results in a different string format so a fallback deserialization pattern
// is used.
//
// Rounding example:
// SELECT
// CAST ('2025-11-23T20:36:25.9226214Z' AS TIMESTAMP WITH TIME ZONE) no_round,
// CAST ('2025-11-23T20:36:25.9226215Z' AS TIMESTAMP WITH TIME ZONE) round;
//
// will result in
// no_round = 2025-11-23 20:36:25.922621 +00:00
// round = 2025-11-23 20:36:25.922622 +00:00
//
// This is why ShouldBeEqualWithDbPrecision assertion is used which does the following:
// 1. truncates both Instants to microseconds and compares them
// 2. allows for a 1 microsecond tolerance to account for the potential rounding of the remaining ticks

StoreOptions(_ => _.UseNodaTime());
StoreOptions(opts =>
{
switch (serializerType)
{
case SerializerType.Newtonsoft:
opts.UseNewtonsoftForSerialization();
break;
case SerializerType.SystemTextJson:
opts.UseSystemTextJsonForSerialization();
break;
default:
throw new ArgumentOutOfRangeException(nameof(serializerType), serializerType, null);
}

opts.UseNodaTime();
});

var dateTime = DateTime.UtcNow;
var instantUTC = Instant.FromDateTimeUtc(dateTime.ToUniversalTime());
Expand All @@ -243,18 +293,26 @@ public async Task bug_1276_can_select_instant(SerializerType serializerType)

using (var query = theStore.QuerySession())
{
var resulta = query.Query<TargetWithDates>()
.Where(c => c.Id == testDoc.Id)
.Single();
var result = query
.Query<TargetWithDates>()
.Single(c => c.Id == testDoc.Id);

var result = query.Query<TargetWithDates>()
var resultWithSelect = query.Query<TargetWithDates>()
.Where(c => c.Id == testDoc.Id)
.Select(c => new { c.Id, c.InstantUTC })
.Select(c => new { c.Id, c.InstantUTC, c.NullableInstantUTC, c.NullInstantUTC })
.Single();

result.ShouldNotBeNull();
result.Id.ShouldBe(testDoc.Id);
ShouldBeEqualWithDbPrecision(result.InstantUTC, instantUTC);
result.NullInstantUTC.ShouldBeNull();
result.InstantUTC.ShouldBeEqualWithDbPrecision(instantUTC);
result.NullableInstantUTC!.Value.ShouldBeEqualWithDbPrecision(instantUTC);

resultWithSelect.ShouldNotBeNull();
resultWithSelect.Id.ShouldBe(testDoc.Id);
resultWithSelect.NullInstantUTC.ShouldBeNull();
resultWithSelect.InstantUTC.ShouldBeEqualWithDbPrecision(instantUTC);
resultWithSelect.NullableInstantUTC!.Value.ShouldBeEqualWithDbPrecision(instantUTC);
}
}

Expand Down Expand Up @@ -324,12 +382,4 @@ public string ToJson(object document)
throw new NotSupportedException();
}
}

private static void ShouldBeEqualWithDbPrecision(Instant actual, Instant expected)
{
static Instant toDbPrecision(Instant date) => Instant.FromUnixTimeMilliseconds(date.ToUnixTimeMilliseconds() / 100 * 100);

toDbPrecision(actual).ShouldBe(toDbPrecision(expected));
}

}
31 changes: 31 additions & 0 deletions src/Marten.NodaTime.Testing/ShouldlyExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using NodaTime;
using NodaTime.Text;
using Shouldly;

namespace Marten.NodaTimePlugin.Testing;

internal static class NodaTimeShouldyExtensions
{
private const long TicksPerMicrosecond = 10;
private const long MaximumMillisecondDifference = 1;

public static void ShouldBeEqualWithDbPrecision(this Instant actual, Instant expected)
{

var actualMicroseconds = ToUnixTimeMicroseconds(actual);
var expectedMicroseconds = ToUnixTimeMicroseconds(expected);


var diff = Math.Abs(actualMicroseconds - expectedMicroseconds);
diff.AssertAwesomely(
d => d <= MaximumMillisecondDifference,
InstantPattern.ExtendedIso.Format(actual),
InstantPattern.ExtendedIso.Format(expected));
}

private static long ToUnixTimeMicroseconds(Instant date)
{
return date.ToUnixTimeTicks() / TicksPerMicrosecond;
}
}
4 changes: 3 additions & 1 deletion src/Marten.NodaTime.Testing/TestData/TargetWithDates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class TargetWithDates: IEquatable<TargetWithDates>
public LocalDateTime? NullableLocalDateTime { get; set; }
public Instant InstantUTC { get; set; }
public Instant? NullableInstantUTC { get; set; }
public Instant? NullInstantUTC { get; set; }

internal static TargetWithDates Generate(DateTime? defaultDateTime = null)
{
Expand All @@ -36,7 +37,8 @@ internal static TargetWithDates Generate(DateTime? defaultDateTime = null)
LocalDateTime = localDateTime,
NullableLocalDateTime = localDateTime,
InstantUTC = instant,
NullableInstantUTC = instant
NullableInstantUTC = instant,
NullInstantUTC = null
};
}

Expand Down
72 changes: 72 additions & 0 deletions src/Marten.NodaTime/InstantJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System;
using System.Text.Json;
using Newtonsoft.Json;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using NodaTime.Text;
using NodaTime.Utility;
using JsonSerializer = Newtonsoft.Json.JsonSerializer;

namespace Marten.NodaTimePlugin;

public class InstantJsonConverter
{
public static readonly StjConverter Stj = new();
public static readonly NewtonsoftConverter Newtonsoft = new();

private static readonly InstantPattern InstantIsoPattern = InstantPattern.ExtendedIso;

private static readonly OffsetDateTimePattern InstantOffsetPattern =
OffsetDateTimePattern.CreateWithInvariantCulture("uuuu'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFo<G>");

public class StjConverter: NodaConverterBase<Instant>
{
protected override Instant ReadJsonImpl(ref Utf8JsonReader reader, JsonSerializerOptions options)
{
var text = reader.GetString()!;
return ParseInstant(text);
}

protected override void WriteJsonImpl(Utf8JsonWriter writer, Instant value, JsonSerializerOptions options)
{
writer.WriteStringValue(InstantIsoPattern.Format(value));
}
}

public class NewtonsoftConverter: NodaTime.Serialization.JsonNet.NodaConverterBase<Instant>
{
protected override Instant ReadJsonImpl(JsonReader reader, JsonSerializer serializer)
{
if (reader.TokenType != JsonToken.String)
{
throw new InvalidNodaDataException(
$"Unexpected token parsing {nameof(Instant)}. Expected String, got {reader.TokenType}.");
}

var text = reader.Value!.ToString();
return ParseInstant(text!);
}

protected override void WriteJsonImpl(JsonWriter writer, Instant value, JsonSerializer serializer)
{
writer.WriteValue(InstantIsoPattern.Format(value));
}
}

private static Instant ParseInstant(string text)
{
var isoParseResult = InstantIsoPattern.Parse(text);
if (isoParseResult.Success)
{
return isoParseResult.Value;
}

var offsetParseResult = InstantOffsetPattern.Parse(text);
if (offsetParseResult.Success)
{
return offsetParseResult.Value.ToInstant();
}

throw new AggregateException(isoParseResult.Exception, offsetParseResult.Exception);
}
}
11 changes: 9 additions & 2 deletions src/Marten.NodaTime/NodaTimeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Npgsql;
using NpgsqlTypes;
using Weasel.Postgresql;
using NodaJsonSettings = NodaTime.Serialization.JsonNet.NodaJsonSettings;

namespace Marten.NodaTimePlugin;

Expand All @@ -34,13 +35,19 @@ public static void UseNodaTime(this StoreOptions storeOptions, bool shouldConfig
case JsonNetSerializer jsonNetSerializer:
jsonNetSerializer.Configure(s =>
{
s.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
s.ConfigureForNodaTime(new NodaJsonSettings(DateTimeZoneProviders.Tzdb)
{
InstantConverter = InstantJsonConverter.Newtonsoft
});
});
break;
case SystemTextJsonSerializer systemTextJsonSerializer:
systemTextJsonSerializer.Configure(s =>
{
s.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
s.ConfigureForNodaTime(new NodaTime.Serialization.SystemTextJson.NodaJsonSettings(DateTimeZoneProviders.Tzdb)
{
InstantConverter = InstantJsonConverter.Stj
});
});
break;
default:
Expand Down
Loading