diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/ValidationTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/ValidationTest.java index 705496de073..7bdc168da09 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/ValidationTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/ValidationTest.java @@ -35,7 +35,6 @@ import com.google.firebase.firestore.Transaction.Function; import com.google.firebase.firestore.testutil.IntegrationTestUtil; import com.google.firebase.firestore.util.Consumer; -import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.Map; @@ -44,6 +43,7 @@ import org.junit.runner.RunWith; // NOTE: The SDK has exhaustive nullability checks, but we don't exhaustively test them. :-) +@SuppressWarnings("ConstantConditions") @RunWith(AndroidJUnit4.class) public class ValidationTest { @@ -387,9 +387,8 @@ public void arrayTransformsRejectArrays() { DocumentReference doc = testDocument(); // This would result in a directly nested array which is not supported. String reason = "Invalid data. Nested arrays are not supported"; - expectError(() -> doc.set(map("x", FieldValue.arrayUnion(1, Arrays.asList("nested")))), reason); - expectError( - () -> doc.set(map("x", FieldValue.arrayRemove(1, Arrays.asList("nested")))), reason); + expectError(() -> doc.set(map("x", FieldValue.arrayUnion(1, asList("nested")))), reason); + expectError(() -> doc.set(map("x", FieldValue.arrayRemove(1, asList("nested")))), reason); } @Test diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/DocumentReference.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/DocumentReference.java index 744281e090f..3eb29d4accf 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/DocumentReference.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/DocumentReference.java @@ -26,10 +26,10 @@ import com.google.android.gms.tasks.Tasks; import com.google.firebase.annotations.PublicApi; import com.google.firebase.firestore.FirebaseFirestoreException.Code; -import com.google.firebase.firestore.UserDataConverter.ParsedDocumentData; -import com.google.firebase.firestore.UserDataConverter.ParsedUpdateData; import com.google.firebase.firestore.core.EventManager.ListenOptions; import com.google.firebase.firestore.core.QueryListener; +import com.google.firebase.firestore.core.UserData.ParsedSetData; +import com.google.firebase.firestore.core.UserData.ParsedUpdateData; import com.google.firebase.firestore.core.ViewSnapshot; import com.google.firebase.firestore.model.Document; import com.google.firebase.firestore.model.DocumentKey; @@ -166,7 +166,7 @@ public Task set(@NonNull Map data) { public Task set(@NonNull Map data, @NonNull SetOptions options) { checkNotNull(data, "Provided data must not be null."); checkNotNull(options, "Provided options must not be null."); - ParsedDocumentData parsed = + ParsedSetData parsed = options.isMerge() ? firestore.getDataConverter().parseMergeData(data, options.getFieldMask()) : firestore.getDataConverter().parseSetData(data); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Transaction.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/Transaction.java index 6ffc97bdd24..14d55652e7a 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Transaction.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Transaction.java @@ -21,8 +21,8 @@ import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.annotations.PublicApi; -import com.google.firebase.firestore.UserDataConverter.ParsedDocumentData; -import com.google.firebase.firestore.UserDataConverter.ParsedUpdateData; +import com.google.firebase.firestore.core.UserData.ParsedSetData; +import com.google.firebase.firestore.core.UserData.ParsedUpdateData; import com.google.firebase.firestore.model.Document; import com.google.firebase.firestore.model.MaybeDocument; import com.google.firebase.firestore.model.NoDocument; @@ -89,7 +89,7 @@ public Transaction set( firestore.validateReference(documentRef); checkNotNull(data, "Provided data must not be null."); checkNotNull(options, "Provided options must not be null."); - ParsedDocumentData parsed = + ParsedSetData parsed = options.isMerge() ? firestore.getDataConverter().parseMergeData(data, options.getFieldMask()) : firestore.getDataConverter().parseSetData(data); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/UserDataConverter.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/UserDataConverter.java index 5ef876f6814..67cc6b3e7d7 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/UserDataConverter.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/UserDataConverter.java @@ -22,18 +22,16 @@ import com.google.firebase.firestore.FieldValue.ArrayUnionFieldValue; import com.google.firebase.firestore.FieldValue.DeleteFieldValue; import com.google.firebase.firestore.FieldValue.ServerTimestampFieldValue; +import com.google.firebase.firestore.core.UserData; +import com.google.firebase.firestore.core.UserData.ParseAccumulator; +import com.google.firebase.firestore.core.UserData.ParseContext; +import com.google.firebase.firestore.core.UserData.ParsedSetData; +import com.google.firebase.firestore.core.UserData.ParsedUpdateData; import com.google.firebase.firestore.model.DatabaseId; -import com.google.firebase.firestore.model.DocumentKey; import com.google.firebase.firestore.model.FieldPath; import com.google.firebase.firestore.model.mutation.ArrayTransformOperation; import com.google.firebase.firestore.model.mutation.FieldMask; -import com.google.firebase.firestore.model.mutation.FieldTransform; -import com.google.firebase.firestore.model.mutation.Mutation; -import com.google.firebase.firestore.model.mutation.PatchMutation; -import com.google.firebase.firestore.model.mutation.Precondition; import com.google.firebase.firestore.model.mutation.ServerTimestampOperation; -import com.google.firebase.firestore.model.mutation.SetMutation; -import com.google.firebase.firestore.model.mutation.TransformMutation; import com.google.firebase.firestore.model.value.ArrayValue; import com.google.firebase.firestore.model.value.BlobValue; import com.google.firebase.firestore.model.value.BooleanValue; @@ -50,16 +48,12 @@ import com.google.firebase.firestore.util.CustomClassMapper; import com.google.firebase.firestore.util.Util; import java.util.ArrayList; -import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; -import java.util.SortedSet; -import java.util.TreeSet; -import java.util.regex.Pattern; import javax.annotation.Nullable; /** @@ -68,209 +62,6 @@ * @hide */ public final class UserDataConverter { - /** The result of parsing document data (e.g. for a setData call). */ - public static class ParsedDocumentData { - private final ObjectValue data; - @Nullable private final FieldMask fieldMask; - private final List fieldTransforms; - - ParsedDocumentData( - ObjectValue data, @Nullable FieldMask fieldMask, List fieldTransforms) { - this.data = data; - this.fieldMask = fieldMask; - this.fieldTransforms = fieldTransforms; - } - - public List toMutationList(DocumentKey key, Precondition precondition) { - ArrayList mutations = new ArrayList<>(); - if (fieldMask != null) { - mutations.add(new PatchMutation(key, data, fieldMask, precondition)); - } else { - mutations.add(new SetMutation(key, data, precondition)); - } - if (!fieldTransforms.isEmpty()) { - mutations.add(new TransformMutation(key, fieldTransforms)); - } - return mutations; - } - } - - /** The result of parsing "update" data (i.e. for an updateData call). */ - public static class ParsedUpdateData { - private final ObjectValue data; - private final FieldMask fieldMask; - private final List fieldTransforms; - - ParsedUpdateData(ObjectValue data, FieldMask fieldMask, List fieldTransforms) { - this.data = data; - this.fieldMask = fieldMask; - this.fieldTransforms = fieldTransforms; - } - - public List getFieldTransforms() { - return fieldTransforms; - } - - public List toMutationList(DocumentKey key, Precondition precondition) { - ArrayList mutations = new ArrayList<>(); - mutations.add(new PatchMutation(key, data, fieldMask, precondition)); - if (!fieldTransforms.isEmpty()) { - mutations.add(new TransformMutation(key, fieldTransforms)); - } - return mutations; - } - } - - /* - * Represents what type of API method provided the data being parsed; useful for determining which - * error conditions apply during parsing and providing better error messages. - */ - private enum UserDataSource { - Set, - MergeSet, - Update, - /** - * Indicates the source is a where clause, cursor bound, arrayUnion() element, etc. Of note, - * isWrite(Argument) will return false. - */ - Argument - } - - private static boolean isWrite(UserDataSource dataSource) { - switch (dataSource) { - case Set: // fall through - case MergeSet: // fall through - case Update: - return true; - case Argument: - return false; - default: - throw Assert.fail("Unexpected case for UserDataSource: %s", dataSource.name()); - } - } - - /** A "context" object passed around while parsing user data. */ - private class ParseContext { - - private final Pattern reservedFieldRegex = Pattern.compile("^__.*__$"); - - /** The current path being parsed. */ - // TODO: path should never be null, but we don't support array paths right now. - @Nullable private final FieldPath path; - - /** Whether or not this context corresponds to an element of an array. */ - private final boolean arrayElement; - - /** - * What type of API method provided the data being parsed; useful for determining which error - * conditions apply during parsing and providing better error messages. - */ - private final UserDataSource dataSource; - - /** Accumulates a list of field transforms found while parsing the data. */ - private final ArrayList fieldTransforms; - - /** Accumulates a list of the field paths found while parsing the data. */ - private final SortedSet fieldMask; - - /** - * Initializes a ParseContext with the given source and path. - * - * @param dataSource Indicates what kind of API method this data came from. - * @param path A path within the object being parsed. This could be an empty path (in which case - * the context represents the root of the data being parsed), or a nonempty path (indicating - * the context represents a nested location within the data). - *

TODO: We don't support array paths right now, so path can be null to indicate the - * context represents any location within an array (in which case certain features will not - * work and errors will be somewhat compromised). - * @param arrayElement Whether or not this context corresponds to an element of an array. - * @param fieldTransforms A mutable list of field transforms encountered while parsing the data. - * @param fieldMask A mutable list of field paths encountered while parsing the data. - */ - private ParseContext( - UserDataSource dataSource, - @Nullable FieldPath path, - boolean arrayElement, - ArrayList fieldTransforms, - SortedSet fieldMask) { - this.dataSource = dataSource; - this.path = path; - this.arrayElement = arrayElement; - this.fieldTransforms = fieldTransforms; - this.fieldMask = fieldMask; - } - - ParseContext(UserDataSource dataSource, @Nullable FieldPath path) { - this(dataSource, path, /*arrayElement=*/ false, new ArrayList<>(), new TreeSet<>()); - validatePath(); - } - - ParseContext childContext(String fieldName) { - FieldPath childPath = path == null ? null : path.append(fieldName); - ParseContext context = - new ParseContext( - dataSource, childPath, /*arrayElement=*/ false, fieldTransforms, fieldMask); - context.validatePathSegment(fieldName); - return context; - } - - ParseContext childContext(FieldPath fieldPath) { - FieldPath childPath = path == null ? null : path.append(fieldPath); - ParseContext context = - new ParseContext( - dataSource, childPath, /*arrayElement=*/ false, fieldTransforms, fieldMask); - context.validatePath(); - return context; - } - - ParseContext childContext(int arrayIndex) { - // TODO: We don't support array paths right now; so make path null. - return new ParseContext( - dataSource, /*path=*/ null, /*arrayElement=*/ true, fieldTransforms, fieldMask); - } - - /** Creates an error including the given reason and the current field path. */ - RuntimeException createError(String reason) { - String fieldDescription = - (this.path == null || this.path.isEmpty()) - ? "" - : " (found in field " + this.path.toString() + ")"; - return new IllegalArgumentException("Invalid data. " + reason + fieldDescription); - } - - /** Returns 'true' if 'fieldPath' was traversed when creating this context. */ - boolean contains(FieldPath fieldPath) { - for (FieldPath field : fieldMask) { - if (fieldPath.isPrefixOf(field)) { - return true; - } - } - - for (FieldTransform fieldTransform : fieldTransforms) { - if (fieldPath.isPrefixOf(fieldTransform.getFieldPath())) { - return true; - } - } - - return false; - } - - private void validatePath() { - // TODO: Remove null check once we have proper paths for fields within arrays. - if (this.path == null) { - return; - } - for (int i = 0; i < this.path.length(); i++) { - this.validatePathSegment(this.path.getSegment(i)); - } - } - - private void validatePathSegment(String segment) { - if (isWrite(dataSource) && reservedFieldRegex.matcher(segment).find()) { - throw this.createError("Document fields cannot begin and end with __"); - } - } - } private final DatabaseId databaseId; @@ -279,33 +70,22 @@ public UserDataConverter(DatabaseId databaseId) { } /** Parse document data from a non-merge set() call. */ - public ParsedDocumentData parseSetData(Map input) { - ParseContext context = new ParseContext(UserDataSource.Set, FieldPath.EMPTY_PATH); - FieldValue parsed = parseData(input, context); - hardAssert(parsed instanceof ObjectValue, "Parse result should be an object."); - - return new ParsedDocumentData( - (ObjectValue) parsed, - /* fieldMask= */ null, - Collections.unmodifiableList(context.fieldTransforms)); + public ParsedSetData parseSetData(Map input) { + ParseAccumulator accumulator = new ParseAccumulator(UserData.Source.Set); + FieldValue updateData = parseData(input, accumulator.rootContext()); + + return accumulator.toSetData((ObjectValue) updateData); } /** Parse document data from a set() call with SetOptions.merge() set. */ - public ParsedDocumentData parseMergeData( - Map input, @Nullable FieldMask fieldMask) { - ParseContext context = new ParseContext(UserDataSource.MergeSet, FieldPath.EMPTY_PATH); - FieldValue parsed = parseData(input, context); - hardAssert(parsed instanceof ObjectValue, "Parse result should be an object."); + public ParsedSetData parseMergeData(Map input, @Nullable FieldMask fieldMask) { + ParseAccumulator accumulator = new ParseAccumulator(UserData.Source.MergeSet); + ObjectValue updateData = (ObjectValue) parseData(input, accumulator.rootContext()); - List fieldTransforms; - - if (fieldMask == null) { - fieldMask = FieldMask.fromCollection(context.fieldMask); - fieldTransforms = context.fieldTransforms; - } else { + if (fieldMask != null) { // Verify that all elements specified in the field mask are part of the parsed context. for (FieldPath field : fieldMask.getMask()) { - if (!context.contains(field)) { + if (!accumulator.contains(field)) { throw new IllegalArgumentException( "Field '" + field.toString() @@ -313,25 +93,21 @@ public ParsedDocumentData parseMergeData( } } - fieldTransforms = new ArrayList<>(); + return accumulator.toMergeData(updateData, fieldMask); - for (FieldTransform parsedTransform : context.fieldTransforms) { - if (fieldMask.covers(parsedTransform.getFieldPath())) { - fieldTransforms.add(parsedTransform); - } - } + } else { + return accumulator.toMergeData(updateData); } - - return new ParsedDocumentData((ObjectValue) parsed, fieldMask, fieldTransforms); } /** Parse update data from an update() call. */ public ParsedUpdateData parseUpdateData(Map data) { checkNotNull(data, "Provided update data must not be null."); - ArrayList fieldMaskPaths = new ArrayList<>(); + + ParseAccumulator accumulator = new ParseAccumulator(UserData.Source.Update); + ParseContext context = accumulator.rootContext(); ObjectValue updateData = ObjectValue.emptyObject(); - ParseContext context = new ParseContext(UserDataSource.Update, FieldPath.EMPTY_PATH); for (Entry entry : data.entrySet()) { FieldPath fieldPath = com.google.firebase.firestore.FieldPath.fromDotSeparatedPath(entry.getKey()) @@ -340,19 +116,17 @@ public ParsedUpdateData parseUpdateData(Map data) { if (fieldValue instanceof DeleteFieldValue) { // Add it to the field mask, but don't add anything to updateData. - fieldMaskPaths.add(fieldPath); + context.addToFieldMask(fieldPath); } else { @Nullable FieldValue parsedValue = parseData(fieldValue, context.childContext(fieldPath)); if (parsedValue != null) { - fieldMaskPaths.add(fieldPath); + context.addToFieldMask(fieldPath); updateData = updateData.set(fieldPath, parsedValue); } } } - FieldMask mask = FieldMask.fromCollection(fieldMaskPaths); - return new ParsedUpdateData( - updateData, mask, Collections.unmodifiableList(context.fieldTransforms)); + return accumulator.toUpdateData(updateData); } /** @@ -360,18 +134,17 @@ public ParsedUpdateData parseUpdateData(Map data) { * both strings and FieldPaths. */ public ParsedUpdateData parseUpdateData(List fieldsAndValues) { - ParseContext context = new ParseContext(UserDataSource.Update, FieldPath.EMPTY_PATH); - ArrayList fieldMaskPaths = new ArrayList<>(); - ObjectValue updateData = ObjectValue.emptyObject(); - // fieldsAndValues.length and alternating types should already be validated by // Util.collectUpdateArguments(). hardAssert( fieldsAndValues.size() % 2 == 0, "Expected fieldAndValues to contain an even number of elements"); - Iterator iterator = fieldsAndValues.iterator(); + ParseAccumulator accumulator = new ParseAccumulator(UserData.Source.Update); + ParseContext context = accumulator.rootContext(); + ObjectValue updateData = ObjectValue.emptyObject(); + Iterator iterator = fieldsAndValues.iterator(); while (iterator.hasNext()) { Object fieldPath = iterator.next(); Object fieldValue = iterator.next(); @@ -393,27 +166,28 @@ public ParsedUpdateData parseUpdateData(List fieldsAndValues) { if (fieldValue instanceof DeleteFieldValue) { // Add it to the field mask, but don't add anything to updateData. - fieldMaskPaths.add(parsedField); + context.addToFieldMask(parsedField); } else { FieldValue parsedValue = parseData(fieldValue, context.childContext(parsedField)); if (parsedValue != null) { - fieldMaskPaths.add(parsedField); + context.addToFieldMask(parsedField); updateData = updateData.set(parsedField, parsedValue); } } } - FieldMask mask = FieldMask.fromCollection(fieldMaskPaths); - return new ParsedUpdateData(updateData, mask, context.fieldTransforms); + return accumulator.toUpdateData(updateData); } /** Parse a "query value" (e.g. value in a where filter or a value in a cursor bound). */ public FieldValue parseQueryValue(Object input) { - ParseContext context = new ParseContext(UserDataSource.Argument, FieldPath.EMPTY_PATH); - @Nullable FieldValue parsed = parseData(input, context); + ParseAccumulator accumulator = new ParseAccumulator(UserData.Source.Argument); + + @Nullable FieldValue parsed = parseData(input, accumulator.rootContext()); hardAssert(parsed != null, "Parsed data should not be null."); hardAssert( - context.fieldTransforms.size() == 0, "Field transforms should have been disallowed."); + accumulator.getFieldTransforms().isEmpty(), + "Field transforms should have been disallowed."); return parsed; } @@ -454,6 +228,7 @@ public Map convertPOJO(Object pojo) { private FieldValue parseData(Object input, ParseContext context) { if (input instanceof Map) { return parseMap((Map) input, context); + } else if (input instanceof com.google.firebase.firestore.FieldValue) { // FieldValues usually parse into transforms (except FieldValue.delete()) in which case we do // not want to include this field in our parsed data (as doing so will overwrite the field @@ -461,16 +236,17 @@ private FieldValue parseData(Object input, ParseContext context) { // context.fieldMask and we return null as our parsing result. this.parseSentinelFieldValue((com.google.firebase.firestore.FieldValue) input, context); return null; + } else { - // If context.path is null we are inside an array and we don't support field mask paths more - // granular than the top-level array. - if (context.path != null) { - context.fieldMask.add(context.path); + // If the context path is null we are inside an array and we don't support field mask paths + // more granular than the top-level array. + if (context.getPath() != null) { + context.addToFieldMask(context.getPath()); } if (input instanceof List) { // TODO: Include the path containing the array in the error message. - if (context.arrayElement) { + if (context.isArrayElement()) { throw context.createError("Nested arrays are not supported"); } return parseList((List) input, context); @@ -517,23 +293,23 @@ private ArrayValue parseList(List list, ParseContext context) { private void parseSentinelFieldValue( com.google.firebase.firestore.FieldValue value, ParseContext context) { // Sentinels are only supported with writes, and not within arrays. - if (!isWrite(context.dataSource)) { + if (!context.isWrite()) { throw context.createError( String.format("%s() can only be used with set() and update()", value.getMethodName())); } - if (context.path == null) { + if (context.getPath() == null) { throw context.createError( String.format("%s() is not currently supported inside arrays", value.getMethodName())); } if (value instanceof DeleteFieldValue) { - if (context.dataSource == UserDataSource.MergeSet) { + if (context.getDataSource() == UserData.Source.MergeSet) { // No transform to add for a delete, but we need to add it to our // fieldMask so it gets deleted. - context.fieldMask.add(context.path); - } else if (context.dataSource == UserDataSource.Update) { + context.addToFieldMask(context.getPath()); + } else if (context.getDataSource() == UserData.Source.Update) { hardAssert( - context.path.length() > 0, + context.getPath().length() > 0, "FieldValue.delete() at the top level should have already been handled."); throw context.createError( "FieldValue.delete() can only appear at the top level of your update data"); @@ -544,18 +320,20 @@ private void parseSentinelFieldValue( + "set() with SetOptions.merge()"); } } else if (value instanceof ServerTimestampFieldValue) { - context.fieldTransforms.add( - new FieldTransform(context.path, ServerTimestampOperation.getInstance())); + context.addToFieldTransforms(context.getPath(), ServerTimestampOperation.getInstance()); + } else if (value instanceof ArrayUnionFieldValue) { List parsedElements = parseArrayTransformElements(((ArrayUnionFieldValue) value).getElements()); ArrayTransformOperation arrayUnion = new ArrayTransformOperation.Union(parsedElements); - context.fieldTransforms.add(new FieldTransform(context.path, arrayUnion)); + context.addToFieldTransforms(context.getPath(), arrayUnion); + } else if (value instanceof ArrayRemoveFieldValue) { List parsedElements = parseArrayTransformElements(((ArrayRemoveFieldValue) value).getElements()); ArrayTransformOperation arrayRemove = new ArrayTransformOperation.Remove(parsedElements); - context.fieldTransforms.add(new FieldTransform(context.path, arrayRemove)); + context.addToFieldTransforms(context.getPath(), arrayRemove); + } else { throw Assert.fail("Unknown FieldValue type: %s", Util.typeName(value)); } @@ -621,13 +399,15 @@ private FieldValue parseScalarValue(Object input, ParseContext context) { } private List parseArrayTransformElements(List elements) { + ParseAccumulator accumulator = new ParseAccumulator(UserData.Source.Argument); + ArrayList result = new ArrayList<>(elements.size()); for (int i = 0; i < elements.size(); i++) { Object element = elements.get(i); // Although array transforms are used with writes, the actual elements // being unioned or removed are not considered writes since they cannot // contain any FieldValue sentinels, etc. - ParseContext context = new ParseContext(UserDataSource.Argument, FieldPath.EMPTY_PATH); + ParseContext context = accumulator.rootContext(); result.add(parseData(element, context.childContext(i))); } return result; diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/WriteBatch.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/WriteBatch.java index 1b6d6124c5c..6590b9254f0 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/WriteBatch.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/WriteBatch.java @@ -21,8 +21,8 @@ import com.google.android.gms.tasks.Task; import com.google.android.gms.tasks.Tasks; import com.google.firebase.annotations.PublicApi; -import com.google.firebase.firestore.UserDataConverter.ParsedDocumentData; -import com.google.firebase.firestore.UserDataConverter.ParsedUpdateData; +import com.google.firebase.firestore.core.UserData.ParsedSetData; +import com.google.firebase.firestore.core.UserData.ParsedUpdateData; import com.google.firebase.firestore.model.mutation.DeleteMutation; import com.google.firebase.firestore.model.mutation.Mutation; import com.google.firebase.firestore.model.mutation.Precondition; @@ -87,7 +87,7 @@ public WriteBatch set( firestore.validateReference(documentRef); checkNotNull(data, "Provided data must not be null."); verifyNotCommitted(); - ParsedDocumentData parsed = + ParsedSetData parsed = options.isMerge() ? firestore.getDataConverter().parseMergeData(data, options.getFieldMask()) : firestore.getDataConverter().parseSetData(data); diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Transaction.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Transaction.java index c1ebc76ee67..ee1d96c2d7a 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Transaction.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/Transaction.java @@ -18,8 +18,8 @@ import com.google.android.gms.tasks.Tasks; import com.google.firebase.firestore.FirebaseFirestoreException; import com.google.firebase.firestore.FirebaseFirestoreException.Code; -import com.google.firebase.firestore.UserDataConverter.ParsedDocumentData; -import com.google.firebase.firestore.UserDataConverter.ParsedUpdateData; +import com.google.firebase.firestore.core.UserData.ParsedSetData; +import com.google.firebase.firestore.core.UserData.ParsedUpdateData; import com.google.firebase.firestore.model.DocumentKey; import com.google.firebase.firestore.model.MaybeDocument; import com.google.firebase.firestore.model.NoDocument; @@ -141,7 +141,7 @@ private Precondition preconditionForUpdate(DocumentKey key) { } /** Stores a set mutation for the given key and value, to be committed when commit() is called. */ - public void set(DocumentKey key, ParsedDocumentData data) { + public void set(DocumentKey key, ParsedSetData data) { write(data.toMutationList(key, precondition(key))); } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/UserData.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/UserData.java new file mode 100644 index 00000000000..00cc3969799 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/UserData.java @@ -0,0 +1,365 @@ +// Copyright 2018 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.core; + +import static java.util.Collections.unmodifiableList; + +import com.google.firebase.firestore.model.DocumentKey; +import com.google.firebase.firestore.model.FieldPath; +import com.google.firebase.firestore.model.mutation.FieldMask; +import com.google.firebase.firestore.model.mutation.FieldTransform; +import com.google.firebase.firestore.model.mutation.Mutation; +import com.google.firebase.firestore.model.mutation.PatchMutation; +import com.google.firebase.firestore.model.mutation.Precondition; +import com.google.firebase.firestore.model.mutation.SetMutation; +import com.google.firebase.firestore.model.mutation.TransformMutation; +import com.google.firebase.firestore.model.mutation.TransformOperation; +import com.google.firebase.firestore.model.value.ObjectValue; +import com.google.firebase.firestore.util.Assert; +import java.util.ArrayList; +import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.regex.Pattern; +import javax.annotation.Nullable; + +public class UserData { + private UserData() {} // Do not instantiate + + /* + * Represents what type of API method provided the data being parsed; useful for determining which + * error conditions apply during parsing and providing better error messages. + */ + public enum Source { + /** The data comes from a regular Set operation, without merge. */ + Set, + /** The data comes from a Set operation with merge enabled. */ + MergeSet, + /** The data comes from an Update operation. */ + Update, + /** + * Indicates the source is a where clause, cursor bound, arrayUnion() element, etc. Of note, + * ParseContext.isWrite() will return false. + */ + Argument + } + + /** + * Accumulates the side-effect results of parsing user input. These include: + * + *
    + *
  • The field mask naming all the fields that have values. + *
  • The transform operations that must be applied in the batch to implement server-generated + * behavior. In the wire protocol these are encoded separately from the Value. + *
+ */ + public static class ParseAccumulator { + /** + * What type of API method provided the data being parsed; useful for determining which error + * conditions apply during parsing and providing better error messages. + */ + private final Source dataSource; + + /** Accumulates a list of the field paths found while parsing the data. */ + private final SortedSet fieldMask; + + /** Accumulates a list of field transforms found while parsing the data. */ + private final ArrayList fieldTransforms; + + /** @param dataSource Indicates what kind of API method this data came from. */ + public ParseAccumulator(Source dataSource) { + this.dataSource = dataSource; + this.fieldMask = new TreeSet<>(); + this.fieldTransforms = new ArrayList<>(); + } + + public Source getDataSource() { + return dataSource; + } + + public List getFieldTransforms() { + return fieldTransforms; + } + + /** Returns a new ParseContext representing the root of a user document. */ + public ParseContext rootContext() { + return new ParseContext(this, FieldPath.EMPTY_PATH, /* arrayElement= */ false); + } + + /** + * Returns {@code true} if the given {@code fieldPath} was encountered in the current document. + */ + public boolean contains(FieldPath fieldPath) { + for (FieldPath field : fieldMask) { + if (fieldPath.isPrefixOf(field)) { + return true; + } + } + + for (FieldTransform fieldTransform : fieldTransforms) { + if (fieldPath.isPrefixOf(fieldTransform.getFieldPath())) { + return true; + } + } + + return false; + } + + /** Adds the given {@code fieldPath} to the accumulated FieldMask. */ + void addToFieldMask(FieldPath fieldPath) { + fieldMask.add(fieldPath); + } + + /** Adds a transformation for the given field path. */ + void addToFieldTransforms(FieldPath fieldPath, TransformOperation transformOperation) { + fieldTransforms.add(new FieldTransform(fieldPath, transformOperation)); + } + + /** + * Wraps the given {@code data} along with any accumulated field mask and transforms into a + * ParsedSetData representing a user-issued merge. + * + * @return ParsedSetData that wraps the contents of this ParseAccumulator. + */ + public ParsedSetData toMergeData(ObjectValue data) { + return new ParsedSetData( + data, FieldMask.fromCollection(fieldMask), unmodifiableList(fieldTransforms)); + } + + /** + * Wraps the given {@code data} and {@code userFieldMask} along with any accumulated transforms + * that are covered by the given field mask into a ParsedSetData that represents a user-issued + * merge. + * + * @param data The converted user data. + * @param userFieldMask The user-supplied field mask that masks out any changes that have been + * accumulated so far. + * @return ParsedSetData that wraps the contents of this ParseAccumulator. The field mask in the + * result will be the userFieldMask and only transforms that are covered by the mask will be + * included. + */ + public ParsedSetData toMergeData(ObjectValue data, FieldMask userFieldMask) { + + ArrayList coveredFieldTransforms = new ArrayList<>(); + + for (FieldTransform parsedTransform : fieldTransforms) { + if (userFieldMask.covers(parsedTransform.getFieldPath())) { + coveredFieldTransforms.add(parsedTransform); + } + } + + return new ParsedSetData(data, userFieldMask, unmodifiableList(coveredFieldTransforms)); + } + + /** + * Wraps the given {@code data} along with any accumulated transforms into a ParsedSetData that + * represents a user-issued Set. + * + * @return ParsedSetData that wraps the contents of this ParseAccumulator. + */ + public ParsedSetData toSetData(ObjectValue data) { + return new ParsedSetData(data, /* fieldMask= */ null, unmodifiableList(fieldTransforms)); + } + + /** + * Wraps the given {@code data} along with any accumulated field mask and transforms into a + * ParsedUpdateData that represents a user-issued Update. + * + * @return ParsedSetData that wraps the contents of this ParseAccumulator. + */ + public ParsedUpdateData toUpdateData(ObjectValue data) { + return new ParsedUpdateData( + data, FieldMask.fromCollection(fieldMask), unmodifiableList(fieldTransforms)); + } + } + + /** + * A "context" object that wraps a ParseAccumulator and refers to a specific location in a + * user-supplied document. Instances are created and passed around while traversing user data + * during parsing in order to conveniently accumulate data in the ParseAccumulator. + */ + public static class ParseContext { + + private final Pattern reservedFieldRegex = Pattern.compile("^__.*__$"); + + private final ParseAccumulator accumulator; + + /** The current path being parsed. */ + // TODO: path should never be null, but we don't support array paths right now. + @Nullable private final FieldPath path; + + /** Whether or not this context corresponds to an element of an array. */ + private final boolean arrayElement; + + /** + * Initializes a ParseContext with the given source and path. + * + * @param accumulator The ParseAccumulator on which to add results. + * @param path A path within the object being parsed. This could be an empty path (in which case + * the context represents the root of the data being parsed), or a nonempty path (indicating + * the context represents a nested location within the data). + *

TODO: We don't support array paths right now, so path can be null to indicate the + * context represents any location within an array (in which case certain features will not + * work and errors will be somewhat compromised). + * @param arrayElement Whether or not this context corresponds to an element of an array. + */ + private ParseContext( + ParseAccumulator accumulator, @Nullable FieldPath path, boolean arrayElement) { + this.accumulator = accumulator; + this.path = path; + this.arrayElement = arrayElement; + } + + /** Whether or not this context corresponds to an element of an array. */ + public boolean isArrayElement() { + return arrayElement; + } + + /** + * What type of API method provided the data being parsed; useful for determining which error + * conditions apply during parsing and providing better error messages. + */ + public Source getDataSource() { + return accumulator.dataSource; + } + + public FieldPath getPath() { + return path; + } + + /** Returns true for the non-query parse contexts (Set, MergeSet and Update). */ + public boolean isWrite() { + switch (accumulator.dataSource) { + case Set: // fall through + case MergeSet: // fall through + case Update: + return true; + case Argument: + return false; + default: + throw Assert.fail( + "Unexpected case for UserDataSource: %s", accumulator.dataSource.name()); + } + } + + public ParseContext childContext(String fieldName) { + FieldPath childPath = path == null ? null : path.append(fieldName); + ParseContext context = new ParseContext(accumulator, childPath, /*arrayElement=*/ false); + context.validatePathSegment(fieldName); + return context; + } + + public ParseContext childContext(FieldPath fieldPath) { + FieldPath childPath = path == null ? null : path.append(fieldPath); + ParseContext context = new ParseContext(accumulator, childPath, /*arrayElement=*/ false); + context.validatePath(); + return context; + } + + @SuppressWarnings("unused") + public ParseContext childContext(int arrayIndex) { + // TODO: We don't support array paths right now; so make path null. + return new ParseContext(accumulator, /*path=*/ null, /*arrayElement=*/ true); + } + + /** Adds the given {@code fieldPath} to the accumulated FieldMask. */ + public void addToFieldMask(FieldPath fieldPath) { + accumulator.addToFieldMask(fieldPath); + } + + /** Adds a transformation for the given field path. */ + public void addToFieldTransforms(FieldPath fieldPath, TransformOperation transformOperation) { + accumulator.addToFieldTransforms(fieldPath, transformOperation); + } + + /** Creates an error including the given reason and the current field path. */ + public RuntimeException createError(String reason) { + String fieldDescription = + (this.path == null || this.path.isEmpty()) + ? "" + : " (found in field " + this.path.toString() + ")"; + return new IllegalArgumentException("Invalid data. " + reason + fieldDescription); + } + + private void validatePath() { + // TODO: Remove null check once we have proper paths for fields within arrays. + if (this.path == null) { + return; + } + for (int i = 0; i < this.path.length(); i++) { + this.validatePathSegment(this.path.getSegment(i)); + } + } + + private void validatePathSegment(String segment) { + if (isWrite() && reservedFieldRegex.matcher(segment).find()) { + throw this.createError("Document fields cannot begin and end with __"); + } + } + } + + /** The result of parsing document data (e.g. for a setData call). */ + public static class ParsedSetData { + private final ObjectValue data; + @Nullable private final FieldMask fieldMask; + private final List fieldTransforms; + + ParsedSetData( + ObjectValue data, @Nullable FieldMask fieldMask, List fieldTransforms) { + this.data = data; + this.fieldMask = fieldMask; + this.fieldTransforms = fieldTransforms; + } + + public List toMutationList(DocumentKey key, Precondition precondition) { + ArrayList mutations = new ArrayList<>(); + if (fieldMask != null) { + mutations.add(new PatchMutation(key, data, fieldMask, precondition)); + } else { + mutations.add(new SetMutation(key, data, precondition)); + } + if (!fieldTransforms.isEmpty()) { + mutations.add(new TransformMutation(key, fieldTransforms)); + } + return mutations; + } + } + + /** The result of parsing "update" data (i.e. for an updateData call). */ + public static class ParsedUpdateData { + private final ObjectValue data; + private final FieldMask fieldMask; + private final List fieldTransforms; + + ParsedUpdateData(ObjectValue data, FieldMask fieldMask, List fieldTransforms) { + this.data = data; + this.fieldMask = fieldMask; + this.fieldTransforms = fieldTransforms; + } + + public List getFieldTransforms() { + return fieldTransforms; + } + + public List toMutationList(DocumentKey key, Precondition precondition) { + ArrayList mutations = new ArrayList<>(); + mutations.add(new PatchMutation(key, data, fieldMask, precondition)); + if (!fieldTransforms.isEmpty()) { + mutations.add(new TransformMutation(key, fieldTransforms)); + } + return mutations; + } + } +} diff --git a/firebase-firestore/src/testUtil/java/com/google/firebase/firestore/testutil/TestUtil.java b/firebase-firestore/src/testUtil/java/com/google/firebase/firestore/testutil/TestUtil.java index 2820c0f7a66..5a1b7009fb3 100644 --- a/firebase-firestore/src/testUtil/java/com/google/firebase/firestore/testutil/TestUtil.java +++ b/firebase-firestore/src/testUtil/java/com/google/firebase/firestore/testutil/TestUtil.java @@ -33,12 +33,12 @@ import com.google.firebase.firestore.DocumentReference; import com.google.firebase.firestore.TestAccessHelper; import com.google.firebase.firestore.UserDataConverter; -import com.google.firebase.firestore.UserDataConverter.ParsedUpdateData; import com.google.firebase.firestore.core.Filter; import com.google.firebase.firestore.core.Filter.Operator; import com.google.firebase.firestore.core.OrderBy; import com.google.firebase.firestore.core.OrderBy.Direction; import com.google.firebase.firestore.core.Query; +import com.google.firebase.firestore.core.UserData.ParsedUpdateData; import com.google.firebase.firestore.local.LocalViewChanges; import com.google.firebase.firestore.local.QueryData; import com.google.firebase.firestore.local.QueryPurpose;