Skip to content

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
merged 17 commits into from
Jan 29, 2020

Conversation

schmidt-sebastian
Copy link
Contributor

@schmidt-sebastian schmidt-sebastian commented Jan 23, 2020

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.

@codecov
Copy link

codecov bot commented Jan 23, 2020

Codecov Report

Merging #1156 into mrschmidt/rewritefieldvalue will increase coverage by 21.05%.
The diff coverage is 97.26%.

Flag Coverage Δ Complexity Δ
#FirebaseFirestore 62.23% <97.26%> (?) 2349 <17> (?)
#FirebaseFirestore_Ktx 41.17% <ø> (ø) 0 <ø> (ø) ⬇️
Impacted Files Coverage Δ Complexity Δ
...ase/firestore/model/protovalue/PrimitiveValue.java 93.61% <ø> (ø) 28 <0> (?)
...le/firebase/firestore/model/value/ProtoValues.java 90.51% <100%> (ø) 62 <10> (?)
...rebase/firestore/model/protovalue/ObjectValue.java 95.55% <96.36%> (ø) 16 <7> (?)
...oogle/firebase/firestore/core/FirestoreClient.java 0% <0%> (ø) 0% <0%> (?)
...loud/datastore/core/number/IndexNumberDecoder.java 0% <0%> (ø) 0% <0%> (?)
...firebase/firestore/remote/ConnectivityMonitor.java 0% <0%> (ø) 0% <0%> (?)
...le/firebase/firestore/model/value/StringValue.java 100% <0%> (ø) 10% <0%> (?)
...firebase/firestore/model/value/ReferenceValue.java 71.42% <0%> (ø) 9% <0%> (?)
...va/com/google/firebase/firestore/core/OrderBy.java 86.2% <0%> (ø) 10% <0%> (?)
...se/firestore/local/MemoryLruReferenceDelegate.java 89.33% <0%> (ø) 29% <0%> (?)
... and 167 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 618f903...035970b. Read the comment docs.

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
@schmidt-sebastian schmidt-sebastian changed the title WIP: Protobuf-backed FieldValues Protobuf-backed FieldValues Jan 23, 2020
@schmidt-sebastian schmidt-sebastian changed the base branch from mrschmidt/rewritefieldvalue to mrschmidt/testhelpers January 23, 2020 22:11
@schmidt-sebastian schmidt-sebastian changed the base branch from mrschmidt/testhelpers to mrschmidt/rewritefieldvalue January 24, 2020 05:37
import java.util.List;
import java.util.Map;

public class PrimitiveValue extends FieldValue {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comments?

Copy link
Contributor Author

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.

Copy link
Contributor

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.

/** 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');
Copy link
Contributor

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')?

Copy link
Contributor Author

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) {
Copy link
Contributor

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.

Copy link
Contributor Author

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));
Copy link
Contributor

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?

Copy link
Contributor Author

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);
Copy link
Contributor

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).

Copy link
Contributor Author

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() {
Copy link
Contributor

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.

Copy link
Contributor Author

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();
Copy link
Contributor

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?

Copy link
Contributor Author

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);
Copy link
Contributor

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);
Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing. Added setsDeeplyNestedFieldInNestedObject()

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff Jan 28, 2020
Copy link
Contributor Author

@schmidt-sebastian schmidt-sebastian left a 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.

* @return Whether any modifications were applied (in any part of the subtree under
* currentPath).
*/
private boolean applyOverlay(FieldPath currentPath, MapValue.Builder resultAtPath) {
Copy link
Contributor Author

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()) {
Copy link
Contributor Author

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));
Copy link
Contributor Author

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');
Copy link
Contributor Author

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) {
Copy link
Contributor Author

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);
Copy link
Contributor Author

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() {
Copy link
Contributor Author

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();
Copy link
Contributor Author

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);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing. Added setsDeeplyNestedFieldInNestedObject()

Copy link
Contributor

@wilhuff wilhuff left a 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);
Copy link
Contributor

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.

Copy link
Contributor Author

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 {
Copy link
Contributor

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.

@wilhuff wilhuff assigned schmidt-sebastian and unassigned wilhuff Jan 29, 2020
@schmidt-sebastian schmidt-sebastian merged commit 9354294 into mrschmidt/rewritefieldvalue Jan 29, 2020
@schmidt-sebastian schmidt-sebastian deleted the mrschmidt/valueproto branch February 7, 2020 00:22
schmidt-sebastian added a commit to firebase/firebase-js-sdk that referenced this pull request Feb 26, 2020
@firebase firebase locked and limited conversation to collaborators Feb 29, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants