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
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,19 @@ SqlExpression Upper()
private SqlExpression? TranslateDateTime(SqlExpression instance, MemberInfo member)
=> member.Name switch
{
"Year" or "Years" => GetDatePartExpression(instance, "year"),
// PostgreSQL has no year zero. trunc(year - 0.5) + 1 preserves positive years and increments negative years,
// matching NodaTime's astronomical numbering without evaluating the date expression more than once.
"Year" => _sqlExpressionFactory.Add(
_sqlExpressionFactory.Convert(
_sqlExpressionFactory.Function(
"trunc",
[_sqlExpressionFactory.Subtract(GetDatePartExpressionDouble(instance, "year"), _sqlExpressionFactory.Constant(0.5))],
nullable: true,
argumentsPropagateNullability: TrueArrays[1],
typeof(double)),
typeof(int)),
_sqlExpressionFactory.Constant(1)),
"Years" => GetDatePartExpression(instance, "year"),
"Month" or "Months" => GetDatePartExpression(instance, "month"),
"DayOfYear" => GetDatePartExpression(instance, "doy"),
"Day" or "Days" => GetDatePartExpression(instance, "day"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@ private readonly NpgsqlJsonPocoTranslator _jsonPocoTranslator
private readonly RelationalTypeMapping _timestampMapping = dependencies.TypeMappingSource.FindMapping("timestamp without time zone")!;
private readonly RelationalTypeMapping _timestampTzMapping = dependencies.TypeMappingSource.FindMapping("timestamp with time zone")!;

private static Type? _nodaTimePeriodType;

private static readonly MethodInfo EscapeLikePatternParameterMethod =
typeof(NpgsqlSqlTranslatingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(ConstructLikePatternParameter))!;

Expand Down Expand Up @@ -228,9 +226,8 @@ protected override Expression VisitBinary(BinaryExpression binaryExpression)
binaryExpression.NodeType != ExpressionType.ArrayIndex,
"During preprocessing, ArrayIndex and List[] get normalized to ElementAt; see NpgsqlArrayTranslator. Should never see ArrayIndex.");

// We pattern match date subtraction before calling base.VisitBinary() as that would produce the default a - b translation, but that
// yields the number of days as an integer; this is incompatible with the .NET side, where LocalDate - LocalDate = Period, and Period
// is mapped to PG interval. So we override subtraction to add make_interval.
// PostgreSQL date subtraction returns an integer number of days; age() returns a calendar period instead.
// Its month-end handling differs from NodaTime's. Cast to timestamp to avoid time zone-dependent arithmetic.
if (binaryExpression.NodeType is ExpressionType.Subtract
&& binaryExpression.Left.Type.UnwrapNullableType().FullName == "NodaTime.LocalDate"
&& binaryExpression.Right.Type.UnwrapNullableType().FullName == "NodaTime.LocalDate")
Expand All @@ -241,18 +238,15 @@ protected override Expression VisitBinary(BinaryExpression binaryExpression)
return QueryCompilationContext.NotTranslatedExpression;
}

var subtraction = _sqlExpressionFactory.MakeBinary(
ExpressionType.Subtract, sqlLeft!, sqlRight!, _typeMappingSource.FindMapping(typeof(int)))!;

return PgFunctionExpression.CreateWithNamedArguments(
"make_interval",
[subtraction],
["days"],
return _sqlExpressionFactory.Function(
"age",
[
_sqlExpressionFactory.Convert(sqlLeft!, typeof(DateTime), _timestampMapping),
_sqlExpressionFactory.Convert(sqlRight!, typeof(DateTime), _timestampMapping)
],
nullable: true,
argumentsPropagateNullability: TrueArrays[1],
builtIn: true,
_nodaTimePeriodType ??= binaryExpression.Left.Type.Assembly.GetType("NodaTime.Period")!,
typeMapping: null);
argumentsPropagateNullability: TrueArrays[2],
binaryExpression.Type);

// Note: many other date/time arithmetic operators are fully supported as-is by PostgreSQL - see NpgsqlSqlExpressionFactory
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,42 @@ await AssertQuery(
"""
SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime"
FROM "NodaTimeTypes" AS n
WHERE date_part('year', n."LocalDateTime")::int = 2018
WHERE trunc(date_part('year', n."LocalDateTime") - 0.5)::int + 1 = 2018
""");
}

[ConditionalTheory]
[InlineData(false, -100)]
[InlineData(true, -100)]
[InlineData(false, 0)]
[InlineData(true, 0)]
[InlineData(false, 1)]
[InlineData(true, 1)]
public async Task Year_astronomical(bool async, int year)
{
await using var context = CreateContext();
await context.Database.CreateExecutionStrategy().ExecuteAsync(
async () =>
{
await using var transaction = await context.Database.BeginTransactionAsync();

var entity = await context.NodaTimeTypes.SingleAsync();
entity.LocalDateTime = new LocalDateTime(year, 12, 31, 12, 0);
await context.SaveChangesAsync();
Fixture.TestSqlLoggerFactory.Clear();

var query = context.NodaTimeTypes.Select(t => t.LocalDateTime.Year);
Assert.Equal(year, async ? await query.SingleAsync() : query.Single());

AssertSql(
"""
SELECT trunc(date_part('year', n."LocalDateTime") - 0.5)::int + 1
FROM "NodaTimeTypes" AS n
LIMIT 2
""");
});
}

[ConditionalTheory]
[MemberData(nameof(IsAsyncData))]
public async Task Month(bool async)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ await AssertQuery(
"""
SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime"
FROM "NodaTimeTypes" AS n
WHERE make_interval(days => n."LocalDate2" - n."LocalDate") = INTERVAL 'P1D'
WHERE age(n."LocalDate2"::timestamp, n."LocalDate"::timestamp) = INTERVAL 'P1D'
""");
}

Expand All @@ -60,7 +60,7 @@ await AssertQuery(

SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime"
FROM "NodaTimeTypes" AS n
WHERE make_interval(days => n."LocalDate2" - @date) = INTERVAL 'P1D'
WHERE age(n."LocalDate2"::timestamp, @date::timestamp) = INTERVAL 'P1D'
""");
}

Expand Down Expand Up @@ -92,7 +92,57 @@ await AssertQuery(
"""
SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime"
FROM "NodaTimeTypes" AS n
WHERE make_interval(days => n."LocalDate2" - DATE '2018-04-20') = INTERVAL 'P1D'
WHERE age(n."LocalDate2"::timestamp, DATE '2018-04-20'::timestamp) = INTERVAL 'P1D'
""");
}

[ConditionalTheory]
[MemberData(nameof(IsAsyncData))]
public async Task Subtract_LocalDate_years_months_days(bool async)
{
await AssertQuery(
async,
ss => ss.Set<NodaTimeTypes>()
.Select(t => t.LocalDate - new LocalDate(2017, 3, 19))
.Where(p => p.Years == 1 && p.Months == 1 && p.Days == 1));

AssertSql(
"""
SELECT age(n."LocalDate"::timestamp, DATE '2017-03-19'::timestamp)
FROM "NodaTimeTypes" AS n
WHERE date_part('year', age(n."LocalDate"::timestamp, DATE '2017-03-19'::timestamp))::int = 1 AND date_part('month', age(n."LocalDate"::timestamp, DATE '2017-03-19'::timestamp))::int = 1 AND date_part('day', age(n."LocalDate"::timestamp, DATE '2017-03-19'::timestamp))::int = 1
""");
}

[ConditionalTheory]
[MemberData(nameof(IsAsyncData))]
public async Task Subtract_LocalDate_negative(bool async)
{
await AssertQuery(
async,
ss => ss.Set<NodaTimeTypes>().Select(t => new LocalDate(2017, 3, 19) - t.LocalDate));

AssertSql(
"""
SELECT age(DATE '2017-03-19'::timestamp, n."LocalDate"::timestamp)
FROM "NodaTimeTypes" AS n
""");
}

[ConditionalTheory]
[MemberData(nameof(IsAsyncData))]
public async Task Subtract_LocalDate_month_end(bool async)
{
// PostgreSQL age() returns 30 days for March 31 to April 30, whereas NodaTime returns one month.
await AssertQuery(
async,
ss => ss.Set<NodaTimeTypes>().Select(t => t.LocalDate + Period.FromDays(10) - new LocalDate(2018, 3, 31)),
expectedQuery: ss => ss.Set<NodaTimeTypes>().Select(t => Period.FromDays(30)));

AssertSql(
"""
SELECT age(CAST(n."LocalDate" + INTERVAL 'P10D' AS timestamp without time zone), DATE '2018-03-31'::timestamp)
FROM "NodaTimeTypes" AS n
""");
}

Expand All @@ -108,8 +158,40 @@ await AssertQuery(
"""
SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime"
FROM "NodaTimeTypes" AS n
WHERE date_part('year', n."LocalDate")::int = 2018
WHERE trunc(date_part('year', n."LocalDate") - 0.5)::int + 1 = 2018
""");
}

[ConditionalTheory]
[InlineData(false, -100)]
[InlineData(true, -100)]
[InlineData(false, 0)]
[InlineData(true, 0)]
[InlineData(false, 1)]
[InlineData(true, 1)]
public async Task Year_astronomical(bool async, int year)
{
await using var context = CreateContext();
await context.Database.CreateExecutionStrategy().ExecuteAsync(
async () =>
{
await using var transaction = await context.Database.BeginTransactionAsync();

var entity = await context.NodaTimeTypes.SingleAsync();
entity.LocalDate = new LocalDate(year, 12, 31);
await context.SaveChangesAsync();
Fixture.TestSqlLoggerFactory.Clear();

var query = context.NodaTimeTypes.Select(t => t.LocalDate.Year);
Assert.Equal(year, async ? await query.SingleAsync() : query.Single());

AssertSql(
"""
SELECT trunc(date_part('year', n."LocalDate") - 0.5)::int + 1
FROM "NodaTimeTypes" AS n
LIMIT 2
""");
});
}

[ConditionalTheory]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ WHERE date_part('year', n."Period")::int = 2018
""");
}

[ConditionalTheory]
[MemberData(nameof(IsAsyncData))]
public async Task Years_negative(bool async)
{
await AssertQuery(
async,
ss => ss.Set<NodaTimeTypes>().Select(t => Period.FromYears(-t.Id).Years));

AssertSql(
"""
SELECT date_part('year', make_interval(years => -n."Id"))::int
FROM "NodaTimeTypes" AS n
""");
}

[ConditionalTheory]
[MemberData(nameof(IsAsyncData))]
public async Task Months(bool async)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,49 @@ await AssertQuery(
"""
SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime"
FROM "NodaTimeTypes" AS n
WHERE date_part('year', n."ZonedDateTime" AT TIME ZONE 'UTC')::int = 2018
WHERE trunc(date_part('year', n."ZonedDateTime" AT TIME ZONE 'UTC') - 0.5)::int + 1 = 2018
""");
}

[ConditionalTheory]
[InlineData(false, -100)]
[InlineData(true, -100)]
[InlineData(false, 0)]
[InlineData(true, 0)]
[InlineData(false, 1)]
[InlineData(true, 1)]
public async Task Year_astronomical(bool async, int year)
{
await using var context = CreateContext();
await context.Database.CreateExecutionStrategy().ExecuteAsync(
async () =>
{
await using var transaction = await context.Database.BeginTransactionAsync();

var entity = await context.NodaTimeTypes.SingleAsync();
entity.ZonedDateTime = new LocalDateTime(year, 12, 31, 12, 0).InUtc();
await context.SaveChangesAsync();
Fixture.TestSqlLoggerFactory.Clear();

var query = context.NodaTimeTypes.Select(
t => new
{
t.ZonedDateTime.Year,
YearInZone = t.ZonedDateTime.ToInstant().InZone(DateTimeZoneProviders.Tzdb["Europe/Berlin"]).Year
});
var result = async ? await query.SingleAsync() : query.Single();
Assert.Equal(year, result.Year);
Assert.Equal(entity.ZonedDateTime.WithZone(DateTimeZoneProviders.Tzdb["Europe/Berlin"]).Year, result.YearInZone);

AssertSql(
"""
SELECT trunc(date_part('year', n."ZonedDateTime" AT TIME ZONE 'UTC') - 0.5)::int + 1 AS "Year", trunc(date_part('year', n."ZonedDateTime" AT TIME ZONE 'Europe/Berlin') - 0.5)::int + 1 AS "YearInZone"
FROM "NodaTimeTypes" AS n
LIMIT 2
""");
});
}

[ConditionalTheory]
[MemberData(nameof(IsAsyncData))]
public async Task Month(bool async)
Expand Down
Loading