Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): Add attributeExists query predicate #4134

Merged
merged 11 commits into from
Apr 30, 2024
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
Loading