From 4fbe7e3cf9348d74418b4e7b3b250fa03a9a9f51 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 11 Sep 2026 13:45:10 +1000 Subject: [PATCH] Restore WithProjection(LambdaExpression) and add nav isNull Two gaps that 35.0.0 left, both found while upgrading a downstream API. WithProjection only took Expression>, so a projection built by reflection or Expression.Lambda had no route in: IncludeAppender is internal, so its public SetProjectionMetadata is unreachable. Adds the LambdaExpression overload back alongside the generic one. Overload resolution prefers the generic for lambdas and for the old cast form, so no existing call site changes meaning, and both share the identity-projection check. A reference navigation could not be tested for null. v34's string path said {path: "nav", comparison: equal}, and WhereExpression still builds that predicate, but the generated nested where has no comparison of its own, and {nav: {id: {equal: null}}} unboxed null into a non nullable Guid rather than emitting IS NULL. WhereGraph gains isNull, Prefix resolves the navigation-only path, and TypeConverter treats null as a comparison a reference typed member can take. isNull at the root of a where has no navigation to bind to and reports that. Version 35.1.0. --- docs/mdsource/query-usage.source.md | 19 ++++++- docs/mdsource/upgradeGuide35.source.md | 34 ++++++++++- docs/query-usage.md | 19 ++++++- docs/upgradeGuide35.md | 34 ++++++++++- src/Directory.Build.props | 2 +- .../GraphApi/FieldBuilderExtensions.cs | 21 ++++++- .../Where/ExpressionBuilder.cs | 7 +++ .../Where/Graphs/WhereGraph.cs | 21 ++++++- .../Where/TypeConverter.cs | 7 +++ .../SchemaPrint.Print.verified.txt | 3 + .../IntegrationTests.SchemaPrint.verified.txt | 50 +++++++++++++++++ ...onTests.Where_is_null_at_root.verified.txt | 4 ++ ...vigation_is_null_isNull=False.verified.txt | 23 ++++++++ ...avigation_is_null_isNull=True.verified.txt | 23 ++++++++ .../IntegrationTests_typed_where.cs | 56 ++++++++++++++++++- .../MappingTests.SchemaPrint.verified.txt | 2 + 16 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 src/Tests/IntegrationTests/IntegrationTests.Where_is_null_at_root.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests.Where_navigation_is_null_isNull=False.verified.txt create mode 100644 src/Tests/IntegrationTests/IntegrationTests.Where_navigation_is_null_isNull=True.verified.txt diff --git a/docs/mdsource/query-usage.source.md b/docs/mdsource/query-usage.source.md index 0744202c..1acabc33 100644 --- a/docs/mdsource/query-usage.source.md +++ b/docs/mdsource/query-usage.source.md @@ -211,7 +211,7 @@ To negate an expression, including a group, wrap it in `not`: #### Nested Properties -A reference navigation, owned type or complex property is a nested where object. No null checking of nested values is done. +A reference navigation, owned type or complex property is a nested where object. No null checking of nested values is done; to test the navigation itself see [Null](#null). ```graphql { @@ -263,6 +263,23 @@ Null is compared with a null value: An empty where, `{}`, applies no filter. +A reference navigation is tested with `isNull`, since a nested where can only constrain the members +of the navigation, not the navigation itself: + +```graphql +{ + entities + (where: {address: {isNull: true}}) + { + property + } +} +``` + +`isNull: false` requires the navigation to be present. A collection navigation uses `none: {}` and +`any: {}` instead. `isNull` has no meaning at the root of a where, since there is no navigation it +was reached through. + #### Variables diff --git a/docs/mdsource/upgradeGuide35.source.md b/docs/mdsource/upgradeGuide35.source.md index 0401776e..addb976b 100644 --- a/docs/mdsource/upgradeGuide35.source.md +++ b/docs/mdsource/upgradeGuide35.source.md @@ -16,7 +16,9 @@ From [#1377](https://github.com/SimonCropp/GraphQL.EntityFramework/pull/1377). T * The extension methods `Resolve`, `ResolveAsync`, `ResolveList` and `ResolveListAsync` take the `IEfGraphQLService` as their first argument. All type arguments are inferred, and the service is used directly at execution time instead of being located through `RequestServices`. * `FieldBuilderResolveAnalyzer` previously never matched the extension methods, so GQLEF003 could not fire. It now resolves the receiver type for extension methods and identifies projection based calls by their `projection` parameter. -The old extension methods and the `LambdaExpression` overload of `WithProjection` are removed rather than kept as overloads. +The old extension methods are removed rather than kept as overloads. The `LambdaExpression` overload +of `WithProjection` was removed in 35.0.0 and restored in 35.1.0, for a projection built by +reflection or by `Expression.Lambda`, where the caller does not have the source and projection types. ### WithProjection @@ -111,6 +113,7 @@ input PersonWhere { and: [PersonWhere!] or: [PersonWhere!] not: PersonWhere + isNull: Boolean id: GuidComparison name: StringComparison age: Int32Comparison @@ -538,6 +541,35 @@ After, pass null: An empty where, `{}`, applies no filter. The old empty list, `[]`, matched nothing. +A path that named a reference navigation rather than one of its properties tested the navigation +itself. The nested where has no comparison of its own, so use `isNull` (added in 35.1.0). + +Before: + +```graphql +{ + entities (where: {path: "Address", comparison: equal}) + { + property + } +} +``` + +After: + +```graphql +{ + entities (where: {address: {isNull: true}}) + { + property + } +} +``` + +`isNull: false` requires the navigation to be present, as `comparison: notEqual` did. A collection +navigation uses `none: {}` and `any: {}` instead, and `isNull` is rejected at the root of a where, +where there is no navigation it was reached through. + ### Variables diff --git a/docs/query-usage.md b/docs/query-usage.md index 3af311ca..315d0e37 100644 --- a/docs/query-usage.md +++ b/docs/query-usage.md @@ -218,7 +218,7 @@ To negate an expression, including a group, wrap it in `not`: #### Nested Properties -A reference navigation, owned type or complex property is a nested where object. No null checking of nested values is done. +A reference navigation, owned type or complex property is a nested where object. No null checking of nested values is done; to test the navigation itself see [Null](#null). ```graphql { @@ -270,6 +270,23 @@ Null is compared with a null value: An empty where, `{}`, applies no filter. +A reference navigation is tested with `isNull`, since a nested where can only constrain the members +of the navigation, not the navigation itself: + +```graphql +{ + entities + (where: {address: {isNull: true}}) + { + property + } +} +``` + +`isNull: false` requires the navigation to be present. A collection navigation uses `none: {}` and +`any: {}` instead. `isNull` has no meaning at the root of a where, since there is no navigation it +was reached through. + #### Variables diff --git a/docs/upgradeGuide35.md b/docs/upgradeGuide35.md index 9b2b15b6..40acd7a5 100644 --- a/docs/upgradeGuide35.md +++ b/docs/upgradeGuide35.md @@ -23,7 +23,9 @@ From [#1377](https://github.com/SimonCropp/GraphQL.EntityFramework/pull/1377). T * The extension methods `Resolve`, `ResolveAsync`, `ResolveList` and `ResolveListAsync` take the `IEfGraphQLService` as their first argument. All type arguments are inferred, and the service is used directly at execution time instead of being located through `RequestServices`. * `FieldBuilderResolveAnalyzer` previously never matched the extension methods, so GQLEF003 could not fire. It now resolves the receiver type for extension methods and identifies projection based calls by their `projection` parameter. -The old extension methods and the `LambdaExpression` overload of `WithProjection` are removed rather than kept as overloads. +The old extension methods are removed rather than kept as overloads. The `LambdaExpression` overload +of `WithProjection` was removed in 35.0.0 and restored in 35.1.0, for a projection built by +reflection or by `Expression.Lambda`, where the caller does not have the source and projection types. ### WithProjection @@ -118,6 +120,7 @@ input PersonWhere { and: [PersonWhere!] or: [PersonWhere!] not: PersonWhere + isNull: Boolean id: GuidComparison name: StringComparison age: Int32Comparison @@ -545,6 +548,35 @@ After, pass null: An empty where, `{}`, applies no filter. The old empty list, `[]`, matched nothing. +A path that named a reference navigation rather than one of its properties tested the navigation +itself. The nested where has no comparison of its own, so use `isNull` (added in 35.1.0). + +Before: + +```graphql +{ + entities (where: {path: "Address", comparison: equal}) + { + property + } +} +``` + +After: + +```graphql +{ + entities (where: {address: {isNull: true}}) + { + property + } +} +``` + +`isNull: false` requires the navigation to be present, as `comparison: notEqual` did. A collection +navigation uses `none: {}` and `any: {}` instead, and `isNull` is rejected at the root of a where, +where there is no navigation it was reached through. + ### Variables diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2d4fac89..39bac7a8 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CS1591;NU5104;CS1573;CS9107;NU1608;NU1109;SC023 - 35.0.0 + 35.1.0 preview 1.0.0 EntityFrameworkCore, EntityFramework, GraphQL diff --git a/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs b/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs index b77f352a..613cb240 100644 --- a/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs +++ b/src/GraphQL.EntityFramework/GraphApi/FieldBuilderExtensions.cs @@ -360,7 +360,26 @@ public static FieldBuilder WithProjection(Expression> projection) + /// + /// Sets projection metadata on a field from a projection whose type is only known at runtime. + /// Prefer the overload, which infers its type arguments and + /// is checked at compile time. This one is for a projection built by reflection or by + /// , where the source and + /// projection types are not available to the caller. + /// + /// The field builder + /// Expression describing the required entity data. Its parameter must be or a type it derives from. + /// The field builder for chaining + public static FieldBuilder WithProjection( + this FieldBuilder builder, + LambdaExpression projection) + { + ValidateProjection(projection); + IncludeAppender.SetProjectionMetadata(builder.FieldType, projection); + return builder; + } + + static void ValidateProjection(LambdaExpression projection) { // Detect identity projection: _ => _ if (projection.Body is ParameterExpression parameter && diff --git a/src/GraphQL.EntityFramework/Where/ExpressionBuilder.cs b/src/GraphQL.EntityFramework/Where/ExpressionBuilder.cs index a91b9dd9..93392748 100644 --- a/src/GraphQL.EntityFramework/Where/ExpressionBuilder.cs +++ b/src/GraphQL.EntityFramework/Where/ExpressionBuilder.cs @@ -91,6 +91,13 @@ public static Expression> BuildPredicate(WhereExpression where) => static Expression MakePredicateBody(string path, Comparison comparison, object?[]? values, bool negate) { + if (path.Length == 0) + { + // isNull tests the navigation a nested where was reached through, so there is nothing + // to test at the root of a where. + throw new($"isNull is only valid on a navigation. It was used at the root of the where for {typeof(T).Name}."); + } + try { Expression expressionBody; diff --git a/src/GraphQL.EntityFramework/Where/Graphs/WhereGraph.cs b/src/GraphQL.EntityFramework/Where/Graphs/WhereGraph.cs index acfd47e3..fe2c24f6 100644 --- a/src/GraphQL.EntityFramework/Where/Graphs/WhereGraph.cs +++ b/src/GraphQL.EntityFramework/Where/Graphs/WhereGraph.cs @@ -3,8 +3,10 @@ namespace GraphQL.EntityFramework; /// /// The where input type for . One field per mapped property, /// typed by its comparisons, one per navigation, and and, or and not to -/// compose them. Sibling fields are and'ed. The parsed value is a -/// tree, so the predicate builder is unchanged. +/// compose them. Sibling fields are and'ed. isNull tests the navigation this where was +/// reached through, which is the one thing a nested where cannot say with its own fields; it has +/// no meaning at the root. The parsed value is a tree, so the +/// predicate builder is unchanged. /// public class WhereGraph : InputObjectGraphType, @@ -55,6 +57,11 @@ public WhereGraph(IEnumerable services) Name = "not", Type = self }); + AddField(new() + { + Name = "isNull", + Type = typeof(BooleanGraphType) + }); foreach (var member in EntityShape.Members(type, services)) { @@ -104,6 +111,14 @@ public override object ParseDictionary(IDictionary value) expressions.Add(negated); } + continue; + case "isNull": + // The navigation itself, rather than a member of it. Prefix turns the empty + // path into the navigation's name. + expressions.Add(new() + { + Comparison = (bool) raw ? Comparison.Equal : Comparison.NotEqual + }); continue; } @@ -219,6 +234,6 @@ static void Prefix(WhereExpression expression, string name) return; } - expression.Path = $"{name}.{expression.Path}"; + expression.Path = expression.Path.Length == 0 ? name : $"{name}.{expression.Path}"; } } diff --git a/src/GraphQL.EntityFramework/Where/TypeConverter.cs b/src/GraphQL.EntityFramework/Where/TypeConverter.cs index 8072f3dd..42a25b74 100644 --- a/src/GraphQL.EntityFramework/Where/TypeConverter.cs +++ b/src/GraphQL.EntityFramework/Where/TypeConverter.cs @@ -123,6 +123,13 @@ static bool ParseBoolean(string value) => if (value is null) { + // Null is a meaningful comparison for a reference typed member: a navigation with no + // related row. Only a non nullable value type has nothing to compare against. + if (!type.IsValueType) + { + return null; + } + throw new($"Null passed for non nullable type '{type.FullName}'."); } diff --git a/src/SampleWeb.Tests/SchemaPrint.Print.verified.txt b/src/SampleWeb.Tests/SchemaPrint.Print.verified.txt index 0cccdbf6..814097f4 100644 --- a/src/SampleWeb.Tests/SchemaPrint.Print.verified.txt +++ b/src/SampleWeb.Tests/SchemaPrint.Print.verified.txt @@ -69,6 +69,7 @@ input EmployeeWhere { and: [EmployeeWhere!] or: [EmployeeWhere!] not: EmployeeWhere + isNull: Boolean age: Int32Comparison company: CompanyWhere companyId: Int32Comparison @@ -91,6 +92,7 @@ input CompanyWhere { and: [CompanyWhere!] or: [CompanyWhere!] not: CompanyWhere + isNull: Boolean content: StringComparison employees: EmployeeCollectionWhere id: Int32Comparison @@ -122,6 +124,7 @@ input DeviceWhere { and: [DeviceWhere!] or: [DeviceWhere!] not: DeviceWhere + isNull: Boolean employees: EmployeeCollectionWhere id: Int32Comparison name: StringComparison diff --git a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt index b95826b8..03c820f5 100644 --- a/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt +++ b/src/Tests/IntegrationTests/IntegrationTests.SchemaPrint.verified.txt @@ -206,6 +206,7 @@ input CustomTypeEntityWhere { and: [CustomTypeEntityWhere!] or: [CustomTypeEntityWhere!] not: CustomTypeEntityWhere + isNull: Boolean id: GuidComparison property: Int64Comparison } @@ -262,6 +263,7 @@ input Level1EntityWhere { and: [Level1EntityWhere!] or: [Level1EntityWhere!] not: Level1EntityWhere + isNull: Boolean id: GuidComparison level2Entity: Level2EntityWhere level2EntityId: Int32Comparison @@ -271,6 +273,7 @@ input Level2EntityWhere { and: [Level2EntityWhere!] or: [Level2EntityWhere!] not: Level2EntityWhere + isNull: Boolean id: GuidComparison level3Entity: Level3EntityWhere level3EntityId: GuidComparison @@ -280,6 +283,7 @@ input Level3EntityWhere { and: [Level3EntityWhere!] or: [Level3EntityWhere!] not: Level3EntityWhere + isNull: Boolean id: GuidComparison property: StringComparison } @@ -337,6 +341,7 @@ input IncludeNonQueryableAWhere { and: [IncludeNonQueryableAWhere!] or: [IncludeNonQueryableAWhere!] not: IncludeNonQueryableAWhere + isNull: Boolean id: GuidComparison includeNonQueryableB: IncludeNonQueryableBWhere includeNonQueryableBId: GuidComparison @@ -346,6 +351,7 @@ input IncludeNonQueryableBWhere { and: [IncludeNonQueryableBWhere!] or: [IncludeNonQueryableBWhere!] not: IncludeNonQueryableBWhere + isNull: Boolean id: GuidComparison includeNonQueryableA: IncludeNonQueryableAWhere includeNonQueryableAId: GuidComparison @@ -386,6 +392,7 @@ input WithManyChildrenEntityWhere { and: [WithManyChildrenEntityWhere!] or: [WithManyChildrenEntityWhere!] not: WithManyChildrenEntityWhere + isNull: Boolean child1: Child1EntityWhere child2: Child2EntityWhere id: GuidComparison @@ -395,6 +402,7 @@ input Child1EntityWhere { and: [Child1EntityWhere!] or: [Child1EntityWhere!] not: Child1EntityWhere + isNull: Boolean id: GuidComparison parent: WithManyChildrenEntityWhere parentId: GuidComparison @@ -404,6 +412,7 @@ input Child2EntityWhere { and: [Child2EntityWhere!] or: [Child2EntityWhere!] not: Child2EntityWhere + isNull: Boolean id: GuidComparison parent: WithManyChildrenEntityWhere parentId: GuidComparison @@ -443,6 +452,7 @@ input ItemEntityWhere { and: [ItemEntityWhere!] or: [ItemEntityWhere!] not: ItemEntityWhere + isNull: Boolean id: GuidComparison parent: WithItemsEntityWhere parentId: GuidComparison @@ -453,6 +463,7 @@ input WithItemsEntityWhere { and: [WithItemsEntityWhere!] or: [WithItemsEntityWhere!] not: WithItemsEntityWhere + isNull: Boolean id: GuidComparison items: ItemEntityCollectionWhere property: StringComparison @@ -491,6 +502,7 @@ input WithNullableEntityWhere { and: [WithNullableEntityWhere!] or: [WithNullableEntityWhere!] not: WithNullableEntityWhere + isNull: Boolean id: GuidComparison nullable: Int32Comparison } @@ -509,6 +521,7 @@ input NamedIdEntityWhere { and: [NamedIdEntityWhere!] or: [NamedIdEntityWhere!] not: NamedIdEntityWhere + isNull: Boolean namedId: GuidComparison property: StringComparison } @@ -534,6 +547,7 @@ input WithMisNamedQueryChildEntityWhere { and: [WithMisNamedQueryChildEntityWhere!] or: [WithMisNamedQueryChildEntityWhere!] not: WithMisNamedQueryChildEntityWhere + isNull: Boolean id: GuidComparison parent: WithMisNamedQueryParentEntityWhere parentId: GuidComparison @@ -543,6 +557,7 @@ input WithMisNamedQueryParentEntityWhere { and: [WithMisNamedQueryParentEntityWhere!] or: [WithMisNamedQueryParentEntityWhere!] not: WithMisNamedQueryParentEntityWhere + isNull: Boolean children: WithMisNamedQueryChildEntityCollectionWhere id: GuidComparison } @@ -654,6 +669,7 @@ input ChildEntityWhere { and: [ChildEntityWhere!] or: [ChildEntityWhere!] not: ChildEntityWhere + isNull: Boolean id: GuidComparison nullable: Int32Comparison parent: ParentEntityWhere @@ -665,6 +681,7 @@ input ParentEntityWhere { and: [ParentEntityWhere!] or: [ParentEntityWhere!] not: ParentEntityWhere + isNull: Boolean children: ChildEntityCollectionWhere id: GuidComparison property: StringComparison @@ -701,6 +718,7 @@ input DateEntityWhere { and: [DateEntityWhere!] or: [DateEntityWhere!] not: DateEntityWhere + isNull: Boolean id: GuidComparison property: DateOnlyComparison } @@ -739,6 +757,7 @@ input EnumEntityWhere { and: [EnumEntityWhere!] or: [EnumEntityWhere!] not: EnumEntityWhere + isNull: Boolean id: GuidComparison property: DayOfWeekComparison } @@ -763,6 +782,7 @@ input StringEntityWhere { and: [StringEntityWhere!] or: [StringEntityWhere!] not: StringEntityWhere + isNull: Boolean id: GuidComparison property: StringComparison } @@ -784,6 +804,7 @@ input TimeEntityWhere { and: [TimeEntityWhere!] or: [TimeEntityWhere!] not: TimeEntityWhere + isNull: Boolean id: GuidComparison property: TimeOnlyComparison } @@ -865,6 +886,7 @@ input ReadOnlyEntityWhere { and: [ReadOnlyEntityWhere!] or: [ReadOnlyEntityWhere!] not: ReadOnlyEntityWhere + isNull: Boolean age: Int32Comparison computedInDb: StringComparison firstName: StringComparison @@ -878,6 +900,7 @@ input ReadOnlyParentEntityWhere { and: [ReadOnlyParentEntityWhere!] or: [ReadOnlyParentEntityWhere!] not: ReadOnlyParentEntityWhere + isNull: Boolean children: ReadOnlyEntityCollectionWhere id: GuidComparison property: StringComparison @@ -971,6 +994,7 @@ input FilterChildEntityWhere { and: [FilterChildEntityWhere!] or: [FilterChildEntityWhere!] not: FilterChildEntityWhere + isNull: Boolean age: Int32Comparison createdAt: DateTimeComparison id: GuidComparison @@ -1002,6 +1026,7 @@ input FilterParentEntityWhere { and: [FilterParentEntityWhere!] or: [FilterParentEntityWhere!] not: FilterParentEntityWhere + isNull: Boolean children: FilterChildEntityCollectionWhere field1: StringComparison field10: GuidComparison @@ -1087,6 +1112,7 @@ input SimpleTypeFilterEntityWhere { and: [SimpleTypeFilterEntityWhere!] or: [SimpleTypeFilterEntityWhere!] not: SimpleTypeFilterEntityWhere + isNull: Boolean boolValue: BooleanComparison dateTimeValue: DateTimeComparison guidValue: GuidComparison @@ -1183,6 +1209,7 @@ input DerivedChildEntityWhere { and: [DerivedChildEntityWhere!] or: [DerivedChildEntityWhere!] not: DerivedChildEntityWhere + isNull: Boolean id: GuidComparison parent: BaseEntityWhere parentId: GuidComparison @@ -1195,6 +1222,7 @@ input BaseEntityWhere { and: [BaseEntityWhere!] or: [BaseEntityWhere!] not: BaseEntityWhere + isNull: Boolean childrenFromBase: DerivedChildEntityCollectionWhere id: GuidComparison property: StringComparison @@ -1211,6 +1239,7 @@ input DerivedWithNavigationEntityWhere { and: [DerivedWithNavigationEntityWhere!] or: [DerivedWithNavigationEntityWhere!] not: DerivedWithNavigationEntityWhere + isNull: Boolean children: DerivedChildEntityCollectionWhere childrenFromBase: DerivedChildEntityCollectionWhere id: GuidComparison @@ -1262,6 +1291,7 @@ input DerivedEntityWhere { and: [DerivedEntityWhere!] or: [DerivedEntityWhere!] not: DerivedEntityWhere + isNull: Boolean childrenFromBase: DerivedChildEntityCollectionWhere id: GuidComparison property: StringComparison @@ -1290,6 +1320,7 @@ input ManyToManyLeftEntityWhere { and: [ManyToManyLeftEntityWhere!] or: [ManyToManyLeftEntityWhere!] not: ManyToManyLeftEntityWhere + isNull: Boolean id: StringComparison leftName: StringComparison rights: ManyToManyRightEntityCollectionWhere @@ -1305,6 +1336,7 @@ input ManyToManyRightEntityWhere { and: [ManyToManyRightEntityWhere!] or: [ManyToManyRightEntityWhere!] not: ManyToManyRightEntityWhere + isNull: Boolean id: StringComparison lefts: ManyToManyLeftEntityCollectionWhere rightName: StringComparison @@ -1334,6 +1366,7 @@ input ParentEntityViewWhere { and: [ParentEntityViewWhere!] or: [ParentEntityViewWhere!] not: ParentEntityViewWhere + isNull: Boolean property: StringComparison } @@ -1376,6 +1409,7 @@ input OwnedParentWhere { and: [OwnedParentWhere!] or: [OwnedParentWhere!] not: OwnedParentWhere + isNull: Boolean child1: OwnedChildWhere child2: OwnedChildWhere id: GuidComparison @@ -1386,6 +1420,7 @@ input OwnedChildWhere { and: [OwnedChildWhere!] or: [OwnedChildWhere!] not: OwnedChildWhere + isNull: Boolean property: StringComparison } @@ -1425,6 +1460,7 @@ input FieldBuilderProjectionEntityWhere { and: [FieldBuilderProjectionEntityWhere!] or: [FieldBuilderProjectionEntityWhere!] not: FieldBuilderProjectionEntityWhere + isNull: Boolean age: Int32Comparison createdAt: DateTimeComparison id: GuidComparison @@ -1441,6 +1477,7 @@ input FieldBuilderProjectionParentEntityWhere { and: [FieldBuilderProjectionParentEntityWhere!] or: [FieldBuilderProjectionParentEntityWhere!] not: FieldBuilderProjectionParentEntityWhere + isNull: Boolean children: FieldBuilderProjectionEntityCollectionWhere id: GuidComparison name: StringComparison @@ -1552,6 +1589,7 @@ input EmployeeEntityWhere { and: [EmployeeEntityWhere!] or: [EmployeeEntityWhere!] not: EmployeeEntityWhere + isNull: Boolean department: DepartmentEntityWhere departmentId: GuidComparison id: GuidComparison @@ -1562,6 +1600,7 @@ input DepartmentEntityWhere { and: [DepartmentEntityWhere!] or: [DepartmentEntityWhere!] not: DepartmentEntityWhere + isNull: Boolean employees: EmployeeEntityCollectionWhere id: GuidComparison isActive: BooleanComparison @@ -1613,6 +1652,7 @@ input FilterReferenceEntityWhere { and: [FilterReferenceEntityWhere!] or: [FilterReferenceEntityWhere!] not: FilterReferenceEntityWhere + isNull: Boolean baseEntity: FilterBaseEntityWhere baseEntityId: GuidComparison id: GuidComparison @@ -1623,6 +1663,7 @@ input FilterBaseEntityWhere { and: [FilterBaseEntityWhere!] or: [FilterBaseEntityWhere!] not: FilterBaseEntityWhere + isNull: Boolean commonProperty: StringComparison field1: StringComparison field10: GuidComparison @@ -1675,6 +1716,7 @@ input DiscriminatorDerivedAEntityWhere { and: [DiscriminatorDerivedAEntityWhere!] or: [DiscriminatorDerivedAEntityWhere!] not: DiscriminatorDerivedAEntityWhere + isNull: Boolean derivedAProperty: StringComparison entityType: DiscriminatorTypeComparison id: GuidComparison @@ -1705,6 +1747,7 @@ input DiscriminatorDerivedBEntityWhere { and: [DiscriminatorDerivedBEntityWhere!] or: [DiscriminatorDerivedBEntityWhere!] not: DiscriminatorDerivedBEntityWhere + isNull: Boolean derivedBProperty: StringComparison entityType: DiscriminatorTypeComparison id: GuidComparison @@ -1736,6 +1779,7 @@ input TphAttachmentEntityWhere { and: [TphAttachmentEntityWhere!] or: [TphAttachmentEntityWhere!] not: TphAttachmentEntityWhere + isNull: Boolean id: GuidComparison property: StringComparison relatedRequest: TphRootEntityWhere @@ -1748,6 +1792,7 @@ input TphRootEntityWhere { and: [TphRootEntityWhere!] or: [TphRootEntityWhere!] not: TphRootEntityWhere + isNull: Boolean attachments: TphAttachmentEntityCollectionWhere id: GuidComparison property: StringComparison @@ -1777,6 +1822,7 @@ input TphMiddleEntityWhere { and: [TphMiddleEntityWhere!] or: [TphMiddleEntityWhere!] not: TphMiddleEntityWhere + isNull: Boolean attachments: TphAttachmentEntityCollectionWhere id: GuidComparison property: StringComparison @@ -1796,6 +1842,7 @@ input GuardedKeyEntityWhere { and: [GuardedKeyEntityWhere!] or: [GuardedKeyEntityWhere!] not: GuardedKeyEntityWhere + isNull: Boolean emailAddress: StringComparison id: GuidComparison } @@ -1814,6 +1861,7 @@ input ConcreteTphBaseEntityWhere { and: [ConcreteTphBaseEntityWhere!] or: [ConcreteTphBaseEntityWhere!] not: ConcreteTphBaseEntityWhere + isNull: Boolean id: GuidComparison property: StringComparison } @@ -1833,6 +1881,7 @@ input TphDerivedNavBaseEntityWhere { and: [TphDerivedNavBaseEntityWhere!] or: [TphDerivedNavBaseEntityWhere!] not: TphDerivedNavBaseEntityWhere + isNull: Boolean id: GuidComparison ownerId: GuidComparison property: StringComparison @@ -1856,6 +1905,7 @@ input TphDerivedNavOwnerEntityWhere { and: [TphDerivedNavOwnerEntityWhere!] or: [TphDerivedNavOwnerEntityWhere!] not: TphDerivedNavOwnerEntityWhere + isNull: Boolean id: GuidComparison item: TphDerivedNavBaseEntityWhere itemId: GuidComparison diff --git a/src/Tests/IntegrationTests/IntegrationTests.Where_is_null_at_root.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Where_is_null_at_root.verified.txt new file mode 100644 index 00000000..c2912509 --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Where_is_null_at_root.verified.txt @@ -0,0 +1,4 @@ +{ + Type: Exception, + Message: isNull is only valid on a navigation. It was used at the root of the where for ChildEntity. +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Where_navigation_is_null_isNull=False.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Where_navigation_is_null_isNull=False.verified.txt new file mode 100644 index 00000000..83e3c26d --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Where_navigation_is_null_isNull=False.verified.txt @@ -0,0 +1,23 @@ +{ + target: { + Data: { + childEntities: [ + { + property: Child1 + } + ] + } + }, + sql: { + Text: +select c.Id, + c.ParentId, + c.Property +from ChildEntities as c + left outer join + ParentEntities as p + on c.ParentId = p.Id +where p.Id is not null +order by c.Property + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests.Where_navigation_is_null_isNull=True.verified.txt b/src/Tests/IntegrationTests/IntegrationTests.Where_navigation_is_null_isNull=True.verified.txt new file mode 100644 index 00000000..2809bb7a --- /dev/null +++ b/src/Tests/IntegrationTests/IntegrationTests.Where_navigation_is_null_isNull=True.verified.txt @@ -0,0 +1,23 @@ +{ + target: { + Data: { + childEntities: [ + { + property: Child2 + } + ] + } + }, + sql: { + Text: +select c.Id, + c.ParentId, + c.Property +from ChildEntities as c + left outer join + ParentEntities as p + on c.ParentId = p.Id +where p.Id is null +order by c.Property + } +} \ No newline at end of file diff --git a/src/Tests/IntegrationTests/IntegrationTests_typed_where.cs b/src/Tests/IntegrationTests/IntegrationTests_typed_where.cs index 9b62e487..356697cc 100644 --- a/src/Tests/IntegrationTests/IntegrationTests_typed_where.cs +++ b/src/Tests/IntegrationTests/IntegrationTests_typed_where.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; public partial class IntegrationTests { @@ -91,6 +91,60 @@ public async Task Where_nested_navigation() await RunQuery(database, query, null, null, false, [parent1, parent2, child1, child2]); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Where_navigation_is_null(bool isNull) + { + var query = + $$""" + { + childEntities + ( + where: {parent: {isNull: {{(isNull ? "true" : "false")}} } }, + orderBy: {property: ascending} + ) + { + property + } + } + """; + + var parent = new ParentEntity + { + Property = "Parent1" + }; + var withParent = new ChildEntity + { + Property = "Child1", + Parent = parent + }; + var orphan = new ChildEntity + { + Property = "Child2" + }; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, [parent, withParent, orphan]); + } + + [Fact] + public async Task Where_is_null_at_root() + { + var query = + """ + { + childEntities (where: {isNull: true}) + { + property + } + } + """; + + await using var database = await sqlInstance.Build(); + await RunQuery(database, query, null, null, false, []); + } + [Theory] [InlineData("any")] [InlineData("all")] diff --git a/src/Tests/Mapping/MappingTests.SchemaPrint.verified.txt b/src/Tests/Mapping/MappingTests.SchemaPrint.verified.txt index ba3406ca..206f93ea 100644 --- a/src/Tests/Mapping/MappingTests.SchemaPrint.verified.txt +++ b/src/Tests/Mapping/MappingTests.SchemaPrint.verified.txt @@ -30,6 +30,7 @@ input MappingChildWhere { and: [MappingChildWhere!] or: [MappingChildWhere!] not: MappingChildWhere + isNull: Boolean id: GuidComparison parent: MappingParentWhere parentId: GuidComparison @@ -50,6 +51,7 @@ input MappingParentWhere { and: [MappingParentWhere!] or: [MappingParentWhere!] not: MappingParentWhere + isNull: Boolean children: MappingChildCollectionWhere id: GuidComparison ignoreByName: StringComparison