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