Skip to content

Commit 2c3ed0c

Browse files
5daws-amplify-ops
andauthored
feat(api): add support for GraphQL filter attributeExists (#3484)
* add support for GraphQL filter attributeExists * fix(datastore): deduplicate SQL statement of attributeExists and eq/ne * fix(datastore): flacky unit test * Update API dumps for new version --------- Co-authored-by: aws-amplify-ops <aws-amplify@amazon.com>
1 parent 2d3b505 commit 2c3ed0c

25 files changed

+559
-80
lines changed

Amplify/Categories/DataStore/Model/Internal/Persistable.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ struct PersistableHelper {
6565
return lhs == rhs
6666
case let (lhs, rhs) as (String, String):
6767
return lhs == rhs
68+
case let (lhs, rhs) as (any EnumPersistable, String):
69+
return lhs.rawValue == rhs
70+
case let (lhs, rhs) as (String, any EnumPersistable):
71+
return lhs == rhs.rawValue
72+
case let (lhs, rhs) as (any EnumPersistable, any EnumPersistable):
73+
return lhs.rawValue == rhs.rawValue
6874
default:
6975
return false
7076
}
@@ -94,6 +100,12 @@ struct PersistableHelper {
94100
return lhs == Double(rhs)
95101
case let (lhs, rhs) as (String, String):
96102
return lhs == rhs
103+
case let (lhs, rhs) as (any EnumPersistable, String):
104+
return lhs.rawValue == rhs
105+
case let (lhs, rhs) as (String, any EnumPersistable):
106+
return lhs == rhs.rawValue
107+
case let (lhs, rhs) as (any EnumPersistable, any EnumPersistable):
108+
return lhs.rawValue == rhs.rawValue
97109
default:
98110
return false
99111
}
@@ -122,6 +134,12 @@ struct PersistableHelper {
122134
return lhs <= Double(rhs)
123135
case let (lhs, rhs) as (String, String):
124136
return lhs <= rhs
137+
case let (lhs, rhs) as (any EnumPersistable, String):
138+
return lhs.rawValue <= rhs
139+
case let (lhs, rhs) as (String, any EnumPersistable):
140+
return lhs <= rhs.rawValue
141+
case let (lhs, rhs) as (any EnumPersistable, any EnumPersistable):
142+
return lhs.rawValue <= rhs.rawValue
125143
default:
126144
return false
127145
}
@@ -150,6 +168,12 @@ struct PersistableHelper {
150168
return lhs < Double(rhs)
151169
case let (lhs, rhs) as (String, String):
152170
return lhs < rhs
171+
case let (lhs, rhs) as (any EnumPersistable, String):
172+
return lhs.rawValue < rhs
173+
case let (lhs, rhs) as (String, any EnumPersistable):
174+
return lhs < rhs.rawValue
175+
case let (lhs, rhs) as (any EnumPersistable, any EnumPersistable):
176+
return lhs.rawValue < rhs.rawValue
153177
default:
154178
return false
155179
}
@@ -178,6 +202,12 @@ struct PersistableHelper {
178202
return lhs >= Double(rhs)
179203
case let (lhs, rhs) as (String, String):
180204
return lhs >= rhs
205+
case let (lhs, rhs) as (any EnumPersistable, String):
206+
return lhs.rawValue >= rhs
207+
case let (lhs, rhs) as (String, any EnumPersistable):
208+
return lhs >= rhs.rawValue
209+
case let (lhs, rhs) as (any EnumPersistable, any EnumPersistable):
210+
return lhs.rawValue >= rhs.rawValue
181211
default:
182212
return false
183213
}
@@ -206,6 +236,12 @@ struct PersistableHelper {
206236
return Double(lhs) > rhs
207237
case let (lhs, rhs) as (String, String):
208238
return lhs > rhs
239+
case let (lhs, rhs) as (any EnumPersistable, String):
240+
return lhs.rawValue > rhs
241+
case let (lhs, rhs) as (String, any EnumPersistable):
242+
return lhs > rhs.rawValue
243+
case let (lhs, rhs) as (any EnumPersistable, any EnumPersistable):
244+
return lhs.rawValue > rhs.rawValue
209245
default:
210246
return false
211247
}

Amplify/Categories/DataStore/Query/ModelKey.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ public protocol ModelKey: CodingKey, CaseIterable, QueryFieldOperation {}
3636

3737
extension CodingKey where Self: ModelKey {
3838

39+
// MARK: - attributeExists
40+
public func attributeExists(_ value: Bool) -> QueryPredicateOperation {
41+
return field(stringValue).attributeExists(value)
42+
}
43+
3944
// MARK: - beginsWith
4045
public func beginsWith(_ value: String) -> QueryPredicateOperation {
4146
return field(stringValue).beginsWith(value)

Amplify/Categories/DataStore/Query/QueryField.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public func field(_ name: String) -> QueryField {
3030
/// - seealso: `ModelKey`
3131
public protocol QueryFieldOperation {
3232
// MARK: - Functions
33-
33+
func attributeExists(_ value: Bool) -> QueryPredicateOperation
3434
func beginsWith(_ value: String) -> QueryPredicateOperation
3535
func between(start: Persistable, end: Persistable) -> QueryPredicateOperation
3636
func contains(_ value: String) -> QueryPredicateOperation
@@ -61,6 +61,11 @@ public struct QueryField: QueryFieldOperation {
6161

6262
public let name: String
6363

64+
// MARK: - attributeExists
65+
public func attributeExists(_ value: Bool) -> QueryPredicateOperation {
66+
return QueryPredicateOperation(field: name, operator: .attributeExists(value))
67+
}
68+
6469
// MARK: - beginsWith
6570
public func beginsWith(_ value: String) -> QueryPredicateOperation {
6671
return QueryPredicateOperation(field: name, operator: .beginsWith(value))

Amplify/Categories/DataStore/Query/QueryOperator+Equatable.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ extension QueryOperator: Equatable {
2424
case let (.between(oneStart, oneEnd), .between(otherStart, otherEnd)):
2525
return PersistableHelper.isEqual(oneStart, otherStart)
2626
&& PersistableHelper.isEqual(oneEnd, otherEnd)
27+
case let (.attributeExists(one), .attributeExists(other)):
28+
return one == other
2729
default:
2830
return false
2931
}

Amplify/Categories/DataStore/Query/QueryOperator.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ public enum QueryOperator: Encodable {
1818
case notContains(_ value: String)
1919
case between(start: Persistable, end: Persistable)
2020
case beginsWith(_ value: String)
21+
case attributeExists(_ value: Bool)
2122

22-
public func evaluate(target: Any) -> Bool {
23+
public func evaluate(target: Any?) -> Bool {
2324
switch self {
2425
case .notEqual(let predicateValue):
2526
return !PersistableHelper.isEqual(target, predicateValue)
@@ -34,20 +35,26 @@ public enum QueryOperator: Encodable {
3435
case .greaterThan(let predicateValue):
3536
return PersistableHelper.isGreaterThan(target, predicateValue)
3637
case .contains(let predicateString):
37-
if let targetString = target as? String {
38+
if let targetString = target.flatMap({ $0 as? String }) {
3839
return targetString.contains(predicateString)
3940
}
4041
return false
4142
case .notContains(let predicateString):
42-
if let targetString = target as? String {
43+
if let targetString = target.flatMap({ $0 as? String }) {
4344
return !targetString.contains(predicateString)
4445
}
4546
case .between(let start, let end):
4647
return PersistableHelper.isBetween(start, end, target)
4748
case .beginsWith(let predicateValue):
48-
if let targetString = target as? String {
49+
if let targetString = target.flatMap({ $0 as? String }) {
4950
return targetString.starts(with: predicateValue)
5051
}
52+
case .attributeExists(let predicateValue):
53+
if case .some = target {
54+
return predicateValue == true
55+
} else {
56+
return predicateValue == false
57+
}
5158
}
5259
return false
5360
}
@@ -105,6 +112,10 @@ public enum QueryOperator: Encodable {
105112
case .beginsWith(let value):
106113
try container.encode("beginsWith", forKey: .type)
107114
try container.encode(value, forKey: .value)
115+
116+
case .attributeExists(let value):
117+
try container.encode("attributeExists", forKey: .type)
118+
try container.encode(value, forKey: .value)
108119
}
109120
}
110121
}

Amplify/Categories/DataStore/Query/QueryPredicate.swift

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -155,34 +155,6 @@ public class QueryPredicateOperation: QueryPredicate, Encodable {
155155
}
156156

157157
public func evaluate(target: Model) -> Bool {
158-
guard let fieldValue = target[field] else {
159-
return false
160-
}
161-
162-
guard let value = fieldValue else {
163-
return false
164-
}
165-
166-
if let booleanValue = value as? Bool {
167-
return self.operator.evaluate(target: booleanValue)
168-
}
169-
170-
if let doubleValue = value as? Double {
171-
return self.operator.evaluate(target: doubleValue)
172-
}
173-
174-
if let intValue = value as? Int {
175-
return self.operator.evaluate(target: intValue)
176-
}
177-
178-
if let timeValue = value as? Temporal.Time {
179-
return self.operator.evaluate(target: timeValue)
180-
}
181-
182-
if let enumValue = value as? EnumPersistable {
183-
return self.operator.evaluate(target: enumValue.rawValue)
184-
}
185-
186-
return self.operator.evaluate(target: value)
158+
return self.operator.evaluate(target: target[field]?.flatMap { $0 })
187159
}
188160
}

AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginFunctionalTests/GraphQLModelBasedTests+List.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,56 @@ extension GraphQLModelBasedTests {
145145
XCTAssertNotNil(error)
146146
}
147147
}
148+
149+
/**
150+
- Given: API with Post schema and optional field 'draft'
151+
- When:
152+
- create a new post with optional field 'draft' value .none
153+
- Then:
154+
- query Posts with filter {eq : null} shouldn't include the post
155+
*/
156+
func test_listModelsWithNilOptionalField_failedWithEqFilter() async throws {
157+
let post = Post(title: UUID().uuidString, content: UUID().uuidString, createdAt: .now())
158+
_ = try await Amplify.API.mutate(request: .create(post))
159+
let posts = try await list(.list(
160+
Post.self,
161+
where: Post.keys.draft == nil && Post.keys.createdAt >= post.createdAt
162+
))
163+
164+
XCTAssertFalse(posts.map(\.id).contains(post.id))
165+
}
166+
167+
/**
168+
- Given: DataStore with Post schema and optional field 'draft'
169+
- When:
170+
- create a new post with optional field 'draft' value .none
171+
- Then:
172+
- query Posts with filter {attributeExists : false} should include the post
173+
*/
174+
func test_listModelsWithNilOptionalField_successWithAttributeExistsFilter() async throws {
175+
let post = Post(title: UUID().uuidString, content: UUID().uuidString, createdAt: .now())
176+
_ = try await Amplify.API.mutate(request: .create(post))
177+
let listPosts = try await list(
178+
.list(
179+
Post.self,
180+
where: Post.keys.draft.attributeExists(false)
181+
&& Post.keys.createdAt >= post.createdAt
182+
)
183+
)
184+
185+
XCTAssertTrue(listPosts.map(\.id).contains(post.id))
186+
}
187+
188+
func list<M: Model>(_ request: GraphQLRequest<List<M>>) async throws -> [M] {
189+
func getAllPages(_ list: List<M>) async throws -> [M] {
190+
if list.hasNextPage() {
191+
return list.elements + (try await getAllPages(list.getNextPage()))
192+
} else {
193+
return list.elements
194+
}
195+
}
196+
197+
return try await getAllPages(try await Amplify.API.query(request: request).get())
198+
}
199+
148200
}

AmplifyPlugins/Core/AWSPluginsCore/Model/Support/QueryPredicate+GraphQL.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ extension QueryOperator {
187187
return "beginsWith"
188188
case .notContains:
189189
return "notContains"
190+
case .attributeExists:
191+
return "attributeExists"
190192
}
191193
}
192194

@@ -212,6 +214,8 @@ extension QueryOperator {
212214
return value
213215
case .notContains(let value):
214216
return value
217+
case .attributeExists(let value):
218+
return value
215219
}
216220
}
217221
}

AmplifyPlugins/Core/AWSPluginsCoreTests/Model/GraphQLDocument/GraphQLListQueryTests.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,88 @@ class GraphQLListQueryTests: XCTestCase {
218218
XCTAssertEqual(variables["limit"] as? Int, 1_000)
219219
XCTAssertNotNil(variables["filter"])
220220
}
221+
222+
/**
223+
- Given:
224+
- A Post schema with optional field 'draft'
225+
- When:
226+
- Using list query to filter records that either don't have 'draft' field or have 'null' value
227+
- Then:
228+
- the query document as expected
229+
- the filter is encoded correctly
230+
*/
231+
func test_listQuery_withAttributeExistsFilter_correctlyBuildGraphQLQueryStatement() {
232+
let post = Post.keys
233+
let predicate = post.id.eq("id")
234+
&& (post.draft.attributeExists(false) || post.draft.eq(nil))
235+
236+
var documentBuilder = ModelBasedGraphQLDocumentBuilder(modelSchema: Post.schema, operationType: .query)
237+
documentBuilder.add(decorator: DirectiveNameDecorator(type: .list))
238+
documentBuilder.add(decorator: PaginationDecorator())
239+
documentBuilder.add(decorator: FilterDecorator(filter: predicate.graphQLFilter(for: Post.schema)))
240+
let document = documentBuilder.build()
241+
let expectedQueryDocument = """
242+
query ListPosts($filter: ModelPostFilterInput, $limit: Int) {
243+
listPosts(filter: $filter, limit: $limit) {
244+
items {
245+
id
246+
content
247+
createdAt
248+
draft
249+
rating
250+
status
251+
title
252+
updatedAt
253+
__typename
254+
}
255+
nextToken
256+
}
257+
}
258+
"""
259+
XCTAssertEqual(document.name, "listPosts")
260+
XCTAssertEqual(document.stringValue, expectedQueryDocument)
261+
guard let variables = document.variables else {
262+
XCTFail("The document doesn't contain variables")
263+
return
264+
}
265+
XCTAssertNotNil(variables["limit"])
266+
XCTAssertEqual(variables["limit"] as? Int, 1_000)
267+
268+
guard let filter = variables["filter"] as? GraphQLFilter else {
269+
XCTFail("variables should contain a valid filter")
270+
return
271+
}
272+
273+
// Test filter for a valid JSON format
274+
let filterJSON = try? JSONSerialization.data(withJSONObject: filter,
275+
options: .prettyPrinted)
276+
XCTAssertNotNil(filterJSON)
277+
278+
let expectedFilterJSON = """
279+
{
280+
"and" : [
281+
{
282+
"id" : {
283+
"eq" : "id"
284+
}
285+
},
286+
{
287+
"or" : [
288+
{
289+
"draft" : {
290+
"attributeExists" : false
291+
}
292+
},
293+
{
294+
"draft" : {
295+
"eq" : null
296+
}
297+
}
298+
]
299+
}
300+
]
301+
}
302+
"""
303+
XCTAssertEqual(String(data: filterJSON!, encoding: .utf8), expectedFilterJSON)
304+
}
221305
}

AmplifyPlugins/Core/AWSPluginsCoreTests/Query/QueryPredicateEvaluateGeneratedBoolTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class QueryPredicateEvaluateGeneratedBoolTests: XCTestCase {
4141

4242
let evaluation = try predicate.evaluate(target: instance.eraseToAnyModel().instance)
4343

44-
XCTAssertFalse(evaluation)
44+
XCTAssertTrue(evaluation)
4545
}
4646

4747
func testBoolfalsenotEqualBooltrue() throws {
@@ -70,7 +70,7 @@ class QueryPredicateEvaluateGeneratedBoolTests: XCTestCase {
7070

7171
let evaluation = try predicate.evaluate(target: instance.eraseToAnyModel().instance)
7272

73-
XCTAssertFalse(evaluation)
73+
XCTAssertTrue(evaluation)
7474
}
7575

7676
func testBooltrueequalsBooltrue() throws {

0 commit comments

Comments
 (0)