Skip to content

Commit a3a0a57

Browse files
committed
Add more AsSelector tests for postgres
1 parent c2eeaa4 commit a3a0a57

1 file changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
using System.Linq.Expressions;
2+
using System.Text.Json;
3+
using System.Text.RegularExpressions;
4+
using GreenDonut.Data;
5+
using HotChocolate.Execution;
6+
using HotChocolate.Types;
7+
using Microsoft.EntityFrameworkCore;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Squadron;
10+
11+
namespace HotChocolate.Data;
12+
13+
[Collection(PostgresCacheCollectionFixture.DefinitionName)]
14+
public sealed partial class AsSelectorEncapsulatedProjectionTests(PostgreSqlResource resource)
15+
{
16+
[Fact]
17+
public async Task AsSelector_Should_Prune_Columns_When_Entity_Has_NonPublic_Ctor_And_Private_Setters()
18+
{
19+
// arrange
20+
var db = "db_" + Guid.NewGuid().ToString("N");
21+
var connectionString = resource.GetConnectionString(db);
22+
23+
await using var services = CreateServer(connectionString);
24+
await SeedAsync(services);
25+
26+
var executor = await services
27+
.GetRequiredService<IRequestExecutorProvider>()
28+
.GetExecutorAsync(cancellationToken: TestContext.Current.CancellationToken);
29+
30+
// act
31+
var result = await executor.ExecuteAsync(
32+
"""
33+
{
34+
encapsulatedStores {
35+
name
36+
}
37+
}
38+
""",
39+
TestContext.Current.CancellationToken);
40+
41+
// assert
42+
var operationResult = result.ExpectOperationResult();
43+
Assert.Empty(operationResult.Errors ?? []);
44+
45+
Assert.Equal(["Basel", "Zurich"], ReadNames(result, "encapsulatedStores"));
46+
47+
var capture = services.GetRequiredService<EncapsulatedSelectorCapture>();
48+
49+
// the selector is a genuine member-init projection (not an identity x => x reuse)
50+
// that binds exactly the selected member and nothing else.
51+
AssertProjectsExactly(
52+
capture.EncapsulatedSelector,
53+
nameof(EncapsulatedStore.Name));
54+
55+
// the generated SQL fetches exactly the selected column, not Region or CountryCode.
56+
Assert.Equal(
57+
[nameof(EncapsulatedStore.Name)],
58+
ExtractSelectedColumns(capture.EncapsulatedSql));
59+
}
60+
61+
[Fact]
62+
public async Task AsSelector_Should_Prune_Columns_When_Entity_Has_Private_Parameterless_And_Public_Business_Ctor()
63+
{
64+
// arrange
65+
var db = "db_" + Guid.NewGuid().ToString("N");
66+
var connectionString = resource.GetConnectionString(db);
67+
68+
await using var services = CreateServer(connectionString);
69+
await SeedAsync(services);
70+
71+
var executor = await services
72+
.GetRequiredService<IRequestExecutorProvider>()
73+
.GetExecutorAsync(cancellationToken: TestContext.Current.CancellationToken);
74+
75+
// act
76+
var result = await executor.ExecuteAsync(
77+
"""
78+
{
79+
mixedCtorStores {
80+
name
81+
}
82+
}
83+
""",
84+
TestContext.Current.CancellationToken);
85+
86+
// assert
87+
var operationResult = result.ExpectOperationResult();
88+
Assert.Empty(operationResult.Errors ?? []);
89+
90+
Assert.Equal(["Bern", "Geneva"], ReadNames(result, "mixedCtorStores"));
91+
92+
var capture = services.GetRequiredService<EncapsulatedSelectorCapture>();
93+
94+
// the public business ctor does not interfere: member-init via the private
95+
// parameterless ctor is still used, binding exactly the selected member.
96+
AssertProjectsExactly(
97+
capture.MixedCtorSelector,
98+
nameof(MixedCtorStore.Name));
99+
100+
// the generated SQL fetches exactly the selected column, not Region or CountryCode.
101+
Assert.Equal(
102+
[nameof(MixedCtorStore.Name)],
103+
ExtractSelectedColumns(capture.MixedCtorSql));
104+
}
105+
106+
private static ServiceProvider CreateServer(string connectionString)
107+
=> new ServiceCollection()
108+
.AddDbContext<EncapsulatedStoreContext>(c => c.UseNpgsql(connectionString))
109+
.AddScoped<EncapsulatedStoreService>()
110+
.AddSingleton<EncapsulatedSelectorCapture>()
111+
.AddGraphQLServer()
112+
.AddQueryContext()
113+
.AddQueryType(descriptor =>
114+
{
115+
descriptor.Name(OperationTypeNames.Query);
116+
117+
descriptor
118+
.Field("encapsulatedStores")
119+
.ResolveWith<EncapsulatedStoreQueryResolver>(
120+
t => t.GetEncapsulatedStoresAsync(default!, default!, default))
121+
.Type<ListType<NonNullType<ObjectType<EncapsulatedStore>>>>();
122+
123+
descriptor
124+
.Field("mixedCtorStores")
125+
.ResolveWith<EncapsulatedStoreQueryResolver>(
126+
t => t.GetMixedCtorStoresAsync(default!, default!, default))
127+
.Type<ListType<NonNullType<ObjectType<MixedCtorStore>>>>();
128+
})
129+
.ModifyRequestOptions(o => o.IncludeExceptionDetails = true)
130+
.Services
131+
.BuildServiceProvider();
132+
133+
private static async Task SeedAsync(IServiceProvider services)
134+
{
135+
await using var scope = services.CreateAsyncScope();
136+
var context = scope.ServiceProvider.GetRequiredService<EncapsulatedStoreContext>();
137+
138+
await context.Database.EnsureCreatedAsync();
139+
140+
context.EncapsulatedStores.AddRange(
141+
EncapsulatedStore.Create("Zurich", "ZH", "CH"),
142+
EncapsulatedStore.Create("Basel", "BS", "CH"));
143+
144+
context.MixedCtorStores.AddRange(
145+
MixedCtorStore.Create("Geneva", "GE", "CH"),
146+
MixedCtorStore.Create("Bern", "BE", "CH"));
147+
148+
await context.SaveChangesAsync();
149+
}
150+
151+
private static string[] ReadNames(IExecutionResult result, string field)
152+
{
153+
using var document = JsonDocument.Parse(result.ToJson());
154+
155+
return document.RootElement
156+
.GetProperty("data")
157+
.GetProperty(field)
158+
.EnumerateArray()
159+
.Select(t => t.GetProperty("name").GetString()!)
160+
.OrderBy(t => t, StringComparer.Ordinal)
161+
.ToArray();
162+
}
163+
164+
private static void AssertProjectsExactly<T>(
165+
Expression<Func<T, T>>? selector,
166+
params string[] expectedMembers)
167+
{
168+
Assert.NotNull(selector);
169+
var body = UnwrapConvert(selector!.Body);
170+
171+
// a real projection materializes a new instance via member-init. The reuse path
172+
// (x => x) returns the parameter itself, so this also proves it is not identity reuse.
173+
var memberInit = Assert.IsType<MemberInitExpression>(body);
174+
175+
var visitor = new RootMemberAccessVisitor(selector.Parameters[0]);
176+
visitor.Visit(memberInit);
177+
178+
Assert.Equal(
179+
expectedMembers.OrderBy(m => m, StringComparer.Ordinal).ToArray(),
180+
visitor.Members.OrderBy(m => m, StringComparer.Ordinal).ToArray());
181+
}
182+
183+
private static string[] ExtractSelectedColumns(string? sql)
184+
{
185+
Assert.NotNull(sql);
186+
187+
var selectIndex = sql!.IndexOf("SELECT", StringComparison.Ordinal);
188+
var fromIndex = sql.IndexOf("FROM", selectIndex, StringComparison.Ordinal);
189+
var selectClause = sql[selectIndex..fromIndex];
190+
191+
return ColumnReferenceRegex()
192+
.Matches(selectClause)
193+
.Select(m => m.Groups["column"].Value)
194+
.Distinct()
195+
.OrderBy(c => c, StringComparer.Ordinal)
196+
.ToArray();
197+
}
198+
199+
[GeneratedRegex("\"(?<column>[^\"]+)\"")]
200+
private static partial Regex ColumnReferenceRegex();
201+
202+
private static Expression UnwrapConvert(Expression expression)
203+
{
204+
while (expression is UnaryExpression
205+
{
206+
NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked,
207+
Operand: { } operand
208+
})
209+
{
210+
expression = operand;
211+
}
212+
213+
return expression;
214+
}
215+
216+
private sealed class RootMemberAccessVisitor(ParameterExpression root) : ExpressionVisitor
217+
{
218+
public HashSet<string> Members { get; } = [];
219+
220+
protected override Expression VisitMember(MemberExpression node)
221+
{
222+
if (IsRootMember(node.Expression))
223+
{
224+
Members.Add(node.Member.Name);
225+
}
226+
227+
return base.VisitMember(node);
228+
}
229+
230+
private bool IsRootMember(Expression? expression)
231+
{
232+
while (expression is UnaryExpression
233+
{
234+
NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked,
235+
Operand: { } operand
236+
})
237+
{
238+
expression = operand;
239+
}
240+
241+
return expression == root;
242+
}
243+
}
244+
245+
private sealed class EncapsulatedStore
246+
{
247+
private EncapsulatedStore()
248+
{
249+
}
250+
251+
private EncapsulatedStore(string name, string region, string countryCode)
252+
{
253+
Name = name;
254+
Region = region;
255+
CountryCode = countryCode;
256+
}
257+
258+
public int Id { get; private set; }
259+
260+
public string Name { get; private set; } = default!;
261+
262+
public string Region { get; private set; } = default!;
263+
264+
public string CountryCode { get; private set; } = default!;
265+
266+
public static EncapsulatedStore Create(string name, string region, string countryCode)
267+
=> new(name, region, countryCode);
268+
}
269+
270+
private sealed class MixedCtorStore
271+
{
272+
private MixedCtorStore()
273+
{
274+
}
275+
276+
public MixedCtorStore(string name, string region, string countryCode)
277+
{
278+
Name = name;
279+
Region = region;
280+
CountryCode = countryCode;
281+
}
282+
283+
public int Id { get; private set; }
284+
285+
public string Name { get; private set; } = default!;
286+
287+
public string Region { get; private set; } = default!;
288+
289+
public string CountryCode { get; private set; } = default!;
290+
291+
public static MixedCtorStore Create(string name, string region, string countryCode)
292+
=> new(name, region, countryCode);
293+
}
294+
295+
private sealed class EncapsulatedStoreContext(DbContextOptions<EncapsulatedStoreContext> options)
296+
: DbContext(options)
297+
{
298+
public DbSet<EncapsulatedStore> EncapsulatedStores => Set<EncapsulatedStore>();
299+
300+
public DbSet<MixedCtorStore> MixedCtorStores => Set<MixedCtorStore>();
301+
302+
protected override void OnModelCreating(ModelBuilder modelBuilder)
303+
{
304+
modelBuilder.Entity<EncapsulatedStore>().HasKey(t => t.Id);
305+
modelBuilder.Entity<MixedCtorStore>().HasKey(t => t.Id);
306+
}
307+
}
308+
309+
private sealed class EncapsulatedSelectorCapture
310+
{
311+
public Expression<Func<EncapsulatedStore, EncapsulatedStore>>? EncapsulatedSelector { get; set; }
312+
313+
public Expression<Func<MixedCtorStore, MixedCtorStore>>? MixedCtorSelector { get; set; }
314+
315+
public string? EncapsulatedSql { get; set; }
316+
317+
public string? MixedCtorSql { get; set; }
318+
}
319+
320+
private sealed class EncapsulatedStoreService(
321+
EncapsulatedStoreContext context,
322+
EncapsulatedSelectorCapture capture)
323+
{
324+
public async Task<IReadOnlyList<EncapsulatedStore>> GetEncapsulatedStoresAsync(
325+
QueryContext<EncapsulatedStore> query,
326+
CancellationToken cancellationToken)
327+
{
328+
capture.EncapsulatedSelector = query.Selector;
329+
330+
var projectedQuery = context.EncapsulatedStores.AsNoTracking().With(query);
331+
capture.EncapsulatedSql = projectedQuery.ToQueryString();
332+
333+
return await projectedQuery.ToListAsync(cancellationToken);
334+
}
335+
336+
public async Task<IReadOnlyList<MixedCtorStore>> GetMixedCtorStoresAsync(
337+
QueryContext<MixedCtorStore> query,
338+
CancellationToken cancellationToken)
339+
{
340+
capture.MixedCtorSelector = query.Selector;
341+
342+
var projectedQuery = context.MixedCtorStores.AsNoTracking().With(query);
343+
capture.MixedCtorSql = projectedQuery.ToQueryString();
344+
345+
return await projectedQuery.ToListAsync(cancellationToken);
346+
}
347+
}
348+
349+
private sealed class EncapsulatedStoreQueryResolver
350+
{
351+
public Task<IReadOnlyList<EncapsulatedStore>> GetEncapsulatedStoresAsync(
352+
QueryContext<EncapsulatedStore> query,
353+
[Service] EncapsulatedStoreService service,
354+
CancellationToken cancellationToken)
355+
=> service.GetEncapsulatedStoresAsync(query, cancellationToken);
356+
357+
public Task<IReadOnlyList<MixedCtorStore>> GetMixedCtorStoresAsync(
358+
QueryContext<MixedCtorStore> query,
359+
[Service] EncapsulatedStoreService service,
360+
CancellationToken cancellationToken)
361+
=> service.GetMixedCtorStoresAsync(query, cancellationToken);
362+
}
363+
}

0 commit comments

Comments
 (0)