diff --git a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CustomClassMapper.java b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CustomClassMapper.java index 5d21a438733a..1ba2de65328f 100644 --- a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CustomClassMapper.java +++ b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CustomClassMapper.java @@ -160,7 +160,8 @@ private static Object serialize(T o, ErrorPath path) { || o instanceof Timestamp || o instanceof GeoPoint || o instanceof Blob - || o instanceof DocumentReference) { + || o instanceof DocumentReference + || o instanceof FieldValue) { return o; } else { Class clazz = (Class) o.getClass(); diff --git a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentMask.java b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentMask.java index c83b5b27d78a..dc2d8f6754bb 100644 --- a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentMask.java +++ b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentMask.java @@ -48,10 +48,10 @@ private static List extractFromMap(Map values, FieldP for (Map.Entry entry : values.entrySet()) { Object value = entry.getValue(); FieldPath childPath = path.append(FieldPath.of(entry.getKey())); - if (entry.getValue() == FieldValue.SERVER_TIMESTAMP_SENTINEL) { - // Ignore - } else if (entry.getValue() == FieldValue.DELETE_SENTINEL) { - fieldPaths.add(childPath); + if (entry.getValue() instanceof FieldValue) { + if (((FieldValue) entry.getValue()).includeInDocumentMask()) { + fieldPaths.add(childPath); + } } else if (value instanceof Map) { fieldPaths.addAll(extractFromMap((Map) value, childPath)); } else { diff --git a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentTransform.java b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentTransform.java index cf66150c65c9..000bbef1f3f3 100644 --- a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentTransform.java +++ b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentTransform.java @@ -46,11 +46,11 @@ static DocumentTransform fromFieldPathMap( for (Map.Entry entry : values.entrySet()) { FieldPath path = entry.getKey(); Object value = entry.getValue(); - if (value == FieldValue.SERVER_TIMESTAMP_SENTINEL) { - FieldTransform.Builder fieldTransform = FieldTransform.newBuilder(); - fieldTransform.setFieldPath(path.getEncodedPath()); - fieldTransform.setSetToServerValue(FieldTransform.ServerValue.REQUEST_TIME); - transforms.put(path, fieldTransform.build()); + if (value instanceof FieldValue) { + FieldValue fieldValue = (FieldValue) value; + if (fieldValue.includeInDocumentTransform()) { + transforms.put(path, fieldValue.toProto(path)); + } } else if (value instanceof Map) { transforms.putAll( extractFromMap((Map) value, path, /* allowTransforms= */ true)); @@ -71,15 +71,15 @@ private static SortedMap extractFromMap( for (Map.Entry entry : values.entrySet()) { Object value = entry.getValue(); path = path.append(FieldPath.of(entry.getKey())); - if (value == FieldValue.SERVER_TIMESTAMP_SENTINEL) { + if (value instanceof FieldValue) { + FieldValue fieldValue = (FieldValue) value; if (allowTransforms) { - FieldTransform.Builder fieldTransform = FieldTransform.newBuilder(); - fieldTransform.setFieldPath(path.getEncodedPath()); - fieldTransform.setSetToServerValue(FieldTransform.ServerValue.REQUEST_TIME); - transforms.put(path, fieldTransform.build()); + if (fieldValue.includeInDocumentTransform()) { + transforms.put(path, fieldValue.toProto(path)); + } } else { throw FirestoreException.invalidState( - "Server timestamps are not supported as Array values."); + fieldValue.getMethodName() + " is not supported inside of an array."); } } else if (value instanceof Map) { transforms.putAll(extractFromMap((Map) value, path, allowTransforms)); @@ -96,9 +96,9 @@ private static void validateArray(List values, FieldPath path) { for (int i = 0; i < values.size(); ++i) { Object value = values.get(i); path = path.append(FieldPath.of(Integer.toString(i))); - if (value == FieldValue.SERVER_TIMESTAMP_SENTINEL) { + if (value instanceof FieldValue) { throw FirestoreException.invalidState( - "Server timestamps are not supported as Array values."); + ((FieldValue) value).getMethodName() + " is not supported inside of an array."); } else if (value instanceof Map) { extractFromMap((Map) value, path, false); } else if (value instanceof List) { diff --git a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldValue.java b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldValue.java index 0c72e715d890..c7480ce653b4 100644 --- a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldValue.java +++ b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldValue.java @@ -16,13 +16,176 @@ package com.google.cloud.firestore; +import com.google.common.base.Preconditions; +import com.google.firestore.v1beta1.ArrayValue; +import com.google.firestore.v1beta1.DocumentTransform.FieldTransform; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; import javax.annotation.Nonnull; /** Sentinel values that can be used when writing document fields with set() or update(). */ public abstract class FieldValue { - static final Object SERVER_TIMESTAMP_SENTINEL = new Object(); - static final Object DELETE_SENTINEL = new Object(); + private static final FieldValue SERVER_TIMESTAMP_SENTINEL = + new FieldValue() { + @Override + boolean includeInDocumentMask() { + return false; + } + + @Override + boolean includeInDocumentTransform() { + return true; + } + + @Override + String getMethodName() { + return "FieldValue.serverTimestamp()"; + } + + @Override + FieldTransform toProto(FieldPath path) { + FieldTransform.Builder fieldTransform = FieldTransform.newBuilder(); + fieldTransform.setFieldPath(path.getEncodedPath()); + fieldTransform.setSetToServerValue(FieldTransform.ServerValue.REQUEST_TIME); + return fieldTransform.build(); + } + }; + + static final FieldValue DELETE_SENTINEL = + new FieldValue() { + @Override + boolean includeInDocumentMask() { + return true; + } + + @Override + boolean includeInDocumentTransform() { + return false; + } + + @Override + String getMethodName() { + return "FieldValue.delete()"; + } + + @Override + FieldTransform toProto(FieldPath path) { + throw new IllegalStateException( + "FieldValue.delete() should not be included in a FieldTransform"); + } + }; + + static class ArrayUnionFieldValue extends FieldValue { + final List elements; + + ArrayUnionFieldValue(List elements) { + this.elements = elements; + } + + @Override + boolean includeInDocumentMask() { + return false; + } + + @Override + boolean includeInDocumentTransform() { + return true; + } + + @Override + String getMethodName() { + return "FieldValue.arrayUnion()"; + } + + @Override + FieldTransform toProto(FieldPath path) { + ArrayValue.Builder encodedElements = ArrayValue.newBuilder(); + + for (Object element : elements) { + encodedElements.addValues( + UserDataConverter.encodeValue(path, element, UserDataConverter.ARGUMENT)); + } + + FieldTransform.Builder fieldTransform = FieldTransform.newBuilder(); + fieldTransform.setFieldPath(path.getEncodedPath()); + fieldTransform.setAppendMissingElements(encodedElements); + return fieldTransform.build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ArrayUnionFieldValue that = (ArrayUnionFieldValue) o; + return Objects.equals(elements, that.elements); + } + + @Override + public int hashCode() { + return Objects.hash(elements); + } + } + + static class ArrayRemoveFieldValue extends FieldValue { + final List elements; + + ArrayRemoveFieldValue(List elements) { + this.elements = elements; + } + + @Override + boolean includeInDocumentMask() { + return false; + } + + @Override + boolean includeInDocumentTransform() { + return true; + } + + @Override + String getMethodName() { + return "FieldValue.arrayRemove()"; + } + + @Override + FieldTransform toProto(FieldPath path) { + ArrayValue.Builder encodedElements = ArrayValue.newBuilder(); + + for (Object element : elements) { + encodedElements.addValues( + UserDataConverter.encodeValue(path, element, UserDataConverter.ARGUMENT)); + } + + FieldTransform.Builder fieldTransform = FieldTransform.newBuilder(); + fieldTransform.setFieldPath(path.getEncodedPath()); + fieldTransform.setRemoveAllFromArray(encodedElements); + return fieldTransform.build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ArrayRemoveFieldValue that = (ArrayRemoveFieldValue) o; + return Objects.equals(elements, that.elements); + } + + @Override + public int hashCode() { + return Objects.hash(elements); + } + } private FieldValue() {} @@ -31,16 +194,59 @@ private FieldValue() {} * written data. */ @Nonnull - public static Object serverTimestamp() { + public static FieldValue serverTimestamp() { return SERVER_TIMESTAMP_SENTINEL; } /** Returns a sentinel used with update() to mark a field for deletion. */ @Nonnull - public static Object delete() { + public static FieldValue delete() { return DELETE_SENTINEL; } + /** + * Returns a special value that can be used with set() or update() that tells the server to union + * the given elements with any array value that already exists on the server. Each specified + * element that doesn't already exist in the array will be added to the end. If the field being + * modified is not already an array it will be overwritten with an array containing exactly the + * specified elements. + * + * @param elements The elements to union into the array. + * @return The FieldValue sentinel for use in a call to set() or update(). + */ + @Nonnull + public static FieldValue arrayUnion(@Nonnull Object... elements) { + Preconditions.checkArgument(elements.length > 0, "arrayUnion() expects at least 1 element"); + return new ArrayUnionFieldValue(Arrays.asList(elements)); + } + + /** + * Returns a special value that can be used with set() or update() that tells the server to remove + * the given elements from any array value that already exists on the server. All instances of + * each element specified will be removed from the array. If the field being modified is not + * already an array it will be overwritten with an empty array. + * + * @param elements The elements to remove from the array. + * @return The FieldValue sentinel for use in a call to set() or update(). + */ + @Nonnull + public static FieldValue arrayRemove(@Nonnull Object... elements) { + Preconditions.checkArgument(elements.length > 0, "arrayRemove() expects at least 1 element"); + return new ArrayRemoveFieldValue(Arrays.asList(elements)); + } + + /** Whether this FieldTransform should be included in the document mask. */ + abstract boolean includeInDocumentMask(); + + /** Whether this FieldTransform should be included in the list of document transforms. */ + abstract boolean includeInDocumentTransform(); + + /** The name of the method that returned this FieldValue instance. */ + abstract String getMethodName(); + + /** Generates the field transform proto. */ + abstract FieldTransform toProto(FieldPath path); + /** * Returns true if this FieldValue is equal to the provided object. * diff --git a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java index f1b3259124c1..61296123d044 100644 --- a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java +++ b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java @@ -16,6 +16,7 @@ package com.google.cloud.firestore; +import static com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator.ARRAY_CONTAINS; import static com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator.EQUAL; import static com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator.GREATER_THAN; import static com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator.GREATER_THAN_OR_EQUAL; @@ -89,7 +90,7 @@ private abstract static class FieldFilter { Value encodeValue() { Object sanitizedObject = CustomClassMapper.serialize(value); Value encodedValue = - UserDataConverter.encodeValue(fieldPath, sanitizedObject, UserDataConverter.NO_DELETES); + UserDataConverter.encodeValue(fieldPath, sanitizedObject, UserDataConverter.ARGUMENT); if (encodedValue == null) { throw FirestoreException.invalidState("Cannot use Firestore Sentinels in FieldFilter"); @@ -351,7 +352,7 @@ private Cursor createCursor(List order, Object[] fieldValues, boolea } Value encodedValue = - UserDataConverter.encodeValue(fieldPath, sanitizedValue, UserDataConverter.NO_DELETES); + UserDataConverter.encodeValue(fieldPath, sanitizedValue, UserDataConverter.ARGUMENT); if (encodedValue == null) { throw FirestoreException.invalidState( @@ -567,6 +568,44 @@ public Query whereGreaterThanOrEqualTo(@Nonnull FieldPath fieldPath, @Nonnull Ob return new Query(firestore, path, newOptions); } + /** + * Creates and returns a new Query with the additional filter that documents must contain the + * specified field, the value must be an array, and that the array must contain the provided + * value. + * + *

A Query can have only one whereArrayContains() filter. + * + * @param field The name of the field containing an array to search + * @param value The value that must be contained in the array + * @return The created Query. + */ + @Nonnull + public Query whereArrayContains(@Nonnull String field, @Nonnull Object value) { + return whereArrayContains(FieldPath.fromDotSeparatedString(field), value); + } + + /** + * Creates and returns a new Query with the additional filter that documents must contain the + * specified field, the value must be an array, and that the array must contain the provided + * value. + * + *

A Query can have only one whereArrayContains() filter. + * + * @param fieldPath The path of the field containing an array to search + * @param value The value that must be contained in the array + * @return The created Query. + */ + @Nonnull + public Query whereArrayContains(@Nonnull FieldPath fieldPath, @Nonnull Object value) { + Preconditions.checkState( + options.startCursor == null && options.endCursor == null, + "Cannot call whereArrayContains() after defining a boundary with startAt(), " + + "startAfter(), endBefore() or endAt()."); + QueryOptions newOptions = new QueryOptions(options); + newOptions.fieldFilters.add(new ComparisonFilter(fieldPath, ARRAY_CONTAINS, value)); + return new Query(firestore, path, newOptions); + } + /** * Creates and returns a new Query that's additionally sorted by the specified field. * diff --git a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/SetOptions.java b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/SetOptions.java index 1cf4299f1227..64e964cf8142 100644 --- a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/SetOptions.java +++ b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/SetOptions.java @@ -131,6 +131,11 @@ EncodingOptions getEncodingOptions() { public boolean allowDelete(FieldPath fieldPath) { return fieldMask.contains(fieldPath); } + + @Override + public boolean allowTransform() { + return true; + } }; } } diff --git a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java index 7b48c1425495..6b69c40bb4bf 100644 --- a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java +++ b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java @@ -518,6 +518,11 @@ private T performUpdate( public boolean allowDelete(FieldPath fieldPath) { return fields.containsKey(fieldPath); } + + @Override + public boolean allowTransform() { + return true; + } }); List fieldPaths = new ArrayList<>(fields.keySet()); DocumentTransform documentTransform = diff --git a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java index 6be73a8d39d5..bda5ad48f0f7 100644 --- a/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java +++ b/google-cloud-clients/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java @@ -35,24 +35,51 @@ class UserDataConverter { interface EncodingOptions { /** Returns whether a field delete at `fieldPath` is allowed. */ boolean allowDelete(FieldPath fieldPath); + + /** Returns whether a field transform (server timestamp, array ops) is allowed. */ + boolean allowTransform(); } - /** Rejects all field deletes. */ + /** Rejects all field deletes and allows all field transforms */ static final EncodingOptions NO_DELETES = new EncodingOptions() { @Override public boolean allowDelete(FieldPath fieldPath) { return false; } + + @Override + public boolean allowTransform() { + return true; + } }; - /** Allows all field deletes. */ + /** Allows all field deletes and allows all field transforms. */ static final EncodingOptions ALLOW_ALL_DELETES = new EncodingOptions() { @Override public boolean allowDelete(FieldPath fieldPath) { return true; } + + @Override + public boolean allowTransform() { + return true; + } + }; + + /** Rejects all field deletes and any field transform. */ + static final EncodingOptions ARGUMENT = + new EncodingOptions() { + @Override + public boolean allowDelete(FieldPath fieldPath) { + return false; + } + + @Override + public boolean allowTransform() { + return false; + } }; private UserDataConverter() {} @@ -71,9 +98,15 @@ static Value encodeValue( FieldPath path, @Nullable Object sanitizedObject, EncodingOptions options) { if (sanitizedObject == FieldValue.DELETE_SENTINEL) { Preconditions.checkArgument( - options.allowDelete(path), "Encountered unexpected delete sentinel at field '%s'.", path); + options.allowDelete(path), "FieldValue.delete() is not supported at field '%s'.", path); return null; - } else if (sanitizedObject == FieldValue.SERVER_TIMESTAMP_SENTINEL) { + } else if (sanitizedObject instanceof FieldValue) { + Preconditions.checkArgument( + options.allowTransform(), + "Cannot use " + + ((FieldValue) sanitizedObject).getMethodName() + + " as an argument at field '%s'.", + path); return null; } else if (sanitizedObject == null) { return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); diff --git a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/DocumentReferenceTest.java b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/DocumentReferenceTest.java index a7adfa19b970..59dab2fea2d8 100644 --- a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/DocumentReferenceTest.java +++ b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/DocumentReferenceTest.java @@ -24,9 +24,9 @@ import static com.google.cloud.firestore.LocalFirestoreHelper.DATE; import static com.google.cloud.firestore.LocalFirestoreHelper.DOCUMENT_NAME; import static com.google.cloud.firestore.LocalFirestoreHelper.DOCUMENT_PATH; +import static com.google.cloud.firestore.LocalFirestoreHelper.FIELD_TRANSFORM_COMMIT_RESPONSE; import static com.google.cloud.firestore.LocalFirestoreHelper.GEO_POINT; import static com.google.cloud.firestore.LocalFirestoreHelper.NESTED_CLASS_OBJECT; -import static com.google.cloud.firestore.LocalFirestoreHelper.SERVER_TIMESTAMP_COMMIT_RESPONSE; import static com.google.cloud.firestore.LocalFirestoreHelper.SERVER_TIMESTAMP_PROTO; import static com.google.cloud.firestore.LocalFirestoreHelper.SERVER_TIMESTAMP_TRANSFORM; import static com.google.cloud.firestore.LocalFirestoreHelper.SINGLE_DELETE_COMMIT_RESPONSE; @@ -36,6 +36,8 @@ import static com.google.cloud.firestore.LocalFirestoreHelper.SINGLE_WRITE_COMMIT_RESPONSE; import static com.google.cloud.firestore.LocalFirestoreHelper.TIMESTAMP; import static com.google.cloud.firestore.LocalFirestoreHelper.UPDATE_PRECONDITION; +import static com.google.cloud.firestore.LocalFirestoreHelper.arrayRemove; +import static com.google.cloud.firestore.LocalFirestoreHelper.arrayUnion; import static com.google.cloud.firestore.LocalFirestoreHelper.assertCommitEquals; import static com.google.cloud.firestore.LocalFirestoreHelper.commit; import static com.google.cloud.firestore.LocalFirestoreHelper.create; @@ -44,6 +46,7 @@ import static com.google.cloud.firestore.LocalFirestoreHelper.getAllResponse; import static com.google.cloud.firestore.LocalFirestoreHelper.map; import static com.google.cloud.firestore.LocalFirestoreHelper.object; +import static com.google.cloud.firestore.LocalFirestoreHelper.serverTimestamp; import static com.google.cloud.firestore.LocalFirestoreHelper.set; import static com.google.cloud.firestore.LocalFirestoreHelper.streamingResponse; import static com.google.cloud.firestore.LocalFirestoreHelper.string; @@ -333,7 +336,10 @@ public void createWithServerTimestamp() throws Exception { documentReference.create(LocalFirestoreHelper.SERVER_TIMESTAMP_MAP).get(); documentReference.create(LocalFirestoreHelper.SERVER_TIMESTAMP_OBJECT).get(); - CommitRequest create = commit(transform(CREATE_PRECONDITION, "foo", "inner.bar")); + CommitRequest create = + commit( + transform( + CREATE_PRECONDITION, "foo", serverTimestamp(), "inner.bar", serverTimestamp())); List commitRequests = commitCapture.getAllValues(); assertCommitEquals(create, commitRequests.get(0)); @@ -342,7 +348,7 @@ public void createWithServerTimestamp() throws Exception { @Test public void setWithServerTimestamp() throws Exception { - doReturn(SERVER_TIMESTAMP_COMMIT_RESPONSE) + doReturn(FIELD_TRANSFORM_COMMIT_RESPONSE) .when(firestoreMock) .sendRequest( commitCapture.capture(), Matchers.>any()); @@ -359,7 +365,7 @@ public void setWithServerTimestamp() throws Exception { @Test public void updateWithServerTimestamp() throws Exception { - doReturn(SERVER_TIMESTAMP_COMMIT_RESPONSE) + doReturn(FIELD_TRANSFORM_COMMIT_RESPONSE) .when(firestoreMock) .sendRequest( commitCapture.capture(), Matchers.>any()); @@ -376,7 +382,10 @@ public void updateWithServerTimestamp() throws Exception { documentReference.update( "foo", FieldValue.serverTimestamp(), "inner.bar", FieldValue.serverTimestamp()); - update = commit(transform(UPDATE_PRECONDITION, "foo", "inner.bar")); + update = + commit( + transform( + UPDATE_PRECONDITION, "foo", serverTimestamp(), "inner.bar", serverTimestamp())); assertCommitEquals(update, commitCapture.getValue()); } @@ -395,7 +404,7 @@ public void mergeWithServerTimestamps() throws Exception { .set(LocalFirestoreHelper.SERVER_TIMESTAMP_OBJECT, SetOptions.mergeFields("inner.bar")) .get(); - CommitRequest set = commit(transform("inner.bar")); + CommitRequest set = commit(transform("inner.bar", serverTimestamp())); List commitRequests = commitCapture.getAllValues(); assertCommitEquals(set, commitRequests.get(0)); @@ -403,7 +412,47 @@ public void mergeWithServerTimestamps() throws Exception { } @Test - public void serverTimestampInArray() throws Exception { + public void setWithArrayUnion() throws Exception { + doReturn(FIELD_TRANSFORM_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), Matchers.>any()); + + documentReference + .set(map("foo", FieldValue.arrayUnion("bar", map("foo", "baz")))) + .get(); + + CommitRequest set = + commit( + set(Collections.emptyMap()), + transform("foo", arrayUnion(string("bar"), object("foo", string("baz"))))); + + CommitRequest commitRequest = commitCapture.getValue(); + assertCommitEquals(set, commitRequest); + } + + @Test + public void setWithArrayRemove() throws Exception { + doReturn(FIELD_TRANSFORM_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), Matchers.>any()); + + documentReference + .set(map("foo", FieldValue.arrayRemove("bar", map("foo", "baz")))) + .get(); + + CommitRequest set = + commit( + set(Collections.emptyMap()), + transform("foo", arrayRemove(string("bar"), object("foo", string("baz"))))); + + CommitRequest commitRequest = commitCapture.getValue(); + assertCommitEquals(set, commitRequest); + } + + @Test + public void serverTimestampInArray() { Map list = new HashMap<>(); list.put("foo", ImmutableList.of(FieldValue.serverTimestamp())); @@ -411,7 +460,9 @@ public void serverTimestampInArray() throws Exception { documentReference.create(list); fail(); } catch (FirestoreException e) { - assertTrue(e.getMessage().endsWith("Server timestamps are not supported as Array values.")); + assertTrue( + e.getMessage() + .endsWith("FieldValue.serverTimestamp() is not supported inside of an array.")); } list.clear(); @@ -421,7 +472,112 @@ public void serverTimestampInArray() throws Exception { documentReference.create(list); fail(); } catch (FirestoreException e) { - assertTrue(e.getMessage().endsWith("Server timestamps are not supported as Array values.")); + assertTrue( + e.getMessage() + .endsWith("FieldValue.serverTimestamp() is not supported inside of an array.")); + } + } + + @Test + public void deleteInArray() { + Map list = new HashMap<>(); + list.put("foo", ImmutableList.of(FieldValue.delete())); + + try { + documentReference.create(list); + fail(); + } catch (IllegalArgumentException e) { + assertTrue( + e.getMessage().endsWith("FieldValue.delete() is not supported at field 'foo.`0`'.")); + } + + list.clear(); + list.put("a", ImmutableList.of(ImmutableList.of("b", map("c", FieldValue.delete())))); + + try { + documentReference.create(list); + fail(); + } catch (IllegalArgumentException e) { + assertTrue( + e.getMessage().endsWith("FieldValue.delete() is not supported at field 'a.`0`.`1`.c'.")); + } + } + + @Test + public void arrayUnionInArray() { + Map list = new HashMap<>(); + list.put("foo", ImmutableList.of(FieldValue.arrayUnion("foo"))); + + try { + documentReference.create(list); + fail(); + } catch (FirestoreException e) { + assertTrue( + e.getMessage().endsWith("FieldValue.arrayUnion() is not supported inside of an array.")); + } + + list.clear(); + list.put("a", ImmutableList.of(ImmutableList.of("b", map("c", FieldValue.arrayUnion("foo"))))); + + try { + documentReference.create(list); + fail(); + } catch (FirestoreException e) { + assertTrue( + e.getMessage().endsWith("FieldValue.arrayUnion() is not supported inside of an array.")); + } + } + + @Test + public void arrayUnionInArrayUnion() { + Map data = new HashMap<>(); + data.put("foo", FieldValue.arrayUnion(FieldValue.arrayUnion("foo"))); + + try { + documentReference.create(data); + fail(); + } catch (IllegalArgumentException e) { + assertTrue( + e.getMessage() + .endsWith("Cannot use FieldValue.arrayUnion() as an argument at field 'foo'.")); + } + } + + @Test + public void deleteInArrayUnion() { + Map data = new HashMap<>(); + data.put("foo", FieldValue.arrayUnion(FieldValue.delete())); + + try { + documentReference.set(data, SetOptions.merge()); + fail(); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().endsWith("FieldValue.delete() is not supported at field 'foo'.")); + } + } + + @Test + public void arrayRemoveInArray() { + Map list = new HashMap<>(); + list.put("foo", ImmutableList.of(FieldValue.arrayRemove("foo"))); + + try { + documentReference.create(list); + fail(); + } catch (FirestoreException e) { + assertTrue( + e.getMessage().endsWith("FieldValue.arrayRemove() is not supported inside of an array.")); + } + + list.clear(); + list.put("a", ImmutableList.of(ImmutableList.of("b", map("c", FieldValue.arrayRemove("foo"))))); + + try { + documentReference.create(list); + fail(); + } catch (FirestoreException e) { + assertTrue( + e.getMessage().endsWith("FieldValue.arrayRemove() is not supported inside of an array.")); } } diff --git a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/FirestoreTest.java b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/FirestoreTest.java index 67274c7976e2..68798058a068 100644 --- a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/FirestoreTest.java +++ b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/FirestoreTest.java @@ -19,6 +19,7 @@ import static com.google.cloud.firestore.LocalFirestoreHelper.SINGLE_FIELD_PROTO; import static com.google.cloud.firestore.LocalFirestoreHelper.getAllResponse; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.fail; import static org.mockito.Mockito.doAnswer; @@ -116,4 +117,28 @@ public void getAll() throws Exception { assertEquals("doc4", snapshot.get(2).getId()); assertEquals("doc3", snapshot.get(3).getId()); } + + @Test + public void arrayUnionEquals() { + FieldValue arrayUnion1 = FieldValue.arrayUnion("foo", "bar"); + FieldValue arrayUnion2 = FieldValue.arrayUnion("foo", "bar"); + FieldValue arrayUnion3 = FieldValue.arrayUnion("foo", "baz"); + FieldValue arrayRemove = FieldValue.arrayRemove("foo", "bar"); + assertEquals(arrayUnion1, arrayUnion1); + assertEquals(arrayUnion1, arrayUnion2); + assertNotEquals(arrayUnion1, arrayUnion3); + assertNotEquals(arrayUnion1, arrayRemove); + } + + @Test + public void arrayRemoveEquals() { + FieldValue arrayRemove1 = FieldValue.arrayRemove("foo", "bar"); + FieldValue arrayRemove2 = FieldValue.arrayRemove("foo", "bar"); + FieldValue arrayRemove3 = FieldValue.arrayRemove("foo", "baz"); + FieldValue arrayUnion = FieldValue.arrayUnion("foo", "bar"); + assertEquals(arrayRemove1, arrayRemove1); + assertEquals(arrayRemove1, arrayRemove2); + assertNotEquals(arrayRemove1, arrayRemove3); + assertNotEquals(arrayRemove1, arrayUnion); + } } diff --git a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java index 9c57c3ec99f7..8876fa83cf64 100644 --- a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java +++ b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java @@ -101,7 +101,7 @@ public final class LocalFirestoreHelper { public static final ApiFuture SINGLE_DELETE_COMMIT_RESPONSE; public static final ApiFuture SINGLE_WRITE_COMMIT_RESPONSE; - public static final ApiFuture SERVER_TIMESTAMP_COMMIT_RESPONSE; + public static final ApiFuture FIELD_TRANSFORM_COMMIT_RESPONSE; public static final Date DATE; public static final Timestamp TIMESTAMP; @@ -266,20 +266,44 @@ public static RollbackRequest rollback() { return rollback.build(); } - public static Write transform(String... fieldPaths) { - return transform(null, fieldPaths); + public static FieldTransform serverTimestamp() { + return FieldTransform.newBuilder() + .setSetToServerValue(FieldTransform.ServerValue.REQUEST_TIME) + .build(); } - public static Write transform(@Nullable Precondition precondition, String... fieldPaths) { + public static FieldTransform arrayUnion(Value... values) { + return FieldTransform.newBuilder() + .setAppendMissingElements(ArrayValue.newBuilder().addAllValues(Arrays.asList(values))) + .build(); + } + + public static FieldTransform arrayRemove(Value... values) { + return FieldTransform.newBuilder() + .setRemoveAllFromArray(ArrayValue.newBuilder().addAllValues(Arrays.asList(values))) + .build(); + } + + public static Write transform( + String fieldPath, FieldTransform fieldTransform, Object... fieldPathOrTransform) { + return transform(null, fieldPath, fieldTransform, fieldPathOrTransform); + } + + public static Write transform( + @Nullable Precondition precondition, + String fieldPath, + FieldTransform fieldTransform, + Object... fieldPathOrTransform) { Write.Builder write = Write.newBuilder(); - DocumentTransform.Builder transform = write.getTransformBuilder(); - transform.setDocument(DOCUMENT_NAME); - - for (String fieldPath : fieldPaths) { - transform - .addFieldTransformsBuilder() - .setFieldPath(fieldPath) - .setSetToServerValue(DocumentTransform.FieldTransform.ServerValue.REQUEST_TIME); + DocumentTransform.Builder documentTransform = write.getTransformBuilder(); + documentTransform.setDocument(DOCUMENT_NAME); + + documentTransform.addFieldTransformsBuilder().setFieldPath(fieldPath).mergeFrom(fieldTransform); + + for (int i = 0; i < fieldPathOrTransform.length; i += 2) { + String path = (String) fieldPathOrTransform[i]; + FieldTransform transform = (FieldTransform) fieldPathOrTransform[i + 1]; + documentTransform.addFieldTransformsBuilder().setFieldPath(path).mergeFrom(transform); } if (precondition != null) { @@ -659,7 +683,8 @@ public boolean equals(Object o) { mapValue.getMapValueBuilder(); SERVER_TIMESTAMP_PROTO = Collections.emptyMap(); SERVER_TIMESTAMP_OBJECT = new ServerTimestamp(); - SERVER_TIMESTAMP_TRANSFORM = transform("foo", "inner.bar"); + SERVER_TIMESTAMP_TRANSFORM = + transform("foo", serverTimestamp(), "inner.bar", serverTimestamp()); ALL_SUPPORTED_TYPES_MAP = new HashMap<>(); ALL_SUPPORTED_TYPES_MAP.put("foo", "bar"); @@ -699,7 +724,7 @@ public boolean equals(Object o) { .setTimestampValue( com.google.protobuf.Timestamp.newBuilder() .setSeconds(479978400) - .setNanos(123000000)) // Dates only support millisecond precision. + .setNanos(123000000)) // Dates only support millisecond precision. .build()) .put( "timestampValue", @@ -707,7 +732,7 @@ public boolean equals(Object o) { .setTimestampValue( com.google.protobuf.Timestamp.newBuilder() .setSeconds(479978400) - .setNanos(123000)) // Timestamps supports microsecond precision. + .setNanos(123000)) // Timestamps supports microsecond precision. .build()) .put( "arrayValue", @@ -730,7 +755,7 @@ public boolean equals(Object o) { SINGLE_DELETE_COMMIT_RESPONSE = commitResponse(/* adds= */ 0, /* deletes= */ 1); SINGLE_CREATE_COMMIT_REQUEST = commit(create(SINGLE_FIELD_PROTO)); - SERVER_TIMESTAMP_COMMIT_RESPONSE = commitResponse(/* adds= */ 2, /* deletes= */ 0); + FIELD_TRANSFORM_COMMIT_RESPONSE = commitResponse(/* adds= */ 2, /* deletes= */ 0); NESTED_CLASS_OBJECT = new NestedClass(); diff --git a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java index 9990d4b364aa..557ed6d00d10 100644 --- a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java +++ b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java @@ -123,6 +123,7 @@ public void withFilter() throws Exception { query.whereGreaterThanOrEqualTo("foo", "bar").get().get(); query.whereLessThan("foo", "bar").get().get(); query.whereLessThanOrEqualTo("foo", "bar").get().get(); + query.whereArrayContains("foo", "bar").get().get(); Iterator expected = Arrays.asList( @@ -133,7 +134,8 @@ public void withFilter() throws Exception { query(filter(StructuredQuery.FieldFilter.Operator.GREATER_THAN)), query(filter(StructuredQuery.FieldFilter.Operator.GREATER_THAN_OR_EQUAL)), query(filter(StructuredQuery.FieldFilter.Operator.LESS_THAN)), - query(filter(StructuredQuery.FieldFilter.Operator.LESS_THAN_OR_EQUAL))) + query(filter(StructuredQuery.FieldFilter.Operator.LESS_THAN_OR_EQUAL)), + query(filter(StructuredQuery.FieldFilter.Operator.ARRAY_CONTAINS))) .iterator(); for (RunQueryRequest actual : runQuery.getAllValues()) { @@ -155,6 +157,7 @@ public void withFieldPathFilter() throws Exception { query.whereGreaterThanOrEqualTo(FieldPath.of("foo"), "bar").get().get(); query.whereLessThan(FieldPath.of("foo"), "bar").get().get(); query.whereLessThanOrEqualTo(FieldPath.of("foo"), "bar").get().get(); + query.whereArrayContains(FieldPath.of("foo"), "bar").get().get(); Iterator expected = Arrays.asList( @@ -162,7 +165,8 @@ public void withFieldPathFilter() throws Exception { query(filter(StructuredQuery.FieldFilter.Operator.GREATER_THAN)), query(filter(StructuredQuery.FieldFilter.Operator.GREATER_THAN_OR_EQUAL)), query(filter(StructuredQuery.FieldFilter.Operator.LESS_THAN)), - query(filter(StructuredQuery.FieldFilter.Operator.LESS_THAN_OR_EQUAL))) + query(filter(StructuredQuery.FieldFilter.Operator.LESS_THAN_OR_EQUAL)), + query(filter(StructuredQuery.FieldFilter.Operator.ARRAY_CONTAINS))) .iterator(); for (RunQueryRequest actual : runQuery.getAllValues()) { diff --git a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java index 7e77aba60e95..55306fd8864d 100644 --- a/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java +++ b/google-cloud-clients/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java @@ -60,7 +60,6 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nullable; import org.junit.After; @@ -168,7 +167,7 @@ public void setDocumentWithMerge() throws Exception { @Test public void mergeDocumentWithServerTimestamp() throws Exception { Map originalMap = LocalFirestoreHelper.map("a", "b"); - Map updateMap = map("c", FieldValue.serverTimestamp()); + Map updateMap = map("c", (Object) FieldValue.serverTimestamp()); randomDoc.set(originalMap).get(); randomDoc.set(updateMap, SetOptions.merge()).get(); DocumentSnapshot documentSnapshot = randomDoc.get().get(); @@ -202,7 +201,8 @@ public void serverTimestamp() throws Exception { @Test public void timestampDoesntGetTruncatedDuringUpdate() throws Exception { - DocumentReference documentReference = addDocument("time", Timestamp.ofTimeSecondsAndNanos(0, 123000)); + DocumentReference documentReference = + addDocument("time", Timestamp.ofTimeSecondsAndNanos(0, 123000)); DocumentSnapshot documentSnapshot = documentReference.get().get(); Timestamp timestamp = documentSnapshot.getTimestamp("time"); @@ -269,7 +269,7 @@ public void noResults() throws Exception { public void queryWithMicrosecondPrecision() throws Exception { Timestamp microsecondTimestamp = Timestamp.ofTimeSecondsAndNanos(0, 123000); - DocumentReference documentReference = addDocument("time", microsecondTimestamp); + DocumentReference documentReference = addDocument("time", microsecondTimestamp); DocumentSnapshot documentSnapshot = documentReference.get().get(); Query query = randomColl.whereEqualTo("time", microsecondTimestamp); @@ -916,4 +916,23 @@ public void queryPaginationWithWhereClause() throws ExecutionException, Interrup assertEquals(3, pageCount); assertEquals(9, results.size()); } + + @Test + public void arrayOperators() throws ExecutionException, InterruptedException { + Query containsQuery = randomColl.whereArrayContains("foo", "bar"); + + assertTrue(containsQuery.get().get().isEmpty()); + + DocumentReference doc1 = randomColl.document(); + DocumentReference doc2 = randomColl.document(); + doc1.set(Collections.singletonMap("foo", (Object) FieldValue.arrayUnion("bar"))).get(); + doc2.set(Collections.singletonMap("foo", (Object) FieldValue.arrayUnion("baz"))).get(); + + assertEquals(1, containsQuery.get().get().size()); + + doc1.set(Collections.singletonMap("foo", (Object) FieldValue.arrayRemove("bar"))).get(); + doc2.set(Collections.singletonMap("foo", (Object) FieldValue.arrayRemove("baz"))).get(); + + assertTrue(containsQuery.get().get().isEmpty()); + } }