diff --git a/docs/defining-graphs.md b/docs/defining-graphs.md index 5d8979c8..d6eaccfb 100644 --- a/docs/defining-graphs.md +++ b/docs/defining-graphs.md @@ -251,6 +251,9 @@ Field, 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 | diff --git a/docs/mdsource/defining-graphs.source.md b/docs/mdsource/defining-graphs.source.md index 3977b8fc..0c083613 100644 --- a/docs/mdsource/defining-graphs.source.md +++ b/docs/mdsource/defining-graphs.source.md @@ -188,6 +188,9 @@ Field, 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 | diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e8e781a9..b26b2afe 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU5104;CS1573;CS9107;NU1608;NU1109 - 35.2.0 + 35.3.0 preview 1.0.0 EntityFrameworkCore, EntityFramework, GraphQL diff --git a/src/GraphQL.EntityFramework/GraphApi/ProjectionPaths.cs b/src/GraphQL.EntityFramework/GraphApi/ProjectionPaths.cs index fa795de3..bd1c781f 100644 --- a/src/GraphQL.EntityFramework/GraphApi/ProjectionPaths.cs +++ b/src/GraphQL.EntityFramework/GraphApi/ProjectionPaths.cs @@ -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]); + + /// + /// 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. + /// + public static ProjectionPaths Analyze(IReadOnlyList projections) { var groups = new List(); var byRoot = new Dictionary(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; @@ -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('.')) { diff --git a/src/GraphQL.EntityFramework/IncludeAppender.cs b/src/GraphQL.EntityFramework/IncludeAppender.cs index d390801c..887a874d 100644 --- a/src/GraphQL.EntityFramework/IncludeAppender.cs +++ b/src/GraphQL.EntityFramework/IncludeAppender.cs @@ -862,11 +862,39 @@ static void AddNavigation( const string projectionKey = "_EF_Projection"; const string projectionPathsKey = "_EF_ProjectionPaths"; + /// + /// 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. + /// 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 Projections(FieldType fieldType) + { + if (!fieldType.Metadata.TryGetValue(projectionKey, out var existing)) + { + return []; + } + + if (existing is List 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) diff --git a/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs index 0e525310..f2410c40 100644 --- a/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs +++ b/src/Tests/IntegrationTests/Graphs/FieldBuilderProjection/FieldBuilderProjectionGraphType.cs @@ -75,6 +75,13 @@ public FieldBuilderProjectionGraphType(IEfGraphQLService 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, string>("statusAndAgeViaTwoProjections") + .WithProjection(_ => _.Status) + .WithProjection(_ => _.Age) + .Resolve(_ => $"{_.Source.Status} at {_.Source.Age}"); + AutoMap(exclusions: [nameof(FieldBuilderProjectionEntity.Parent)]); } } diff --git a/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_WithProjection_twice_includes_both_in_select.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_WithProjection_twice_includes_both_in_select.verified.txt new file mode 100644 index 00000000..66632954 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.FieldBuilder_WithProjection_twice_includes_both_in_select.verified.txt @@ -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 + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt index 03c820f5..a7094a82 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt @@ -1437,6 +1437,7 @@ type FieldBuilderProjection { statusDisplay: String! parentName: String! statusViaWithProjection: String! + statusAndAgeViaTwoProjections: String! age: Int! createdAt: DateTime! id: ID! diff --git a/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs b/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs index 9fe95b78..2a5faf34 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_FieldBuilderExtensions.cs @@ -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]); + } }