-
Notifications
You must be signed in to change notification settings - Fork 615
Protobuf-backed FieldValues #1156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
schmidt-sebastian
merged 17 commits into
mrschmidt/rewritefieldvalue
from
mrschmidt/valueproto
Jan 29, 2020
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
5d3f44d
Adding Proto-based equality and comparison
schmidt-sebastian 5d006f0
Adding test utilities to create Value types
schmidt-sebastian 8440ced
Merge branch 'mrschmidt/comparisons' into mrschmidt/testhelpers
schmidt-sebastian c315cdc
Address feeback
schmidt-sebastian a6f880a
Address feeback
schmidt-sebastian 529881b
Merge branch 'mrschmidt/comparisons' into mrschmidt/testhelpers
schmidt-sebastian c8d1ce8
Review feedback
schmidt-sebastian 074f88a
Add Protobuf-backed FieldValue
schmidt-sebastian 85e9fff
Fix compile
schmidt-sebastian 74f8895
More compile fixes
schmidt-sebastian 618f903
Format
schmidt-sebastian 2750928
Merge
schmidt-sebastian c599106
Remove duplicate file
schmidt-sebastian 9354a50
Remove exceess newline
schmidt-sebastian 2baaba1
Imports
schmidt-sebastian 7bf5e40
Add package-info.java
schmidt-sebastian 035970b
Review comments
schmidt-sebastian File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
237 changes: 237 additions & 0 deletions
237
...e-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/ObjectValue.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<FieldPath> fields = new HashSet<>(); | ||
for (Map.Entry<String, Value> entry : value.getFieldsMap().entrySet()) { | ||
FieldPath currentPath = FieldPath.fromSingleSegment(entry.getKey()); | ||
if (isType(entry.getValue(), TYPE_ORDER_OBJECT)) { | ||
FieldMask nestedMask = extractFieldMask(entry.getValue().getMapValue()); | ||
Set<FieldPath> 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<String, Object>` values (to represent additional nesting) or `null` (to | ||
* represent field deletes). | ||
*/ | ||
private Map<String, Object> 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<String, Object> 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<String, Object>) currentValue; | ||
} else if (currentValue instanceof Value | ||
&& ((Value) currentValue).getValueTypeCase() == Value.ValueTypeCase.MAP_VALUE) { | ||
// Convert the existing Protobuf MapValue into a Java map | ||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> entry : currentOverlays.entrySet()) { | ||
String pathSegment = entry.getKey(); | ||
Object value = entry.getValue(); | ||
|
||
if (value instanceof Map) { | ||
@Nullable | ||
MapValue nested = | ||
applyOverlay(currentPath.append(pathSegment), (Map<String, Object>) 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; | ||
} | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
null
is pretty overloaded. I wonder if it would be helpful to store a sentinel in here instead.Something like this up top:
And then this would read as
I don't think this changes any of the handling below, so probably not worth it, but it has some visual appeal.
Feel free to ignore.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am leaving this as is for now. The reason is the DELETE sentinel would need to of type
Value
so I can pass it tosetOverlay
. That makes myinstanceof
checks more complicated, as I now have to differentiate between user-provided values and the sentinel.