Skip to content

Commit 5d7fd16

Browse files
authored
feat(api): Add attributeExists query predicate (#4134)
1 parent d089371 commit 5d7fd16

File tree

10 files changed

+615
-36
lines changed

10 files changed

+615
-36
lines changed

packages/amplify_core/lib/src/types/query/query_field.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,27 @@ class QueryField<T> {
255255
QueryPredicateOperation beginsWith(String value) =>
256256
QueryPredicateOperation(fieldName, BeginsWithQueryOperator(value));
257257

258+
/// An **attribute exists** operation.
259+
///
260+
/// Matches models whether the given field exists or not.
261+
///
262+
/// ### Example:
263+
/// The example returns Blog where the optional Author attribute exists.
264+
///
265+
/// ```dart
266+
/// ModelQueries.list(
267+
/// Blog.classType,
268+
/// where: Blog.AUTHOR.attributeExists(),
269+
/// );
270+
/// ```
271+
QueryPredicateOperation attributeExists({bool exists = true}) =>
272+
QueryPredicateOperation(
273+
fieldName,
274+
AttributeExistsQueryOperator(
275+
exists: exists,
276+
),
277+
);
278+
258279
/// Sorts models by the given field in ascending order
259280
///
260281
/// ### Example:

packages/amplify_core/lib/src/types/query/query_field_operators.dart

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ enum QueryFieldOperatorType {
1414
greater_than,
1515
contains,
1616
between,
17-
begins_with
17+
begins_with,
18+
attribute_exists,
1819
}
1920

2021
extension QueryFieldOperatorTypeExtension on QueryFieldOperatorType {
@@ -257,3 +258,26 @@ class BeginsWithQueryOperator extends QueryFieldOperatorSingleValue<String> {
257258
return other.startsWith(value);
258259
}
259260
}
261+
262+
class AttributeExistsQueryOperator<T> extends QueryFieldOperator<T> {
263+
const AttributeExistsQueryOperator({this.exists = true})
264+
: super(QueryFieldOperatorType.attribute_exists);
265+
266+
final bool exists;
267+
268+
@override
269+
bool evaluate(T? other) {
270+
if (exists == true) {
271+
return other != null;
272+
}
273+
return other == null;
274+
}
275+
276+
@override
277+
Map<String, dynamic> serializeAsMap() {
278+
return <String, dynamic>{
279+
'operatorName': QueryFieldOperatorType.attribute_exists.toShortString(),
280+
'exists': this.exists,
281+
};
282+
}
283+
}

packages/amplify_datastore/example/integration_test/observe_test.dart

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,5 +124,36 @@ void main() {
124124
await Amplify.DataStore.delete(updatedBlog);
125125
await Amplify.DataStore.save(otherBlog);
126126
});
127+
128+
testWidgets(
129+
'observe with attribute exists query predicate filters out non matches',
130+
(WidgetTester tester) async {
131+
HasOneChild hasAttribute = HasOneChild(name: 'name - ${uuid()}');
132+
HasOneChild hasNoAttribute = HasOneChild();
133+
134+
var hasAttributeStream = Amplify.DataStore.observe(HasOneChild.classType,
135+
where: Blog.NAME.attributeExists())
136+
.map((event) => event.item);
137+
expectLater(
138+
hasAttributeStream,
139+
emitsInOrder(
140+
[hasAttribute],
141+
),
142+
);
143+
144+
var hasNoAttributeStream = Amplify.DataStore.observe(
145+
HasOneChild.classType,
146+
where: Blog.NAME.attributeExists(exists: false))
147+
.map((event) => event.item);
148+
expectLater(
149+
hasNoAttributeStream,
150+
emitsInOrder(
151+
[hasNoAttribute],
152+
),
153+
);
154+
155+
await Amplify.DataStore.save(hasAttribute);
156+
await Amplify.DataStore.save(hasNoAttribute);
157+
});
127158
});
128159
}

packages/amplify_datastore/test/query_predicate_test.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,12 @@ void main() {
315315
expect(testPredicate.evaluate(post2), isTrue);
316316
});
317317

318+
test('attributeExists', () {
319+
QueryPredicate testPredicate = Post.LIKECOUNT.attributeExists();
320+
expect(testPredicate.evaluate(post4), isFalse);
321+
expect(testPredicate.evaluate(post2), isTrue);
322+
});
323+
318324
test('Temporal type', () {
319325
QueryPredicate testPredicate = Post.CREATED.lt(TemporalDateTime(
320326
DateTime(2020, 01, 01, 12, 00),
Lines changed: 65 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
1-
type Blog @model @auth(rules: [
2-
{ allow: public, operations: [read], provider: apiKey},
3-
{ allow: public, operations: [read], provider: iam},
4-
{ allow: private, operations: [read], provider: iam},
5-
{ allow: private, operations: [read], provider: userPools},
6-
{ allow: owner, operations: [create, read, update, delete] }
7-
]) {
1+
type Blog
2+
@model
3+
@auth(
4+
rules: [
5+
{ allow: public, operations: [read], provider: apiKey }
6+
{ allow: public, operations: [read], provider: iam }
7+
{ allow: private, operations: [read], provider: iam }
8+
{ allow: private, operations: [read], provider: userPools }
9+
{ allow: owner, operations: [create, read, update, delete] }
10+
]
11+
) {
812
id: ID!
913
name: String!
1014
posts: [Post] @hasMany(indexName: "byBlog", fields: ["id"])
1115
}
1216

13-
type Post @model @auth(rules: [
14-
{ allow: public, operations: [read], provider: iam},
15-
{ allow: private, operations: [read], provider: iam},
16-
{ allow: private, operations: [read], provider: userPools},
17-
{ allow: owner, operations: [create, read, update, delete] }
18-
]) {
17+
type Post
18+
@model
19+
@auth(
20+
rules: [
21+
{ allow: public, operations: [read], provider: iam }
22+
{ allow: private, operations: [read], provider: iam }
23+
{ allow: private, operations: [read], provider: userPools }
24+
{ allow: owner, operations: [create, read, update, delete] }
25+
]
26+
) {
1927
id: ID!
2028
title: String!
2129
rating: Int!
@@ -24,37 +32,41 @@ type Post @model @auth(rules: [
2432
comments: [Comment] @hasMany(indexName: "byPost", fields: ["id"])
2533
}
2634

27-
type Comment @model @auth(rules: [
28-
{ allow: private, operations: [read], provider: iam},
29-
{ allow: private, operations: [read], provider: userPools},
30-
{ allow: owner, operations: [create, read, update, delete] }
31-
]) {
35+
type Comment
36+
@model
37+
@auth(
38+
rules: [
39+
{ allow: private, operations: [read], provider: iam }
40+
{ allow: private, operations: [read], provider: userPools }
41+
{ allow: owner, operations: [create, read, update, delete] }
42+
]
43+
) {
3244
id: ID!
3345
postID: ID! @index(name: "byPost")
3446
post: Post @belongsTo(fields: ["postID"])
3547
content: String!
3648
}
3749

38-
type CpkOneToOneBidirectionalParentCD @model @auth(rules: [
39-
{ allow: private, provider: iam}
40-
]) {
50+
type CpkOneToOneBidirectionalParentCD
51+
@model
52+
@auth(rules: [{ allow: private, provider: iam }]) {
4153
customId: ID! @primaryKey(sortKeyFields: ["name"])
4254
name: String!
4355
implicitChild: CpkOneToOneBidirectionalChildImplicitCD @hasOne
4456
explicitChild: CpkOneToOneBidirectionalChildExplicitCD @hasOne
4557
}
4658

47-
type CpkOneToOneBidirectionalChildImplicitCD @model @auth(rules: [
48-
{ allow: private, provider: iam}
49-
]) {
59+
type CpkOneToOneBidirectionalChildImplicitCD
60+
@model
61+
@auth(rules: [{ allow: private, provider: iam }]) {
5062
id: ID! @primaryKey(sortKeyFields: ["name"])
5163
name: String!
5264
belongsToParent: CpkOneToOneBidirectionalParentCD @belongsTo
5365
}
5466

55-
type CpkOneToOneBidirectionalChildExplicitCD @model @auth(rules: [
56-
{ allow: private, provider: iam}
57-
]) {
67+
type CpkOneToOneBidirectionalChildExplicitCD
68+
@model
69+
@auth(rules: [{ allow: private, provider: iam }]) {
5870
id: ID! @primaryKey(sortKeyFields: ["name"])
5971
name: String!
6072
belongsToParentID: ID
@@ -63,22 +75,41 @@ type CpkOneToOneBidirectionalChildExplicitCD @model @auth(rules: [
6375
@belongsTo(fields: ["belongsToParentID", "belongsToParentName"])
6476
}
6577

66-
type OwnerOnly @model @auth(rules: [{allow: owner}]) {
78+
type OwnerOnly @model @auth(rules: [{ allow: owner }]) {
6779
id: ID!
6880
name: String!
69-
}
81+
}
7082

7183
type lowerCase
7284
@model
7385
@auth(
7486
rules: [
75-
{ allow: public, operations: [read], provider: apiKey },
76-
{ allow: public, operations: [read], provider: iam },
77-
{ allow: private, operations: [read], provider: iam },
78-
{ allow: private, operations: [read], provider: userPools },
87+
{ allow: public, operations: [read], provider: apiKey }
88+
{ allow: public, operations: [read], provider: iam }
89+
{ allow: private, operations: [read], provider: iam }
90+
{ allow: private, operations: [read], provider: userPools }
7991
{ allow: owner, operations: [create, read, update, delete] }
8092
]
8193
) {
8294
id: ID!
8395
name: String!
84-
}
96+
}
97+
98+
type Sample
99+
@model
100+
@auth(
101+
rules: [
102+
{ allow: public, operations: [read], provider: apiKey }
103+
{ allow: public, operations: [read], provider: iam }
104+
{ allow: private, operations: [read], provider: iam }
105+
{ allow: private, operations: [read], provider: userPools }
106+
{ allow: owner, operations: [create, read, update, delete] }
107+
]
108+
) {
109+
id: ID!
110+
name: String
111+
number: Int
112+
flag: Boolean
113+
date: AWSTime
114+
rootbeer: Float
115+
}

packages/api/amplify_api/example/integration_test/graphql/iam_test.dart

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33
import 'dart:async';
44
import 'dart:convert';
5+
import 'dart:math';
56

67
import 'package:amplify_api/amplify_api.dart';
78
import 'package:amplify_api_example/models/ModelProvider.dart';
@@ -20,6 +21,8 @@ import '../util.dart';
2021
/// increase past the default limit.
2122
const _limit = 10000;
2223

24+
const _max = 10000;
25+
2326
void main({bool useExistingTestUser = false}) {
2427
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
2528

@@ -188,6 +191,51 @@ void main({bool useExistingTestUser = false}) {
188191
expect(postFromResponse?.title, title);
189192
});
190193

194+
testWidgets('should return model if attribute exists',
195+
(WidgetTester tester) async {
196+
// Use same name to scope the query to the created model.
197+
final name = 'Lorem Ipsum Test Sample: ${uuid()}';
198+
final number = Random().nextInt(_max);
199+
await addSamplePartial(
200+
name,
201+
number: number,
202+
);
203+
await addSamplePartial(name);
204+
205+
final existsRequest = ModelQueries.list(
206+
Sample.classType,
207+
where: Sample.NUMBER.attributeExists().and(Sample.NAME.eq(name)),
208+
limit: _limit,
209+
);
210+
211+
final existsResponse = await Amplify.API
212+
.query(
213+
request: existsRequest,
214+
)
215+
.response;
216+
217+
final existsData = existsResponse.data;
218+
expect(existsData?.items.length, 1);
219+
expect(existsData?.items[0]?.number, number);
220+
221+
final doesNotExistRequest = ModelQueries.list(
222+
Sample.classType,
223+
where: Sample.NUMBER
224+
.attributeExists(exists: false)
225+
.and(Sample.NAME.eq(name)),
226+
limit: _limit,
227+
);
228+
final doesNotExistResponse = await Amplify.API
229+
.query(
230+
request: doesNotExistRequest,
231+
)
232+
.response;
233+
234+
final doesNotExistData = doesNotExistResponse.data;
235+
expect(doesNotExistData?.items.length, 1);
236+
expect(doesNotExistData?.items[0]?.number, null);
237+
});
238+
191239
testWidgets('should copyWith request', (WidgetTester tester) async {
192240
final title = 'Lorem Ipsum Test Post: ${uuid()}';
193241
const rating = 0;

0 commit comments

Comments
 (0)