Skip to content

Commit 074f88a

Browse files
Add Protobuf-backed FieldValue
1 parent c8d1ce8 commit 074f88a

File tree

7 files changed

+767
-77
lines changed

7 files changed

+767
-77
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: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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.util.Assert.hardAssert;
18+
import static com.google.firebase.firestore.util.ValueUtil.isType;
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.HashSet;
27+
import java.util.Iterator;
28+
import java.util.Map;
29+
import java.util.Set;
30+
import java.util.SortedMap;
31+
import java.util.TreeMap;
32+
33+
public class ObjectValue extends PrimitiveValue {
34+
private static final ObjectValue EMPTY_VALUE =
35+
new ObjectValue(
36+
com.google.firestore.v1.Value.newBuilder()
37+
.setMapValue(com.google.firestore.v1.MapValue.getDefaultInstance())
38+
.build());
39+
40+
public static ObjectValue emptyObject() {
41+
return EMPTY_VALUE;
42+
}
43+
44+
public ObjectValue(Value value) {
45+
super(value);
46+
hardAssert(isType(value, TYPE_ORDER_OBJECT), "ObjectValues must be backed by a MapValue");
47+
}
48+
49+
/**
50+
* Returns the value at the given path or null.
51+
*
52+
* @param fieldPath the path to search
53+
* @return The value at the path or if there it doesn't exist.
54+
*/
55+
public @Nullable FieldValue get(FieldPath fieldPath) {
56+
Value value = internalValue;
57+
58+
for (int i = 0; i < fieldPath.length() - 1; ++i) {
59+
value = value.getMapValue().getFieldsOrDefault(fieldPath.getSegment(i), null);
60+
if (!isType(value, TYPE_ORDER_OBJECT)) {
61+
return null;
62+
}
63+
}
64+
65+
value = value.getMapValue().getFieldsOrDefault(fieldPath.getLastSegment(), null);
66+
return value != null ? FieldValue.of(value) : null;
67+
}
68+
69+
/** Recursively extracts the FieldPaths that are set in this ObjectValue. */
70+
public FieldMask getFieldMask() {
71+
return extractFieldMask(internalValue.getMapValue());
72+
}
73+
74+
private FieldMask extractFieldMask(MapValue value) {
75+
Set<FieldPath> fields = new HashSet<>();
76+
for (Map.Entry<String, Value> entry : value.getFieldsMap().entrySet()) {
77+
FieldPath currentPath = FieldPath.fromSingleSegment(entry.getKey());
78+
if (isType(entry.getValue(), TYPE_ORDER_OBJECT)) {
79+
FieldMask nestedMask = extractFieldMask(entry.getValue().getMapValue());
80+
Set<FieldPath> nestedFields = nestedMask.getMask();
81+
if (nestedFields.isEmpty()) {
82+
// Preserve the empty map by adding it to the FieldMask.
83+
fields.add(currentPath);
84+
} else {
85+
// For nested and non-empty ObjectValues, add the FieldPath of the leaf nodes.
86+
for (FieldPath nestedPath : nestedFields) {
87+
fields.add(currentPath.append(nestedPath));
88+
}
89+
}
90+
} else {
91+
fields.add(currentPath);
92+
}
93+
}
94+
return FieldMask.fromSet(fields);
95+
}
96+
97+
/** Creates a ObjectValue.Builder instance that is based on the current value. */
98+
public ObjectValue.Builder toBuilder() {
99+
return new Builder(this);
100+
}
101+
102+
/** An ObjectValue.Builder provides APIs to set and delete fields from an ObjectValue. */
103+
public static class Builder {
104+
105+
/** The existing data to mutate. */
106+
private ObjectValue baseObject;
107+
108+
/**
109+
* A list of FieldPath/Value pairs to apply to the base object. `null` values indicate field
110+
* deletes. MapValues are expanded before they are stored in the overlay map, so that an entry
111+
* exists for each leaf node.
112+
*/
113+
private SortedMap<FieldPath, Value> overlayMap;
114+
115+
Builder(ObjectValue baseObject) {
116+
this.baseObject = baseObject;
117+
this.overlayMap = new TreeMap<>();
118+
}
119+
120+
/**
121+
* Sets the field to the provided value.
122+
*
123+
* @param path The field path to set.
124+
* @param value The value to set.
125+
* @return The current Builder instance.
126+
*/
127+
public Builder set(FieldPath path, Value value) {
128+
hardAssert(!path.isEmpty(), "Cannot set field for empty path on ObjectValue");
129+
removeConflictingOverlays(path);
130+
setOverlay(path, value);
131+
return this;
132+
}
133+
134+
/**
135+
* Removes the field at the specified path. If there is no field at the specified path nothing
136+
* is changed.
137+
*
138+
* @param path The field path to remove
139+
* @return The current Builder instance.
140+
*/
141+
public Builder delete(FieldPath path) {
142+
hardAssert(!path.isEmpty(), "Cannot delete field for empty path on ObjectValue");
143+
removeConflictingOverlays(path);
144+
setOverlay(path, null);
145+
return this;
146+
}
147+
148+
/** Remove any existing overlays that would be replaced by setting `path` to a new value. */
149+
private void removeConflictingOverlays(FieldPath path) {
150+
Iterator<FieldPath> iterator =
151+
overlayMap.subMap(path, createSuccessor(path)).keySet().iterator();
152+
while (iterator.hasNext()) {
153+
iterator.next();
154+
iterator.remove();
155+
}
156+
}
157+
158+
/**
159+
* Adds `value` to the overlay map at `path`. MapValues are recursively expanded into one
160+
* overlay per leaf node.
161+
*/
162+
private void setOverlay(FieldPath path, @Nullable Value value) {
163+
if (!isType(value, TYPE_ORDER_OBJECT) || value.getMapValue().getFieldsCount() == 0) {
164+
overlayMap.put(path, value);
165+
} else {
166+
for (Map.Entry<String, Value> entry : value.getMapValue().getFieldsMap().entrySet()) {
167+
setOverlay(path.append(entry.getKey()), entry.getValue());
168+
}
169+
}
170+
}
171+
172+
/** Returns an ObjectValue with all mutations applied. */
173+
public ObjectValue build() {
174+
if (overlayMap.isEmpty()) {
175+
return baseObject;
176+
} else {
177+
MapValue.Builder result = baseObject.internalValue.getMapValue().toBuilder();
178+
applyOverlay(FieldPath.EMPTY_PATH, result);
179+
return new ObjectValue(Value.newBuilder().setMapValue(result).build());
180+
}
181+
}
182+
183+
/**
184+
* Applies any overlays from `overlayMap` that exist at `currentPath` to the `resultAtPath` map.
185+
* Overlays are expanded recursively based on their location in the backing ObjectValue's
186+
* subtree and are processed by nesting level.
187+
*
188+
* <p>Example: Overlays { 'a.b.c' : 'foo', 'a.b.d' : 'bar', 'a.e' : 'foobar' }
189+
*
190+
* <p>To apply these overlays, the methods first creates a MapValue.Builder for `a`. It then
191+
* calls applyOverlay() with a current path of `a` and the newly created MapValue.Builder. In
192+
* its second call, `applyOverlay` assigns `a.b` to a new MapBuilder and `a.e` to 'foobar'. The
193+
* third call assigns `a.b.c` and `a.b.d` to the MapValue.Builder created in the second step.
194+
*
195+
* <p>The overall aim of this method is to minimize conversions between MapValues and their
196+
* builders.
197+
*
198+
* @param currentPath The path at the current nesting level. Can be set toFieldValue.EMPTY_PATH
199+
* to represent the root.
200+
* @param resultAtPath A mutable copy of the existing data at the current nesting level.
201+
* Overlays are applied to this argument.
202+
* @return Whether any modifications were applied (in any part of the subtree under
203+
* currentPath).
204+
*/
205+
private boolean applyOverlay(FieldPath currentPath, MapValue.Builder resultAtPath) {
206+
// Extract the data that exists at or below the current path. Te extracted subtree is
207+
// subdivided during each iteration. The iteration stops when the slice becomes empty.
208+
SortedMap<FieldPath, Value> currentSlice =
209+
currentPath.isEmpty()
210+
? overlayMap
211+
: overlayMap.subMap(currentPath, createSuccessor(currentPath));
212+
213+
boolean modified = false;
214+
215+
while (!currentSlice.isEmpty()) {
216+
FieldPath fieldPath = currentSlice.firstKey();
217+
218+
if (fieldPath.length() == currentPath.length() + 1) {
219+
// The key in the slice is a leaf node. We can apply the value directly.
220+
String fieldName = fieldPath.getLastSegment();
221+
Value overlayValue = overlayMap.get(fieldPath);
222+
if (overlayValue != null) {
223+
resultAtPath.putFields(fieldName, overlayValue);
224+
modified = true;
225+
} else if (resultAtPath.containsFields(fieldName)) {
226+
resultAtPath.removeFields(fieldName);
227+
modified = true;
228+
}
229+
} else {
230+
// Since we need a MapValue.Builder at each nesting level (e.g. to create the field for
231+
// `a.b.c` we need to create a MapValue.Builder for `a` as well as `a.b`), we invoke
232+
// applyOverlay() recursively with the next nesting level.
233+
FieldPath nextSliceStart = fieldPath.keepFirst(currentPath.length() + 1);
234+
@Nullable FieldValue existingValue = baseObject.get(nextSliceStart);
235+
MapValue.Builder nextSliceBuilder =
236+
existingValue instanceof ObjectValue
237+
// If there is already data at the current path, base our modifications on top
238+
// of the existing data.
239+
? ((ObjectValue) existingValue).internalValue.getMapValue().toBuilder()
240+
: MapValue.newBuilder();
241+
modified = applyOverlay(nextSliceStart, nextSliceBuilder) || modified;
242+
if (modified) {
243+
// Only apply the result if a field has been modified. This avoids adding an empty
244+
// map entry for deletes of non-existing fields.
245+
resultAtPath.putFields(
246+
nextSliceStart.getLastSegment(),
247+
Value.newBuilder().setMapValue(nextSliceBuilder).build());
248+
}
249+
}
250+
251+
// Shrink the subtree to contain only values after the current field path. Note that we are
252+
// still bound by the subtree created at the initial method invocation. The current loop
253+
// exits when the subtree becomes empty.
254+
currentSlice = currentSlice.tailMap(createSuccessor(fieldPath));
255+
}
256+
257+
return modified;
258+
}
259+
260+
/** Create the first field path that is not part of the subtree created by `currentPath`. */
261+
private FieldPath createSuccessor(FieldPath currentPath) {
262+
hardAssert(!currentPath.isEmpty(), "Can't create a successor for an empty path");
263+
return currentPath.popLast().append(currentPath.getLastSegment() + '0');
264+
}
265+
}
266+
}

0 commit comments

Comments
 (0)