Skip to content

Commit 7e58c1a

Browse files
committed
feat: add translation of min/max aggregates to postgres
1 parent 61a460f commit 7e58c1a

8 files changed

Lines changed: 426 additions & 1 deletion

File tree

.claude/CLAUDE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,20 @@ private NpgsqlBulkOperationExecutor SUT => field ??= ActDbContext.GetService<Npg
246246
| Bulk Insert from Query | Y | Y | Y |
247247
| Truncate Table (dedicated) | Y | Y | Y |
248248

249+
### PostgreSQL `max`/`min` over `uuid`
250+
251+
PostgreSQL has no native `max(uuid)`/`min(uuid)` aggregate (`max`/`min` over a `Guid`-mapped
252+
`uuid` column translates but fails at execution: `function max(uuid) does not exist`). The Npgsql
253+
provider auto-rewrites these to `max(col::text)::uuid` / `min(col::text)::uuid` (text ordering of
254+
canonical uuid strings matches uuid byte ordering, so results are identical). **Always-on** (no
255+
feature flag) via `NpgsqlAggregateMethodCallTranslatorPlugin`
256+
`NpgsqlUuidAggregateMethodCallTranslator` (`IAggregateMethodCallTranslator`), registered
257+
unconditionally in `NpgsqlDbContextOptionsExtension.ApplyServices`. Triggers only when the
258+
operand's `TypeMapping.StoreType == "uuid"` (covers `Guid` and `Guid?`; a `Guid``text`
259+
value-converter column is left untouched); returns `null` on every other path so EF falls through
260+
to the built-in translator. SQL Server (`uniqueidentifier`) and SQLite (Guid as text/blob) already
261+
support `max`/`min` and are not touched.
262+
249263
### Bulk Operation Details
250264

251265
- All return `Task<int>` (affected rows including owned entities)

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<Copyright>(c) $([System.DateTime]::Now.Year), Pawel Gerr. All rights reserved.</Copyright>
5-
<VersionPrefix>10.2.0</VersionPrefix>
5+
<VersionPrefix>10.3.0</VersionPrefix>
66
<Authors>Pawel Gerr</Authors>
77
<GenerateDocumentationFile>true</GenerateDocumentationFile>
88
<PackageProjectUrl>https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore</PackageProjectUrl>

src/Thinktecture.EntityFrameworkCore.PostgreSQL/EntityFrameworkCore/Infrastructure/NpgsqlDbContextOptionsExtension.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ public void ApplyServices(IServiceCollection services)
115115

116116
services.Add<IMethodCallTranslatorPlugin, NpgsqlMethodCallTranslatorPlugin>(GetLifetime<IMethodCallTranslatorPlugin>());
117117

118+
// Always-on: rewrites max(uuid)/min(uuid) into max(col::text)::uuid since PostgreSQL has no native uuid aggregate.
119+
services.Add<IAggregateMethodCallTranslatorPlugin, NpgsqlAggregateMethodCallTranslatorPlugin>(GetLifetime<IAggregateMethodCallTranslatorPlugin>());
120+
118121
if (AddCustomQueryableMethodTranslatingExpressionVisitorFactory)
119122
AddWithCheck<IQueryableMethodTranslatingExpressionVisitorFactory, ThinktectureNpgsqlQueryableMethodTranslatingExpressionVisitorFactory, NpgsqlQueryableMethodTranslatingExpressionVisitorFactory>(services);
120123

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using Microsoft.EntityFrameworkCore.Query;
2+
using Microsoft.EntityFrameworkCore.Storage;
3+
using Npgsql.EntityFrameworkCore.PostgreSQL.Query;
4+
5+
namespace Thinktecture.EntityFrameworkCore.Query.ExpressionTranslators;
6+
7+
/// <summary>
8+
/// Plugin for aggregate method call translators.
9+
/// </summary>
10+
public sealed class NpgsqlAggregateMethodCallTranslatorPlugin : IAggregateMethodCallTranslatorPlugin
11+
{
12+
/// <inheritdoc />
13+
public IEnumerable<IAggregateMethodCallTranslator> Translators { get; }
14+
15+
/// <summary>
16+
/// Initializes new instance of <see cref="NpgsqlAggregateMethodCallTranslatorPlugin"/>.
17+
/// </summary>
18+
/// <param name="sqlExpressionFactory">The SQL expression factory (Npgsql provider).</param>
19+
/// <param name="typeMappingSource">The relational type mapping source.</param>
20+
public NpgsqlAggregateMethodCallTranslatorPlugin(
21+
ISqlExpressionFactory sqlExpressionFactory,
22+
IRelationalTypeMappingSource typeMappingSource)
23+
{
24+
ArgumentNullException.ThrowIfNull(sqlExpressionFactory);
25+
ArgumentNullException.ThrowIfNull(typeMappingSource);
26+
27+
// AggregateFunction is defined on the concrete Npgsql factory; on the Npgsql provider
28+
// ISqlExpressionFactory always resolves to NpgsqlSqlExpressionFactory.
29+
var npgsqlSqlExpressionFactory = (NpgsqlSqlExpressionFactory)sqlExpressionFactory;
30+
31+
Translators =
32+
[
33+
new NpgsqlUuidAggregateMethodCallTranslator(npgsqlSqlExpressionFactory, typeMappingSource)
34+
];
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Reflection;
3+
using Microsoft.EntityFrameworkCore.Diagnostics;
4+
using Microsoft.EntityFrameworkCore.Query;
5+
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
6+
using Microsoft.EntityFrameworkCore.Storage;
7+
using Npgsql.EntityFrameworkCore.PostgreSQL.Query;
8+
9+
namespace Thinktecture.EntityFrameworkCore.Query.ExpressionTranslators;
10+
11+
/// <summary>
12+
/// Rewrites <c>max</c>/<c>min</c> aggregates over <c>uuid</c> columns into
13+
/// <c>max(col::text)::uuid</c> / <c>min(col::text)::uuid</c> because PostgreSQL has no native
14+
/// <c>max(uuid)</c>/<c>min(uuid)</c> aggregate.
15+
/// </summary>
16+
/// <remarks>
17+
/// Returns <c>null</c> on every non-matching path so EF Core falls through to the built-in
18+
/// Npgsql aggregate translator unchanged. Aggregate plugins run before built-in translators, so
19+
/// for <c>uuid</c> operands this translator takes over.
20+
/// </remarks>
21+
internal sealed class NpgsqlUuidAggregateMethodCallTranslator : IAggregateMethodCallTranslator
22+
{
23+
private readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory;
24+
private readonly IRelationalTypeMappingSource _typeMappingSource;
25+
26+
public NpgsqlUuidAggregateMethodCallTranslator(
27+
NpgsqlSqlExpressionFactory sqlExpressionFactory,
28+
IRelationalTypeMappingSource typeMappingSource)
29+
{
30+
_sqlExpressionFactory = sqlExpressionFactory ?? throw new ArgumentNullException(nameof(sqlExpressionFactory));
31+
_typeMappingSource = typeMappingSource ?? throw new ArgumentNullException(nameof(typeMappingSource));
32+
}
33+
34+
/// <inheritdoc />
35+
[SuppressMessage("Usage", "EF1001", MessageId = "Internal EF Core API usage.")]
36+
public SqlExpression? Translate(
37+
MethodInfo method,
38+
EnumerableExpression source,
39+
IReadOnlyList<SqlExpression> arguments,
40+
IDiagnosticsLogger<DbLoggerCategory.Query> logger)
41+
{
42+
if (method.DeclaringType != typeof(Queryable))
43+
return null;
44+
45+
var methodInfo = method.IsGenericMethod ? method.GetGenericMethodDefinition() : method;
46+
47+
var functionName = GetAggregateFunctionName(methodInfo);
48+
49+
if (functionName is null)
50+
return null;
51+
52+
// EnumerableExpression.Selector is typed as Expression; only a SqlExpression can carry a type mapping.
53+
if (source.Selector is not SqlExpression selector)
54+
return null;
55+
56+
// Trigger exclusively on the "uuid" store type. This covers Guid and Guid?, while a Guid
57+
// mapped to e.g. "text" via a value converter is correctly left to the built-in translator.
58+
if (selector.TypeMapping?.StoreType != "uuid")
59+
return null;
60+
61+
var textMapping = _typeMappingSource.FindMapping(typeof(string));
62+
63+
// max(col::text) / min(col::text). Passing "source" carries DISTINCT/FILTER/ORDER BY modifiers.
64+
var castToText = _sqlExpressionFactory.Convert(selector, typeof(string), textMapping);
65+
var aggregate = _sqlExpressionFactory.AggregateFunction(
66+
functionName,
67+
[castToText],
68+
source,
69+
nullable: true,
70+
argumentsPropagateNullability: [false],
71+
returnType: typeof(string),
72+
typeMapping: textMapping);
73+
74+
// (max(col::text))::uuid — reuse the original uuid mapping and CLR type (Guid or Guid?).
75+
return _sqlExpressionFactory.Convert(aggregate, selector.Type, selector.TypeMapping);
76+
}
77+
78+
private static string? GetAggregateFunctionName(MethodInfo methodInfo)
79+
{
80+
if (methodInfo == QueryableMethods.MaxWithoutSelector || methodInfo == QueryableMethods.MaxWithSelector)
81+
return "max";
82+
83+
if (methodInfo == QueryableMethods.MinWithoutSelector || methodInfo == QueryableMethods.MinWithSelector)
84+
return "min";
85+
86+
return null;
87+
}
88+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using Thinktecture.TestDatabaseContext;
2+
3+
namespace Thinktecture.Query;
4+
5+
// ReSharper disable once InconsistentNaming
6+
public class MaxMinUuidAggregate : IntegrationTestsBase
7+
{
8+
// Text-ordered ascending so the expected max/min are unambiguous (uuid::text ordering == uuid byte ordering).
9+
private static readonly Guid _id1 = new("11111111-1111-1111-1111-111111111111");
10+
private static readonly Guid _id2 = new("22222222-2222-2222-2222-222222222222");
11+
private static readonly Guid _id3 = new("33333333-3333-3333-3333-333333333333");
12+
private static readonly Guid _id4 = new("44444444-4444-4444-4444-444444444444");
13+
14+
public MaxMinUuidAggregate(ITestOutputHelper testOutputHelper, NpgsqlFixture npgsqlFixture)
15+
: base(testOutputHelper, npgsqlFixture)
16+
{
17+
}
18+
19+
[Fact]
20+
public async Task Should_translate_Max_over_uuid_grouping()
21+
{
22+
ArrangeDbContext.TestEntities.AddRange(
23+
new TestEntity { Id = _id1, Count = 1, RequiredName = "R" },
24+
new TestEntity { Id = _id2, Count = 1, RequiredName = "R" },
25+
new TestEntity { Id = _id3, Count = 2, RequiredName = "R" },
26+
new TestEntity { Id = _id4, Count = 2, RequiredName = "R" });
27+
await ArrangeDbContext.SaveChangesAsync();
28+
29+
var result = await ActDbContext.TestEntities
30+
.GroupBy(e => e.Count)
31+
.Select(g => new { Count = g.Key, MaxId = g.Max(e => e.Id) })
32+
.OrderBy(x => x.Count)
33+
.ToListAsync();
34+
35+
result.Should().HaveCount(2);
36+
result[0].MaxId.Should().Be(_id2);
37+
result[1].MaxId.Should().Be(_id4);
38+
39+
ExecutedCommands.Last().Should().Contain("max").And.Contain("text").And.Contain("uuid");
40+
}
41+
42+
[Fact]
43+
public async Task Should_translate_Min_over_uuid_grouping()
44+
{
45+
ArrangeDbContext.TestEntities.AddRange(
46+
new TestEntity { Id = _id1, Count = 1, RequiredName = "R" },
47+
new TestEntity { Id = _id2, Count = 1, RequiredName = "R" },
48+
new TestEntity { Id = _id3, Count = 2, RequiredName = "R" },
49+
new TestEntity { Id = _id4, Count = 2, RequiredName = "R" });
50+
await ArrangeDbContext.SaveChangesAsync();
51+
52+
var result = await ActDbContext.TestEntities
53+
.GroupBy(e => e.Count)
54+
.Select(g => new { Count = g.Key, MinId = g.Min(e => e.Id) })
55+
.OrderBy(x => x.Count)
56+
.ToListAsync();
57+
58+
result.Should().HaveCount(2);
59+
result[0].MinId.Should().Be(_id1);
60+
result[1].MinId.Should().Be(_id3);
61+
62+
ExecutedCommands.Last().Should().Contain("min").And.Contain("text").And.Contain("uuid");
63+
}
64+
65+
[Fact]
66+
public async Task Should_translate_Max_over_nullable_uuid()
67+
{
68+
// Parents (Count = 0) referenced by ParentId; excluded from the aggregated group via the Where filter.
69+
ArrangeDbContext.TestEntities.AddRange(
70+
new TestEntity { Id = _id1, Count = 0, RequiredName = "R" },
71+
new TestEntity { Id = _id2, Count = 0, RequiredName = "R" });
72+
ArrangeDbContext.TestEntities.AddRange(
73+
new TestEntity { Id = _id3, Count = 7, ParentId = null, RequiredName = "R" },
74+
new TestEntity { Id = _id4, Count = 7, ParentId = _id1, RequiredName = "R" },
75+
new TestEntity { Id = new("55555555-5555-5555-5555-555555555555"), Count = 7, ParentId = _id2, RequiredName = "R" });
76+
await ArrangeDbContext.SaveChangesAsync();
77+
78+
var result = await ActDbContext.TestEntities
79+
.Where(e => e.Count == 7)
80+
.GroupBy(e => e.Count)
81+
.Select(g => g.Max(e => e.ParentId))
82+
.ToListAsync();
83+
84+
result.Should().ContainSingle();
85+
result[0].Should().Be(_id2); // max(ParentId) over {null, _id1, _id2} == _id2, nulls ignored
86+
}
87+
88+
[Fact]
89+
public async Task Should_translate_full_ntile_groupby_max_pipeline()
90+
{
91+
const int rangeCount = 2;
92+
93+
ArrangeDbContext.TestEntities.AddRange(
94+
new TestEntity { Id = _id1, RequiredName = "R" },
95+
new TestEntity { Id = _id2, RequiredName = "R" },
96+
new TestEntity { Id = _id3, RequiredName = "R" },
97+
new TestEntity { Id = _id4, RequiredName = "R" });
98+
await ArrangeDbContext.SaveChangesAsync();
99+
100+
var tileEnds = await ActDbContext.TestEntities
101+
.Select(b => new
102+
{
103+
b.Id,
104+
Tile = EF.Functions.NTile(rangeCount, EF.Functions.OrderBy(b.Id))
105+
})
106+
.AsSubQuery()
107+
.GroupBy(x => x.Tile)
108+
.Select(g => new { Tile = g.Key, End = g.Max(x => x.Id) })
109+
.OrderBy(x => x.Tile)
110+
.ToListAsync();
111+
112+
// 4 rows ordered by Id, 2 buckets: bucket 1 = {_id1, _id2}, bucket 2 = {_id3, _id4}.
113+
tileEnds.Should().HaveCount(rangeCount);
114+
tileEnds[0].End.Should().Be(_id2);
115+
tileEnds[1].End.Should().Be(_id4);
116+
}
117+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using Thinktecture.TestDatabaseContext;
2+
3+
namespace Thinktecture.Query;
4+
5+
// Verifies SQL Server translates MAX/MIN over a uniqueidentifier column natively (no provider-side rewrite needed).
6+
// ReSharper disable once InconsistentNaming
7+
public class MaxMinUuidAggregate : IntegrationTestsBase
8+
{
9+
// Uniform-byte GUIDs so the expected max/min are unambiguous even under SQL Server's
10+
// uniqueidentifier comparison rules (which differ from byte/text ordering).
11+
private static readonly Guid _id1 = new("11111111-1111-1111-1111-111111111111");
12+
private static readonly Guid _id2 = new("22222222-2222-2222-2222-222222222222");
13+
private static readonly Guid _id3 = new("33333333-3333-3333-3333-333333333333");
14+
private static readonly Guid _id4 = new("44444444-4444-4444-4444-444444444444");
15+
16+
public MaxMinUuidAggregate(ITestOutputHelper testOutputHelper, SqlServerFixture sqlServerFixture)
17+
: base(testOutputHelper, sqlServerFixture)
18+
{
19+
}
20+
21+
[Fact]
22+
public async Task Should_translate_Max_over_uuid_grouping()
23+
{
24+
ArrangeDbContext.TestEntities.AddRange(
25+
new TestEntity { Id = _id1, Count = 1, RequiredName = "R" },
26+
new TestEntity { Id = _id2, Count = 1, RequiredName = "R" },
27+
new TestEntity { Id = _id3, Count = 2, RequiredName = "R" },
28+
new TestEntity { Id = _id4, Count = 2, RequiredName = "R" });
29+
await ArrangeDbContext.SaveChangesAsync();
30+
31+
var result = await ActDbContext.TestEntities
32+
.GroupBy(e => e.Count)
33+
.Select(g => new { Count = g.Key, MaxId = g.Max(e => e.Id) })
34+
.OrderBy(x => x.Count)
35+
.ToListAsync();
36+
37+
result.Should().HaveCount(2);
38+
result[0].MaxId.Should().Be(_id2);
39+
result[1].MaxId.Should().Be(_id4);
40+
}
41+
42+
[Fact]
43+
public async Task Should_translate_Min_over_uuid_grouping()
44+
{
45+
ArrangeDbContext.TestEntities.AddRange(
46+
new TestEntity { Id = _id1, Count = 1, RequiredName = "R" },
47+
new TestEntity { Id = _id2, Count = 1, RequiredName = "R" },
48+
new TestEntity { Id = _id3, Count = 2, RequiredName = "R" },
49+
new TestEntity { Id = _id4, Count = 2, RequiredName = "R" });
50+
await ArrangeDbContext.SaveChangesAsync();
51+
52+
var result = await ActDbContext.TestEntities
53+
.GroupBy(e => e.Count)
54+
.Select(g => new { Count = g.Key, MinId = g.Min(e => e.Id) })
55+
.OrderBy(x => x.Count)
56+
.ToListAsync();
57+
58+
result.Should().HaveCount(2);
59+
result[0].MinId.Should().Be(_id1);
60+
result[1].MinId.Should().Be(_id3);
61+
}
62+
63+
[Fact]
64+
public async Task Should_translate_Max_over_nullable_uuid()
65+
{
66+
ArrangeDbContext.TestEntities.AddRange(
67+
new TestEntity { Id = _id1, Count = 0, RequiredName = "R" },
68+
new TestEntity { Id = _id2, Count = 0, RequiredName = "R" });
69+
ArrangeDbContext.TestEntities.AddRange(
70+
new TestEntity { Id = _id3, Count = 7, ParentId = null, RequiredName = "R" },
71+
new TestEntity { Id = _id4, Count = 7, ParentId = _id1, RequiredName = "R" },
72+
new TestEntity { Id = new("55555555-5555-5555-5555-555555555555"), Count = 7, ParentId = _id2, RequiredName = "R" });
73+
await ArrangeDbContext.SaveChangesAsync();
74+
75+
var result = await ActDbContext.TestEntities
76+
.Where(e => e.Count == 7)
77+
.GroupBy(e => e.Count)
78+
.Select(g => g.Max(e => e.ParentId))
79+
.ToListAsync();
80+
81+
result.Should().ContainSingle();
82+
result[0].Should().Be(_id2);
83+
}
84+
}

0 commit comments

Comments
 (0)