Skip to content

Commit

Permalink
feat(api): Add attributeExists query predicate (#4134)
Browse files Browse the repository at this point in the history
  • Loading branch information
MarlonJD authored Apr 30, 2024
1 parent d089371 commit 5d7fd16
Show file tree
Hide file tree
Showing 10 changed files with 615 additions and 36 deletions.
21 changes: 21 additions & 0 deletions packages/amplify_core/lib/src/types/query/query_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,27 @@ class QueryField<T> {
QueryPredicateOperation beginsWith(String value) =>
QueryPredicateOperation(fieldName, BeginsWithQueryOperator(value));

/// An **attribute exists** operation.
///
/// Matches models whether the given field exists or not.
///
/// ### Example:
/// The example returns Blog where the optional Author attribute exists.
///
/// ```dart
/// ModelQueries.list(
/// Blog.classType,
/// where: Blog.AUTHOR.attributeExists(),
/// );
/// ```
QueryPredicateOperation attributeExists({bool exists = true}) =>
QueryPredicateOperation(
fieldName,
AttributeExistsQueryOperator(
exists: exists,
),
);

/// Sorts models by the given field in ascending order
///
/// ### Example:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ enum QueryFieldOperatorType {
greater_than,
contains,
between,
begins_with
begins_with,
attribute_exists,
}

extension QueryFieldOperatorTypeExtension on QueryFieldOperatorType {
Expand Down Expand Up @@ -257,3 +258,26 @@ class BeginsWithQueryOperator extends QueryFieldOperatorSingleValue<String> {
return other.startsWith(value);
}
}

class AttributeExistsQueryOperator<T> extends QueryFieldOperator<T> {
const AttributeExistsQueryOperator({this.exists = true})
: super(QueryFieldOperatorType.attribute_exists);

final bool exists;

@override
bool evaluate(T? other) {
if (exists == true) {
return other != null;
}
return other == null;
}

@override
Map<String, dynamic> serializeAsMap() {
return <String, dynamic>{
'operatorName': QueryFieldOperatorType.attribute_exists.toShortString(),
'exists': this.exists,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,36 @@ void main() {
await Amplify.DataStore.delete(updatedBlog);
await Amplify.DataStore.save(otherBlog);
});

testWidgets(
'observe with attribute exists query predicate filters out non matches',
(WidgetTester tester) async {
HasOneChild hasAttribute = HasOneChild(name: 'name - ${uuid()}');
HasOneChild hasNoAttribute = HasOneChild();

var hasAttributeStream = Amplify.DataStore.observe(HasOneChild.classType,
where: Blog.NAME.attributeExists())
.map((event) => event.item);
expectLater(
hasAttributeStream,
emitsInOrder(
[hasAttribute],
),
);

var hasNoAttributeStream = Amplify.DataStore.observe(
HasOneChild.classType,
where: Blog.NAME.attributeExists(exists: false))
.map((event) => event.item);
expectLater(
hasNoAttributeStream,
emitsInOrder(
[hasNoAttribute],
),
);

await Amplify.DataStore.save(hasAttribute);
await Amplify.DataStore.save(hasNoAttribute);
});
});
}
6 changes: 6 additions & 0 deletions packages/amplify_datastore/test/query_predicate_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,12 @@ void main() {
expect(testPredicate.evaluate(post2), isTrue);
});

test('attributeExists', () {
QueryPredicate testPredicate = Post.LIKECOUNT.attributeExists();
expect(testPredicate.evaluate(post4), isFalse);
expect(testPredicate.evaluate(post2), isTrue);
});

test('Temporal type', () {
QueryPredicate testPredicate = Post.CREATED.lt(TemporalDateTime(
DateTime(2020, 01, 01, 12, 00),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
type Blog @model @auth(rules: [
{ allow: public, operations: [read], provider: apiKey},
{ allow: public, operations: [read], provider: iam},
{ allow: private, operations: [read], provider: iam},
{ allow: private, operations: [read], provider: userPools},
{ allow: owner, operations: [create, read, update, delete] }
]) {
type Blog
@model
@auth(
rules: [
{ allow: public, operations: [read], provider: apiKey }
{ allow: public, operations: [read], provider: iam }
{ allow: private, operations: [read], provider: iam }
{ allow: private, operations: [read], provider: userPools }
{ allow: owner, operations: [create, read, update, delete] }
]
) {
id: ID!
name: String!
posts: [Post] @hasMany(indexName: "byBlog", fields: ["id"])
}

type Post @model @auth(rules: [
{ allow: public, operations: [read], provider: iam},
{ allow: private, operations: [read], provider: iam},
{ allow: private, operations: [read], provider: userPools},
{ allow: owner, operations: [create, read, update, delete] }
]) {
type Post
@model
@auth(
rules: [
{ allow: public, operations: [read], provider: iam }
{ allow: private, operations: [read], provider: iam }
{ allow: private, operations: [read], provider: userPools }
{ allow: owner, operations: [create, read, update, delete] }
]
) {
id: ID!
title: String!
rating: Int!
Expand All @@ -24,37 +32,41 @@ type Post @model @auth(rules: [
comments: [Comment] @hasMany(indexName: "byPost", fields: ["id"])
}

type Comment @model @auth(rules: [
{ allow: private, operations: [read], provider: iam},
{ allow: private, operations: [read], provider: userPools},
{ allow: owner, operations: [create, read, update, delete] }
]) {
type Comment
@model
@auth(
rules: [
{ allow: private, operations: [read], provider: iam }
{ allow: private, operations: [read], provider: userPools }
{ allow: owner, operations: [create, read, update, delete] }
]
) {
id: ID!
postID: ID! @index(name: "byPost")
post: Post @belongsTo(fields: ["postID"])
content: String!
}

type CpkOneToOneBidirectionalParentCD @model @auth(rules: [
{ allow: private, provider: iam}
]) {
type CpkOneToOneBidirectionalParentCD
@model
@auth(rules: [{ allow: private, provider: iam }]) {
customId: ID! @primaryKey(sortKeyFields: ["name"])
name: String!
implicitChild: CpkOneToOneBidirectionalChildImplicitCD @hasOne
explicitChild: CpkOneToOneBidirectionalChildExplicitCD @hasOne
}

type CpkOneToOneBidirectionalChildImplicitCD @model @auth(rules: [
{ allow: private, provider: iam}
]) {
type CpkOneToOneBidirectionalChildImplicitCD
@model
@auth(rules: [{ allow: private, provider: iam }]) {
id: ID! @primaryKey(sortKeyFields: ["name"])
name: String!
belongsToParent: CpkOneToOneBidirectionalParentCD @belongsTo
}

type CpkOneToOneBidirectionalChildExplicitCD @model @auth(rules: [
{ allow: private, provider: iam}
]) {
type CpkOneToOneBidirectionalChildExplicitCD
@model
@auth(rules: [{ allow: private, provider: iam }]) {
id: ID! @primaryKey(sortKeyFields: ["name"])
name: String!
belongsToParentID: ID
Expand All @@ -63,22 +75,41 @@ type CpkOneToOneBidirectionalChildExplicitCD @model @auth(rules: [
@belongsTo(fields: ["belongsToParentID", "belongsToParentName"])
}

type OwnerOnly @model @auth(rules: [{allow: owner}]) {
type OwnerOnly @model @auth(rules: [{ allow: owner }]) {
id: ID!
name: String!
}
}

type lowerCase
@model
@auth(
rules: [
{ allow: public, operations: [read], provider: apiKey },
{ allow: public, operations: [read], provider: iam },
{ allow: private, operations: [read], provider: iam },
{ allow: private, operations: [read], provider: userPools },
{ allow: public, operations: [read], provider: apiKey }
{ allow: public, operations: [read], provider: iam }
{ allow: private, operations: [read], provider: iam }
{ allow: private, operations: [read], provider: userPools }
{ allow: owner, operations: [create, read, update, delete] }
]
) {
id: ID!
name: String!
}
}

type Sample
@model
@auth(
rules: [
{ allow: public, operations: [read], provider: apiKey }
{ allow: public, operations: [read], provider: iam }
{ allow: private, operations: [read], provider: iam }
{ allow: private, operations: [read], provider: userPools }
{ allow: owner, operations: [create, read, update, delete] }
]
) {
id: ID!
name: String
number: Int
flag: Boolean
date: AWSTime
rootbeer: Float
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import 'dart:async';
import 'dart:convert';
import 'dart:math';

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

const _max = 10000;

void main({bool useExistingTestUser = false}) {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

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

testWidgets('should return model if attribute exists',
(WidgetTester tester) async {
// Use same name to scope the query to the created model.
final name = 'Lorem Ipsum Test Sample: ${uuid()}';
final number = Random().nextInt(_max);
await addSamplePartial(
name,
number: number,
);
await addSamplePartial(name);

final existsRequest = ModelQueries.list(
Sample.classType,
where: Sample.NUMBER.attributeExists().and(Sample.NAME.eq(name)),
limit: _limit,
);

final existsResponse = await Amplify.API
.query(
request: existsRequest,
)
.response;

final existsData = existsResponse.data;
expect(existsData?.items.length, 1);
expect(existsData?.items[0]?.number, number);

final doesNotExistRequest = ModelQueries.list(
Sample.classType,
where: Sample.NUMBER
.attributeExists(exists: false)
.and(Sample.NAME.eq(name)),
limit: _limit,
);
final doesNotExistResponse = await Amplify.API
.query(
request: doesNotExistRequest,
)
.response;

final doesNotExistData = doesNotExistResponse.data;
expect(doesNotExistData?.items.length, 1);
expect(doesNotExistData?.items[0]?.number, null);
});

testWidgets('should copyWith request', (WidgetTester tester) async {
final title = 'Lorem Ipsum Test Post: ${uuid()}';
const rating = 0;
Expand Down
Loading

0 comments on commit 5d7fd16

Please sign in to comment.