-
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
Protobuf-backed FieldValues #1156
Conversation
This will be used (and tested) in the follow-up CL that adds the FieldValue tpes.
This exposes a similar interface as wrap() and allows me to change the existing FieldValue test to use Value protos
0c0064d
to
7bd837f
Compare
7bd837f
to
074f88a
Compare
import java.util.List; | ||
import java.util.Map; | ||
|
||
public class PrimitiveValue extends FieldValue { |
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.
Comments?
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.
Added. FWIW, I am debating merging this with FieldValue before the final merge.
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.
Merging SGTM. There seems to be little reason to keep this separate.
...base-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/ObjectValue.java
Outdated
Show resolved
Hide resolved
/** Create the first field path that is not part of the subtree created by `currentPath`. */ | ||
private FieldPath createSuccessor(FieldPath currentPath) { | ||
hardAssert(!currentPath.isEmpty(), "Can't create a successor for an empty path"); | ||
return currentPath.popLast().append(currentPath.getLastSegment() + '0'); |
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.
Shouldn't this be a null byte ('\0'
)?
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.
This is also no longer needed (yay for simpler code).
I chose '0'
because it is the first ASCII code that can be part of a valid field path. '\0'
may trigger validation errors in our field value handling.
* @return Whether any modifications were applied (in any part of the subtree under | ||
* currentPath). | ||
*/ | ||
private boolean applyOverlay(FieldPath currentPath, MapValue.Builder resultAtPath) { |
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.
This method is quite complex and pretty hard to follow. You could extract a few methods here that would make this clearer. For example, you could extract a applyOverlayLeaf(String, @Nullable Value)
which would pull lines 219 through 228 out.
You might also consider pulling some part of 230 through 240 out into a existingMapValueBuilder(FieldPath)
or similar.
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 believe this was close to impossible to follow, but it was the cleanest design I could come up with given the data structure I chose. I have now chosen a different data structure for the overlap map, which allowed me to drastically simplify the code.
// Shrink the subtree to contain only values after the current field path. Note that we are | ||
// still bound by the subtree created at the initial method invocation. The current loop | ||
// exits when the subtree becomes empty. | ||
currentSlice = currentSlice.tailMap(createSuccessor(fieldPath)); |
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.
Why not just remove fieldPath
?
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.
This is no longer part of the PR, but a remove
here would make this method non-idempotent and might incur additional tree re-balancing.
public void emptyBuilder() { | ||
ObjectValue.Builder builder = ObjectValue.emptyObject().toBuilder(); | ||
ObjectValue object = builder.build(); | ||
assertEquals(wrapObject(), object); |
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.
Is this test verifying that wrapObject
with no parameters results in an empty object?
It seems like this should really be assertEquals(ObjectValue.emptyObject(), object)
.
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.
Good catch. It is meant to verify that a build on an empty builder works.
} | ||
|
||
@Test | ||
public void setSingleField() { |
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.
Consider naming these methods as phrases with an implicit leading "it" (in this case setsASingleField). This makes the names directly portable to our typescript tests.
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.
Done
|
||
@Test | ||
public void setSingleField() { | ||
ObjectValue.Builder builder = ObjectValue.emptyObject().toBuilder(); |
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 could have sworn we added an ObjectValue.newBuilder
or some short form like that. Could we use that to cut down some of the verbosity here?
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.
This only exists in the old value.ObjectValue
. I also added it to protovalue.ObjectValue
public void setSuccessorField() { | ||
ObjectValue.Builder builder = ObjectValue.emptyObject().toBuilder(); | ||
builder.set(field("a"), fooValue); | ||
builder.set(field("a0"), fooValue); |
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.
Your current successor implementation would break if you also had a value with a character with an ordinal less than 0 (U+0030), e.g. space (U+0020) or bang (U+0021).
public void setFieldInNestedObject() { | ||
ObjectValue.Builder builder = ObjectValue.emptyObject().toBuilder(); | ||
builder.set(field("a"), map("b", fooValue)); | ||
builder.set(field("a.c"), fooValue); |
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.
Also consider a test where you set a nested object at a more-than-one-level difference of depth.
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.
Sure thing. Added setsDeeplyNestedFieldInNestedObject()
47c659c
to
035970b
Compare
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.
Thank you for your review. I drastically simplified the implementation based on your feedback.
...base-firestore/src/main/java/com/google/firebase/firestore/model/protovalue/ObjectValue.java
Outdated
Show resolved
Hide resolved
* @return Whether any modifications were applied (in any part of the subtree under | ||
* currentPath). | ||
*/ | ||
private boolean applyOverlay(FieldPath currentPath, MapValue.Builder resultAtPath) { |
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 believe this was close to impossible to follow, but it was the cleanest design I could come up with given the data structure I chose. I have now chosen a different data structure for the overlap map, which allowed me to drastically simplify the code.
|
||
boolean modified = false; | ||
|
||
while (!currentSlice.isEmpty()) { |
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 mainly used the TreeMap representation because it allowed me to store a <FieldPath, Value>
map, which is more typesafe than any nested data structure I could come up with. If I trade type safety for readable code, the end result looks pretty much like you suggested. I am now storing the overlays in a <String, Object>
map, where the key represents a single field segment and the value represents either:
- A
Value
proto for a leaf node (this includes MapValues that were set as part of a single user operation) - A
Map<String, Value>
value that indicates additional nesting null
to indicate field removals
This drastically simplifies the code and reduce the runtime of applyOverlay()
to O(n * m)
(where n
is the number of entries and m
the nesting level). I believe before it was O(n log n * m)
.
// Shrink the subtree to contain only values after the current field path. Note that we are | ||
// still bound by the subtree created at the initial method invocation. The current loop | ||
// exits when the subtree becomes empty. | ||
currentSlice = currentSlice.tailMap(createSuccessor(fieldPath)); |
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.
This is no longer part of the PR, but a remove
here would make this method non-idempotent and might incur additional tree re-balancing.
/** Create the first field path that is not part of the subtree created by `currentPath`. */ | ||
private FieldPath createSuccessor(FieldPath currentPath) { | ||
hardAssert(!currentPath.isEmpty(), "Can't create a successor for an empty path"); | ||
return currentPath.popLast().append(currentPath.getLastSegment() + '0'); |
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.
This is also no longer needed (yay for simpler code).
I chose '0'
because it is the first ASCII code that can be part of a valid field path. '\0'
may trigger validation errors in our field value handling.
return (ObjectValue) object; | ||
} | ||
|
||
private PrimitiveValue wrap(Object map) { |
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.
Changed to value
.
public void emptyBuilder() { | ||
ObjectValue.Builder builder = ObjectValue.emptyObject().toBuilder(); | ||
ObjectValue object = builder.build(); | ||
assertEquals(wrapObject(), object); |
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.
Good catch. It is meant to verify that a build on an empty builder works.
} | ||
|
||
@Test | ||
public void setSingleField() { |
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.
Done
|
||
@Test | ||
public void setSingleField() { | ||
ObjectValue.Builder builder = ObjectValue.emptyObject().toBuilder(); |
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.
This only exists in the old value.ObjectValue
. I also added it to protovalue.ObjectValue
public void setFieldInNestedObject() { | ||
ObjectValue.Builder builder = ObjectValue.emptyObject().toBuilder(); | ||
builder.set(field("a"), map("b", fooValue)); | ||
builder.set(field("a.c"), fooValue); |
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.
Sure thing. Added setsDeeplyNestedFieldInNestedObject()
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.
LGTM
*/ | ||
public Builder delete(FieldPath path) { | ||
hardAssert(!path.isEmpty(), "Cannot delete field for empty path on ObjectValue"); | ||
setOverlay(path, null); |
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:
private static final Object DELETED = new Object();
And then this would read as
setOverlay(path, DELETED);
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 to setOverlay
. That makes my instanceof
checks more complicated, as I now have to differentiate between user-provided values and the sentinel.
import java.util.List; | ||
import java.util.Map; | ||
|
||
public class PrimitiveValue extends FieldValue { |
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.
Merging SGTM. There seems to be little reason to keep this separate.
This adds a new top-level 'firestore.model.protovalue' package that adds a new ObjectValue and PrimitiveValue. Both are backed by Protobuf Value types. The reason I am adding a new package is to avoid breaking existing code. Before merging into master, I will move the files into the existing package.
The new ObjectValue should be mostly API compatible with the existing ObjectValue. I went through great length to figure out how to efficiently manipulate nested Protobuf Maps. I hope I succeeded.