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
3 changes: 3 additions & 0 deletions docs/defining-graphs.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ Field<NonNullGraphType<StringGraphType>, string>("statusLabel")
3. Scalar properties referenced in the expression (e.g., `Status`) are added to the SELECT column list
4. Navigation properties referenced in the expression trigger the appropriate `Include` calls

`WithProjection` can be called more than once on a field, and the data of every projection is
loaded. This suits a field whose value comes from several checks, each declaring what it reads.

**When to use `WithProjection` vs `Resolve<..., TProjection>`:**

| Scenario | Use |
Expand Down
3 changes: 3 additions & 0 deletions docs/mdsource/defining-graphs.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ Field<NonNullGraphType<StringGraphType>, string>("statusLabel")
3. Scalar properties referenced in the expression (e.g., `Status`) are added to the SELECT column list
4. Navigation properties referenced in the expression trigger the appropriate `Include` calls

`WithProjection` can be called more than once on a field, and the data of every projection is
loaded. This suits a field whose value comes from several checks, each declaring what it reads.

**When to use `WithProjection` vs `Resolve<..., TProjection>`:**

| Scenario | Use |
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<NoWarn>CS1591;NU5104;CS1573;CS9107;NU1608;NU1109</NoWarn>
<Version>35.2.0</Version>
<Version>35.3.0</Version>
<LangVersion>preview</LangVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<PackageTags>EntityFrameworkCore, EntityFramework, GraphQL</PackageTags>
Expand Down
17 changes: 15 additions & 2 deletions src/GraphQL.EntityFramework/GraphApi/ProjectionPaths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ sealed class ProjectionPaths
public string? PrimaryRoot =>
Groups.Count == 0 ? null : Groups[0].Root;

public static ProjectionPaths Analyze(LambdaExpression projection)
public static ProjectionPaths Analyze(LambdaExpression projection) =>
Analyze([projection]);

/// <summary>
/// The paths of every projection a field declares, in the order they were declared. A field
/// can be given more than one projection, and each one's data has to be loaded.
/// </summary>
public static ProjectionPaths Analyze(IReadOnlyList<LambdaExpression> projections)
{
var groups = new List<ProjectionPathGroup>();
var byRoot = new Dictionary<string, ProjectionPathGroup>(StringComparer.OrdinalIgnoreCase);

foreach (var path in ProjectionAnalyzer.ExtractPropertyPaths(projection))
foreach (var path in projections.SelectMany(ProjectionAnalyzer.ExtractPropertyPaths))
{
var dotIndex = path.IndexOf('.');
var root = dotIndex >= 0 ? path[..dotIndex] : path;
Expand Down Expand Up @@ -70,6 +77,12 @@ sealed class ProjectionPathGroup(string root)

internal void Add(string path)
{
// Two projections on one field can read the same path.
if (nested.Contains(path))
{
return;
}

nested.Add(path);
if (!path.Contains('.'))
{
Expand Down
32 changes: 30 additions & 2 deletions src/GraphQL.EntityFramework/IncludeAppender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -862,11 +862,39 @@ static void AddNavigation(
const string projectionKey = "_EF_Projection";
const string projectionPathsKey = "_EF_ProjectionPaths";

/// <summary>
/// Adds a projection to a field. A field can be given several, and every one of them is loaded.
/// They replaced each other, so a field with two projections loaded only the last one's data
/// and its other resolver read an unloaded property.
/// </summary>
public static void SetProjectionMetadata(FieldType fieldType, LambdaExpression projection)
{
fieldType.Metadata[projectionKey] = projection;
var projections = Projections(fieldType);
projections.Add(projection);
fieldType.Metadata[projectionKey] = projections;
// Analyzed here, once, rather than on every request that selects the field
fieldType.Metadata[projectionPathsKey] = ProjectionPaths.Analyze(projection);
fieldType.Metadata[projectionPathsKey] = ProjectionPaths.Analyze(projections);
}

static List<LambdaExpression> Projections(FieldType fieldType)
{
if (!fieldType.Metadata.TryGetValue(projectionKey, out var existing))
{
return [];
}

if (existing is List<LambdaExpression> projections)
{
return projections;
}

// An expression placed in the metadata directly, without going through here
if (existing is LambdaExpression expression)
{
return [expression];
}

return [];
}

static bool TryGetProjectionMetadata(FieldType fieldType, [NotNullWhen(true)] out ProjectionPaths? projection)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ public FieldBuilderProjectionGraphType(IEfGraphQLService<IntegrationDbContext> g
_ => "Unknown"
});

// Two projections on one field. Both are loaded: the second used to replace the first,
// leaving the resolver to read whatever the first one asked for as a default value.
Field<NonNullGraphType<StringGraphType>, string>("statusAndAgeViaTwoProjections")
.WithProjection(_ => _.Status)
.WithProjection(_ => _.Age)
.Resolve(_ => $"{_.Source.Status} at {_.Source.Age}");

AutoMap(exclusions: [nameof(FieldBuilderProjectionEntity.Parent)]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
target: {
Data: {
fieldBuilderProjectionEntities: [
{
name: BothProjections,
statusAndAgeViaTwoProjections: Pending at 41
}
]
}
},
sql: {
Text:
select f.Age,
f.Id,
f.Name,
f.Status
from FieldBuilderProjectionEntities as f
order by f.Name
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,7 @@ type FieldBuilderProjection {
statusDisplay: String!
parentName: String!
statusViaWithProjection: String!
statusAndAgeViaTwoProjections: String!
age: Int!
createdAt: DateTime!
id: ID!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,4 +433,31 @@ public async Task FieldBuilder_WithProjection_scalar_field_included_in_select()
await using var database = await sqlInstance.Build();
await RunQuery(database, query, null, null, false, [entity1, entity2]);
}

[Fact]
public async Task FieldBuilder_WithProjection_twice_includes_both_in_select()
{
// Both projections of a field are loaded. The second replaced the first, so the resolver
// read the property the first one asked for as its default value.
var query =
"""
{
fieldBuilderProjectionEntities
{
name
statusAndAgeViaTwoProjections
}
}
""";

var entity = new FieldBuilderProjectionEntity
{
Name = "BothProjections",
Age = 41,
Status = EntityStatus.Pending
};

await using var database = await sqlInstance.Build();
await RunQuery(database, query, null, null, false, [entity]);
}
}
Loading