diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java index 405db082690..a38ee616b4d 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java @@ -169,6 +169,26 @@ public void testCanMergeEmptyObject() { listenerRegistration.remove(); } + @Test + public void testUpdateWithEmptyObjectReplacesAllFields() { + DocumentReference documentReference = testDocument(); + documentReference.set(map("a", "a")); + + waitFor(documentReference.update("a", Collections.emptyMap())); + DocumentSnapshot snapshot = waitFor(documentReference.get()); + assertEquals(map("a", Collections.emptyMap()), snapshot.getData()); + } + + @Test + public void testMergeWithEmptyObjectReplacesAllFields() { + DocumentReference documentReference = testDocument(); + documentReference.set(map("a", "a")); + + waitFor(documentReference.set(map("a", Collections.emptyMap()), SetOptions.merge())); + DocumentSnapshot snapshot = waitFor(documentReference.get()); + assertEquals(map("a", Collections.emptyMap()), snapshot.getData()); + } + @Test public void testCanDeleteFieldUsingMerge() { DocumentReference documentReference = testCollection("rooms").document("eros"); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/ObjectValue.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/ObjectValue.java new file mode 100644 index 00000000000..08f4d9de0c3 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/ObjectValue.java @@ -0,0 +1,237 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.firestore.model.protovalue; + +import static com.google.firebase.firestore.model.value.ProtoValues.isType; +import static com.google.firebase.firestore.util.Assert.hardAssert; + +import androidx.annotation.Nullable; +import com.google.firebase.firestore.model.FieldPath; +import com.google.firebase.firestore.model.mutation.FieldMask; +import com.google.firebase.firestore.model.value.FieldValue; +import com.google.firestore.v1.MapValue; +import com.google.firestore.v1.Value; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class ObjectValue extends PrimitiveValue { + private static final ObjectValue EMPTY_VALUE = + new ObjectValue( + com.google.firestore.v1.Value.newBuilder() + .setMapValue(com.google.firestore.v1.MapValue.getDefaultInstance()) + .build()); + + public ObjectValue(Value value) { + super(value); + hardAssert(isType(value, TYPE_ORDER_OBJECT), "ObjectValues must be backed by a MapValue"); + } + + public static ObjectValue emptyObject() { + return EMPTY_VALUE; + } + + /** Returns a new Builder instance that is based on an empty object. */ + public static ObjectValue.Builder newBuilder() { + return EMPTY_VALUE.toBuilder(); + } + + /** + * Returns the value at the given path or null. + * + * @param fieldPath the path to search + * @return The value at the path or if there it doesn't exist. + */ + public @Nullable FieldValue get(FieldPath fieldPath) { + if (fieldPath.isEmpty()) { + return this; + } else { + Value value = internalValue; + for (int i = 0; i < fieldPath.length() - 1; ++i) { + value = value.getMapValue().getFieldsOrDefault(fieldPath.getSegment(i), null); + if (!isType(value, TYPE_ORDER_OBJECT)) { + return null; + } + } + value = value.getMapValue().getFieldsOrDefault(fieldPath.getLastSegment(), null); + return value != null ? FieldValue.of(value) : null; + } + } + + /** Recursively extracts the FieldPaths that are set in this ObjectValue. */ + public FieldMask getFieldMask() { + return extractFieldMask(internalValue.getMapValue()); + } + + private FieldMask extractFieldMask(MapValue value) { + Set fields = new HashSet<>(); + for (Map.Entry entry : value.getFieldsMap().entrySet()) { + FieldPath currentPath = FieldPath.fromSingleSegment(entry.getKey()); + if (isType(entry.getValue(), TYPE_ORDER_OBJECT)) { + FieldMask nestedMask = extractFieldMask(entry.getValue().getMapValue()); + Set nestedFields = nestedMask.getMask(); + if (nestedFields.isEmpty()) { + // Preserve the empty map by adding it to the FieldMask. + fields.add(currentPath); + } else { + // For nested and non-empty ObjectValues, add the FieldPath of the leaf nodes. + for (FieldPath nestedPath : nestedFields) { + fields.add(currentPath.append(nestedPath)); + } + } + } else { + fields.add(currentPath); + } + } + return FieldMask.fromSet(fields); + } + + /** Creates a ObjectValue.Builder instance that is based on the current value. */ + public ObjectValue.Builder toBuilder() { + return new Builder(this); + } + + /** An ObjectValue.Builder provides APIs to set and delete fields from an ObjectValue. */ + public static class Builder { + + /** The existing data to mutate. */ + private ObjectValue baseObject; + + /** + * A nested map that contains the accumulated changes in this builder. Values can either be + * `Value` protos, `Map` values (to represent additional nesting) or `null` (to + * represent field deletes). + */ + private Map overlayMap; + + Builder(ObjectValue baseObject) { + this.baseObject = baseObject; + this.overlayMap = new HashMap<>(); + } + + /** + * Sets the field to the provided value. + * + * @param path The field path to set. + * @param value The value to set. + * @return The current Builder instance. + */ + public Builder set(FieldPath path, Value value) { + hardAssert(!path.isEmpty(), "Cannot set field for empty path on ObjectValue"); + setOverlay(path, value); + return this; + } + + /** + * Removes the field at the specified path. If there is no field at the specified path nothing + * is changed. + * + * @param path The field path to remove + * @return The current Builder instance. + */ + public Builder delete(FieldPath path) { + hardAssert(!path.isEmpty(), "Cannot delete field for empty path on ObjectValue"); + setOverlay(path, null); + return this; + } + + /** Adds `value` to the overlay map at `path`. Creates nested map entries if needed. */ + private void setOverlay(FieldPath path, @Nullable Value value) { + Map currentLevel = overlayMap; + + for (int i = 0; i < path.length() - 1; ++i) { + String currentSegment = path.getSegment(i); + Object currentValue = currentLevel.get(currentSegment); + + if (currentValue instanceof Map) { + // Re-use a previously created map + currentLevel = (Map) currentValue; + } else if (currentValue instanceof Value + && ((Value) currentValue).getValueTypeCase() == Value.ValueTypeCase.MAP_VALUE) { + // Convert the existing Protobuf MapValue into a Java map + Map nextLevel = + new HashMap<>(((Value) currentValue).getMapValue().getFieldsMap()); + currentLevel.put(currentSegment, nextLevel); + currentLevel = nextLevel; + } else { + // Create an empty hash map to represent the current nesting level + Map nextLevel = new HashMap<>(); + currentLevel.put(currentSegment, nextLevel); + currentLevel = nextLevel; + } + } + + currentLevel.put(path.getLastSegment(), value); + } + + /** Returns an ObjectValue with all mutations applied. */ + public ObjectValue build() { + MapValue mergedResult = applyOverlay(FieldPath.EMPTY_PATH, overlayMap); + if (mergedResult != null) { + return new ObjectValue(Value.newBuilder().setMapValue(mergedResult).build()); + } else { + return this.baseObject; + } + } + + /** + * Applies any overlays from `currentOverlays` that exist at `currentPath` and returns the + * merged data at `currentPath` (or null if there were no changes). + * + * @param currentPath The path at the current nesting level. Can be set toFieldValue.EMPTY_PATH + * to represent the root. + * @param currentOverlays The overlays at the current nesting level in the same format as + * `overlayMap`. + * @return The merged data at `currentPath` or null if no modifications were applied. + */ + private @Nullable MapValue applyOverlay( + FieldPath currentPath, Map currentOverlays) { + boolean modified = false; + + @Nullable FieldValue existingValue = baseObject.get(currentPath); + MapValue.Builder resultAtPath = + existingValue instanceof ObjectValue + // If there is already data at the current path, base our modifications on top + // of the existing data. + ? ((ObjectValue) existingValue).internalValue.getMapValue().toBuilder() + : MapValue.newBuilder(); + + for (Map.Entry entry : currentOverlays.entrySet()) { + String pathSegment = entry.getKey(); + Object value = entry.getValue(); + + if (value instanceof Map) { + @Nullable + MapValue nested = + applyOverlay(currentPath.append(pathSegment), (Map) value); + if (nested != null) { + resultAtPath.putFields(pathSegment, Value.newBuilder().setMapValue(nested).build()); + modified = true; + } + } else if (value instanceof Value) { + resultAtPath.putFields(pathSegment, (Value) value); + modified = true; + } else if (resultAtPath.containsFields(pathSegment)) { + hardAssert(value == null, "Expected entry to be a Map, a Value or null"); + resultAtPath.removeFields(pathSegment); + modified = true; + } + } + + return modified ? resultAtPath.build() : null; + } + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/PrimitiveValue.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/PrimitiveValue.java new file mode 100644 index 00000000000..8baecfe0e3a --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/PrimitiveValue.java @@ -0,0 +1,153 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.firestore.model.protovalue; + +import static com.google.firebase.firestore.util.Assert.fail; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.firebase.Timestamp; +import com.google.firebase.firestore.Blob; +import com.google.firebase.firestore.GeoPoint; +import com.google.firebase.firestore.model.DocumentKey; +import com.google.firebase.firestore.model.ResourcePath; +import com.google.firebase.firestore.model.value.FieldValue; +import com.google.firebase.firestore.model.value.ProtoValues; +import com.google.firebase.firestore.model.value.ServerTimestampValue; +import com.google.firebase.firestore.util.Assert; +import com.google.firestore.v1.ArrayValue; +import com.google.firestore.v1.Value; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a FieldValue that is backed by a single Firestore V1 Value proto and implements + * Firestore's Value semantics for ordering and equality. + */ +public class PrimitiveValue extends FieldValue { + protected final Value internalValue; + + public PrimitiveValue(Value value) { + this.internalValue = value; + } + + @Override + public int typeOrder() { + return ProtoValues.typeOrder(internalValue); + } + + @Nullable + @Override + public Object value() { + return convertValue(internalValue); + } + + @Nullable + private Object convertValue(Value value) { + switch (value.getValueTypeCase()) { + case NULL_VALUE: + return null; + case BOOLEAN_VALUE: + return value.getBooleanValue(); + case INTEGER_VALUE: + return value.getIntegerValue(); + case DOUBLE_VALUE: + return value.getDoubleValue(); + case TIMESTAMP_VALUE: + return new Timestamp( + value.getTimestampValue().getSeconds(), value.getTimestampValue().getNanos()); + case STRING_VALUE: + return value.getStringValue(); + case BYTES_VALUE: + return Blob.fromByteString(value.getBytesValue()); + case REFERENCE_VALUE: + return convertReference(value.getReferenceValue()); + case GEO_POINT_VALUE: + return new GeoPoint( + value.getGeoPointValue().getLatitude(), value.getGeoPointValue().getLongitude()); + case ARRAY_VALUE: + return convertArray(value.getArrayValue()); + case MAP_VALUE: + return convertMap(value.getMapValue()); + default: + throw fail("Unknown value type: " + value.getValueTypeCase()); + } + } + + private Object convertReference(String value) { + // TODO(mrschmidt): Move `value()` and `convertValue()` to DocumentSnapshot, which would + // allow us to validate that the resource name points to the current project. + ResourcePath resourceName = ResourcePath.fromString(value); + Assert.hardAssert( + resourceName.length() > 4 && resourceName.getSegment(4).equals("documents"), + "Tried to deserialize invalid key %s", + resourceName); + return DocumentKey.fromPath(resourceName.popFirst(5)); + } + + private List convertArray(ArrayValue arrayValue) { + ArrayList result = new ArrayList<>(arrayValue.getValuesCount()); + for (Value v : arrayValue.getValuesList()) { + result.add(convertValue(v)); + } + return result; + } + + private Map convertMap(com.google.firestore.v1.MapValue mapValue) { + Map result = new HashMap<>(); + for (Map.Entry entry : mapValue.getFieldsMap().entrySet()) { + result.put(entry.getKey(), convertValue(entry.getValue())); + } + return result; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o instanceof PrimitiveValue) { + PrimitiveValue value = (PrimitiveValue) o; + return ProtoValues.equals(this.internalValue, value.internalValue); + } + + return false; + } + + @Override + public int hashCode() { + return internalValue.hashCode(); + } + + @Override + public int compareTo(@NonNull FieldValue other) { + if (other instanceof PrimitiveValue) { + return ProtoValues.compare(this.internalValue, ((PrimitiveValue) other).internalValue); + } else if (ProtoValues.isType(this.internalValue, TYPE_ORDER_TIMESTAMP) + && other instanceof ServerTimestampValue) { + // TODO(mrschmidt): Handle timestamps directly in PrimitiveValue + return -1; + } else { + return defaultCompareTo(other); + } + } + + public Value toProto() { + return internalValue; + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/package-info.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/package-info.java new file mode 100644 index 00000000000..8cce1a8e192 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/package-info.java @@ -0,0 +1,19 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** @hide */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +package com.google.firebase.firestore.model.protovalue; + +import androidx.annotation.RestrictTo; diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/value/FieldValue.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/value/FieldValue.java index 0ce1d37b8b9..4feb7611a9f 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/value/FieldValue.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/value/FieldValue.java @@ -18,7 +18,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.firebase.firestore.model.protovalue.ObjectValue; +import com.google.firebase.firestore.model.protovalue.PrimitiveValue; import com.google.firebase.firestore.util.Util; +import com.google.firestore.v1.Value; /** * A field value represents a data type as stored by Firestore. @@ -41,6 +44,9 @@ * */ public abstract class FieldValue implements Comparable { + + // TODO(mrschmidt): Reduce visibility of these types once `protovalue` + // is merged into `value` package /** The order of types in Firestore; this order is defined by the backend. */ public static final int TYPE_ORDER_NULL = 0; @@ -54,6 +60,15 @@ public abstract class FieldValue implements Comparable { public static final int TYPE_ORDER_ARRAY = 8; public static final int TYPE_ORDER_OBJECT = 9; + /** Creates a new FieldValue based on the Protobuf Value. */ + public static FieldValue of(Value value) { + if (value.getValueTypeCase() == Value.ValueTypeCase.MAP_VALUE) { + return new ObjectValue(value); + } else { + return new PrimitiveValue(value); + } + } + public abstract int typeOrder(); /** diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/value/ServerTimestampValue.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/value/ServerTimestampValue.java index 81e68040d9e..73456fb4ba1 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/value/ServerTimestampValue.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/value/ServerTimestampValue.java @@ -31,6 +31,9 @@ public final class ServerTimestampValue extends FieldValue { private final Timestamp localWriteTime; @Nullable private final FieldValue previousValue; + // TODO(mrschmidt): Represent ServerTimestamps as a PrimitiveType with a Map containing a private + // `__type__` field (or similar). + public ServerTimestampValue(Timestamp localWriteTime, @Nullable FieldValue previousValue) { this.localWriteTime = localWriteTime; this.previousValue = previousValue; @@ -86,7 +89,7 @@ public int hashCode() { public int compareTo(FieldValue o) { if (o instanceof ServerTimestampValue) { return localWriteTime.compareTo(((ServerTimestampValue) o).localWriteTime); - } else if (o instanceof TimestampValue) { + } else if (o.typeOrder() == TYPE_ORDER_TIMESTAMP) { // Server timestamps come after all concrete timestamps. return 1; } else { diff --git a/firebase-firestore/src/roboUtil/java/com/google/firebase/firestore/Values.java b/firebase-firestore/src/roboUtil/java/com/google/firebase/firestore/Values.java index 054813597a8..cb10ae4c20a 100644 --- a/firebase-firestore/src/roboUtil/java/com/google/firebase/firestore/Values.java +++ b/firebase-firestore/src/roboUtil/java/com/google/firebase/firestore/Values.java @@ -54,7 +54,6 @@ public static Value valueOf(Object o) { return Value.newBuilder().setStringValue((String) o).build(); } else if (o instanceof Blob) { return Value.newBuilder().setBytesValue(((Blob) o).toByteString()).build(); - } else if (o instanceof DocumentReference) { return Value.newBuilder() .setReferenceValue( diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/model/FieldValueTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/model/FieldValueTest.java index 8423a883267..fcc6e059fba 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/model/FieldValueTest.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/model/FieldValueTest.java @@ -14,43 +14,34 @@ package com.google.firebase.firestore.model; +import static com.google.firebase.firestore.Values.map; +import static com.google.firebase.firestore.Values.refValue; +import static com.google.firebase.firestore.Values.valueOf; import static com.google.firebase.firestore.testutil.TestUtil.blob; import static com.google.firebase.firestore.testutil.TestUtil.dbId; import static com.google.firebase.firestore.testutil.TestUtil.field; import static com.google.firebase.firestore.testutil.TestUtil.fieldMask; import static com.google.firebase.firestore.testutil.TestUtil.key; -import static com.google.firebase.firestore.testutil.TestUtil.map; import static com.google.firebase.firestore.testutil.TestUtil.ref; -import static com.google.firebase.firestore.testutil.TestUtil.wrap; -import static com.google.firebase.firestore.testutil.TestUtil.wrapObject; +import static junit.framework.TestCase.assertTrue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; import com.google.common.testing.EqualsTester; import com.google.firebase.Timestamp; import com.google.firebase.firestore.GeoPoint; import com.google.firebase.firestore.model.mutation.FieldMask; -import com.google.firebase.firestore.model.value.BlobValue; -import com.google.firebase.firestore.model.value.BooleanValue; -import com.google.firebase.firestore.model.value.DoubleValue; +import com.google.firebase.firestore.model.protovalue.ObjectValue; +import com.google.firebase.firestore.model.protovalue.PrimitiveValue; import com.google.firebase.firestore.model.value.FieldValue; -import com.google.firebase.firestore.model.value.GeoPointValue; -import com.google.firebase.firestore.model.value.IntegerValue; -import com.google.firebase.firestore.model.value.NullValue; -import com.google.firebase.firestore.model.value.ObjectValue; -import com.google.firebase.firestore.model.value.ReferenceValue; import com.google.firebase.firestore.model.value.ServerTimestampValue; -import com.google.firebase.firestore.model.value.StringValue; -import com.google.firebase.firestore.model.value.TimestampValue; import com.google.firebase.firestore.testutil.ComparatorTester; +import com.google.firestore.v1.Value; import java.util.Arrays; import java.util.Calendar; import java.util.Date; -import java.util.Map; import java.util.TimeZone; -import java.util.TreeMap; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; @@ -74,10 +65,10 @@ public FieldValueTest() { @Test public void testExtractsFields() { - FieldValue val = wrapObject("foo", map("a", 1, "b", true, "c", "string")); - assertTrue(val instanceof ObjectValue); - ObjectValue obj = (ObjectValue) val; - assertTrue(obj.get(field("foo")) instanceof ObjectValue); + Value nestedValue = map("a", 1, "b", true, "c", "string"); + + ObjectValue obj = wrapObject("foo", nestedValue); + assertEquals(wrap(nestedValue), obj.get(field("foo"))); assertEquals(wrap(1), obj.get(field("foo.a"))); assertEquals(wrap(true), obj.get(field("foo.b"))); assertEquals(wrap("string"), obj.get(field("foo.c"))); @@ -89,7 +80,7 @@ public void testExtractsFields() { @Test public void testExtractsFieldMask() { - FieldValue val = + ObjectValue val = wrapObject( "a", "b", @@ -97,8 +88,7 @@ public void testExtractsFieldMask() { map("a", 1, "b", true, "c", "string", "nested", map("d", "e")), "emptymap", map()); - assertTrue(val instanceof ObjectValue); - FieldMask mask = ((ObjectValue) val).getFieldMask(); + FieldMask mask = val.getFieldMask(); assertEquals(fieldMask("a", "map.a", "map.b", "map.c", "map.nested.d", "emptymap"), mask); } @@ -115,7 +105,7 @@ public void testOverwritesExistingFields() { public void testAddsNewFields() { ObjectValue empty = ObjectValue.emptyObject(); ObjectValue mod = setField(empty, "a", wrap("mod")); - assertEquals(wrap(new TreeMap()), empty); + assertEquals(wrapObject(), empty); assertEquals(wrapObject("a", "mod"), mod); ObjectValue old = mod; @@ -127,8 +117,8 @@ public void testAddsNewFields() { @Test public void testAddsMultipleNewFields() { ObjectValue object = ObjectValue.emptyObject(); - object = object.toBuilder().set(field("a"), wrap("a")).build(); - object = object.toBuilder().set(field("b"), wrap("b")).set(field("c"), wrap("c")).build(); + object = object.toBuilder().set(field("a"), valueOf("a")).build(); + object = object.toBuilder().set(field("b"), valueOf("b")).set(field("c"), valueOf("c")).build(); assertEquals(wrapObject("a", "a", "b", "b", "c", "c"), object); } @@ -146,7 +136,7 @@ public void testImplicitlyCreatesObjects() { @Test public void testCanOverwritePrimitivesWithObjects() { ObjectValue old = wrapObject("a", map("b", "old")); - ObjectValue mod = setField(old, "a", wrapObject("b", "mod")); + ObjectValue mod = setField(old, "a", map("b", "mod")); assertNotEquals(old, mod); assertEquals(wrapObject("a", map("b", "old")), old); assertEquals(wrapObject("a", map("b", "mod")), mod); @@ -184,40 +174,40 @@ public void testDeletesHandleMissingKeys() { assertEquals(wrapObject("a", map("b", 1, "c", 2)), mod); mod = deleteField(old, "a.d"); - assertEquals(mod, old); + assertEquals(old, mod); assertEquals(wrapObject("a", map("b", 1, "c", 2)), mod); mod = deleteField(old, "a.b.c"); - assertEquals(mod, old); + assertEquals(old, mod); assertEquals(wrapObject("a", map("b", 1, "c", 2)), mod); } @Test public void testDeletesNestedKeys() { - Map orig = map("a", map("b", 1, "c", map("d", 2, "e", 3))); - ObjectValue old = wrapObject(orig); + Value orig = map("a", map("b", 1, "c", map("d", 2, "e", 3))); + ObjectValue old = (ObjectValue) FieldValue.of(orig); ObjectValue mod = deleteField(old, "a.c.d"); assertNotEquals(mod, old); - assertEquals(wrapObject(orig), old); + assertEquals(wrap(orig), old); - Map second = map("a", map("b", 1, "c", map("e", 3))); - assertEquals(wrapObject(second), mod); + Value second = map("a", map("b", 1, "c", map("e", 3))); + assertEquals(wrap(second), mod); old = mod; mod = deleteField(old, "a.c"); assertNotEquals(old, mod); - assertEquals(wrapObject(second), old); + assertEquals(wrap(second), old); - Map third = map("a", map("b", 1)); - assertEquals(wrapObject(third), mod); + Value third = map("a", map("b", 1)); + assertEquals(wrap(third), mod); old = mod; mod = deleteField(old, "a"); assertNotEquals(old, mod); - assertEquals(wrapObject(third), old); + assertEquals(wrap(third), old); assertEquals(ObjectValue.emptyObject(), mod); } @@ -233,46 +223,47 @@ public void testDeletesMultipleNewFields() { @Test public void testValueEquality() { new EqualsTester() - .addEqualityGroup(wrap(true), BooleanValue.valueOf(true)) - .addEqualityGroup(wrap(false), BooleanValue.valueOf(false)) - .addEqualityGroup(wrap(null), NullValue.nullValue()) + .addEqualityGroup(wrap(true), wrap(valueOf(true))) + .addEqualityGroup(wrap(false), wrap(valueOf(false))) + .addEqualityGroup(wrap(null), wrap(valueOf(null))) .addEqualityGroup( - wrap(0.0 / 0.0), wrap(Double.longBitsToDouble(0x7ff8000000000000L)), DoubleValue.NaN) + wrap(0.0 / 0.0), + wrap(Double.longBitsToDouble(0x7ff8000000000000L)), + wrap(valueOf(Double.NaN))) // -0.0 and 0.0 compareTo the same but are not equal. .addEqualityGroup(wrap(-0.0)) .addEqualityGroup(wrap(0.0)) - .addEqualityGroup(wrap(1), IntegerValue.valueOf(1L)) + .addEqualityGroup(wrap(1), wrap(valueOf(1))) // Doubles and Longs aren't equal. - .addEqualityGroup(wrap(1.0), DoubleValue.valueOf(1.0)) - .addEqualityGroup(wrap(1.1), DoubleValue.valueOf(1.1)) - .addEqualityGroup(wrap(blob(0, 1, 2)), BlobValue.valueOf(blob(0, 1, 2))) + .addEqualityGroup(wrap(1.0), wrap(valueOf(1.0))) + .addEqualityGroup(wrap(1.1), wrap(valueOf(1.1))) + .addEqualityGroup(wrap(blob(0, 1, 2)), wrap(valueOf(blob(0, 1, 2)))) .addEqualityGroup(wrap(blob(0, 1))) - .addEqualityGroup(wrap("string"), StringValue.valueOf("string")) - .addEqualityGroup(StringValue.valueOf("strin")) + .addEqualityGroup(wrap("string"), wrap(valueOf("string"))) + .addEqualityGroup(wrap("strin")) // latin small letter e + combining acute accent - .addEqualityGroup(StringValue.valueOf("e\u0301b")) + .addEqualityGroup(wrap("e\u0301b")) // latin small letter e with acute accent - .addEqualityGroup(StringValue.valueOf("\u00e9a")) - .addEqualityGroup(wrap(date1), TimestampValue.valueOf(new Timestamp(date1))) - .addEqualityGroup(TimestampValue.valueOf(new Timestamp(date2))) + .addEqualityGroup(wrap("\u00e9a")) + .addEqualityGroup(wrap(new Timestamp(date1)), wrap(valueOf(new Timestamp(date1)))) + .addEqualityGroup(wrap(new Timestamp(date2))) // NOTE: ServerTimestampValues can't be parsed via wrap(). .addEqualityGroup( new ServerTimestampValue(new Timestamp(date1), null), new ServerTimestampValue(new Timestamp(date1), null)) .addEqualityGroup(new ServerTimestampValue(new Timestamp(date2), null)) - .addEqualityGroup(wrap(new GeoPoint(0, 1)), GeoPointValue.valueOf(new GeoPoint(0, 1))) - .addEqualityGroup(GeoPointValue.valueOf(new GeoPoint(1, 0))) - .addEqualityGroup( - wrap(ref("coll/doc1")), ReferenceValue.valueOf(dbId("project"), key("coll/doc1"))) - .addEqualityGroup(ReferenceValue.valueOf(dbId("project", "bar"), key("coll/doc2"))) - .addEqualityGroup(ReferenceValue.valueOf(dbId("project", "baz"), key("coll/doc2"))) + .addEqualityGroup(wrap(new GeoPoint(0, 1)), wrap(new GeoPoint(0, 1))) + .addEqualityGroup(wrap(new GeoPoint(1, 0))) + .addEqualityGroup(wrap(ref("coll/doc1")), wrap(ref("coll/doc1"))) + .addEqualityGroup(wrapRef(dbId("projectId", "bar"), key("coll/doc2"))) + .addEqualityGroup(wrapRef(dbId("projectId", "baz"), key("coll/doc2"))) .addEqualityGroup(wrap(Arrays.asList("foo", "bar")), wrap(Arrays.asList("foo", "bar"))) .addEqualityGroup(wrap(Arrays.asList("foo", "bar", "baz"))) .addEqualityGroup(wrap(Arrays.asList("foo"))) - .addEqualityGroup(wrapObject(map("bar", 1, "foo", 2)), wrapObject(map("foo", 2, "bar", 1))) - .addEqualityGroup(wrapObject(map("bar", 2, "foo", 1))) - .addEqualityGroup(wrapObject(map("bar", 1))) - .addEqualityGroup(wrapObject(map("foo", 1))) + .addEqualityGroup(wrapObject("bar", 1, "foo", 2), wrapObject("foo", 2, "bar", 1)) + .addEqualityGroup(wrapObject("bar", 2, "foo", 1)) + .addEqualityGroup(wrapObject("bar", 1)) + .addEqualityGroup(wrapObject("foo", 1)) .testEquals(); } @@ -311,8 +302,8 @@ public void testValueOrdering() { .addEqualityGroup(wrap(Double.POSITIVE_INFINITY)) // dates - .addEqualityGroup(wrap(date1)) - .addEqualityGroup(wrap(date2)) + .addEqualityGroup(wrap(new Timestamp(date1))) + .addEqualityGroup(wrap(new Timestamp(date2))) // server timestamps come after all concrete timestamps. // NOTE: server timestamps can't be parsed with wrap(). @@ -339,12 +330,12 @@ public void testValueOrdering() { .addEqualityGroup(wrap(blob(255))) // resource names - .addEqualityGroup(ReferenceValue.valueOf(dbId("p1", "d1"), key("c1/doc1"))) - .addEqualityGroup(ReferenceValue.valueOf(dbId("p1", "d1"), key("c1/doc2"))) - .addEqualityGroup(ReferenceValue.valueOf(dbId("p1", "d1"), key("c10/doc1"))) - .addEqualityGroup(ReferenceValue.valueOf(dbId("p1", "d1"), key("c2/doc1"))) - .addEqualityGroup(ReferenceValue.valueOf(dbId("p1", "d2"), key("c1/doc1"))) - .addEqualityGroup(ReferenceValue.valueOf(dbId("p2", "d1"), key("c1/doc1"))) + .addEqualityGroup(wrapRef(dbId("p1", "d1"), key("c1/doc1"))) + .addEqualityGroup(wrapRef(dbId("p1", "d1"), key("c1/doc2"))) + .addEqualityGroup(wrapRef(dbId("p1", "d1"), key("c10/doc1"))) + .addEqualityGroup(wrapRef(dbId("p1", "d1"), key("c2/doc1"))) + .addEqualityGroup(wrapRef(dbId("p1", "d2"), key("c1/doc1"))) + .addEqualityGroup(wrapRef(dbId("p2", "d1"), key("c1/doc1"))) // geo points .addEqualityGroup(wrap(new GeoPoint(-90, -180))) @@ -367,19 +358,38 @@ public void testValueOrdering() { .addEqualityGroup(wrap(Arrays.asList("foo", "0"))) // objects - .addEqualityGroup(wrapObject(map("bar", 0))) - .addEqualityGroup(wrapObject(map("bar", 0, "foo", 1))) - .addEqualityGroup(wrapObject(map("foo", 1))) - .addEqualityGroup(wrapObject(map("foo", 2))) - .addEqualityGroup(wrapObject(map("foo", "0"))) + .addEqualityGroup(wrapObject("bar", 0)) + .addEqualityGroup(wrapObject("bar", 0, "foo", 1)) + .addEqualityGroup(wrapObject("foo", 1)) + .addEqualityGroup(wrapObject("foo", 2)) + .addEqualityGroup(wrapObject("foo", "0")) .testCompare(); } - private ObjectValue setField(ObjectValue objectValue, String fieldPath, FieldValue value) { + private ObjectValue setField(ObjectValue objectValue, String fieldPath, PrimitiveValue value) { + return objectValue.toBuilder().set(field(fieldPath), value.toProto()).build(); + } + + private ObjectValue setField(ObjectValue objectValue, String fieldPath, Value value) { return objectValue.toBuilder().set(field(fieldPath), value).build(); } private ObjectValue deleteField(ObjectValue objectValue, String fieldPath) { return objectValue.toBuilder().delete(field(fieldPath)).build(); } + + // TODO(mrschmidt): Clean up the helpers and merge wrap() with TestUtil.wrap() + private ObjectValue wrapObject(Object... entries) { + FieldValue object = FieldValue.of(map(entries)); + assertTrue(object instanceof ObjectValue); + return (ObjectValue) object; + } + + private PrimitiveValue wrap(Object value) { + return (PrimitiveValue) FieldValue.of(valueOf(value)); + } + + private PrimitiveValue wrapRef(DatabaseId dbId, DocumentKey key) { + return (PrimitiveValue) FieldValue.of(refValue(dbId, key)); + } } diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/model/ObjectValueBuilderTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/model/ObjectValueBuilderTest.java new file mode 100644 index 00000000000..e879110d45c --- /dev/null +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/model/ObjectValueBuilderTest.java @@ -0,0 +1,237 @@ +// Copyright 2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.firestore.model; + +import static com.google.firebase.firestore.Values.map; +import static com.google.firebase.firestore.Values.valueOf; +import static com.google.firebase.firestore.testutil.TestUtil.field; +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertTrue; + +import com.google.firebase.firestore.model.protovalue.ObjectValue; +import com.google.firebase.firestore.model.value.FieldValue; +import com.google.firestore.v1.Value; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class ObjectValueBuilderTest { + private Value fooValue = valueOf("foo"); + private Value barValue = valueOf("bar"); + private Value emptyObject = valueOf(Collections.emptyMap()); + + @Test + public void supportsEmptyBuilders() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + ObjectValue object = builder.build(); + assertEquals(ObjectValue.emptyObject(), object); + } + + @Test + public void setsSingleField() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("foo"), fooValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("foo", fooValue), object); + } + + @Test + public void setsEmptyObject() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("foo"), emptyObject); + ObjectValue object = builder.build(); + assertEquals(wrapObject("foo", emptyObject), object); + } + + @Test + public void setsMultipleFields() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("foo"), fooValue); + builder.set(field("bar"), fooValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("foo", fooValue, "bar", fooValue), object); + } + + @Test + public void setsNestedField() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("a.b"), fooValue); + builder.set(field("c.d.e"), fooValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", fooValue), "c", map("d", map("e", fooValue))), object); + } + + @Test + public void setsTwoFieldsInNestedObject() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("a.b"), fooValue); + builder.set(field("a.c"), fooValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", fooValue, "c", fooValue)), object); + } + + @Test + public void setsFieldInNestedObject() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("a"), map("b", fooValue)); + builder.set(field("a.c"), fooValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", fooValue, "c", fooValue)), object); + } + + @Test + public void setsDeeplyNestedFieldInNestedObject() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("a.b.c.d.e.f"), fooValue); + ObjectValue object = builder.build(); + assertEquals( + wrapObject("a", map("b", map("c", map("d", map("e", map("f", fooValue)))))), object); + } + + @Test + public void setsNestedFieldMultipleTimes() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("a.c"), fooValue); + builder.set(field("a"), map("b", fooValue)); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", fooValue)), object); + } + + @Test + public void setsAndDeletesField() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("foo"), fooValue); + builder.delete(field("foo")); + ObjectValue object = builder.build(); + assertEquals(wrapObject(), object); + } + + @Test + public void setsAndDeletesNestedField() { + ObjectValue.Builder builder = ObjectValue.newBuilder(); + builder.set(field("a.b.c"), fooValue); + builder.set(field("a.b.d"), fooValue); + builder.set(field("f.g"), fooValue); + builder.set(field("h"), fooValue); + builder.delete(field("a.b.c")); + builder.delete(field("h")); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", map("d", fooValue)), "f", map("g", fooValue)), object); + } + + @Test + public void setsSingleFieldInExistingObject() { + ObjectValue.Builder builder = wrapObject("a", fooValue).toBuilder(); + builder.set(field("b"), fooValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", fooValue, "b", fooValue), object); + } + + @Test + public void overwritesField() { + ObjectValue.Builder builder = wrapObject("a", fooValue).toBuilder(); + builder.set(field("a"), barValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", barValue), object); + } + + @Test + public void overwritesNestedFields() { + ObjectValue.Builder builder = + wrapObject("a", map("b", fooValue, "c", map("d", fooValue))).toBuilder(); + builder.set(field("a.b"), barValue); + builder.set(field("a.c.d"), barValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", barValue, "c", map("d", barValue))), object); + } + + @Test + public void overwritesDeeplyNestedField() { + ObjectValue.Builder builder = wrapObject("a", map("b", fooValue)).toBuilder(); + builder.set(field("a.b.c"), barValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", map("c", barValue))), object); + } + + @Test + public void mergesExistingObject() { + ObjectValue.Builder builder = wrapObject("a", map("b", fooValue)).toBuilder(); + builder.set(field("a.c"), fooValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", fooValue, "c", fooValue)), object); + } + + @Test + public void overwritesNestedObject() { + ObjectValue.Builder builder = + wrapObject("a", map("b", map("c", fooValue, "d", fooValue))).toBuilder(); + builder.set(field("a.b"), barValue); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", barValue)), object); + } + + @Test + public void replacesNestedObject() { + Value singleValueObject = valueOf(map("c", barValue)); + ObjectValue.Builder builder = wrapObject("a", map("b", fooValue)).toBuilder(); + builder.set(field("a"), singleValueObject); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("c", barValue)), object); + } + + @Test + public void deletesSingleField() { + ObjectValue.Builder builder = wrapObject("a", fooValue, "b", fooValue).toBuilder(); + builder.delete(field("a")); + ObjectValue object = builder.build(); + assertEquals(wrapObject("b", fooValue), object); + } + + @Test + public void deletesNestedObject() { + ObjectValue.Builder builder = + wrapObject("a", map("b", map("c", fooValue, "d", fooValue), "f", fooValue)).toBuilder(); + builder.delete(field("a.b")); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("f", fooValue)), object); + } + + @Test + public void deletesNonExistingField() { + ObjectValue.Builder builder = wrapObject("a", fooValue).toBuilder(); + builder.delete(field("b")); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", fooValue), object); + } + + @Test + public void deletesNonExistingNestedField() { + ObjectValue.Builder builder = wrapObject("a", map("b", fooValue)).toBuilder(); + builder.delete(field("a.b.c")); + ObjectValue object = builder.build(); + assertEquals(wrapObject("a", map("b", fooValue)), object); + } + + /** Creates a new ObjectValue based on key/value argument pairs. */ + private ObjectValue wrapObject(Object... entries) { + FieldValue object = FieldValue.of(map(entries)); + assertTrue(object instanceof ObjectValue); + return (ObjectValue) object; + } +}