Skip to content

Commit 9354294

Browse files
Protobuf-backed FieldValues (#1156)
1 parent 94844bb commit 9354294

File tree

9 files changed

+771
-78
lines changed

9 files changed

+771
-78
lines changed

firebase-firestore/src/androidTest/java/com/google/firebase/firestore/FirestoreTest.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,26 @@ public void testCanMergeEmptyObject() {
169169
listenerRegistration.remove();
170170
}
171171

172+
@Test
173+
public void testUpdateWithEmptyObjectReplacesAllFields() {
174+
DocumentReference documentReference = testDocument();
175+
documentReference.set(map("a", "a"));
176+
177+
waitFor(documentReference.update("a", Collections.emptyMap()));
178+
DocumentSnapshot snapshot = waitFor(documentReference.get());
179+
assertEquals(map("a", Collections.emptyMap()), snapshot.getData());
180+
}
181+
182+
@Test
183+
public void testMergeWithEmptyObjectReplacesAllFields() {
184+
DocumentReference documentReference = testDocument();
185+
documentReference.set(map("a", "a"));
186+
187+
waitFor(documentReference.set(map("a", Collections.emptyMap()), SetOptions.merge()));
188+
DocumentSnapshot snapshot = waitFor(documentReference.get());
189+
assertEquals(map("a", Collections.emptyMap()), snapshot.getData());
190+
}
191+
172192
@Test
173193
public void testCanDeleteFieldUsingMerge() {
174194
DocumentReference documentReference = testCollection("rooms").document("eros");
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// Copyright 2020 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package com.google.firebase.firestore.model.protovalue;
16+
17+
import static com.google.firebase.firestore.model.value.ProtoValues.isType;
18+
import static com.google.firebase.firestore.util.Assert.hardAssert;
19+
20+
import androidx.annotation.Nullable;
21+
import com.google.firebase.firestore.model.FieldPath;
22+
import com.google.firebase.firestore.model.mutation.FieldMask;
23+
import com.google.firebase.firestore.model.value.FieldValue;
24+
import com.google.firestore.v1.MapValue;
25+
import com.google.firestore.v1.Value;
26+
import java.util.HashMap;
27+
import java.util.HashSet;
28+
import java.util.Map;
29+
import java.util.Set;
30+
31+
public class ObjectValue extends PrimitiveValue {
32+
private static final ObjectValue EMPTY_VALUE =
33+
new ObjectValue(
34+
com.google.firestore.v1.Value.newBuilder()
35+
.setMapValue(com.google.firestore.v1.MapValue.getDefaultInstance())
36+
.build());
37+
38+
public ObjectValue(Value value) {
39+
super(value);
40+
hardAssert(isType(value, TYPE_ORDER_OBJECT), "ObjectValues must be backed by a MapValue");
41+
}
42+
43+
public static ObjectValue emptyObject() {
44+
return EMPTY_VALUE;
45+
}
46+
47+
/** Returns a new Builder instance that is based on an empty object. */
48+
public static ObjectValue.Builder newBuilder() {
49+
return EMPTY_VALUE.toBuilder();
50+
}
51+
52+
/**
53+
* Returns the value at the given path or null.
54+
*
55+
* @param fieldPath the path to search
56+
* @return The value at the path or if there it doesn't exist.
57+
*/
58+
public @Nullable FieldValue get(FieldPath fieldPath) {
59+
if (fieldPath.isEmpty()) {
60+
return this;
61+
} else {
62+
Value value = internalValue;
63+
for (int i = 0; i < fieldPath.length() - 1; ++i) {
64+
value = value.getMapValue().getFieldsOrDefault(fieldPath.getSegment(i), null);
65+
if (!isType(value, TYPE_ORDER_OBJECT)) {
66+
return null;
67+
}
68+
}
69+
value = value.getMapValue().getFieldsOrDefault(fieldPath.getLastSegment(), null);
70+
return value != null ? FieldValue.of(value) : null;
71+
}
72+
}
73+
74+
/** Recursively extracts the FieldPaths that are set in this ObjectValue. */
75+
public FieldMask getFieldMask() {
76+
return extractFieldMask(internalValue.getMapValue());
77+
}
78+
79+
private FieldMask extractFieldMask(MapValue value) {
80+
Set<FieldPath> fields = new HashSet<>();
81+
for (Map.Entry<String, Value> entry : value.getFieldsMap().entrySet()) {
82+
FieldPath currentPath = FieldPath.fromSingleSegment(entry.getKey());
83+
if (isType(entry.getValue(), TYPE_ORDER_OBJECT)) {
84+
FieldMask nestedMask = extractFieldMask(entry.getValue().getMapValue());
85+
Set<FieldPath> nestedFields = nestedMask.getMask();
86+
if (nestedFields.isEmpty()) {
87+
// Preserve the empty map by adding it to the FieldMask.
88+
fields.add(currentPath);
89+
} else {
90+
// For nested and non-empty ObjectValues, add the FieldPath of the leaf nodes.
91+
for (FieldPath nestedPath : nestedFields) {
92+
fields.add(currentPath.append(nestedPath));
93+
}
94+
}
95+
} else {
96+
fields.add(currentPath);
97+
}
98+
}
99+
return FieldMask.fromSet(fields);
100+
}
101+
102+
/** Creates a ObjectValue.Builder instance that is based on the current value. */
103+
public ObjectValue.Builder toBuilder() {
104+
return new Builder(this);
105+
}
106+
107+
/** An ObjectValue.Builder provides APIs to set and delete fields from an ObjectValue. */
108+
public static class Builder {
109+
110+
/** The existing data to mutate. */
111+
private ObjectValue baseObject;
112+
113+
/**
114+
* A nested map that contains the accumulated changes in this builder. Values can either be
115+
* `Value` protos, `Map<String, Object>` values (to represent additional nesting) or `null` (to
116+
* represent field deletes).
117+
*/
118+
private Map<String, Object> overlayMap;
119+
120+
Builder(ObjectValue baseObject) {
121+
this.baseObject = baseObject;
122+
this.overlayMap = new HashMap<>();
123+
}
124+
125+
/**
126+
* Sets the field to the provided value.
127+
*
128+
* @param path The field path to set.
129+
* @param value The value to set.
130+
* @return The current Builder instance.
131+
*/
132+
public Builder set(FieldPath path, Value value) {
133+
hardAssert(!path.isEmpty(), "Cannot set field for empty path on ObjectValue");
134+
setOverlay(path, value);
135+
return this;
136+
}
137+
138+
/**
139+
* Removes the field at the specified path. If there is no field at the specified path nothing
140+
* is changed.
141+
*
142+
* @param path The field path to remove
143+
* @return The current Builder instance.
144+
*/
145+
public Builder delete(FieldPath path) {
146+
hardAssert(!path.isEmpty(), "Cannot delete field for empty path on ObjectValue");
147+
setOverlay(path, null);
148+
return this;
149+
}
150+
151+
/** Adds `value` to the overlay map at `path`. Creates nested map entries if needed. */
152+
private void setOverlay(FieldPath path, @Nullable Value value) {
153+
Map<String, Object> currentLevel = overlayMap;
154+
155+
for (int i = 0; i < path.length() - 1; ++i) {
156+
String currentSegment = path.getSegment(i);
157+
Object currentValue = currentLevel.get(currentSegment);
158+
159+
if (currentValue instanceof Map) {
160+
// Re-use a previously created map
161+
currentLevel = (Map<String, Object>) currentValue;
162+
} else if (currentValue instanceof Value
163+
&& ((Value) currentValue).getValueTypeCase() == Value.ValueTypeCase.MAP_VALUE) {
164+
// Convert the existing Protobuf MapValue into a Java map
165+
Map<String, Object> nextLevel =
166+
new HashMap<>(((Value) currentValue).getMapValue().getFieldsMap());
167+
currentLevel.put(currentSegment, nextLevel);
168+
currentLevel = nextLevel;
169+
} else {
170+
// Create an empty hash map to represent the current nesting level
171+
Map<String, Object> nextLevel = new HashMap<>();
172+
currentLevel.put(currentSegment, nextLevel);
173+
currentLevel = nextLevel;
174+
}
175+
}
176+
177+
currentLevel.put(path.getLastSegment(), value);
178+
}
179+
180+
/** Returns an ObjectValue with all mutations applied. */
181+
public ObjectValue build() {
182+
MapValue mergedResult = applyOverlay(FieldPath.EMPTY_PATH, overlayMap);
183+
if (mergedResult != null) {
184+
return new ObjectValue(Value.newBuilder().setMapValue(mergedResult).build());
185+
} else {
186+
return this.baseObject;
187+
}
188+
}
189+
190+
/**
191+
* Applies any overlays from `currentOverlays` that exist at `currentPath` and returns the
192+
* merged data at `currentPath` (or null if there were no changes).
193+
*
194+
* @param currentPath The path at the current nesting level. Can be set toFieldValue.EMPTY_PATH
195+
* to represent the root.
196+
* @param currentOverlays The overlays at the current nesting level in the same format as
197+
* `overlayMap`.
198+
* @return The merged data at `currentPath` or null if no modifications were applied.
199+
*/
200+
private @Nullable MapValue applyOverlay(
201+
FieldPath currentPath, Map<String, Object> currentOverlays) {
202+
boolean modified = false;
203+
204+
@Nullable FieldValue existingValue = baseObject.get(currentPath);
205+
MapValue.Builder resultAtPath =
206+
existingValue instanceof ObjectValue
207+
// If there is already data at the current path, base our modifications on top
208+
// of the existing data.
209+
? ((ObjectValue) existingValue).internalValue.getMapValue().toBuilder()
210+
: MapValue.newBuilder();
211+
212+
for (Map.Entry<String, Object> entry : currentOverlays.entrySet()) {
213+
String pathSegment = entry.getKey();
214+
Object value = entry.getValue();
215+
216+
if (value instanceof Map) {
217+
@Nullable
218+
MapValue nested =
219+
applyOverlay(currentPath.append(pathSegment), (Map<String, Object>) value);
220+
if (nested != null) {
221+
resultAtPath.putFields(pathSegment, Value.newBuilder().setMapValue(nested).build());
222+
modified = true;
223+
}
224+
} else if (value instanceof Value) {
225+
resultAtPath.putFields(pathSegment, (Value) value);
226+
modified = true;
227+
} else if (resultAtPath.containsFields(pathSegment)) {
228+
hardAssert(value == null, "Expected entry to be a Map, a Value or null");
229+
resultAtPath.removeFields(pathSegment);
230+
modified = true;
231+
}
232+
}
233+
234+
return modified ? resultAtPath.build() : null;
235+
}
236+
}
237+
}

0 commit comments

Comments
 (0)