From 723bff450fdf22a4f2327232d58e5140e162f6db Mon Sep 17 00:00:00 2001 From: Marcel Schwarz Date: Fri, 24 Mar 2023 17:47:35 +0100 Subject: [PATCH 1/4] Add test for filter joined by AND on the same field with the same operator --- test/tests/queries/filter.test.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/tests/queries/filter.test.js b/test/tests/queries/filter.test.js index 57a49a86..ebbc62bf 100644 --- a/test/tests/queries/filter.test.js +++ b/test/tests/queries/filter.test.js @@ -281,6 +281,28 @@ describe('graphql - filter', () => { expect(response.data).toEqual({ data }) }) + test('query with filter joined by AND on the same field with the same operator', async () => { + const query = gql` + { + AdminService { + Books(filter: { title: { contains: ["Wuthering", "Heights"] } }) { + nodes { + ID + title + } + } + } + } + ` + const data = { + AdminService: { + Books: { nodes: [{ ID: 201, title: 'Wuthering Heights' }] } + } + } + const response = await POST('/graphql', { query }) + expect(response.data).toEqual({ data }) + }) + test('query with filter joined by AND on different fields', async () => { const query = gql` { From eb8ab2ded65488ae73d738ca551be7c4ef939324 Mon Sep 17 00:00:00 2001 From: Marcel Schwarz Date: Fri, 24 Mar 2023 17:50:21 +0100 Subject: [PATCH 2/4] Add list to allow for AND filters on the same column with the same operator --- lib/resolvers/parse/ast/enrich.js | 2 +- lib/resolvers/parse/ast/fromObject.js | 2 +- lib/resolvers/parse/ast2cqn/where.js | 19 ++- lib/schema/args/filter.js | 2 +- test/schemas/bookshop-graphql.gql | 70 ++++---- .../edge-cases/field-named-localized.gql | 30 ++-- .../model-structure/composition-of-aspect.gql | 12 +- test/schemas/types.gql | 158 +++++++++--------- test/tests/queries/variables.test.js | 4 +- 9 files changed, 154 insertions(+), 145 deletions(-) diff --git a/lib/resolvers/parse/ast/enrich.js b/lib/resolvers/parse/ast/enrich.js index c6d987a6..036e31a6 100644 --- a/lib/resolvers/parse/ast/enrich.js +++ b/lib/resolvers/parse/ast/enrich.js @@ -36,7 +36,7 @@ const _traverseArgumentOrObjectField = (info, argumentOrObjectField, _fieldOr_ar argumentOrObjectField.value = substituteVariable(info, value) break case Kind.LIST: - _traverseListValue(info, value, type.getFields()) + _traverseListValue(info, value, type.getFields?.()) break case Kind.OBJECT: _traverseObjectValue(info, value, type.getFields()) diff --git a/lib/resolvers/parse/ast/fromObject.js b/lib/resolvers/parse/ast/fromObject.js index 11240b9a..fa35feca 100644 --- a/lib/resolvers/parse/ast/fromObject.js +++ b/lib/resolvers/parse/ast/fromObject.js @@ -26,7 +26,7 @@ const _objectToObjectValue = object => ({ const _arrayToListValue = array => ({ kind: Kind.LIST, - values: array.map(a => _objectToObjectValue(a)) + values: array.map(a => _variableToValue(a)) }) const _variableToValue = variable => { diff --git a/lib/resolvers/parse/ast2cqn/where.js b/lib/resolvers/parse/ast2cqn/where.js index 2d69bd07..e94d3c4a 100644 --- a/lib/resolvers/parse/ast2cqn/where.js +++ b/lib/resolvers/parse/ast2cqn/where.js @@ -19,18 +19,27 @@ const GQL_TO_CDS_QL_OPERATOR = { const _gqlOperatorToCdsOperator = gqlOperator => GQL_TO_CDS_QL_OPERATOR[gqlOperator] || GQL_TO_CDS_STRING_OPERATIONS[gqlOperator] -const _objectFieldTo_xpr = (objectField, columnName) => { - const gqlOperator = objectField.name.value +const _to_xpr = (ref, gqlOperator, value) => { const cdsOperator = _gqlOperatorToCdsOperator(gqlOperator) - - const ref = { ref: [columnName] } - const val = { val: objectField.value.value } + const val = { val: value } if (STRING_OPERATIONS[gqlOperator]) return [{ func: cdsOperator, args: [ref, val] }] return [ref, cdsOperator, val] } +const _objectFieldTo_xpr = (objectField, columnName) => { + const ref = { ref: [columnName] } + const gqlOperator = objectField.name.value + + if (objectField.value.kind === Kind.LIST) { + const _xprs = objectField.value.values.map(value => _to_xpr(ref, gqlOperator, value.value)) + return _joinedXprFrom_xprs(_xprs, 'and') + } + + return _to_xpr(ref, gqlOperator, objectField.value.value) +} + const _parseObjectField = (objectField, columnName) => { if (columnName) return _objectFieldTo_xpr(objectField, columnName) diff --git a/lib/schema/args/filter.js b/lib/schema/args/filter.js index de55de6d..d386176e 100644 --- a/lib/schema/args/filter.js +++ b/lib/schema/args/filter.js @@ -86,7 +86,7 @@ module.exports = cache => { const cacheFilterType = cache.get(filterName) if (cacheFilterType) return cacheFilterType - const fields = Object.fromEntries(operations.map(op => [[op], { type: gqlType }])) + const fields = Object.fromEntries(operations.map(op => [[op], { type: new GraphQLList(gqlType) }])) const newFilterType = new GraphQLInputObjectType({ name: filterName, fields }) cache.set(filterName, newFilterType) diff --git a/test/schemas/bookshop-graphql.gql b/test/schemas/bookshop-graphql.gql index 5871a6cc..a6b4be44 100644 --- a/test/schemas/bookshop-graphql.gql +++ b/test/schemas/bookshop-graphql.gql @@ -643,8 +643,8 @@ The `Binary` scalar type represents binary values as `base64url` encoded strings scalar Binary input Binary_filter { - eq: Binary - ne: Binary + eq: [Binary] + ne: [Binary] } type CatalogService { @@ -1293,12 +1293,12 @@ The `Date` scalar type represents date values as strings in the ISO 8601 format scalar Date input Date_filter { - eq: Date - ge: Date - gt: Date - le: Date - lt: Date - ne: Date + eq: [Date] + ge: [Date] + gt: [Date] + le: [Date] + lt: [Date] + ne: [Date] } """ @@ -1307,21 +1307,21 @@ The `Decimal` scalar type represents exact signed decimal values. Decimal repres scalar Decimal input Decimal_filter { - eq: Decimal - ge: Decimal - gt: Decimal - le: Decimal - lt: Decimal - ne: Decimal + eq: [Decimal] + ge: [Decimal] + gt: [Decimal] + le: [Decimal] + lt: [Decimal] + ne: [Decimal] } input Int_filter { - eq: Int - ge: Int - gt: Int - le: Int - lt: Int - ne: Int + eq: [Int] + ge: [Int] + gt: [Int] + le: [Int] + lt: [Int] + ne: [Int] } type Mutation { @@ -1340,15 +1340,15 @@ enum SortDirection { } input String_filter { - contains: String - endswith: String - eq: String - ge: String - gt: String - le: String - lt: String - ne: String - startswith: String + contains: [String] + endswith: [String] + eq: [String] + ge: [String] + gt: [String] + le: [String] + lt: [String] + ne: [String] + startswith: [String] } """ @@ -1357,10 +1357,10 @@ The `Timestamp` scalar type represents timestamp values as strings in the ISO 86 scalar Timestamp input Timestamp_filter { - eq: Timestamp - ge: Timestamp - gt: Timestamp - le: Timestamp - lt: Timestamp - ne: Timestamp + eq: [Timestamp] + ge: [Timestamp] + gt: [Timestamp] + le: [Timestamp] + lt: [Timestamp] + ne: [Timestamp] } diff --git a/test/schemas/edge-cases/field-named-localized.gql b/test/schemas/edge-cases/field-named-localized.gql index 03908ca5..02bf9257 100644 --- a/test/schemas/edge-cases/field-named-localized.gql +++ b/test/schemas/edge-cases/field-named-localized.gql @@ -116,12 +116,12 @@ input FieldNamedLocalizedService_localized_orderBy { } input Int_filter { - eq: Int - ge: Int - gt: Int - le: Int - lt: Int - ne: Int + eq: [Int] + ge: [Int] + gt: [Int] + le: [Int] + lt: [Int] + ne: [Int] } type Mutation { @@ -138,13 +138,13 @@ enum SortDirection { } input String_filter { - contains: String - endswith: String - eq: String - ge: String - gt: String - le: String - lt: String - ne: String - startswith: String + contains: [String] + endswith: [String] + eq: [String] + ge: [String] + gt: [String] + le: [String] + lt: [String] + ne: [String] + startswith: [String] } diff --git a/test/schemas/model-structure/composition-of-aspect.gql b/test/schemas/model-structure/composition-of-aspect.gql index a51ac15c..9d0bd5b8 100644 --- a/test/schemas/model-structure/composition-of-aspect.gql +++ b/test/schemas/model-structure/composition-of-aspect.gql @@ -144,12 +144,12 @@ type CompositionOfAspectService_input { } input ID_filter { - eq: ID - ge: ID - gt: ID - le: ID - lt: ID - ne: ID + eq: [ID] + ge: [ID] + gt: [ID] + le: [ID] + lt: [ID] + ne: [ID] } type Mutation { diff --git a/test/schemas/types.gql b/test/schemas/types.gql index e2ee45ec..9dbe02f6 100644 --- a/test/schemas/types.gql +++ b/test/schemas/types.gql @@ -4,13 +4,13 @@ The `Binary` scalar type represents binary values as `base64url` encoded strings scalar Binary input Binary_filter { - eq: Binary - ne: Binary + eq: [Binary] + ne: [Binary] } input Boolean_filter { - eq: Boolean - ne: Boolean + eq: [Boolean] + ne: [Boolean] } """ @@ -24,21 +24,21 @@ The `DateTime` scalar type represents datetime values as strings in the ISO 8601 scalar DateTime input DateTime_filter { - eq: DateTime - ge: DateTime - gt: DateTime - le: DateTime - lt: DateTime - ne: DateTime + eq: [DateTime] + ge: [DateTime] + gt: [DateTime] + le: [DateTime] + lt: [DateTime] + ne: [DateTime] } input Date_filter { - eq: Date - ge: Date - gt: Date - le: Date - lt: Date - ne: Date + eq: [Date] + ge: [Date] + gt: [Date] + le: [Date] + lt: [Date] + ne: [Date] } """ @@ -47,30 +47,30 @@ The `Decimal` scalar type represents exact signed decimal values. Decimal repres scalar Decimal input Decimal_filter { - eq: Decimal - ge: Decimal - gt: Decimal - le: Decimal - lt: Decimal - ne: Decimal + eq: [Decimal] + ge: [Decimal] + gt: [Decimal] + le: [Decimal] + lt: [Decimal] + ne: [Decimal] } input Float_filter { - eq: Float - ge: Float - gt: Float - le: Float - lt: Float - ne: Float + eq: [Float] + ge: [Float] + gt: [Float] + le: [Float] + lt: [Float] + ne: [Float] } input ID_filter { - eq: ID - ge: ID - gt: ID - le: ID - lt: ID - ne: ID + eq: [ID] + ge: [ID] + gt: [ID] + le: [ID] + lt: [ID] + ne: [ID] } """ @@ -79,12 +79,12 @@ The `Int16` scalar type represents 16-bit non-fractional signed whole numeric va scalar Int16 input Int16_filter { - eq: Int16 - ge: Int16 - gt: Int16 - le: Int16 - lt: Int16 - ne: Int16 + eq: [Int16] + ge: [Int16] + gt: [Int16] + le: [Int16] + lt: [Int16] + ne: [Int16] } """ @@ -93,21 +93,21 @@ The `Int64` scalar type represents 64-bit non-fractional signed whole numeric va scalar Int64 input Int64_filter { - eq: Int64 - ge: Int64 - gt: Int64 - le: Int64 - lt: Int64 - ne: Int64 + eq: [Int64] + ge: [Int64] + gt: [Int64] + le: [Int64] + lt: [Int64] + ne: [Int64] } input Int_filter { - eq: Int - ge: Int - gt: Int - le: Int - lt: Int - ne: Int + eq: [Int] + ge: [Int] + gt: [Int] + le: [Int] + lt: [Int] + ne: [Int] } type Mutation { @@ -124,15 +124,15 @@ enum SortDirection { } input String_filter { - contains: String - endswith: String - eq: String - ge: String - gt: String - le: String - lt: String - ne: String - startswith: String + contains: [String] + endswith: [String] + eq: [String] + ge: [String] + gt: [String] + le: [String] + lt: [String] + ne: [String] + startswith: [String] } """ @@ -141,12 +141,12 @@ The `Time` scalar type represents time values as strings in the ISO 8601 format scalar Time input Time_filter { - eq: Time - ge: Time - gt: Time - le: Time - lt: Time - ne: Time + eq: [Time] + ge: [Time] + gt: [Time] + le: [Time] + lt: [Time] + ne: [Time] } """ @@ -155,12 +155,12 @@ The `Timestamp` scalar type represents timestamp values as strings in the ISO 86 scalar Timestamp input Timestamp_filter { - eq: Timestamp - ge: Timestamp - gt: Timestamp - le: Timestamp - lt: Timestamp - ne: Timestamp + eq: [Timestamp] + ge: [Timestamp] + gt: [Timestamp] + le: [Timestamp] + lt: [Timestamp] + ne: [Timestamp] } type TypesService { @@ -310,10 +310,10 @@ The `UInt8` scalar type represents 8-bit non-fractional unsigned whole numeric v scalar UInt8 input UInt8_filter { - eq: UInt8 - ge: UInt8 - gt: UInt8 - le: UInt8 - lt: UInt8 - ne: UInt8 + eq: [UInt8] + ge: [UInt8] + gt: [UInt8] + le: [UInt8] + lt: [UInt8] + ne: [UInt8] } diff --git a/test/tests/queries/variables.test.js b/test/tests/queries/variables.test.js index 6f1e0714..46b89b48 100644 --- a/test/tests/queries/variables.test.js +++ b/test/tests/queries/variables.test.js @@ -111,7 +111,7 @@ describe('graphql - variables', () => { test('query variable of type scalar value passed as a field of an argument', async () => { const query = gql` - query ($filter: Int) { + query ($filter: [Int]) { AdminServiceBasic { Books(filter: { ID: { ge: $filter } }) { ID @@ -254,7 +254,7 @@ describe('graphql - variables', () => { test('query variable of type scalar value passed as a field of an argument', async () => { const query = gql` - query ($filter: Int) { + query ($filter: [Int]) { AdminService { Books(filter: { ID: { ge: $filter } }) { nodes { From 89226bab29640a45f10e841d9930cf6ea573607a Mon Sep 17 00:00:00 2001 From: Marcel Schwarz Date: Fri, 24 Mar 2023 17:57:15 +0100 Subject: [PATCH 3/4] Add changelog entry --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28dbf018..80805c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support for filtering by `null` values +- Allow multiple filters on the same field, with the same operator, that are logically joined by `AND`. For example, filtering for all books with titles that contain both strings, "Wuthering" and "Heights": + ```graphql + { + AdminService { + # Books where title contains "Wuthering" and title contains "Heights" + Books(filter: { title: { contains: ["Wuthering", "Heights"] } }) { + nodes { + title + } + } + } + } + ``` ### Changed From 9f0ba9f7dd2dc53070ca2734171a59f3b115a027 Mon Sep 17 00:00:00 2001 From: Marcel Schwarz Date: Fri, 24 Mar 2023 17:59:41 +0100 Subject: [PATCH 4/4] Remove comment from example query --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80805c8b..b53323ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ```graphql { AdminService { - # Books where title contains "Wuthering" and title contains "Heights" Books(filter: { title: { contains: ["Wuthering", "Heights"] } }) { nodes { title