diff --git a/firebase-firestore/CHANGELOG.md b/firebase-firestore/CHANGELOG.md index 924eb34d326..57fa74b7f27 100644 --- a/firebase-firestore/CHANGELOG.md +++ b/firebase-firestore/CHANGELOG.md @@ -1,7 +1,7 @@ # Unreleased +- [feature] Added support for `minimum` and `maximum` FieldValue operations. # 26.3.0 - - [feature] Added search stage support for `languageCode`, `offset`, `limit`, and `retrievalDepth`. - [feature] Added support for Pipeline expressions `arraySlice`, `arraySliceToEnd`, `arrayFilter`, `arrayTransform` and `arrayTransformWithIndex`. [#7989](https://github.com/firebase/firebase-android-sdk/pull/7989) diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 51e21d3d1a3..7232a19b414 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -154,6 +154,10 @@ package com.google.firebase.firestore { method public static com.google.firebase.firestore.FieldValue delete(); method public static com.google.firebase.firestore.FieldValue increment(double); method public static com.google.firebase.firestore.FieldValue increment(long); + method public static com.google.firebase.firestore.FieldValue maximum(double); + method public static com.google.firebase.firestore.FieldValue maximum(long); + method public static com.google.firebase.firestore.FieldValue minimum(double); + method public static com.google.firebase.firestore.FieldValue minimum(long); method public static com.google.firebase.firestore.FieldValue serverTimestamp(); method public static com.google.firebase.firestore.VectorValue vector(double[]); } diff --git a/firebase-firestore/gradle.properties b/firebase-firestore/gradle.properties index e5b086323ec..ed45c5b82da 100644 --- a/firebase-firestore/gradle.properties +++ b/firebase-firestore/gradle.properties @@ -1,2 +1,2 @@ -version=26.3.1 +version=26.4.0 latestReleasedVersion=26.3.0 diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/NumericTransformsTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/NumericTransformsTest.java index af4687eeb12..9b3e089d04e 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/NumericTransformsTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/NumericTransformsTest.java @@ -71,18 +71,31 @@ private void writeInitialData(Map initialData) { private void expectLocalAndRemoteValue(double expectedSum) { DocumentSnapshot snap = accumulator.awaitLocalEvent(); + org.junit.Assert.assertTrue(snap.get("sum") instanceof Double); assertEquals(expectedSum, snap.getDouble("sum"), DOUBLE_EPSILON); snap = accumulator.awaitRemoteEvent(); + org.junit.Assert.assertTrue(snap.get("sum") instanceof Double); assertEquals(expectedSum, snap.getDouble("sum"), DOUBLE_EPSILON); } private void expectLocalAndRemoteValue(long expectedSum) { DocumentSnapshot snap = accumulator.awaitLocalEvent(); + org.junit.Assert.assertTrue(snap.get("sum") instanceof Long); assertEquals(expectedSum, (long) snap.getLong("sum")); snap = accumulator.awaitRemoteEvent(); + org.junit.Assert.assertTrue(snap.get("sum") instanceof Long); assertEquals(expectedSum, (long) snap.getLong("sum")); } + private void expectLocalAndRemoteNaN() { + DocumentSnapshot snap = accumulator.awaitLocalEvent(); + org.junit.Assert.assertTrue(snap.get("sum") instanceof Double); + org.junit.Assert.assertTrue(Double.isNaN(snap.getDouble("sum"))); + snap = accumulator.awaitRemoteEvent(); + org.junit.Assert.assertTrue(snap.get("sum") instanceof Double); + org.junit.Assert.assertTrue(Double.isNaN(snap.getDouble("sum"))); + } + @Test public void createDocumentWithIncrement() { waitFor(docRef.set(map("sum", FieldValue.increment(1337)))); @@ -218,4 +231,128 @@ public void serverTimestampAndIncrement() throws ExecutionException, Interrupted snap = accumulator.awaitRemoteEvent(); assertEquals(1, (long) snap.getLong("val")); } + + @Test + public void createDocumentWithMinimum() { + waitFor(docRef.set(map("sum", FieldValue.minimum(1337)))); + expectLocalAndRemoteValue(1337L); + } + + @Test + public void createDocumentWithMaximum() { + waitFor(docRef.set(map("sum", FieldValue.maximum(1337)))); + expectLocalAndRemoteValue(1337L); + } + + @Test + public void minimumWithExistingInteger() { + writeInitialData(map("sum", 10L)); + waitFor(docRef.update("sum", FieldValue.minimum(5L))); + expectLocalAndRemoteValue(5L); + + waitFor(docRef.update("sum", FieldValue.minimum(20L))); + expectLocalAndRemoteValue(5L); + } + + @Test + public void maximumWithExistingInteger() { + writeInitialData(map("sum", 10L)); + waitFor(docRef.update("sum", FieldValue.maximum(5L))); + expectLocalAndRemoteValue(10L); + + waitFor(docRef.update("sum", FieldValue.maximum(20L))); + expectLocalAndRemoteValue(20L); + } + + @Test + public void minimumWithExistingDouble() { + writeInitialData(map("sum", 10.5D)); + waitFor(docRef.update("sum", FieldValue.minimum(5.5D))); + expectLocalAndRemoteValue(5.5D); + + waitFor(docRef.update("sum", FieldValue.minimum(20.5D))); + expectLocalAndRemoteValue(5.5D); + } + + @Test + public void maximumWithExistingDouble() { + writeInitialData(map("sum", 10.5D)); + waitFor(docRef.update("sum", FieldValue.maximum(5.5D))); + expectLocalAndRemoteValue(10.5D); + + waitFor(docRef.update("sum", FieldValue.maximum(20.5D))); + expectLocalAndRemoteValue(20.5D); + } + + @Test + public void mixedTypesPreserveOperandTypeForMinimum() { + // field and input value of mixed types: field takes on type of smaller operand + writeInitialData(map("sum", 10L)); + waitFor(docRef.update("sum", FieldValue.minimum(5.5D))); + expectLocalAndRemoteValue(5.5D); + + writeInitialData(map("sum", 10.5D)); + waitFor(docRef.update("sum", FieldValue.minimum(5L))); + expectLocalAndRemoteValue(5L); + } + + @Test + public void mixedTypesPreserveOperandTypeForMaximum() { + // field and input value of mixed types: field takes on type of larger operand + writeInitialData(map("sum", 10L)); + waitFor(docRef.update("sum", FieldValue.maximum(20.5D))); + expectLocalAndRemoteValue(20.5D); + + writeInitialData(map("sum", 10.5D)); + waitFor(docRef.update("sum", FieldValue.maximum(20L))); + expectLocalAndRemoteValue(20L); + } + + @Test + public void equivalentValuesDoNotChangeTypeForMinimum() { + // equivalent (e.g. 3 and 3.0), field does not change type + writeInitialData(map("sum", 3L)); + waitFor(docRef.update("sum", FieldValue.minimum(3.0D))); + expectLocalAndRemoteValue(3L); + + writeInitialData(map("sum", 3.0D)); + waitFor(docRef.update("sum", FieldValue.minimum(3L))); + expectLocalAndRemoteValue(3.0D); + } + + @Test + public void equivalentValuesDoNotChangeTypeForMaximum() { + // equivalent (e.g. 3 and 3.0), field does not change type + writeInitialData(map("sum", 3L)); + waitFor(docRef.update("sum", FieldValue.maximum(3.0D))); + expectLocalAndRemoteValue(3L); + + writeInitialData(map("sum", 3.0D)); + waitFor(docRef.update("sum", FieldValue.maximum(3L))); + expectLocalAndRemoteValue(3.0D); + } + + @Test + public void minimumWithNaN() { + // If one of the values is NaN, minimum is NaN + writeInitialData(map("sum", Double.NaN)); + waitFor(docRef.update("sum", FieldValue.minimum(5L))); + expectLocalAndRemoteNaN(); + + writeInitialData(map("sum", 5L)); + waitFor(docRef.update("sum", FieldValue.minimum(Double.NaN))); + expectLocalAndRemoteNaN(); + } + + @Test + public void maximumWithNaN() { + // If one of the values is NaN, maximum is NaN + writeInitialData(map("sum", Double.NaN)); + waitFor(docRef.update("sum", FieldValue.maximum(5L))); + expectLocalAndRemoteNaN(); + + writeInitialData(map("sum", 5L)); + waitFor(docRef.update("sum", FieldValue.maximum(Double.NaN))); + expectLocalAndRemoteNaN(); + } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/FieldValue.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/FieldValue.java index f899457acdb..871038a2813 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/FieldValue.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/FieldValue.java @@ -102,6 +102,42 @@ Number getOperand() { } } + /** {@code FieldValue} class for {@link #minimum()} transforms. */ + static class NumericMinimumFieldValue extends FieldValue { + private final Number operand; + + NumericMinimumFieldValue(Number operand) { + this.operand = operand; + } + + @Override + String getMethodName() { + return "FieldValue.minimum"; + } + + Number getOperand() { + return operand; + } + } + + /** {@code FieldValue} class for {@link #maximum()} transforms. */ + static class NumericMaximumFieldValue extends FieldValue { + private final Number operand; + + NumericMaximumFieldValue(Number operand) { + this.operand = operand; + } + + @Override + String getMethodName() { + return "FieldValue.maximum"; + } + + Number getOperand() { + return operand; + } + } + private static final DeleteFieldValue DELETE_INSTANCE = new DeleteFieldValue(); private static final ServerTimestampFieldValue SERVER_TIMESTAMP_INSTANCE = new ServerTimestampFieldValue(); @@ -183,6 +219,50 @@ public static FieldValue increment(double l) { return new NumericIncrementFieldValue(l); } + /** + * Returns a special value that can be used with {@code set()} or {@code update()} that tells the + * server to set the field to the minimum of its current value and the given value. + * + * @return The {@code FieldValue} sentinel for use in a call to {@code set()} or {@code update()}. + */ + @NonNull + public static FieldValue minimum(long l) { + return new NumericMinimumFieldValue(l); + } + + /** + * Returns a special value that can be used with {@code set()} or {@code update()} that tells the + * server to set the field to the minimum of its current value and the given value. + * + * @return The {@code FieldValue} sentinel for use in a call to {@code set()} or {@code update()}. + */ + @NonNull + public static FieldValue minimum(double l) { + return new NumericMinimumFieldValue(l); + } + + /** + * Returns a special value that can be used with {@code set()} or {@code update()} that tells the + * server to set the field to the maximum of its current value and the given value. + * + * @return The {@code FieldValue} sentinel for use in a call to {@code set()} or {@code update()}. + */ + @NonNull + public static FieldValue maximum(long l) { + return new NumericMaximumFieldValue(l); + } + + /** + * Returns a special value that can be used with {@code set()} or {@code update()} that tells the + * server to set the field to the maximum of its current value and the given value. + * + * @return The {@code FieldValue} sentinel for use in a call to {@code set()} or {@code update()}. + */ + @NonNull + public static FieldValue maximum(double l) { + return new NumericMaximumFieldValue(l); + } + /** * Creates a new {@link VectorValue} constructed with a copy of the given array of doubles. * diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/UserDataReader.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/UserDataReader.java index 20fcd9054ca..28d29f4b743 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/UserDataReader.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/UserDataReader.java @@ -23,6 +23,9 @@ import com.google.firebase.firestore.FieldValue.ArrayRemoveFieldValue; import com.google.firebase.firestore.FieldValue.ArrayUnionFieldValue; import com.google.firebase.firestore.FieldValue.DeleteFieldValue; +import com.google.firebase.firestore.FieldValue.NumericIncrementFieldValue; +import com.google.firebase.firestore.FieldValue.NumericMaximumFieldValue; +import com.google.firebase.firestore.FieldValue.NumericMinimumFieldValue; import com.google.firebase.firestore.FieldValue.ServerTimestampFieldValue; import com.google.firebase.firestore.core.UserData; import com.google.firebase.firestore.core.UserData.ParseAccumulator; @@ -36,6 +39,8 @@ import com.google.firebase.firestore.model.mutation.ArrayTransformOperation; import com.google.firebase.firestore.model.mutation.FieldMask; import com.google.firebase.firestore.model.mutation.NumericIncrementTransformOperation; +import com.google.firebase.firestore.model.mutation.NumericMaximumTransformOperation; +import com.google.firebase.firestore.model.mutation.NumericMinimumTransformOperation; import com.google.firebase.firestore.model.mutation.ServerTimestampOperation; import com.google.firebase.firestore.pipeline.Expression; import com.google.firebase.firestore.util.Assert; @@ -369,16 +374,27 @@ private void parseSentinelFieldValue( ArrayTransformOperation arrayRemove = new ArrayTransformOperation.Remove(parsedElements); context.addToFieldTransforms(context.getPath(), arrayRemove); - } else if (value - instanceof com.google.firebase.firestore.FieldValue.NumericIncrementFieldValue) { - com.google.firebase.firestore.FieldValue.NumericIncrementFieldValue - numericIncrementFieldValue = - (com.google.firebase.firestore.FieldValue.NumericIncrementFieldValue) value; + } else if (value instanceof NumericIncrementFieldValue) { + NumericIncrementFieldValue numericIncrementFieldValue = (NumericIncrementFieldValue) value; Value operand = parseQueryValue(numericIncrementFieldValue.getOperand()); NumericIncrementTransformOperation incrementOperation = new NumericIncrementTransformOperation(operand); context.addToFieldTransforms(context.getPath(), incrementOperation); + } else if (value instanceof NumericMinimumFieldValue) { + NumericMinimumFieldValue numericMinimumFieldValue = (NumericMinimumFieldValue) value; + Value operand = parseQueryValue(numericMinimumFieldValue.getOperand()); + NumericMinimumTransformOperation minimumOperation = + new NumericMinimumTransformOperation(operand); + context.addToFieldTransforms(context.getPath(), minimumOperation); + + } else if (value instanceof NumericMaximumFieldValue) { + NumericMaximumFieldValue numericMaximumFieldValue = (NumericMaximumFieldValue) value; + Value operand = parseQueryValue(numericMaximumFieldValue.getOperand()); + NumericMaximumTransformOperation maximumOperation = + new NumericMaximumTransformOperation(operand); + context.addToFieldTransforms(context.getPath(), maximumOperation); + } else { throw Assert.fail("Unknown FieldValue type: %s", Util.typeName(value)); } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericIncrementTransformOperation.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericIncrementTransformOperation.java index 0dae39ae03d..51a9cd755a8 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericIncrementTransformOperation.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericIncrementTransformOperation.java @@ -16,7 +16,6 @@ import static com.google.firebase.firestore.model.Values.isDouble; import static com.google.firebase.firestore.model.Values.isInteger; -import static com.google.firebase.firestore.util.Assert.fail; import static com.google.firebase.firestore.util.Assert.hardAssert; import androidx.annotation.Nullable; @@ -29,14 +28,16 @@ * Converts all field values to longs or doubles and resolves overflows to * Long.MAX_VALUE/Long.MIN_VALUE. */ -public class NumericIncrementTransformOperation implements TransformOperation { - private Value operand; - +public class NumericIncrementTransformOperation extends NumericTransformOperation { public NumericIncrementTransformOperation(Value operand) { - hardAssert( - Values.isNumber(operand), - "NumericIncrementTransformOperation expects a NumberValue operand"); - this.operand = operand; + super(operand); + } + + @Override + public Value computeBaseValue(@Nullable Value previousValue) { + return Values.isNumber(previousValue) + ? previousValue + : Value.newBuilder().setIntegerValue(0).build(); } @Override @@ -60,26 +61,6 @@ public Value applyToLocalView(@Nullable Value previousValue, Timestamp localWrit } } - @Override - public Value applyToRemoteDocument(@Nullable Value previousValue, Value transformResult) { - return transformResult; - } - - public Value getOperand() { - return operand; - } - - /** - * Inspects the provided value, returning the provided value if it is already a NumberValue, - * otherwise returning a coerced IntegerValue of 0. - */ - @Override - public Value computeBaseValue(@Nullable Value previousValue) { - return Values.isNumber(previousValue) - ? previousValue - : Value.newBuilder().setIntegerValue(0).build(); - } - /** * Implementation of Java 8's `addExact()` that resolves positive and negative numeric overflows * to Long.MAX_VALUE or Long.MIN_VALUE respectively (instead of throwing an ArithmeticException). @@ -98,28 +79,4 @@ private long safeIncrement(long x, long y) { return Long.MAX_VALUE; } } - - private double operandAsDouble() { - if (isDouble(operand)) { - return operand.getDoubleValue(); - } else if (isInteger(operand)) { - return operand.getIntegerValue(); - } else { - throw fail( - "Expected 'operand' to be of Number type, but was " - + operand.getClass().getCanonicalName()); - } - } - - private long operandAsLong() { - if (isDouble(operand)) { - return (long) operand.getDoubleValue(); - } else if (isInteger(operand)) { - return operand.getIntegerValue(); - } else { - throw fail( - "Expected 'operand' to be of Number type, but was " - + operand.getClass().getCanonicalName()); - } - } } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericMaximumTransformOperation.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericMaximumTransformOperation.java new file mode 100644 index 00000000000..a6a7a41e9bb --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericMaximumTransformOperation.java @@ -0,0 +1,64 @@ +// Copyright 2026 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.mutation; + +import static com.google.firebase.firestore.model.Values.isInteger; + +import androidx.annotation.Nullable; +import com.google.firebase.Timestamp; +import com.google.firebase.firestore.model.Values; +import com.google.firestore.v1.Value; + +/** + * Implements the backend semantics for locally computed NUMERIC_MAX (maximum) transforms. + */ +public class NumericMaximumTransformOperation extends NumericTransformOperation { + public NumericMaximumTransformOperation(Value operand) { + super(operand); + } + + @Override + public Value applyToLocalView(@Nullable Value previousValue, Timestamp localWriteTime) { + if (!Values.isNumber(previousValue)) { + return operand; + } + + // Return an integer value only if the previous value and the operand is an integer. + if (isInteger(previousValue) && isInteger(operand)) { + long max = Math.max(previousValue.getIntegerValue(), operandAsLong()); + return Value.newBuilder().setIntegerValue(max).build(); + } else { + double prevDouble = + isInteger(previousValue) + ? previousValue.getIntegerValue() + : previousValue.getDoubleValue(); + double operDouble = operandAsDouble(); + + if (Double.isNaN(prevDouble)) { + return previousValue; + } + if (Double.isNaN(operDouble)) { + return operand; + } + + if (prevDouble == operDouble) { + return previousValue; + } + + boolean choosePrevious = prevDouble > operDouble; + return choosePrevious ? previousValue : operand; + } + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericMinimumTransformOperation.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericMinimumTransformOperation.java new file mode 100644 index 00000000000..939b2eb21de --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericMinimumTransformOperation.java @@ -0,0 +1,64 @@ +// Copyright 2026 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.mutation; + +import static com.google.firebase.firestore.model.Values.isInteger; + +import androidx.annotation.Nullable; +import com.google.firebase.Timestamp; +import com.google.firebase.firestore.model.Values; +import com.google.firestore.v1.Value; + +/** + * Implements the backend semantics for locally computed NUMERIC_MIN (minimum) transforms. + */ +public class NumericMinimumTransformOperation extends NumericTransformOperation { + public NumericMinimumTransformOperation(Value operand) { + super(operand); + } + + @Override + public Value applyToLocalView(@Nullable Value previousValue, Timestamp localWriteTime) { + if (!Values.isNumber(previousValue)) { + return operand; + } + + // Return an integer value only if the previous value and the operand is an integer. + if (isInteger(previousValue) && isInteger(operand)) { + long min = Math.min(previousValue.getIntegerValue(), operandAsLong()); + return Value.newBuilder().setIntegerValue(min).build(); + } else { + double prevDouble = + isInteger(previousValue) + ? previousValue.getIntegerValue() + : previousValue.getDoubleValue(); + double operDouble = operandAsDouble(); + + if (Double.isNaN(prevDouble)) { + return previousValue; + } + if (Double.isNaN(operDouble)) { + return operand; + } + + if (prevDouble == operDouble) { + return previousValue; + } + + boolean choosePrevious = prevDouble < operDouble; + return choosePrevious ? previousValue : operand; + } + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericTransformOperation.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericTransformOperation.java new file mode 100644 index 00000000000..2c32e24e8c8 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/model/mutation/NumericTransformOperation.java @@ -0,0 +1,98 @@ +// Copyright 2026 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.mutation; + +import static com.google.firebase.firestore.model.Values.isDouble; +import static com.google.firebase.firestore.model.Values.isInteger; +import static com.google.firebase.firestore.util.Assert.fail; +import static com.google.firebase.firestore.util.Assert.hardAssert; + +import androidx.annotation.Nullable; +import com.google.firebase.firestore.model.Values; +import com.google.firestore.v1.Value; + +/** + * Implements the backend semantics for locally computed numeric transforms. + * Base class for increment, minimum, and maximum transforms. + */ +public abstract class NumericTransformOperation implements TransformOperation { + protected final Value operand; + + public NumericTransformOperation(Value operand) { + hardAssert(Values.isNumber(operand), "NumericTransformOperation expects a NumberValue operand"); + this.operand = operand; + } + + @Override + public Value applyToRemoteDocument(@Nullable Value previousValue, Value transformResult) { + return transformResult; + } + + public Value getOperand() { + return operand; + } + + /** + * Returns null since minimum and maximum operations do not require a base value. + */ + @Override + public Value computeBaseValue(@Nullable Value previousValue) { + return null; + } + + protected double operandAsDouble() { + if (isDouble(operand)) { + return operand.getDoubleValue(); + } else if (isInteger(operand)) { + return operand.getIntegerValue(); + } else { + throw fail( + "Expected 'operand' to be of Number type, but was " + + operand.getClass().getCanonicalName()); + } + } + + protected long operandAsLong() { + if (isDouble(operand)) { + return (long) operand.getDoubleValue(); + } else if (isInteger(operand)) { + return operand.getIntegerValue(); + } else { + throw fail( + "Expected 'operand' to be of Number type, but was " + + operand.getClass().getCanonicalName()); + } + } + + @Override + @SuppressWarnings("EqualsGetClass") + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + NumericTransformOperation that = (NumericTransformOperation) o; + return operand.equals(that.operand); + } + + @Override + public int hashCode() { + int result = getClass().hashCode(); + result = 31 * result + operand.hashCode(); + return result; + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java index 1c82f0f9581..76c9001b41f 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteSerializer.java @@ -48,6 +48,8 @@ import com.google.firebase.firestore.model.mutation.Mutation; import com.google.firebase.firestore.model.mutation.MutationResult; import com.google.firebase.firestore.model.mutation.NumericIncrementTransformOperation; +import com.google.firebase.firestore.model.mutation.NumericMaximumTransformOperation; +import com.google.firebase.firestore.model.mutation.NumericMinimumTransformOperation; import com.google.firebase.firestore.model.mutation.PatchMutation; import com.google.firebase.firestore.model.mutation.Precondition; import com.google.firebase.firestore.model.mutation.ServerTimestampOperation; @@ -419,6 +421,20 @@ private DocumentTransform.FieldTransform encodeFieldTransform(FieldTransform fie .setFieldPath(fieldTransform.getFieldPath().canonicalString()) .setIncrement(incrementOperation.getOperand()) .build(); + } else if (transform instanceof NumericMinimumTransformOperation) { + NumericMinimumTransformOperation minimumOperation = + (NumericMinimumTransformOperation) transform; + return DocumentTransform.FieldTransform.newBuilder() + .setFieldPath(fieldTransform.getFieldPath().canonicalString()) + .setMinimum(minimumOperation.getOperand()) + .build(); + } else if (transform instanceof NumericMaximumTransformOperation) { + NumericMaximumTransformOperation maximumOperation = + (NumericMaximumTransformOperation) transform; + return DocumentTransform.FieldTransform.newBuilder() + .setFieldPath(fieldTransform.getFieldPath().canonicalString()) + .setMaximum(maximumOperation.getOperand()) + .build(); } else { throw fail("Unknown transform: %s", transform); } @@ -449,6 +465,14 @@ private FieldTransform decodeFieldTransform(DocumentTransform.FieldTransform fie return new FieldTransform( FieldPath.fromServerFormat(fieldTransform.getFieldPath()), new NumericIncrementTransformOperation(fieldTransform.getIncrement())); + case MINIMUM: + return new FieldTransform( + FieldPath.fromServerFormat(fieldTransform.getFieldPath()), + new NumericMinimumTransformOperation(fieldTransform.getMinimum())); + case MAXIMUM: + return new FieldTransform( + FieldPath.fromServerFormat(fieldTransform.getFieldPath()), + new NumericMaximumTransformOperation(fieldTransform.getMaximum())); default: throw fail("Unknown FieldTransform proto: %s", fieldTransform); } diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/model/mutation/MutationTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/model/mutation/MutationTest.java index cc3671ff242..45a23e5f7e3 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/model/mutation/MutationTest.java +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/model/mutation/MutationTest.java @@ -233,6 +233,134 @@ public void testAppliesIncrementTransformToMissingField() { verifyTransform(baseDoc, transform, expected); } + @Test + public void testAppliesMinimumTransformToDocument() { + Map baseDoc = + map( + "longMinLong", + 5, + "longMinDouble", + 5, + "doubleMinLong", + 5.5, + "doubleMinDouble", + 5.5, + "longMinNan", + 5, + "doubleMinNan", + 5.5); + Map transform = + map( + "longMinLong", + FieldValue.minimum(2), + "longMinDouble", + FieldValue.minimum(2.2), + "doubleMinLong", + FieldValue.minimum(2), + "doubleMinDouble", + FieldValue.minimum(2.2), + "longMinNan", + FieldValue.minimum(Double.NaN), + "doubleMinNan", + FieldValue.minimum(Double.NaN)); + Map expected = + map( + "longMinLong", + 2L, + "longMinDouble", + 2.2D, + "doubleMinLong", + 2.0D, + "doubleMinDouble", + 2.2D, + "longMinNan", + Double.NaN, + "doubleMinNan", + Double.NaN); + + verifyTransform(baseDoc, transform, expected); + } + + @Test + public void testAppliesMaximumTransformToDocument() { + Map baseDoc = + map( + "longMaxLong", + 5, + "longMaxDouble", + 5, + "doubleMaxLong", + 5.5, + "doubleMaxDouble", + 5.5, + "longMaxNan", + 5, + "doubleMaxNan", + 5.5); + Map transform = + map( + "longMaxLong", + FieldValue.maximum(8), + "longMaxDouble", + FieldValue.maximum(8.8), + "doubleMaxLong", + FieldValue.maximum(8), + "doubleMaxDouble", + FieldValue.maximum(8.8), + "longMaxNan", + FieldValue.maximum(Double.NaN), + "doubleMaxNan", + FieldValue.maximum(Double.NaN)); + Map expected = + map( + "longMaxLong", + 8L, + "longMaxDouble", + 8.8D, + "doubleMaxLong", + 8.0D, + "doubleMaxDouble", + 8.8D, + "longMaxNan", + Double.NaN, + "doubleMaxNan", + Double.NaN); + + verifyTransform(baseDoc, transform, expected); + } + + @Test + public void testAppliesMinimumTransformToUnexpectedType() { + Map baseDoc = map("string", "value"); + Map transform = map("string", FieldValue.minimum(1)); + Map expected = map("string", 1); + verifyTransform(baseDoc, transform, expected); + } + + @Test + public void testAppliesMaximumTransformToUnexpectedType() { + Map baseDoc = map("string", "value"); + Map transform = map("string", FieldValue.maximum(1)); + Map expected = map("string", 1); + verifyTransform(baseDoc, transform, expected); + } + + @Test + public void testAppliesMinimumTransformToMissingField() { + Map baseDoc = map(); + Map transform = map("missing", FieldValue.minimum(1)); + Map expected = map("missing", 1); + verifyTransform(baseDoc, transform, expected); + } + + @Test + public void testAppliesMaximumTransformToMissingField() { + Map baseDoc = map(); + Map transform = map("missing", FieldValue.maximum(1)); + Map expected = map("missing", 1); + verifyTransform(baseDoc, transform, expected); + } + @Test public void testAppliesIncrementTransformsConsecutively() { Map baseDoc = map("number", 1); @@ -679,6 +807,56 @@ public void testNumericIncrementBaseValue() { assertEquals(expected, baseValue.get(FieldPath.EMPTY_PATH)); } + @Test + public void testNumericMinimumBaseValue() { + Map allValues = + map("ignore", "foo", "double", 42.0, "long", 42, "string", "foo", "map", map()); + allValues.put("nested", new HashMap<>(allValues)); + MutableDocument baseDoc = doc("collection/key", 1, allValues); + + Map allTransforms = + map( + "double", + FieldValue.minimum(1), + "long", + FieldValue.minimum(1), + "string", + FieldValue.minimum(1), + "map", + FieldValue.minimum(1), + "missing", + FieldValue.minimum(1)); + allTransforms.put("nested", new HashMap<>(allTransforms)); + + Mutation mutation = patchMutation("collection/key", allTransforms); + assertNull(mutation.extractTransformBaseValue(baseDoc)); + } + + @Test + public void testNumericMaximumBaseValue() { + Map allValues = + map("ignore", "foo", "double", 42.0, "long", 42, "string", "foo", "map", map()); + allValues.put("nested", new HashMap<>(allValues)); + MutableDocument baseDoc = doc("collection/key", 1, allValues); + + Map allTransforms = + map( + "double", + FieldValue.maximum(1), + "long", + FieldValue.maximum(1), + "string", + FieldValue.maximum(1), + "map", + FieldValue.maximum(1), + "missing", + FieldValue.maximum(1)); + allTransforms.put("nested", new HashMap<>(allTransforms)); + + Mutation mutation = patchMutation("collection/key", allTransforms); + assertNull(mutation.extractTransformBaseValue(baseDoc)); + } + @Test public void testIncrementTwice() { MutableDocument patchDoc = doc("collection/key", 1, map("sum", "0"));