diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/BsonBinaryData.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/BsonBinaryData.java new file mode 100644 index 000000000..effeb832f --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/BsonBinaryData.java @@ -0,0 +1,128 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import com.google.firestore.v1.MapValue; +import com.google.protobuf.ByteString; +import java.io.Serializable; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Represents a BSON Binary Data type in Firestore documents. */ +public class BsonBinaryData implements Serializable { + private static final long serialVersionUID = 1830984831902814656L; + private final int subtype; + @Nonnull private final ByteString data; + + private BsonBinaryData(int subtype, @Nonnull ByteString data) { + // By definition the subtype should be 1 byte and should therefore + // have a value between 0 and 255 + if (subtype < 0 || subtype > 255) { + throw new IllegalArgumentException( + "The subtype for BsonBinaryData must be a value in the inclusive [0, 255] range."); + } + this.subtype = subtype; + this.data = data; + } + + /** + * Creates a new BsonBinaryData instance from the provided ByteString and subtype. + * + * @param subtype The subtype to use for this instance. + * @param byteString The byteString to use for this instance. + * @return The new BsonBinaryData instance + */ + @Nonnull + public static BsonBinaryData fromByteString(int subtype, @Nonnull ByteString byteString) { + return new BsonBinaryData(subtype, byteString); + } + + /** + * Creates a new BsonBinaryData instance from the provided bytes and subtype. Makes a copy of the + * bytes passed in. + * + * @param subtype The subtype to use for this instance. + * @param bytes The bytes to use for this instance. + * @return The new BsonBinaryData instance + */ + @Nonnull + public static BsonBinaryData fromBytes(int subtype, @Nonnull byte[] bytes) { + return new BsonBinaryData(subtype, ByteString.copyFrom(bytes)); + } + + /** + * Returns the underlying data as a ByteString. + * + * @return The data as a ByteString. + */ + @Nonnull + public ByteString dataAsByteString() { + return data; + } + + /** + * Returns a copy of the underlying data as a byte[] array. + * + * @return The data as a byte[] array. + */ + @Nonnull + public byte[] dataAsBytes() { + return data.toByteArray(); + } + + /** + * Returns the subtype of this binary data. + * + * @return The subtype of the binary data. + */ + public int subtype() { + return this.subtype; + } + + /** + * Returns true if this BsonBinaryData is equal to the provided object. + * + * @param obj The object to compare against. + * @return Whether this BsonBinaryData is equal to the provided object. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + BsonBinaryData other = (BsonBinaryData) obj; + return this.subtype == other.subtype && Objects.equals(this.data, other.data); + } + + @Override + public int hashCode() { + return Objects.hash(this.subtype, this.data); + } + + @Nonnull + @Override + public String toString() { + return "BsonBinaryData{subtype=" + this.subtype + ", data=" + this.data.toString() + "}"; + } + + MapValue toProto() { + return UserDataConverter.encodeBsonBinaryData(subtype, data); + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/BsonObjectId.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/BsonObjectId.java new file mode 100644 index 000000000..ac0343cd8 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/BsonObjectId.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import com.google.firestore.v1.MapValue; +import java.io.Serializable; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Represents a BSON ObjectId type in Firestore documents. */ +public class BsonObjectId implements Serializable { + private static final long serialVersionUID = 430753173775328933L; + @Nonnull public final String value; + + /** + * Constructor that creates a new BSON ObjectId value with the given value. + * + * @param oid The 24-character hex string representing the ObjectId. + */ + public BsonObjectId(@Nonnull String oid) { + this.value = oid; + } + + MapValue toProto() { + return UserDataConverter.encodeBsonObjectId(value); + } + + /** + * Returns true if this BsonObjectId is equal to the provided object. + * + * @param obj The object to compare against. + * @return Whether this BsonObjectId is equal to the provided object. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + BsonObjectId other = (BsonObjectId) obj; + return Objects.equals(this.value, other.value); + } + + @Override + public int hashCode() { + return Objects.hash(this.value); + } + + @Nonnull + @Override + public String toString() { + return "BsonObjectId{value=" + this.value + "}"; + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/BsonTimestamp.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/BsonTimestamp.java new file mode 100644 index 000000000..e1b3255e8 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/BsonTimestamp.java @@ -0,0 +1,81 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import com.google.firestore.v1.MapValue; +import java.io.Serializable; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Represents a BSON Timestamp type in Firestore documents. */ +public class BsonTimestamp implements Serializable { + private static final long serialVersionUID = -1693962317170687337L; + public final long seconds; + public final long increment; + + /** + * Constructor that creates a new BSON Timestamp value with the given values. + * + * @param seconds An unsigned 32-bit integer value stored as long representing the seconds. + * @param increment An unsigned 32-bit integer value stored as long representing the increment. + */ + public BsonTimestamp(long seconds, long increment) { + if (seconds < 0 || seconds > 4294967295L) { + throw new IllegalArgumentException( + "BsonTimestamp 'seconds' must be in the range of a 32-bit unsigned integer."); + } + if (increment < 0 || increment > 4294967295L) { + throw new IllegalArgumentException( + "BsonTimestamp 'increment' must be in the range of a 32-bit unsigned integer."); + } + this.seconds = seconds; + this.increment = increment; + } + + MapValue toProto() { + return UserDataConverter.encodeBsonTimestamp(seconds, increment); + } + + /** + * Returns true if this BsonTimestamp is equal to the provided object. + * + * @param obj The object to compare against. + * @return Whether this BsonTimestamp is equal to the provided object. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + BsonTimestamp other = (BsonTimestamp) obj; + return this.seconds == other.seconds && this.increment == other.increment; + } + + @Override + public int hashCode() { + return Objects.hash(this.seconds, this.increment); + } + + @Nonnull + @Override + public String toString() { + return "BsonTimestamp{seconds=" + this.seconds + ", increment=" + this.increment + "}"; + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Decimal128Value.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Decimal128Value.java new file mode 100644 index 000000000..398fb5e75 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Decimal128Value.java @@ -0,0 +1,75 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import com.google.firestore.v1.MapValue; +import java.io.Serializable; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Represents a 128-bit decimal type in Firestore documents. */ +public class Decimal128Value implements Serializable { + private static final long serialVersionUID = 8091951856970036899L; + + public final String stringValue; + final Quadruple value; + + public Decimal128Value(String val) { + this.stringValue = val; + this.value = Quadruple.fromString(val); + } + + MapValue toProto() { + return UserDataConverter.encodeDecimal128Value(stringValue); + } + + /** + * Returns true if this Decimal128Value is equal to the provided object. + * + * @param obj The object to compare against. + * @return Whether this Decimal128Value is equal to the provided object. + */ + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + Quadruple lhs = this.value; + Quadruple rhs = ((Decimal128Value) obj).value; + + // Firestore considers +0 and -0 to be equal, but `Quadruple.compareTo()` does not. + if (lhs.isZero() && rhs.isZero()) return true; + + return this == obj || lhs.compareTo(rhs) == 0; + } + + @Override + public int hashCode() { + // Since +0 and -0 are considered equal, they should have the same hash code. + Quadruple quadruple = + (this.value.compareTo(Quadruple.NEGATIVE_ZERO) == 0) ? Quadruple.POSITIVE_ZERO : this.value; + + return Objects.hash(quadruple); + } + + @Nonnull + @Override + public String toString() { + return "Decimal128Value{value=" + this.stringValue + "}"; + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentSnapshot.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentSnapshot.java index e1aab1cac..cef358190 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentSnapshot.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentSnapshot.java @@ -398,6 +398,102 @@ public VectorValue getVectorValue(@Nonnull String field) { return (VectorValue) get(field); } + /** + * Returns the value of the field as a MinKey. + * + * @param field The path to the field. + * @throws RuntimeException if the value is not a MinKey. + * @return The value of the field. + */ + @Nullable + public MinKey getMinKey(@Nonnull String field) { + return (MinKey) get(field); + } + + /** + * Returns the value of the field as a MaxKey. + * + * @param field The path to the field. + * @throws RuntimeException if the value is not a MaxKey. + * @return The value of the field. + */ + @Nullable + public MaxKey getMaxKey(@Nonnull String field) { + return (MaxKey) get(field); + } + + /** + * Returns the value of the field as a RegexValue. + * + * @param field The path to the field. + * @throws RuntimeException if the value is not a RegexValue. + * @return The value of the field. + */ + @Nullable + public RegexValue getRegexValue(@Nonnull String field) { + return (RegexValue) get(field); + } + + /** + * Returns the value of the field as a 32-bit integer. + * + * @param field The path to the field. + * @throws RuntimeException if the value is not a Int32Value. + * @return The value of the field. + */ + @Nullable + public Int32Value getInt32Value(@Nonnull String field) { + return (Int32Value) get(field); + } + + /** + * Returns the value of the field as a 128-bit decimal. + * + * @param field The path to the field. + * @throws RuntimeException if the value is not a Decimal128Value. + * @return The value of the field. + */ + @Nullable + public Decimal128Value getDecimal128Value(@Nonnull String field) { + return (Decimal128Value) get(field); + } + + /** + * Returns the value of the field as a BsonObjectId. + * + * @param field The path to the field. + * @throws RuntimeException if the value is not a BsonObjectId. + * @return The value of the field. + */ + @Nullable + public BsonObjectId getBsonObjectId(@Nonnull String field) { + return (BsonObjectId) get(field); + } + + /** + * Returns the value of the field as a BsonTimestamp. + * + * @param field The path to the field. + * @throws RuntimeException if the value is not a BsonTimestamp. + * @return The value of the field. + */ + @Nullable + public BsonTimestamp getBsonTimestamp(@Nonnull String field) { + return (BsonTimestamp) get(field); + } + + /** + * Returns the value of the field as a BsonBinaryData. + * + * @param field The path to the field. + * @throws RuntimeException if the value is not a BsonBinaryData. + * @return The value of the field. + */ + @Nullable + public BsonBinaryData getBsonBinaryData(@Nonnull String field) { + return (BsonBinaryData) get(field); + } + /** * Gets the reference to the document. * diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Int32Value.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Int32Value.java new file mode 100644 index 000000000..69e618926 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Int32Value.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import com.google.firestore.v1.MapValue; +import java.io.Serializable; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Represents a 32-bit integer type in Firestore documents. */ +public class Int32Value implements Serializable { + private static final long serialVersionUID = -2744130750746368548L; + public final int value; + + public Int32Value(int value) { + this.value = value; + } + + MapValue toProto() { + return UserDataConverter.encodeInt32Value(value); + } + + /** + * Returns true if this Int32Value is equal to the provided object. + * + * @param obj The object to compare against. + * @return Whether this BsonObjectId is equal to the provided object. + */ + @Override + public boolean equals(Object obj) { + if (obj == null || getClass() != obj.getClass()) { + return false; + } + return this == obj || this.value == ((Int32Value) obj).value; + } + + @Override + public int hashCode() { + return Objects.hash(this.value); + } + + @Nonnull + @Override + public String toString() { + return "Int32Value{value=" + this.value + "}"; + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MapType.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MapType.java index d04b77f96..ccd28aabd 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MapType.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MapType.java @@ -20,4 +20,32 @@ abstract class MapType { static final String RESERVED_MAP_KEY = "__type__"; static final String RESERVED_MAP_KEY_VECTOR_VALUE = "__vector__"; static final String VECTOR_MAP_VECTORS_KEY = "value"; + + // For MinKey type + static final String RESERVED_MIN_KEY = "__min__"; + + // For MaxKey type + static final String RESERVED_MAX_KEY = "__max__"; + + // For Regex type + static final String RESERVED_REGEX_KEY = "__regex__"; + static final String RESERVED_REGEX_PATTERN_KEY = "pattern"; + static final String RESERVED_REGEX_OPTIONS_KEY = "options"; + + // For ObjectId type + static final String RESERVED_OBJECT_ID_KEY = "__oid__"; + + // For Int32 type + static final String RESERVED_INT32_KEY = "__int__"; + + // For Decimal128 type. + static final String RESERVED_DECIMAL128_KEY = "__decimal128__"; + + // For RequestTimestamp + static final String RESERVED_BSON_TIMESTAMP_KEY = "__request_timestamp__"; + static final String RESERVED_BSON_TIMESTAMP_SECONDS_KEY = "seconds"; + static final String RESERVED_BSON_TIMESTAMP_INCREMENT_KEY = "increment"; + + // For BSON Binary Data + static final String RESERVED_BSON_BINARY_KEY = "__binary__"; } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MaxKey.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MaxKey.java new file mode 100644 index 000000000..c5da59e7c --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MaxKey.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import com.google.firestore.v1.MapValue; +import java.io.Serializable; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Represents the Firestore "Max Key" data type. */ +public class MaxKey implements Serializable { + private static final long serialVersionUID = -3351949427549917324L; + private static final MaxKey INSTANCE = new MaxKey(); + + private MaxKey() {} + + @Nonnull + public static MaxKey instance() { + return INSTANCE; + } + + MapValue toProto() { + return UserDataConverter.encodeMaxKey(); + } + + /** + * Returns true if this MaxKey is equal to the provided object. + * + * @param obj The object to compare against. + * @return Whether this MaxKey is equal to the provided object. + */ + @Override + public boolean equals(Object obj) { + return this == obj; + } + + @Override + public int hashCode() { + return Objects.hash(serialVersionUID); + } + + @Nonnull + @Override + public String toString() { + return "MaxKey{}"; + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MinKey.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MinKey.java new file mode 100644 index 000000000..d470eed78 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/MinKey.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import com.google.firestore.v1.MapValue; +import java.io.Serializable; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Represents the Firestore "Min Key" data type. */ +public class MinKey implements Serializable { + private static final long serialVersionUID = -44516739217726882L; + private static final MinKey INSTANCE = new MinKey(); + + private MinKey() {} + + @Nonnull + public static MinKey instance() { + return INSTANCE; + } + + MapValue toProto() { + return UserDataConverter.encodeMinKey(); + } + + /** + * Returns true if this MinKey is equal to the provided object. + * + * @param obj The object to compare against. + * @return Whether this MinKey is equal to the provided object. + */ + @Override + public boolean equals(Object obj) { + return this == obj; + } + + @Override + public int hashCode() { + return Objects.hash(serialVersionUID); + } + + @Nonnull + @Override + public String toString() { + return "MinKey{}"; + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Order.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Order.java index 7f37d8e05..a2be6efd6 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Order.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Order.java @@ -34,16 +34,22 @@ class Order implements Comparator { enum TypeOrder implements Comparable { // NOTE: This order is defined by the backend and cannot be changed. NULL, + MIN_KEY, BOOLEAN, NUMBER, TIMESTAMP, + BSON_TIMESTAMP, STRING, BLOB, + BSON_BINARY, REF, + BSON_OBJECT_ID, GEO_POINT, + REGEX, ARRAY, VECTOR, - OBJECT; + OBJECT, + MAX_KEY; static TypeOrder fromValue(Value value) { switch (value.getValueTypeCase()) { @@ -52,7 +58,6 @@ static TypeOrder fromValue(Value value) { case BOOLEAN_VALUE: return BOOLEAN; case INTEGER_VALUE: - return NUMBER; case DOUBLE_VALUE: return NUMBER; case TIMESTAMP_VALUE: @@ -79,6 +84,21 @@ static TypeOrder fromMapValue(MapValue mapValue) { switch (UserDataConverter.detectMapRepresentation(mapValue)) { case VECTOR_VALUE: return TypeOrder.VECTOR; + case MIN_KEY: + return TypeOrder.MIN_KEY; + case MAX_KEY: + return TypeOrder.MAX_KEY; + case REGEX: + return TypeOrder.REGEX; + case DECIMAL128: + case INT32: + return TypeOrder.NUMBER; + case BSON_OBJECT_ID: + return TypeOrder.BSON_OBJECT_ID; + case BSON_TIMESTAMP: + return TypeOrder.BSON_TIMESTAMP; + case BSON_BINARY_DATA: + return TypeOrder.BSON_BINARY; case UNKNOWN: case NONE: default: @@ -106,8 +126,11 @@ public int compare(@Nonnull Value left, @Nonnull Value right) { // So they are the same type. switch (leftType) { + // Nulls are all equal, MaxKeys are all equal, and MinKeys are all equal. case NULL: - return 0; // Nulls are all equal. + case MIN_KEY: + case MAX_KEY: + return 0; case BOOLEAN: return Boolean.compare(left.getBooleanValue(), right.getBooleanValue()); case NUMBER: @@ -129,6 +152,14 @@ public int compare(@Nonnull Value left, @Nonnull Value right) { return compareObjects(left, right); case VECTOR: return compareVectors(left, right); + case REGEX: + return compareRegex(left, right); + case BSON_OBJECT_ID: + return compareBsonObjectId(left, right); + case BSON_TIMESTAMP: + return compareBsonTimestamp(left, right); + case BSON_BINARY: + return compareBsonBinary(left, right); default: throw new IllegalArgumentException("Cannot compare " + leftType); } @@ -290,6 +321,20 @@ private int compareVectors(Value left, Value right) { return compareArrays(leftArray, rightArray); } + /** + * Returns a long from a 32-bit or 64-bit proto integer value. Throws an exception if the value is + * not an integer. + */ + private long getIntegerValue(Value value) { + if (value.hasIntegerValue()) { + return value.getIntegerValue(); + } + if (UserDataConverter.isInt32Value(value)) { + return value.getMapValue().getFieldsMap().get(MapType.RESERVED_INT32_KEY).getIntegerValue(); + } + throw new IllegalArgumentException("getIntegerValue was called on a non-integer value."); + } + private int compareNumbers(Value left, Value right) { // NaN is smaller than any other numbers if (isNaN(left)) { @@ -298,23 +343,104 @@ private int compareNumbers(Value left, Value right) { return 1; } + // If either argument is Decimal128, we cast both to wider (128-bit) representation, and compare + // Quadruple values. + if (UserDataConverter.isDecimal128Value(left) || UserDataConverter.isDecimal128Value(right)) { + Quadruple leftQuadruple = convertNumberToQuadruple(left); + Quadruple rightQuadruple = convertNumberToQuadruple(right); + + // Firestore considers +0 and -0 to be equal, but `Quadruple.compareTo()` does not. + if (leftQuadruple.isZero() && rightQuadruple.isZero()) return 0; + + return leftQuadruple.compareTo(rightQuadruple); + } + if (left.getValueTypeCase() == ValueTypeCase.DOUBLE_VALUE) { if (right.getValueTypeCase() == ValueTypeCase.DOUBLE_VALUE) { + // left and right are both doubles. return compareDoubles(left.getDoubleValue(), right.getDoubleValue()); } else { - return compareDoubleAndLong(left.getDoubleValue(), right.getIntegerValue()); + // left is a double and right is a 32/64-bit integer. + return compareDoubleAndLong(left.getDoubleValue(), getIntegerValue(right)); } } else { - if (right.getValueTypeCase() == ValueTypeCase.INTEGER_VALUE) { - return Long.compare(left.getIntegerValue(), right.getIntegerValue()); + if (right.getValueTypeCase() == ValueTypeCase.DOUBLE_VALUE) { // left is a 32/64-bit integer + // left is a 32/64-bit integer and right is a double. + return -compareDoubleAndLong(right.getDoubleValue(), getIntegerValue(left)); } else { - return -compareDoubleAndLong(right.getDoubleValue(), left.getIntegerValue()); + // left and right are both 32/64-bit integers. + return Long.compare(getIntegerValue(left), getIntegerValue(right)); } } } + private int compareRegex(Value left, Value right) { + RegexValue lhs = UserDataConverter.decodeRegexValue(left.getMapValue()); + RegexValue rhs = UserDataConverter.decodeRegexValue(right.getMapValue()); + int comparePatterns = compareUtf8Strings(lhs.pattern, rhs.pattern); + return comparePatterns != 0 ? comparePatterns : lhs.options.compareTo(rhs.options); + } + + private int compareBsonObjectId(Value left, Value right) { + BsonObjectId lhs = UserDataConverter.decodeBsonObjectId(left.getMapValue()); + BsonObjectId rhs = UserDataConverter.decodeBsonObjectId(right.getMapValue()); + return compareUtf8Strings(lhs.value, rhs.value); + } + + private int compareBsonTimestamp(Value left, Value right) { + BsonTimestamp lhs = UserDataConverter.decodeBsonTimestamp(left.getMapValue()); + BsonTimestamp rhs = UserDataConverter.decodeBsonTimestamp(right.getMapValue()); + int secondsDiff = Long.compare(lhs.seconds, rhs.seconds); + return secondsDiff != 0 ? secondsDiff : Long.compare(lhs.increment, rhs.increment); + } + + private int compareBsonBinary(Value left, Value right) { + ByteString lhs = + left.getMapValue().getFieldsMap().get(MapType.RESERVED_BSON_BINARY_KEY).getBytesValue(); + ByteString rhs = + right.getMapValue().getFieldsMap().get(MapType.RESERVED_BSON_BINARY_KEY).getBytesValue(); + return compareByteStrings(lhs, rhs); + } + private boolean isNaN(Value value) { - return value.hasDoubleValue() && Double.isNaN(value.getDoubleValue()); + if (value.hasDoubleValue() && Double.isNaN(value.getDoubleValue())) { + return true; + } + + if (UserDataConverter.isDecimal128Value(value)) { + return value + .getMapValue() + .getFieldsMap() + .get(MapType.RESERVED_DECIMAL128_KEY) + .getStringValue() + .equals("NaN"); + } + + return false; + } + + /** + * Converts the given number value to a Quadruple. Throws an exception if the value is not a + * number. + */ + private Quadruple convertNumberToQuadruple(Value value) { + // Doubles + if (value.hasDoubleValue()) { + return Quadruple.fromDouble(value.getDoubleValue()); + } + + // 32-bit and 64-bit integers. + if (UserDataConverter.isIntegerValue(value)) { + return Quadruple.fromLong(getIntegerValue(value)); + } + + // Decimal128 numbers + if (UserDataConverter.isDecimal128Value(value)) { + return UserDataConverter.decodeDecimal128Value(value.getMapValue()).value; + } + + throw new IllegalArgumentException( + "convertNumberToQuadruple was called on a non-numeric value."); } private int compareDoubles(double left, double right) { diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Quadruple.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Quadruple.java new file mode 100644 index 000000000..513296e70 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Quadruple.java @@ -0,0 +1,297 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import static com.google.cloud.firestore.QuadrupleBuilder.EXPONENT_OF_INFINITY; + +import java.io.Serializable; + +/** + * A 128-bit binary floating point number which supports comparisons and creation from long, double + * and string. + */ +public final class Quadruple implements Comparable, Serializable { + public static final Quadruple POSITIVE_ZERO = new Quadruple(false, 0, 0, 0); + public static final Quadruple NEGATIVE_ZERO = new Quadruple(true, 0, 0, 0); + public static final Quadruple NaN = new Quadruple(false, (int) EXPONENT_OF_INFINITY, 1L << 63, 0); + public static final Quadruple NEGATIVE_INFINITY = + new Quadruple(true, (int) EXPONENT_OF_INFINITY, 0, 0); + public static final Quadruple POSITIVE_INFINITY = + new Quadruple(false, (int) EXPONENT_OF_INFINITY, 0, 0); + private static final Quadruple MIN_LONG = new Quadruple(true, bias(63), 0, 0); + private static final Quadruple POSITIVE_ONE = new Quadruple(false, bias(0), 0, 0); + private static final Quadruple NEGATIVE_ONE = new Quadruple(true, bias(0), 0, 0); + private final boolean negative; + private final int biasedExponent; + private final long mantHi; + private final long mantLo; + /** + * Build a new quadruple from its raw representation - sign, biased exponent, 128-bit mantissa. + * + * @param negative the sign of the number. + * @param biasedExponent the unsigned and biased (by 0x7FFF_FFFF) binary exponent. + * @param mantHi the unsigned high 64 bits of the mantissa (leading 1 omitted). + * @param mantLo the unsigned low 64 bits of the mantissa. + */ + public Quadruple(boolean negative, int biasedExponent, long mantHi, long mantLo) { + this.negative = negative; + this.biasedExponent = biasedExponent; + this.mantHi = mantHi; + this.mantLo = mantLo; + } + /** Return the sign of this {@link Quadruple}. */ + public boolean negative() { + return negative; + } + /** Return the unsigned-32-bit biased exponent of this {@link Quadruple}. */ + public int biasedExponent() { + return biasedExponent; + } + /** Return the high-order unsigned-64-bits of the mantissa of this {@link Quadruple}. */ + public long mantHi() { + return mantHi; + } + /** Return the low-order unsigned-64-bits of the mantissa of this {@link Quadruple}. */ + public long mantLo() { + return mantLo; + } + /** Return the (unbiased) exponent of this {@link Quadruple}. */ + public int exponent() { + return biasedExponent - QuadrupleBuilder.EXPONENT_BIAS; + } + /** Return true if this {@link Quadruple} is -0 or +0 */ + public boolean isZero() { + return biasedExponent == 0 && mantHi == 0 && mantLo == 0; + } + /** Return true if this {@link Quadruple} is -infinity or +infinity */ + public boolean isInfinite() { + return biasedExponent == (int) EXPONENT_OF_INFINITY && mantHi == 0 && mantLo == 0; + } + /** Return true if this {@link Quadruple} is a NaN. */ + public boolean isNaN() { + return biasedExponent == (int) EXPONENT_OF_INFINITY && !(mantHi == 0 && mantLo == 0); + } + // equals (and hashCode) follow Double.equals: all NaNs are equal and -0 != 0 + @Override + public boolean equals(Object other) { + if (!(other instanceof Quadruple)) { + return false; + } + Quadruple otherQuadruple = (Quadruple) other; + if (isNaN()) { + return otherQuadruple.isNaN(); + } else { + return negative == otherQuadruple.negative + && biasedExponent == otherQuadruple.biasedExponent + && mantHi == otherQuadruple.mantHi + && mantLo == otherQuadruple.mantLo; + } + } + + @Override + public int hashCode() { + if (isNaN()) { + return HASH_NAN; + } else { + int hashCode = Boolean.hashCode(negative); + hashCode = hashCode * 31 + Integer.hashCode(biasedExponent); + hashCode = hashCode * 31 + Long.hashCode(mantHi); + hashCode = hashCode * 31 + Long.hashCode(mantLo); + return hashCode; + } + } + + private static final int HASH_NAN = 31 * 31 * Integer.hashCode((int) EXPONENT_OF_INFINITY); + // Compare two quadruples, with -0 < 0, and all NaNs equal and larger than all numbers. + @Override + public int compareTo(Quadruple other) { + if (isNaN()) { + return other.isNaN() ? 0 : 1; + } + if (other.isNaN()) { + return -1; + } + int lessThan; + int greaterThan; + if (negative) { + if (!other.negative) { + return -1; + } + lessThan = 1; + greaterThan = -1; + } else { + if (other.negative) { + return 1; + } + lessThan = -1; + greaterThan = 1; + } + int expCompare = Integer.compareUnsigned(biasedExponent, other.biasedExponent); + if (expCompare < 0) { + return lessThan; + } + if (expCompare > 0) { + return greaterThan; + } + int mantHiCompare = Long.compareUnsigned(mantHi, other.mantHi); + if (mantHiCompare < 0) { + return lessThan; + } + if (mantHiCompare > 0) { + return greaterThan; + } + int mantLoCompare = Long.compareUnsigned(mantLo, other.mantLo); + if (mantLoCompare < 0) { + return lessThan; + } + if (mantLoCompare > 0) { + return greaterThan; + } + return 0; + } + + public static Quadruple fromLong(long value) { + if (value == Long.MIN_VALUE) { + return MIN_LONG; + } + if (value == 0) { + return POSITIVE_ZERO; + } + if (value == 1) { + return POSITIVE_ONE; + } + if (value == -1) { + return NEGATIVE_ONE; + } + boolean negative = value < 0; + if (negative) { + value = -value; + } + // Left-justify with the leading 1 dropped - value=0 or 1 is handled separately above, so + // leadingZeros+1 <= 63. + int leadingZeros = Long.numberOfLeadingZeros(value); + return new Quadruple(negative, bias(63 - leadingZeros), value << (leadingZeros + 1), 0); + } + + public static Quadruple fromDouble(double value) { + if (Double.isNaN(value)) { + return NaN; + } + if (Double.isInfinite(value)) { + return value < 0 ? NEGATIVE_INFINITY : POSITIVE_INFINITY; + } + if (Double.compare(value, 0.0) == 0) { + return POSITIVE_ZERO; + } + if (Double.compare(value, -0.0) == 0) { + return NEGATIVE_ZERO; + } + long bits = Double.doubleToLongBits(value); + long mantHi = bits << 12; + long exponent = bits >>> 52 & 0x7ff; + if (exponent == 0) { + // subnormal - mantHi cannot be zero as that means value==+/-0 + int leadingZeros = Long.numberOfLeadingZeros(mantHi); + mantHi = leadingZeros < 63 ? mantHi << (leadingZeros + 1) : 0; + exponent = -leadingZeros; + } + return new Quadruple(value < 0, bias((int) (exponent - 1023)), mantHi, 0); + } + /** + * Converts a decimal number to a {@link Quadruple}. The supported format (no whitespace allowed) + * is: + * + *
    + *
  • NaN for Quadruple.NaN + *
  • Infinity or +Infinity for Quadruple.POSITIVE_INFINITY + *
  • -Infinity for Quadruple.NEGATIVE_INFINITY + *
  • regular expression: [+-]?[0-9]*(.[0-9]*)?([eE][+-]?[0-9]+)? - the exponent cannot be more + * than 9 digits, and the whole string cannot be empty + *
+ */ + public static Quadruple fromString(String s) { + if (s.equals("NaN")) { + return NaN; + } + if (s.equals("-Infinity")) { + return NEGATIVE_INFINITY; + } + if (s.equals("Infinity") || s.equals("+Infinity")) { + return POSITIVE_INFINITY; + } + char[] chars = s.toCharArray(); + byte[] digits = new byte[chars.length]; + int len = chars.length; + int i = 0; + int j = 0; + int exponent = 0; + boolean negative = false; + if (i < len) { + if (chars[i] == '-') { + negative = true; + i++; + } else if (chars[i] == '+') { + i++; + } + } + while (i < len && Character.isDigit(chars[i])) { + digits[j++] = (byte) (chars[i++] - '0'); + } + if (i < len && chars[i] == '.') { + int decimal = ++i; + while (i < len && Character.isDigit(chars[i])) { + digits[j++] = (byte) (chars[i++] - '0'); + } + exponent = decimal - i; + } + if (i < len && (chars[i] == 'e' || chars[i] == 'E')) { + int exponentValue = 0; + i++; + int exponentSign = 1; + if (i < len) { + if (chars[i] == '-') { + exponentSign = -1; + i++; + } else if (chars[i] == '+') { + i++; + } + } + int firstExponent = i; + while (i < len && Character.isDigit(chars[i])) { + exponentValue = exponentValue * 10 + chars[i++] - '0'; + if (i - firstExponent > 9) { + throw new NumberFormatException("Exponent too large " + s); + } + } + if (i == firstExponent) { + throw new NumberFormatException("Invalid number " + s); + } + exponent += exponentValue * exponentSign; + } + if (j == 0 || i != len) { + throw new NumberFormatException("Invalid number " + s); + } + byte[] digitsCopy = new byte[j]; + System.arraycopy(digits, 0, digitsCopy, 0, j); + QuadrupleBuilder parsed = QuadrupleBuilder.parseDecimal(digitsCopy, exponent); + return new Quadruple(negative, parsed.exponent, parsed.mantHi, parsed.mantLo); + } + + private static final int bias(int exponent) { + return exponent + QuadrupleBuilder.EXPONENT_BIAS; + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QuadrupleBuilder.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QuadrupleBuilder.java new file mode 100644 index 000000000..2b8d7ae85 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QuadrupleBuilder.java @@ -0,0 +1,800 @@ +/* + * Copyright 2025 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. + */ + +/* + * Copyright 2021 M.Vokhmentsev + * + * 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.cloud.firestore; + +public class QuadrupleBuilder { + public static QuadrupleBuilder parseDecimal(byte[] digits, int exp10) { + QuadrupleBuilder q = new QuadrupleBuilder(); + q.parse(digits, exp10); + return q; + } + // The fields containing the value of the instance + public int exponent; + public long mantHi; + public long mantLo; + // 2^192 = 6.277e57, so the 58-th digit after point may affect the result + static final int MAX_MANTISSA_LENGTH = 59; + // Max value of the decimal exponent, corresponds to EXPONENT_OF_MAX_VALUE + static final int MAX_EXP10 = 646456993; + // Min value of the decimal exponent, corresponds to EXPONENT_OF_MIN_NORMAL + static final int MIN_EXP10 = -646457032; + // (2^63) / 10 =~ 9.223372e17 + static final double TWO_POW_63_DIV_10 = 922337203685477580.0; + // Just for convenience: 0x8000_0000_0000_0000L + static final long HIGH_BIT = 0x8000000000000000L; + // Just for convenience: 0x8000_0000L, 2^31 + static final double POW_2_31 = 2147483648.0; + // Just for convenience: 0x0000_0000_FFFF_FFFFL + static final long LOWER_32_BITS = 0x00000000FFFFFFFFL; + // Just for convenience: 0xFFFF_FFFF_0000_0000L; + static final long HIGHER_32_BITS = 0xFFFFFFFF00000000L; + // Approximate value of log2(10) + static final double LOG2_10 = Math.log(10) / Math.log(2); + // Approximate value of log2(e) + static final double LOG2_E = 1 / Math.log(2.0); + // The value of the exponent (biased) corresponding to {@code 1.0 == 2^0}; equals to 2_147_483_647 + // ({@code 0x7FFF_FFFF}). + static final int EXPONENT_BIAS = 0x7FFF_FFFF; + // The value of the exponent (biased), corresponding to {@code Infinity}, {@code _Infinty}, and + // {@code NaN} + static final long EXPONENT_OF_INFINITY = 0xFFFFFFFFL; + // An array of positive powers of two, each value consists of 4 longs: decimal exponent and 3 x 64 + // bits of mantissa, divided by ten Used to find an arbitrary power of 2 (by powerOfTwo(long exp)) + private static final long[][] POS_POWERS_OF_2 = { // 0: 2^0 = 1 = 0.1e1 + { + 1, 0x1999_9999_9999_9999L, 0x9999_9999_9999_9999L, 0x9999_9999_9999_999aL + }, // 1: 2^(2^0) = 2^1 = 2 = 0.2e1 + {1, 0x3333_3333_3333_3333L, 0x3333_3333_3333_3333L, 0x3333_3333_3333_3334L}, // *** + // 2: 2^(2^1) = 2^2 = 4 = 0.4e1 + {1, 0x6666_6666_6666_6666L, 0x6666_6666_6666_6666L, 0x6666_6666_6666_6667L}, // *** + // 3: 2^(2^2) = 2^4 = 16 = 0.16e2 + {2, 0x28f5_c28f_5c28_f5c2L, 0x8f5c_28f5_c28f_5c28L, 0xf5c2_8f5c_28f5_c290L}, // *** + // 4: 2^(2^3) = 2^8 = 256 = 0.256e3 + {3, 0x4189_374b_c6a7_ef9dL, 0xb22d_0e56_0418_9374L, 0xbc6a_7ef9_db22_d0e6L}, // *** + // 5: 2^(2^4) = 2^16 = 65536 = 0.65536e5 + { + 5, 0xa7c5_ac47_1b47_8423L, 0x0fcf_80dc_3372_1d53L, 0xcddd_6e04_c059_2104L + }, // 6: 2^(2^5) = 2^32 = 4294967296 = 0.4294967296e10 + { + 10, 0x6df3_7f67_5ef6_eadfL, 0x5ab9_a207_2d44_268dL, 0x97df_837e_6748_956eL + }, // 7: 2^(2^6) = 2^64 = 18446744073709551616 = 0.18446744073709551616e20 + { + 20, 0x2f39_4219_2484_46baL, 0xa23d_2ec7_29af_3d61L, 0x0607_aa01_67dd_94cbL + }, // 8: 2^(2^7) = 2^128 = 340282366920938463463374607431768211456 = + // 0.340282366920938463463374607431768211456e39 + { + 39, 0x571c_bec5_54b6_0dbbL, 0xd5f6_4baf_0506_840dL, 0x451d_b70d_5904_029bL + }, // 9: 2^(2^8) = 2^256 = + // 1.1579208923731619542357098500868790785326998466564056403945758401E+77 = + // 0.11579208923731619542357098500868790785326998466564056403945758401e78 + {78, 0x1da4_8ce4_68e7_c702L, 0x6520_247d_3556_476dL, 0x1469_caf6_db22_4cfaL}, // *** + // 10: 2^(2^9) = 2^512 = + // 1.3407807929942597099574024998205846127479365820592393377723561444E+154 = + // 0.13407807929942597099574024998205846127479365820592393377723561444e155 + { + 155, 0x2252_f0e5_b397_69dcL, 0x9ae2_eea3_0ca3_ade0L, 0xeeaa_3c08_dfe8_4e30L + }, // 11: 2^(2^10) = 2^1024 = + // 1.7976931348623159077293051907890247336179769789423065727343008116E+308 = + // 0.17976931348623159077293051907890247336179769789423065727343008116e309 + { + 309, 0x2e05_5c9a_3f6b_a793L, 0x1658_3a81_6eb6_0a59L, 0x22c4_b082_6cf1_ebf7L + }, // 12: 2^(2^11) = 2^2048 = + // 3.2317006071311007300714876688669951960444102669715484032130345428E+616 = + // 0.32317006071311007300714876688669951960444102669715484032130345428e617 + { + 617, 0x52bb_45e9_cf23_f17fL, 0x7688_c076_06e5_0364L, 0xb344_79aa_9d44_9a57L + }, // 13: 2^(2^12) = 2^4096 = + // 1.0443888814131525066917527107166243825799642490473837803842334833E+1233 = + // 0.10443888814131525066917527107166243825799642490473837803842334833e1234 + { + 1234, 0x1abc_81c8_ff5f_846cL, 0x8f5e_3c98_53e3_8c97L, 0x4506_0097_f3bf_9296L + }, // 14: 2^(2^13) = 2^8192 = + // 1.0907481356194159294629842447337828624482641619962326924318327862E+2466 = + // 0.10907481356194159294629842447337828624482641619962326924318327862e2467 + { + 2467, 0x1bec_53b5_10da_a7b4L, 0x4836_9ed7_7dbb_0eb1L, 0x3b05_587b_2187_b41eL + }, // 15: 2^(2^14) = 2^16384 = + // 1.1897314953572317650857593266280071307634446870965102374726748212E+4932 = + // 0.11897314953572317650857593266280071307634446870965102374726748212e4933 + { + 4933, 0x1e75_063a_5ba9_1326L, 0x8abf_b8e4_6001_6ae3L, 0x2800_8702_d29e_8a3cL + }, // 16: 2^(2^15) = 2^32768 = + // 1.4154610310449547890015530277449516013481307114723881672343857483E+9864 = + // 0.14154610310449547890015530277449516013481307114723881672343857483e9865 + { + 9865, 0x243c_5d8b_b5c5_fa55L, 0x40c6_d248_c588_1915L, 0x4c0f_d99f_d5be_fc22L + }, // 17: 2^(2^16) = 2^65536 = + // 2.0035299304068464649790723515602557504478254755697514192650169737E+19728 = + // 0.20035299304068464649790723515602557504478254755697514192650169737e19729 + { + 19729, 0x334a_5570_c3f4_ef3cL, 0xa13c_36c4_3f97_9c90L, 0xda7a_c473_555f_b7a8L + }, // 18: 2^(2^17) = 2^131072 = + // 4.0141321820360630391660606060388767343771510270414189955825538065E+39456 = + // 0.40141321820360630391660606060388767343771510270414189955825538065e39457 + { + 39457, 0x66c3_0444_5dd9_8f3bL, 0xa8c2_93a2_0e47_a41bL, 0x4c5b_03dc_1260_4964L + }, // 19: 2^(2^18) = 2^262144 = + // 1.6113257174857604736195721184520050106440238745496695174763712505E+78913 = + // 0.16113257174857604736195721184520050106440238745496695174763712505e78914 + { + 78914, 0x293f_fbf5_fb02_8cc4L, 0x89d3_e5ff_4423_8406L, 0x369a_339e_1bfe_8c9bL + }, // 20: 2^(2^19) = 2^524288 = + // 2.5963705678310007761265964957268828277447343763484560463573654868E+157826 = + // 0.25963705678310007761265964957268828277447343763484560463573654868e157827 + { + 157827, 0x4277_92fb_b68e_5d20L, 0x7b29_7cd9_fc15_4b62L, 0xf091_4211_4aa9_a20cL + }, // 21: 2^(2^20) = 2^1048576 = + // 6.7411401254990734022690651047042454376201859485326882846944915676E+315652 = + // 0.67411401254990734022690651047042454376201859485326882846944915676e315653 + { + 315653, 0xac92_bc65_ad5c_08fcL, 0x00be_eb11_5a56_6c19L, 0x4ba8_82d8_a462_2437L + }, // 22: 2^(2^21) = 2^2097152 = + // 4.5442970191613663099961595907970650433180103994591456270882095573E+631305 = + // 0.45442970191613663099961595907970650433180103994591456270882095573e631306 + { + 631306, 0x7455_8144_0f92_e80eL, 0x4da8_22cf_7f89_6f41L, 0x509d_5986_7816_4ecdL + }, // 23: 2^(2^22) = 2^4194304 = + // 2.0650635398358879243991194945816501695274360493029670347841664177E+1262611 = + // 0.20650635398358879243991194945816501695274360493029670347841664177e1262612 + { + 1262612, 0x34dd_99b4_c695_23a5L, 0x64bc_2e8f_0d8b_1044L, 0xb03b_1c96_da5d_d349L + }, // 24: 2^(2^23) = 2^8388608 = + // 4.2644874235595278724327289260856157547554200794957122157246170406E+2525222 = + // 0.42644874235595278724327289260856157547554200794957122157246170406e2525223 + { + 2525223, 0x6d2b_bea9_d6d2_5a08L, 0xa0a4_606a_88e9_6b70L, 0x1820_63bb_c2fe_8520L + }, // 25: 2^(2^24) = 2^16777216 = + // 1.8185852985697380078927713277749906189248596809789408311078112486E+5050445 = + // 0.18185852985697380078927713277749906189248596809789408311078112486e5050446 + { + 5050446, 0x2e8e_47d6_3bfd_d6e3L, 0x2b55_fa89_76ea_a3e9L, 0x1a6b_9d30_8641_2a73L + }, // 26: 2^(2^25) = 2^33554432 = + // 3.3072524881739831340558051919726975471129152081195558970611353362E+10100890 = + // 0.33072524881739831340558051919726975471129152081195558970611353362e10100891 + { + 10100891, 0x54aa_68ef_a1d7_19dfL, 0xd850_5806_612c_5c8fL, 0xad06_8837_fee8_b43aL + }, // 27: 2^(2^26) = 2^67108864 = + // 1.0937919020533002449982468634925923461910249420785622990340704603E+20201781 = + // 0.10937919020533002449982468634925923461910249420785622990340704603e20201782 + { + 20201782, 0x1c00_464c_cb7b_ae77L, 0x9e38_7778_4c77_982cL, 0xd94a_f3b6_1717_404fL + }, // 28: 2^(2^27) = 2^134217728 = + // 1.1963807249973763567102377630870670302911237824129274789063323723E+40403562 = + // 0.11963807249973763567102377630870670302911237824129274789063323723e40403563 + { + 40403563, 0x1ea0_99c8_be2b_6cd0L, 0x8bfb_6d53_9fa5_0466L, 0x6d3b_c37e_69a8_4218L + }, // 29: 2^(2^28) = 2^268435456 = + // 1.4313268391452478724777126233530788980596273340675193575004129517E+80807124 = + // 0.14313268391452478724777126233530788980596273340675193575004129517e80807125 + { + 80807125, 0x24a4_57f4_66ce_8d18L, 0xf2c8_f3b8_1bc6_bb59L, 0xa78c_7576_92e0_2d49L + }, // 30: 2^(2^29) = 2^536870912 = + // 2.0486965204575262773910959587280218683219330308711312100181276813E+161614248 = + // 0.20486965204575262773910959587280218683219330308711312100181276813e161614249 + { + 161614249, 0x3472_5667_7aba_6b53L, 0x3fbf_90d3_0611_a67cL, 0x1e03_9d87_e0bd_b32bL + }, // 31: 2^(2^30) = 2^1073741824 = + // 4.1971574329347753848087162337676781412761959309467052555732924370E+323228496 = + // 0.41971574329347753848087162337676781412761959309467052555732924370e323228497 + { + 323228497, 0x6b72_7daf_0fd3_432aL, 0x71f7_1121_f9e4_200fL, 0x8fcd_9942_d486_c10cL + }, // 32: 2^(2^31) = 2^2147483648 = + // 1.7616130516839633532074931497918402856671115581881347960233679023E+646456993 = + // 0.17616130516839633532074931497918402856671115581881347960233679023e646456994 + {646456994, 0x2d18_e844_84d9_1f78L, 0x4079_bfe7_829d_ec6fL, 0x2155_1643_e365_abc6L} + }; + // An array of negative powers of two, each value consists of 4 longs: decimal exponent and 3 x 64 + // bits of mantissa, divided by ten. Used to find an arbitrary power of 2 (by powerOfTwo(long + // exp)) + private static final long[][] NEG_POWERS_OF_2 = { // v18 + // 0: 2^0 = 1 = 0.1e1 + { + 1, 0x1999_9999_9999_9999L, 0x9999_9999_9999_9999L, 0x9999_9999_9999_999aL + }, // 1: 2^-(2^0) = 2^-1 = 0.5 = 0.5e0 + { + 0, 0x8000_0000_0000_0000L, 0x0000_0000_0000_0000L, 0x0000_0000_0000_0000L + }, // 2: 2^-(2^1) = 2^-2 = 0.25 = 0.25e0 + // {0, 0x4000_0000_0000_0000L, 0x0000_0000_0000_0000L, 0x0000_0000_0000_0000L}, + {0, 0x4000_0000_0000_0000L, 0x0000_0000_0000_0000L, 0x0000_0000_0000_0001L}, // *** + // 3: 2^-(2^2) = 2^-4 = 0.0625 = 0.625e-1 + { + -1, 0xa000_0000_0000_0000L, 0x0000_0000_0000_0000L, 0x0000_0000_0000_0000L + }, // 4: 2^-(2^3) = 2^-8 = 0.00390625 = 0.390625e-2 + { + -2, 0x6400_0000_0000_0000L, 0x0000_0000_0000_0000L, 0x0000_0000_0000_0000L + }, // 5: 2^-(2^4) = 2^-16 = 0.0000152587890625 = 0.152587890625e-4 + {-4, 0x2710_0000_0000_0000L, 0x0000_0000_0000_0000L, 0x0000_0000_0000_0001L}, // *** + // 6: 2^-(2^5) = 2^-32 = 2.3283064365386962890625E-10 = 0.23283064365386962890625e-9 + {-9, 0x3b9a_ca00_0000_0000L, 0x0000_0000_0000_0000L, 0x0000_0000_0000_0001L}, // *** + // 7: 2^-(2^6) = 2^-64 = 5.42101086242752217003726400434970855712890625E-20 = + // 0.542101086242752217003726400434970855712890625e-19 + { + -19, 0x8ac7_2304_89e8_0000L, 0x0000_0000_0000_0000L, 0x0000_0000_0000_0000L + }, // 8: 2^-(2^7) = 2^-128 = + // 2.9387358770557187699218413430556141945466638919302188037718792657E-39 = + // 0.29387358770557187699218413430556141945466638919302188037718792657e-38 + {-38, 0x4b3b_4ca8_5a86_c47aL, 0x098a_2240_0000_0000L, 0x0000_0000_0000_0001L}, // *** + // 9: 2^-(2^8) = 2^-256 = + // 8.6361685550944446253863518628003995711160003644362813850237034700E-78 = + // 0.86361685550944446253863518628003995711160003644362813850237034700e-77 + { + -77, 0xdd15_fe86_affa_d912L, 0x49ef_0eb7_13f3_9ebeL, 0xaa98_7b6e_6fd2_a002L + }, // 10: 2^-(2^9) = 2^-512 = + // 7.4583407312002067432909653154629338373764715346004068942715183331E-155 = + // 0.74583407312002067432909653154629338373764715346004068942715183331e-154 + { + -154, 0xbeee_fb58_4aff_8603L, 0xaafb_550f_facf_d8faL, 0x5ca4_7e4f_88d4_5371L + }, // 11: 2^-(2^10) = 2^-1024 = + // 5.5626846462680034577255817933310101605480399511558295763833185421E-309 = + // 0.55626846462680034577255817933310101605480399511558295763833185421e-308 + {-308, 0x8e67_9c2f_5e44_ff8fL, 0x570f_09ea_a7ea_7648L, 0x5961_db50_c6d2_b888L}, // *** + // 12: 2^-(2^11) = 2^-2048 = + // 3.0943460473825782754801833699711978538925563038849690459540984582E-617 = + // 0.30943460473825782754801833699711978538925563038849690459540984582e-616 + { + -616, 0x4f37_1b33_99fc_2ab0L, 0x8170_041c_9feb_05aaL, 0xc7c3_4344_7c75_bcf6L + }, // 13: 2^-(2^12) = 2^-4096 = + // 9.5749774609521853579467310122804202420597417413514981491308464986E-1234 = + // 0.95749774609521853579467310122804202420597417413514981491308464986e-1233 + { + -1233, 0xf51e_9281_7901_3fd3L, 0xde4b_d12c_de4d_985cL, 0x4a57_3ca6_f94b_ff14L + }, // 14: 2^-(2^13) = 2^-8192 = + // 9.1680193377742358281070619602424158297818248567928361864131947526E-2467 = + // 0.91680193377742358281070619602424158297818248567928361864131947526e-2466 + { + -2466, 0xeab3_8812_7bcc_aff7L, 0x1667_6391_42b9_fbaeL, 0x775e_c999_5e10_39fbL + }, // 15: 2^-(2^14) = 2^-16384 = + // 8.4052578577802337656566945433043815064951983621161781002720680748E-4933 = + // 0.84052578577802337656566945433043815064951983621161781002720680748e-4932 + { + -4932, 0xd72c_b2a9_5c7e_f6ccL, 0xe81b_f1e8_25ba_7515L, 0xc2fe_b521_d6cb_5dcdL + }, // 16: 2^-(2^15) = 2^-32768 = + // 7.0648359655776364427774021878587184537374439102725065590941425796E-9865 = + // 0.70648359655776364427774021878587184537374439102725065590941425796e-9864 + {-9864, 0xb4dc_1be6_6045_02dcL, 0xd491_079b_8eef_6535L, 0x578d_3965_d24d_e84dL}, // *** + // 17: 2^-(2^16) = 2^-65536 = + // 4.9911907220519294656590574792132451973746770423207674161425040336E-19729 = + // 0.49911907220519294656590574792132451973746770423207674161425040336e-19728 + {-19728, 0x7fc6_447b_ee60_ea43L, 0x2548_da5c_8b12_5b27L, 0x5f42_d114_2f41_d349L}, // *** + // 18: 2^-(2^17) = 2^-131072 = + // 2.4911984823897261018394507280431349807329035271689521242878455599E-39457 = + // 0.24911984823897261018394507280431349807329035271689521242878455599e-39456 + {-39456, 0x3fc6_5180_f88a_f8fbL, 0x6a69_15f3_8334_9413L, 0x063c_3708_b6ce_b291L}, // *** + // 19: 2^-(2^18) = 2^-262144 = + // 6.2060698786608744707483205572846793091942192651991171731773832448E-78914 = + // 0.62060698786608744707483205572846793091942192651991171731773832448e-78913 + { + -78913, 0x9ee0_197c_8dcd_55bfL, 0x2b2b_9b94_2c38_f4a2L, 0x0f8b_a634_e9c7_06aeL + }, // 20: 2^-(2^19) = 2^-524288 = + // 3.8515303338821801176537443725392116267291403078581314096728076497E-157827 = + // 0.38515303338821801176537443725392116267291403078581314096728076497e-157826 + {-157826, 0x6299_63a2_5b8b_2d79L, 0xd00b_9d22_86f7_0876L, 0xe970_0470_0c36_44fcL}, // *** + // 21: 2^-(2^20) = 2^-1048576 = + // 1.4834285912814577854404052243709225888043963245995136935174170977E-315653 = + // 0.14834285912814577854404052243709225888043963245995136935174170977e-315652 + { + -315652, 0x25f9_cc30_8cee_f4f3L, 0x40f1_9543_911a_4546L, 0xa2cd_3894_52cf_c366L + }, // 22: 2^-(2^21) = 2^-2097152 = + // 2.2005603854312903332428997579002102976620485709683755186430397089E-631306 = + // 0.22005603854312903332428997579002102976620485709683755186430397089e-631305 + { + -631305, 0x3855_97b0_d47e_76b8L, 0x1b9f_67e1_03bf_2329L, 0xc311_9848_5959_85f7L + }, // 23: 2^-(2^22) = 2^-4194304 = + // 4.8424660099295090687215589310713586524081268589231053824420510106E-1262612 = + // 0.48424660099295090687215589310713586524081268589231053824420510106e-1262611 + {-1262611, 0x7bf7_95d2_76c1_2f66L, 0x66a6_1d62_a446_659aL, 0xa1a4_d73b_ebf0_93d5L}, // *** + // 24: 2^-(2^23) = 2^-8388608 = + // 2.3449477057322620222546775527242476219043877555386221929831430440E-2525223 = + // 0.23449477057322620222546775527242476219043877555386221929831430440e-2525222 + {-2525222, 0x3c07_d96a_b1ed_7799L, 0xcb73_55c2_2cc0_5ac0L, 0x4ffc_0ab7_3b1f_6a49L}, // *** + // 25: 2^-(2^24) = 2^-16777216 = + // 5.4987797426189993226257377747879918011694025935111951649826798628E-5050446 = + // 0.54987797426189993226257377747879918011694025935111951649826798628e-5050445 + {-5050445, 0x8cc4_cd8c_3ede_fb9aL, 0x6c8f_f86a_90a9_7e0cL, 0x166c_fddb_f98b_71bfL}, // *** + // 26: 2^-(2^25) = 2^-33554432 = + // 3.0236578657837068435515418409027857523343464783010706819696074665E-10100891 = + // 0.30236578657837068435515418409027857523343464783010706819696074665e-10100890 + {-10100890, 0x4d67_d81c_c88e_1228L, 0x1d7c_fb06_666b_79b3L, 0x7b91_6728_aaa4_e70dL}, // *** + // 27: 2^-(2^26) = 2^-67108864 = + // 9.1425068893156809483320844568740945600482370635012633596231964471E-20201782 = + // 0.91425068893156809483320844568740945600482370635012633596231964471e-20201781 + {-20201781, 0xea0c_5549_4e7a_552dL, 0xb88c_b948_4bb8_6c61L, 0x8d44_893c_610b_b7dFL}, // *** + // 28: 2^-(2^27) = 2^-134217728 = + // 8.3585432221184688810803924874542310018191301711943564624682743545E-40403563 = + // 0.83585432221184688810803924874542310018191301711943564624682743545e-40403562 + { + -40403562, 0xd5fa_8c82_1ec0_c24aL, 0xa80e_46e7_64e0_f8b0L, 0xa727_6bfa_432f_ac7eL + }, // 29: 2^-(2^28) = 2^-268435456 = + // 6.9865244796022595809958912202005005328020601847785697028605460277E-80807125 = + // 0.69865244796022595809958912202005005328020601847785697028605460277e-80807124 + { + -80807124, 0xb2da_e307_426f_6791L, 0xc970_b82f_58b1_2918L, 0x0472_592f_7f39_190eL + }, // 30: 2^-(2^29) = 2^-536870912 = + // 4.8811524304081624052042871019605298977947353140996212667810837790E-161614249 = + // 0.48811524304081624052042871019605298977947353140996212667810837790e-161614248 + // {-161614248, 0x7cf5_1edd_8a15_f1c9L, 0x656d_ab34_98f8_e697L, 0x12da_a2a8_0e53_c809L}, + { + -161614248, 0x7cf5_1edd_8a15_f1c9L, 0x656d_ab34_98f8_e697L, 0x12da_a2a8_0e53_c807L + }, // 31: 2^-(2^30) = 2^-1073741824 = + // 2.3825649048879510732161697817326745204151961255592397879550237608E-323228497 = + // 0.23825649048879510732161697817326745204151961255592397879550237608e-323228496 + { + -323228496, 0x3cfe_609a_b588_3c50L, 0xbec8_b5d2_2b19_8871L, 0xe184_7770_3b46_22b4L + }, // 32: 2^-(2^31) = 2^-2147483648 = + // 5.6766155260037313438164181629489689531186932477276639365773003794E-646456994 = + // 0.56766155260037313438164181629489689531186932477276639365773003794e-646456993 + {-646456993, 0x9152_447b_9d7c_da9aL, 0x3b4d_3f61_10d7_7aadL, 0xfa81_bad1_c394_adb4L} + }; + // Buffers used internally + // The order of words in the arrays is big-endian: the highest part is in buff[0] (in buff[1] for + // buffers of 10 words) + + private final long[] buffer4x64B = new long[4]; + private final long[] buffer6x32A = new long[6]; + private final long[] buffer6x32B = new long[6]; + private final long[] buffer6x32C = new long[6]; + private final long[] buffer12x32 = new long[12]; + + private void parse(byte[] digits, int exp10) { + exp10 += (digits).length - 1; // digits is viewed as x.yyy below. + this.exponent = 0; + this.mantHi = 0L; + this.mantLo = 0L; + // Finds numeric value of the decimal mantissa + long[] mantissa = this.buffer6x32C; + int exp10Corr = parseMantissa(digits, mantissa); + if (exp10Corr == 0 && isEmpty(mantissa)) { + // Mantissa == 0 + return; + } + // takes account of the point position in the mant string and possible carry as a result of + // round-up (like 9.99e1 -> 1.0e2) + exp10 += exp10Corr; + if (exp10 < MIN_EXP10) { + return; + } + if (exp10 > MAX_EXP10) { + this.exponent = ((int) (long) (EXPONENT_OF_INFINITY)); + return; + } + double exp2 = findBinaryExponent(exp10, mantissa); + // Finds binary mantissa and possible exponent correction. Fills the fields. + findBinaryMantissa(exp10, exp2, mantissa); + } + + private int parseMantissa(byte[] digits, long[] mantissa) { + for (int i = (0); i < (6); i++) { + mantissa[i] = 0L; + } + // Skip leading zeroes + int firstDigit = 0; + while (firstDigit < (digits).length && digits[firstDigit] == 0) { + firstDigit += 1; + } + if (firstDigit == (digits).length) { + return 0; // All zeroes + } + int expCorr = -firstDigit; + // Limit the string length to avoid unnecessary fuss + if ((digits).length - firstDigit > MAX_MANTISSA_LENGTH) { + boolean carry = digits[MAX_MANTISSA_LENGTH] >= 5; // The highest digit to be truncated + byte[] truncated = new byte[MAX_MANTISSA_LENGTH]; + ; + for (int i = (0); i < (MAX_MANTISSA_LENGTH); i++) { + truncated[i] = digits[i + firstDigit]; + } + if (carry) { // Round-up: add carry + expCorr += addCarry(truncated); // May add an extra digit in front of it (99..99 -> 100) + } + digits = truncated; + firstDigit = 0; + } + for (int i = ((digits).length) - 1; i >= (firstDigit); i--) { // digits, starting from the last + mantissa[0] |= ((long) (digits[i])) << 32L; + divBuffBy10(mantissa); + } + return expCorr; + } + // Divides the unpacked value stored in the given buffer by 10 + // @param buffer contains the unpacked value to divide (32 least significant bits are used) + private void divBuffBy10(long[] buffer) { + int maxIdx = (buffer).length; + // big/endian + for (int i = (0); i < (maxIdx); i++) { + long r = buffer[i] % 10L; + buffer[i] = ((buffer[i]) / (10L)); + if (i + 1 < maxIdx) { + buffer[i + 1] += r << 32L; + } + } + } + // Checks if the buffer is empty (contains nothing but zeros) + // @param buffer the buffer to check + // @return {@code true} if the buffer is empty, {@code false} otherwise + private boolean isEmpty(long[] buffer) { + for (int i = (0); i < ((buffer).length); i++) { + if (buffer[i] != 0L) { + return false; + } + } + return true; + } + // Adds one to a decimal number represented as a sequence of decimal digits. propagates carry as + // needed, so that {@code addCarryTo("6789") = "6790", addCarryTo("9999") = "10000"} etc. + // @return 1 if an additional higher "1" was added in front of the number as a result of + // rounding-up, 0 otherwise + private int addCarry(byte[] digits) { + for (int i = ((digits).length) - 1; i >= (0); i--) { // starting with the lowest digit + byte c = digits[i]; + if (c == 9) { + digits[i] = 0; + } else { + digits[i] = ((byte) (digits[i] + 1)); + return 0; + } + } + digits[0] = 1; + return 1; + } + // Finds binary exponent, using decimal exponent and mantissa.
+ // exp2 = exp10 * log2(10) + log2(mant)
+ // @param exp10 decimal exponent + // @param mantissa array of longs containing decimal mantissa (divided by 10) + // @return found value of binary exponent + private double findBinaryExponent(int exp10, long[] mantissa) { + long mant10 = + mantissa[0] << 31L | ((mantissa[1]) >>> (1L)); // Higher 63 bits of the mantissa, in range + // 0x0CC..CCC -- 0x7FF..FFF (2^63/10 -- 2^63-1) + // decimal value of the mantissa in range 1.0..9.9999... + double mant10d = ((double) (mant10)) / TWO_POW_63_DIV_10; + return ((long) Math.floor(((double) (exp10)) * LOG2_10 + log2(mant10d))); // Binary exponent + } + // Calculates log2 of the given x + // @param x argument that can't be 0 + // @return the value of log2(x) + private double log2(double x) { + // x can't be 0 + return LOG2_E * Math.log(x); + } + + private void findBinaryMantissa(int exp10, double exp2, long[] mantissa) { + // pow(2, -exp2): division by 2^exp2 is multiplication by 2^(-exp2) actually + long[] powerOf2 = this.buffer4x64B; + powerOfTwo(-exp2, powerOf2); + long[] product = this.buffer12x32; // use it for the product (M * 10^E / 2^e) + multUnpacked6x32byPacked(mantissa, powerOf2, product); // product in buff_12x32 + multBuffBy10(product); // "Quasidecimals" are numbers divided by 10 + // The powerOf2[0] is stored as an unsigned value + if (((long) (powerOf2[0])) != ((long) (-exp10))) { + // For some combinations of exp2 and exp10, additional multiplication needed + // (see mant2_from_M_E_e.xls) + multBuffBy10(product); + } + // compensate possible inaccuracy of logarithms used to compute exp2 + exp2 += normalizeMant(product); + exp2 += EXPONENT_BIAS; // add bias + // For subnormal values, exp2 <= 0. We just return 0 for them, as they are + // far from any range we are interested in. + if (exp2 <= 0) { + return; + } + exp2 += roundUp(product); // round up, may require exponent correction + if (((long) (exp2)) >= EXPONENT_OF_INFINITY) { + this.exponent = ((int) (long) (EXPONENT_OF_INFINITY)); + } else { + this.exponent = ((int) (long) (exp2)); + this.mantHi = ((product[0] << 32L) + product[1]); + this.mantLo = ((product[2] << 32L) + product[3]); + } + } + // Calculates the required power and returns the result in the quasidecimal format (an array of + // longs, where result[0] is the decimal exponent of the resulting value, and result[1] -- + // result[3] contain 192 bits of the mantissa divided by ten (so that 8 looks like + //
{@code {1, 0xCCCC_.._CCCCL, 0xCCCC_.._CCCCL, 0xCCCC_.._CCCDL}}}
+ // uses arrays buffer4x64B, buffer6x32A, buffer6x32B, buffer12x32, + // @param exp the power to raise 2 to + // @param power (result) the value of {@code2^exp} + private void powerOfTwo(double exp, long[] power) { + if (exp == 0) { + array_copy(POS_POWERS_OF_2[0], power); + return; + } + // positive powers of 2 (2^0, 2^1, 2^2, 2^4, 2^8 ... 2^(2^31) ) + long[][] powers = (POS_POWERS_OF_2); + if (exp < 0) { + exp = -exp; + powers = (NEG_POWERS_OF_2); // positive powers of 2 (2^0, 2^-1, 2^-2, 2^-4, 2^-8 ... 2^30) + } + // 2^31 = 0x8000_0000L; a single bit that will be shifted right at every iteration + double currPowOf2 = POW_2_31; + int idx = 32; // Index in the table of powers + boolean first_power = true; + // if exp = b31 * 2^31 + b30 * 2^30 + .. + b0 * 2^0, where b0..b31 are the values of the bits in + // exp, then 2^exp = 2^b31 * 2^b30 ... * 2^b0. Find the product, using a table of powers of 2. + while (exp > 0) { + if (exp >= currPowOf2) { // the current bit in the exponent is 1 + if (first_power) { + // 4 longs, power[0] -- decimal (?) exponent, power[1..3] -- 192 bits of mantissa + array_copy((powers)[idx], power); + first_power = false; + } else { + // Multiply by the corresponding power of 2 + multPacked3x64_AndAdjustExponent(power, (powers)[idx], power); + } + exp -= currPowOf2; + } + idx -= 1; + currPowOf2 = currPowOf2 * 0.5; // Note: this is exact + } + } + // Copies from into to. + private void array_copy(long[] source, long[] dest) { + for (int i = (0); i < ((dest).length); i++) { + dest[i] = source[i]; + } + } + // Multiplies two quasidecimal numbers contained in buffers of 3 x 64 bits with exponents, puts + // the product to buffer4x64B
+ // and returns it. Both each of the buffers and the product contain 4 longs - exponent and 3 x 64 + // bits of mantissa. If the higher word of mantissa of the product is less than + // 0x1999_9999_9999_9999L (i.e. mantissa is less than 0.1) multiplies mantissa by 10 and adjusts + // the exponent respectively. + private void multPacked3x64_AndAdjustExponent(long[] factor1, long[] factor2, long[] result) { + multPacked3x64_simply(factor1, factor2, this.buffer12x32); + int expCorr = correctPossibleUnderflow(this.buffer12x32); + pack_6x32_to_3x64(this.buffer12x32, result); + // result[0] is a signed int64 value stored in an uint64 + result[0] = factor1[0] + factor2[0] + ((long) (expCorr)); // product.exp = f1.exp + f2.exp + } + // Multiplies mantissas of two packed quasidecimal values (each is an array of 4 longs, exponent + + // 3 x 64 bits of mantissa) Returns the product as unpacked buffer of 12 x 32 (12 x 32 bits of + // product) + // uses arrays buffer6x32A, buffer6x32B + // @param factor1 an array of longs containing factor 1 as packed quasidecimal + // @param factor2 an array of longs containing factor 2 as packed quasidecimal + // @param result an array of 12 longs filled with the product of mantissas + private void multPacked3x64_simply(long[] factor1, long[] factor2, long[] result) { + for (int i = (0); i < ((result).length); i++) { + result[i] = 0L; + } + // TODO2 19.01.16 21:23:06 for the next version -- rebuild the table of powers to make the + // numbers unpacked, to avoid packing/unpacking + unpack_3x64_to_6x32(factor1, this.buffer6x32A); + unpack_3x64_to_6x32(factor2, this.buffer6x32B); + for (int i = (6) - 1; i >= (0); i--) { // compute partial 32-bit products + for (int j = (6) - 1; j >= (0); j--) { + long part = this.buffer6x32A[i] * this.buffer6x32B[j]; + result[j + i + 1] = (result[j + i + 1] + (part & LOWER_32_BITS)); + result[j + i] = (result[j + i] + ((part) >>> (32L))); + } + } + // Carry higher bits of the product to the lower bits of the next word + for (int i = (12) - 1; i >= (1); i--) { + result[i - 1] = (result[i - 1] + ((result[i]) >>> (32L))); + result[i] &= LOWER_32_BITS; + } + } + // Corrects possible underflow of the decimal mantissa, passed in in the {@code mantissa}, by + // multiplying it by a power of ten. The corresponding value to adjust the decimal exponent is + // returned as the result + // @param mantissa a buffer containing the mantissa to be corrected + // @return a corrective (addition) that is needed to adjust the decimal exponent of the number + private int correctPossibleUnderflow(long[] mantissa) { + int expCorr = 0; + while (isLessThanOne(mantissa)) { // Underflow + multBuffBy10(mantissa); + expCorr -= 1; + } + return expCorr; + } + // Checks if the unpacked quasidecimal value held in the given buffer is less than one (in this + // format, one is represented as { 0x1999_9999L, 0x9999_9999L, 0x9999_9999L,...} + // @param buffer a buffer containing the value to check + // @return {@code true}, if the value is less than one + private boolean isLessThanOne(long[] buffer) { + if (buffer[0] < 0x1999_9999L) { + return true; + } + if (buffer[0] > 0x1999_9999L) { + return false; + } + // A note regarding the coverage: + // Multiplying a 128-bit number by another 192-bit number, + // as well as multiplying of two 192-bit numbers, + // can never produce 320 (or 384 bits, respectively) of 0x1999_9999L, 0x9999_9999L, + for (int i = (1); i < ((buffer).length); i++) { + // so this loop can't be covered entirely + if (buffer[i] < 0x9999_9999L) { + return true; + } + if (buffer[i] > 0x9999_9999L) { + return false; + } + } + // and it can never reach this point in real life. + return false; // Still Java requires the return statement here. + } + // Multiplies unpacked 192-bit value by a packed 192-bit factor
+ // uses static arrays buffer6x32B + // @param factor1 a buffer containing unpacked quasidecimal mantissa (6 x 32 bits) + // @param factor2 an array of 4 longs containing packed quasidecimal power of two + // @param product a buffer of at least 12 longs to hold the product + private void multUnpacked6x32byPacked(long[] factor1, long[] factor2, long[] product) { + for (int i = (0); i < ((product).length); i++) { + product[i] = 0L; + } + long[] unpacked2 = this.buffer6x32B; + unpack_3x64_to_6x32(factor2, unpacked2); // It's the powerOf2, with exponent in 0'th word + int maxFactIdx = (factor1).length; + for (int i = (maxFactIdx) - 1; i >= (0); i--) { // compute partial 32-bit products + for (int j = (maxFactIdx) - 1; j >= (0); j--) { + long part = factor1[i] * unpacked2[j]; + product[j + i + 1] = (product[j + i + 1] + (part & LOWER_32_BITS)); + product[j + i] = (product[j + i] + ((part) >>> (32L))); + } + } + // Carry higher bits of the product to the lower bits of the next word + for (int i = (12) - 1; i >= (1); i--) { + product[i - 1] = (product[i - 1] + ((product[i]) >>> (32L))); + product[i] &= LOWER_32_BITS; + } + } + // Multiplies the unpacked value stored in the given buffer by 10 + // @param buffer contains the unpacked value to multiply (32 least significant bits are used) + private void multBuffBy10(long[] buffer) { + int maxIdx = (buffer).length - 1; + buffer[0] &= LOWER_32_BITS; + buffer[maxIdx] *= 10L; + for (int i = (maxIdx) - 1; i >= (0); i--) { + buffer[i] = (buffer[i] * 10L + ((buffer[i + 1]) >>> (32L))); + buffer[i + 1] &= LOWER_32_BITS; + } + } + // Makes sure that the (unpacked) mantissa is normalized, + // i.e. buff[0] contains 1 in bit 32 (the implied integer part) and higher 32 of mantissa in bits + // 31..0, + // and buff[1]..buff[4] contain other 96 bits of mantissa in their lower halves: + //
0x0000_0001_XXXX_XXXXL, 0x0000_0000_XXXX_XXXXL...
+ // If necessary, divides the mantissa by appropriate power of 2 to make it normal. + // @param mantissa a buffer containing unpacked mantissa + // @return if the mantissa was not normal initially, a correction that should be added to the + // result's exponent, or 0 otherwise + private int normalizeMant(long[] mantissa) { + int expCorr = 31 - Long.numberOfLeadingZeros(mantissa[0]); + if (expCorr != 0) { + divBuffByPower2(mantissa, expCorr); + } + return expCorr; + } + // Rounds up the contents of the unpacked buffer to 128 bits by adding unity one bit lower than + // the lowest of these 128 bits. If carry propagates up to bit 33 of buff[0], shifts the buffer + // rightwards to keep it normalized. + // @param mantissa the buffer to get rounded + // @return 1 if the buffer was shifted, 0 otherwise + private int roundUp(long[] mantissa) { + // due to the limited precision of the power of 2, a number with exactly half LSB in its + // mantissa + // (i.e that would have 0x8000_0000_0000_0000L in bits 128..191 if it were computed precisely), + // after multiplication by this power of 2, may get erroneous bits 185..191 (counting from the + // MSB), + // taking a value from + // 0xXXXX_XXXX_XXXX_XXXXL 0xXXXX_XXXX_XXXX_XXXXL 0x7FFF_FFFF_FFFF_FFD8L. + // to + // 0xXXXX_XXXX_XXXX_XXXXL 0xXXXX_XXXX_XXXX_XXXXL 0x8000_0000_0000_0014L, or something alike. + // To round it up, we first add + // 0x0000_0000_0000_0000L 0x0000_0000_0000_0000L 0x0000_0000_0000_0028L, to turn it into + // 0xXXXX_XXXX_XXXX_XXXXL 0xXXXX_XXXX_XXXX_XXXXL 0x8000_0000_0000_00XXL, + // and then add + // 0x0000_0000_0000_0000L 0x0000_0000_0000_0000L 0x8000_0000_0000_0000L, to provide carry to + // higher bits. + addToBuff(mantissa, 5, 100L); // to compensate possible inaccuracy + addToBuff(mantissa, 4, 0x8000_0000L); // round-up, if bits 128..159 >= 0x8000_0000L + if ((mantissa[0] & (HIGHER_32_BITS << 1L)) != 0L) { + // carry's got propagated beyond the highest bit + divBuffByPower2(mantissa, 1); + return 1; + } + return 0; + } + // converts 192 most significant bits of the mantissa of a number from an unpacked quasidecimal + // form (where 32 least significant bits only used) to a packed quasidecimal form (where buff[0] + // contains the exponent and buff[1]..buff[3] contain 3 x 64 = 192 bits of mantissa) + // @param unpackedMant a buffer of at least 6 longs containing an unpacked value + // @param result a buffer of at least 4 long to hold the packed value + // @return packedQD192 with words 1..3 filled with the packed mantissa. packedQD192[0] is not + // affected. + private void pack_6x32_to_3x64(long[] unpackedMant, long[] result) { + result[1] = (unpackedMant[0] << 32L) + unpackedMant[1]; + result[2] = (unpackedMant[2] << 32L) + unpackedMant[3]; + result[3] = (unpackedMant[4] << 32L) + unpackedMant[5]; + } + // Unpacks the mantissa of a 192-bit quasidecimal (4 longs: exp10, mantHi, mantMid, mantLo) to a + // buffer of 6 longs, where the least significant 32 bits of each long contains respective 32 bits + // of the mantissa + // @param qd192 array of 4 longs containing the number to unpack + // @param buff_6x32 buffer of 6 long to hold the unpacked mantissa + private void unpack_3x64_to_6x32(long[] qd192, long[] buff_6x32) { + buff_6x32[0] = ((qd192[1]) >>> (32L)); + buff_6x32[1] = qd192[1] & LOWER_32_BITS; + buff_6x32[2] = ((qd192[2]) >>> (32L)); + buff_6x32[3] = qd192[2] & LOWER_32_BITS; + buff_6x32[4] = ((qd192[3]) >>> (32L)); + buff_6x32[5] = qd192[3] & LOWER_32_BITS; + } + // Divides the contents of the buffer by 2^exp2
+ // (shifts the buffer rightwards by exp2 if the exp2 is positive, and leftwards if it's negative), + // keeping it unpacked (only lower 32 bits of each element are used, except the buff[0] whose + // higher half is intended to contain integer part) + // @param buffer the buffer to divide + // @param exp2 the exponent of the power of two to divide by, expected to be + private void divBuffByPower2(long[] buffer, int exp2) { + int maxIdx = (buffer).length - 1; + long backShift = ((long) (32 - Math.abs(exp2))); + if (exp2 > 0) { // Shift to the right + long exp2Shift = ((long) (exp2)); + for (int i = (maxIdx + 1) - 1; i >= (1); i--) { + buffer[i] = ((buffer[i]) >>> (exp2Shift)) | ((buffer[i - 1] << backShift) & LOWER_32_BITS); + } + buffer[0] = ((buffer[0]) >>> (exp2Shift)); // Preserve the high half of buff[0] + } else if (exp2 < 0) { // Shift to the left + long exp2Shift = ((long) (-exp2)); + buffer[0] = + ((buffer[0] << exp2Shift) + | ((buffer[1]) >>> (backShift))); // Preserve the high half of buff[0] + for (int i = (1); i < (maxIdx); i++) { + buffer[i] = + (((buffer[i] << exp2Shift) & LOWER_32_BITS) | ((buffer[i + 1]) >>> (backShift))); + } + buffer[maxIdx] = (buffer[maxIdx] << exp2Shift) & LOWER_32_BITS; + } + } + // Adds the summand to the idx'th word of the unpacked value stored in the buffer + // and propagates carry as necessary + // @param buff the buffer to add the summand to + // @param idx the index of the element to which the summand is to be added + // @param summand the summand to add to the idx'th element of the buffer + private void addToBuff(long[] buff, int idx, long summand) { + int maxIdx = idx; + buff[maxIdx] = (buff[maxIdx] + summand); // Big-endian, the lowest word + for (int i = (maxIdx + 1) - 1; + i >= (1); + i--) { // from the lowest word upwards, except the highest + if ((buff[i] & HIGHER_32_BITS) != 0L) { + buff[i] &= LOWER_32_BITS; + buff[i - 1] += 1L; + } else { + break; + } + } + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/RegexValue.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/RegexValue.java new file mode 100644 index 000000000..94c400c19 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/RegexValue.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import com.google.firestore.v1.MapValue; +import java.io.Serializable; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Represents a regular expression type in Firestore documents. */ +public class RegexValue implements Serializable { + private static final long serialVersionUID = 8656163047921688827L; + + public final String pattern; + public final String options; + + public RegexValue(@Nonnull String pattern, @Nonnull String options) { + this.pattern = pattern; + this.options = options; + } + + MapValue toProto() { + return UserDataConverter.encodeRegexValue(pattern, options); + } + + /** + * Returns true if this RegexValue is equal to the provided object. + * + * @param obj The object to compare against. + * @return Whether this RegexValue is equal to the provided object. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + RegexValue other = (RegexValue) obj; + return Objects.equals(pattern, other.pattern) && Objects.equals(options, other.options); + } + + @Override + public int hashCode() { + return Objects.hash(this.pattern, this.options); + } + + @Nonnull + @Override + public String toString() { + return "RegexValue{pattern=" + this.pattern + ", options=" + this.options + "}"; + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java index 45f2a6627..4a020ab5b 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java @@ -16,6 +16,12 @@ package com.google.cloud.firestore; +import static com.google.firestore.v1.Value.ValueTypeCase.BYTES_VALUE; +import static com.google.firestore.v1.Value.ValueTypeCase.INTEGER_VALUE; +import static com.google.firestore.v1.Value.ValueTypeCase.MAP_VALUE; +import static com.google.firestore.v1.Value.ValueTypeCase.NULL_VALUE; +import static com.google.firestore.v1.Value.ValueTypeCase.STRING_VALUE; + import com.google.cloud.Timestamp; import com.google.common.base.Preconditions; import com.google.common.collect.Lists; @@ -24,6 +30,7 @@ import com.google.firestore.v1.ArrayValue; import com.google.firestore.v1.MapValue; import com.google.firestore.v1.Value; +import com.google.protobuf.ByteString; import com.google.protobuf.NullValue; import com.google.protobuf.Struct; import java.util.ArrayList; @@ -189,6 +196,30 @@ static Value encodeValue( } else if (sanitizedObject instanceof VectorValue) { VectorValue vectorValue = (VectorValue) sanitizedObject; return Value.newBuilder().setMapValue(vectorValue.toProto()).build(); + } else if (sanitizedObject instanceof MinKey) { + MinKey minKey = (MinKey) sanitizedObject; + return Value.newBuilder().setMapValue(minKey.toProto()).build(); + } else if (sanitizedObject instanceof MaxKey) { + MaxKey maxKey = (MaxKey) sanitizedObject; + return Value.newBuilder().setMapValue(maxKey.toProto()).build(); + } else if (sanitizedObject instanceof RegexValue) { + RegexValue regexValue = (RegexValue) sanitizedObject; + return Value.newBuilder().setMapValue(regexValue.toProto()).build(); + } else if (sanitizedObject instanceof Int32Value) { + Int32Value int32Value = (Int32Value) sanitizedObject; + return Value.newBuilder().setMapValue(int32Value.toProto()).build(); + } else if (sanitizedObject instanceof Decimal128Value) { + Decimal128Value decimal128Value = (Decimal128Value) sanitizedObject; + return Value.newBuilder().setMapValue(decimal128Value.toProto()).build(); + } else if (sanitizedObject instanceof BsonObjectId) { + BsonObjectId bsonObjectId = (BsonObjectId) sanitizedObject; + return Value.newBuilder().setMapValue(bsonObjectId.toProto()).build(); + } else if (sanitizedObject instanceof BsonTimestamp) { + BsonTimestamp bsonTimestamp = (BsonTimestamp) sanitizedObject; + return Value.newBuilder().setMapValue(bsonTimestamp.toProto()).build(); + } else if (sanitizedObject instanceof BsonBinaryData) { + BsonBinaryData bsonBinaryData = (BsonBinaryData) sanitizedObject; + return Value.newBuilder().setMapValue(bsonBinaryData.toProto()).build(); } throw FirestoreException.forInvalidArgument( @@ -214,6 +245,85 @@ static MapValue encodeVector(double[] rawVector) { return res.build(); } + static MapValue encodeMinKey() { + return MapValue.newBuilder() + .putFields( + MapType.RESERVED_MIN_KEY, Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .build(); + } + + static MapValue encodeMaxKey() { + return MapValue.newBuilder() + .putFields( + MapType.RESERVED_MAX_KEY, Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .build(); + } + + static MapValue encodeRegexValue(String pattern, String options) { + return MapValue.newBuilder() + .putFields( + MapType.RESERVED_REGEX_KEY, + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + MapType.RESERVED_REGEX_PATTERN_KEY, + Value.newBuilder().setStringValue(pattern).build()) + .putFields( + MapType.RESERVED_REGEX_OPTIONS_KEY, + Value.newBuilder().setStringValue(options).build()) + .build()) + .build()) + .build(); + } + + static MapValue encodeInt32Value(int value) { + return MapValue.newBuilder() + .putFields(MapType.RESERVED_INT32_KEY, Value.newBuilder().setIntegerValue(value).build()) + .build(); + } + + static MapValue encodeDecimal128Value(String value) { + return MapValue.newBuilder() + .putFields( + MapType.RESERVED_DECIMAL128_KEY, Value.newBuilder().setStringValue(value).build()) + .build(); + } + + static MapValue encodeBsonBinaryData(int subtype, ByteString data) { + return MapValue.newBuilder() + .putFields( + MapType.RESERVED_BSON_BINARY_KEY, + Value.newBuilder() + .setBytesValue(ByteString.copyFrom(new byte[] {(byte) subtype}).concat(data)) + .build()) + .build(); + } + + static MapValue encodeBsonObjectId(String oid) { + return MapValue.newBuilder() + .putFields(MapType.RESERVED_OBJECT_ID_KEY, Value.newBuilder().setStringValue(oid).build()) + .build(); + } + + static MapValue encodeBsonTimestamp(long seconds, long increment) { + return MapValue.newBuilder() + .putFields( + MapType.RESERVED_BSON_TIMESTAMP_KEY, + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + MapType.RESERVED_BSON_TIMESTAMP_SECONDS_KEY, + Value.newBuilder().setIntegerValue(seconds).build()) + .putFields( + MapType.RESERVED_BSON_TIMESTAMP_INCREMENT_KEY, + Value.newBuilder().setIntegerValue(increment).build()) + .build()) + .build()) + .build(); + } + static Object decodeValue(FirestoreRpcContext rpcContext, Value v) { Value.ValueTypeCase typeCase = v.getValueTypeCase(); switch (typeCase) { @@ -252,6 +362,69 @@ static Object decodeValue(FirestoreRpcContext rpcContext, Value v) { } } + /** Decodes the given MapValue into a Regex. Assumes the given map is a regex. */ + static RegexValue decodeRegexValue(MapValue mapValue) { + Map regexMap = + mapValue.getFieldsMap().get(MapType.RESERVED_REGEX_KEY).getMapValue().getFieldsMap(); + String pattern = regexMap.get(MapType.RESERVED_REGEX_PATTERN_KEY).getStringValue(); + String options = regexMap.get(MapType.RESERVED_REGEX_OPTIONS_KEY).getStringValue(); + return new RegexValue(pattern, options); + } + + /** Decodes the given MapValue into a BsonObjectId. Assumes the given map is a BSON object ID. */ + static BsonObjectId decodeBsonObjectId(MapValue mapValue) { + return new BsonObjectId( + mapValue.getFieldsMap().get(MapType.RESERVED_OBJECT_ID_KEY).getStringValue()); + } + + /** Decodes the given MapValue into a BsonObjectId. Assumes the given map is a BSON object ID. */ + static BsonTimestamp decodeBsonTimestamp(MapValue mapValue) { + Map timestampMap = + mapValue + .getFieldsMap() + .get(MapType.RESERVED_BSON_TIMESTAMP_KEY) + .getMapValue() + .getFieldsMap(); + long seconds = timestampMap.get(MapType.RESERVED_BSON_TIMESTAMP_SECONDS_KEY).getIntegerValue(); + long increment = + timestampMap.get(MapType.RESERVED_BSON_TIMESTAMP_INCREMENT_KEY).getIntegerValue(); + return new BsonTimestamp(seconds, increment); + } + + /** Decodes the given MapValue into a BsonBinaryData. Assumes the given map is a BSON binary. */ + static BsonBinaryData decodeBsonBinary(MapValue mapValue) { + ByteString bytes = + mapValue.getFieldsMap().get(MapType.RESERVED_BSON_BINARY_KEY).getBytesValue(); + // Note: A byte is interpreted as a signed 8-bit value. Since values larger than 127 have a + // leading '1' bit, simply casting them to integer results in sign-extension and lead to a + // negative integer value. For example, the byte `0x80` casted to `int` results in `-128`, + // rather than `128`, and the byte `0xFF` casted to `int` will be `-1` rather than `255`. + // Since we want the `subtype` to be an unsigned byte, we need to perform 0-extension (rather + // than sign-extension) to convert it to an int. + int subtype = bytes.byteAt(0) & 0xFF; + return BsonBinaryData.fromByteString(subtype, bytes.substring(1)); + } + + /** + * Decodes the given MapValue into an Int32Value. Assumes the given map is a 32-bit integer value. + */ + static Int32Value decodeInt32Value(MapValue mapValue) { + // The "integer_value" in the proto is a 64-bit integer, but since this + // value is in a special map with the "__int__" field, we know we can + // safely cast this value down to a 32-bit integer value. + long value = mapValue.getFieldsMap().get(MapType.RESERVED_INT32_KEY).getIntegerValue(); + return new Int32Value((int) value); + } + + /** + * Decodes the given MapValue into a Decimal128Value. Assumes the given map is a 128-bit decimal + * value. + */ + static Decimal128Value decodeDecimal128Value(MapValue mapValue) { + String value = mapValue.getFieldsMap().get(MapType.RESERVED_DECIMAL128_KEY).getStringValue(); + return new Decimal128Value(value); + } + static Object decodeMap(FirestoreRpcContext rpcContext, MapValue mapValue) { MapRepresentation mapRepresentation = detectMapRepresentation(mapValue); Map inputMap = mapValue.getFieldsMap(); @@ -272,6 +445,22 @@ static Object decodeMap(FirestoreRpcContext rpcContext, MapValue mapValue) { .mapToDouble(val -> val.getDoubleValue()) .toArray(); return new VectorValue(values); + case MIN_KEY: + return MinKey.instance(); + case MAX_KEY: + return MaxKey.instance(); + case REGEX: + return decodeRegexValue(mapValue); + case INT32: + return decodeInt32Value(mapValue); + case DECIMAL128: + return decodeDecimal128Value(mapValue); + case BSON_OBJECT_ID: + return decodeBsonObjectId(mapValue); + case BSON_TIMESTAMP: + return decodeBsonTimestamp(mapValue); + case BSON_BINARY_DATA: + return decodeBsonBinary(mapValue); default: throw FirestoreException.forInvalidArgument( String.format("Unsupported MapRepresentation: %s", mapRepresentation)); @@ -285,31 +474,136 @@ enum MapRepresentation { /** The MapValue does not represent any special data type. */ NONE, /** The MapValue represents a VectorValue. */ - VECTOR_VALUE + VECTOR_VALUE, + /** The MapValue represents a MinKey. */ + MIN_KEY, + /** The MapValue represents a MaxKey. */ + MAX_KEY, + /** The MapValue represents a regular expression. */ + REGEX, + /** The MapValue represents a 32-bit integer. */ + INT32, + /** The MapValue represents a 128-bit decimal. */ + DECIMAL128, + /** The MapValue represents a BSON ObjectId. */ + BSON_OBJECT_ID, + /** The MapValue represents a BSON Timestamp. */ + BSON_TIMESTAMP, + /** The MapValue represents a BSON Binary Data. */ + BSON_BINARY_DATA, } - static MapRepresentation detectMapRepresentation(MapValue mapValue) { - Map fields = mapValue.getFieldsMap(); - if (!fields.containsKey(MapType.RESERVED_MAP_KEY)) { - return MapRepresentation.NONE; + private static boolean isMapWithSingleKeyAndType( + MapValue mapValue, String key, Value.ValueTypeCase type) { + return mapValue.getFieldsCount() == 1 + && mapValue.getFieldsMap().containsKey(key) + && mapValue.getFieldsMap().get(key).getValueTypeCase().equals(type); + } + + static boolean isMinKey(MapValue mapValue) { + return isMapWithSingleKeyAndType(mapValue, MapType.RESERVED_MIN_KEY, NULL_VALUE); + } + + static boolean isMaxKey(MapValue mapValue) { + return isMapWithSingleKeyAndType(mapValue, MapType.RESERVED_MAX_KEY, NULL_VALUE); + } + + static boolean isInt32Value(MapValue mapValue) { + return isMapWithSingleKeyAndType(mapValue, MapType.RESERVED_INT32_KEY, INTEGER_VALUE); + } + + static boolean isInt32Value(Value value) { + return value.hasMapValue() && isInt32Value(value.getMapValue()); + } + + static boolean isIntegerValue(Value value) { + return value.hasIntegerValue() || isInt32Value(value); + } + + static boolean isDecimal128Value(MapValue mapValue) { + return isMapWithSingleKeyAndType(mapValue, MapType.RESERVED_DECIMAL128_KEY, STRING_VALUE); + } + + static boolean isDecimal128Value(Value value) { + return value.hasMapValue() && isDecimal128Value(value.getMapValue()); + } + + static boolean isBsonObjectId(MapValue mapValue) { + return isMapWithSingleKeyAndType(mapValue, MapType.RESERVED_OBJECT_ID_KEY, STRING_VALUE); + } + + static boolean isBsonBinaryData(MapValue mapValue) { + return isMapWithSingleKeyAndType(mapValue, MapType.RESERVED_BSON_BINARY_KEY, BYTES_VALUE); + } + + static boolean isRegexValue(MapValue mapValue) { + if (isMapWithSingleKeyAndType(mapValue, MapType.RESERVED_REGEX_KEY, MAP_VALUE)) { + MapValue innerMapValue = + mapValue.getFieldsMap().get(MapType.RESERVED_REGEX_KEY).getMapValue(); + Map values = innerMapValue.getFieldsMap(); + return innerMapValue.getFieldsCount() == 2 + && values.containsKey(MapType.RESERVED_REGEX_PATTERN_KEY) + && values.containsKey(MapType.RESERVED_REGEX_OPTIONS_KEY) + && values.get(MapType.RESERVED_REGEX_PATTERN_KEY).hasStringValue() + && values.get(MapType.RESERVED_REGEX_OPTIONS_KEY).hasStringValue(); } + return false; + } - Value typeValue = fields.get(MapType.RESERVED_MAP_KEY); - if (typeValue.getValueTypeCase() != Value.ValueTypeCase.STRING_VALUE) { - LOGGER.warning( - "Unable to parse __type__ field of map. Unsupported value type: " - + typeValue.getValueTypeCase().toString()); - return MapRepresentation.UNKNOWN; + static boolean isBsonTimestamp(MapValue mapValue) { + if (isMapWithSingleKeyAndType(mapValue, MapType.RESERVED_BSON_TIMESTAMP_KEY, MAP_VALUE)) { + MapValue innerMapValue = + mapValue.getFieldsMap().get(MapType.RESERVED_BSON_TIMESTAMP_KEY).getMapValue(); + Map values = innerMapValue.getFieldsMap(); + return innerMapValue.getFieldsCount() == 2 + && values.containsKey(MapType.RESERVED_BSON_TIMESTAMP_SECONDS_KEY) + && values.containsKey(MapType.RESERVED_BSON_TIMESTAMP_INCREMENT_KEY) + && values.get(MapType.RESERVED_BSON_TIMESTAMP_SECONDS_KEY).hasIntegerValue() + && values.get(MapType.RESERVED_BSON_TIMESTAMP_INCREMENT_KEY).hasIntegerValue(); } + return false; + } - String typeString = typeValue.getStringValue(); + static MapRepresentation detectMapRepresentation(MapValue mapValue) { + Map fields = mapValue.getFieldsMap(); - if (typeString.equals(MapType.RESERVED_MAP_KEY_VECTOR_VALUE)) { - return MapRepresentation.VECTOR_VALUE; - } + if (isMinKey(mapValue)) { + return MapRepresentation.MIN_KEY; + } else if (isMaxKey(mapValue)) { + return MapRepresentation.MAX_KEY; + } else if (isRegexValue(mapValue)) { + return MapRepresentation.REGEX; + } else if (isInt32Value(mapValue)) { + return MapRepresentation.INT32; + } else if (isDecimal128Value(mapValue)) { + return MapRepresentation.DECIMAL128; + } else if (isBsonBinaryData(mapValue)) { + return MapRepresentation.BSON_BINARY_DATA; + } else if (isBsonObjectId(mapValue)) { + return MapRepresentation.BSON_OBJECT_ID; + } else if (isBsonTimestamp(mapValue)) { + return MapRepresentation.BSON_TIMESTAMP; + } else if (fields.containsKey(MapType.RESERVED_MAP_KEY)) { // Vector + Value typeValue = fields.get(MapType.RESERVED_MAP_KEY); + if (typeValue.getValueTypeCase() != Value.ValueTypeCase.STRING_VALUE) { + LOGGER.warning( + "Unable to parse __type__ field of map. Unsupported value type: " + + typeValue.getValueTypeCase().toString()); + return MapRepresentation.UNKNOWN; + } + + String typeString = typeValue.getStringValue(); + + if (typeString.equals(MapType.RESERVED_MAP_KEY_VECTOR_VALUE)) { + return MapRepresentation.VECTOR_VALUE; + } - LOGGER.warning("Unsupported __type__ value for map: " + typeString); - return MapRepresentation.UNKNOWN; + LOGGER.warning("Unsupported __type__ value for map: " + typeString); + return MapRepresentation.UNKNOWN; + } else { + // Regular map. + return MapRepresentation.NONE; + } } static Object decodeGoogleProtobufValue(com.google.protobuf.Value v) { diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/CustomClassMapper.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/CustomClassMapper.java index 536fbd618..ed01783e1 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/CustomClassMapper.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/CustomClassMapper.java @@ -19,9 +19,17 @@ import com.google.api.core.InternalApi; import com.google.cloud.Timestamp; import com.google.cloud.firestore.Blob; +import com.google.cloud.firestore.BsonBinaryData; +import com.google.cloud.firestore.BsonObjectId; +import com.google.cloud.firestore.BsonTimestamp; +import com.google.cloud.firestore.Decimal128Value; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.FieldValue; import com.google.cloud.firestore.GeoPoint; +import com.google.cloud.firestore.Int32Value; +import com.google.cloud.firestore.MaxKey; +import com.google.cloud.firestore.MinKey; +import com.google.cloud.firestore.RegexValue; import com.google.cloud.firestore.VectorValue; import com.google.cloud.firestore.annotation.DocumentId; import com.google.cloud.firestore.annotation.PropertyName; @@ -156,6 +164,14 @@ static Object serialize(T o, DeserializeContext.ErrorPath path) { } } else if (o instanceof Date || o instanceof Timestamp + || o instanceof MinKey + || o instanceof MaxKey + || o instanceof RegexValue + || o instanceof Int32Value + || o instanceof Decimal128Value + || o instanceof BsonTimestamp + || o instanceof BsonObjectId + || o instanceof BsonBinaryData || o instanceof GeoPoint || o instanceof Blob || o instanceof DocumentReference @@ -316,6 +332,22 @@ private static T deserializeToClass(Object o, Class clazz, DeserializeCon return (T) convertGeoPoint(o, context.errorPath); } else if (VectorValue.class.isAssignableFrom(clazz)) { return (T) convertVectorValue(o, context.errorPath); + } else if (MinKey.class.isAssignableFrom(clazz)) { + return (T) convertMinKey(o, context.errorPath); + } else if (MaxKey.class.isAssignableFrom(clazz)) { + return (T) convertMaxKey(o, context.errorPath); + } else if (RegexValue.class.isAssignableFrom(clazz)) { + return (T) convertRegexValue(o, context.errorPath); + } else if (Int32Value.class.isAssignableFrom(clazz)) { + return (T) convertInt32Value(o, context.errorPath); + } else if (Decimal128Value.class.isAssignableFrom(clazz)) { + return (T) convertDecimal128Value(o, context.errorPath); + } else if (BsonObjectId.class.isAssignableFrom(clazz)) { + return (T) convertBsonObjectId(o, context.errorPath); + } else if (BsonTimestamp.class.isAssignableFrom(clazz)) { + return (T) convertBsonTimestamp(o, context.errorPath); + } else if (BsonBinaryData.class.isAssignableFrom(clazz)) { + return (T) convertBsonBinaryData(o, context.errorPath); } else if (DocumentReference.class.isAssignableFrom(clazz)) { return (T) convertDocumentReference(o, context.errorPath); } else if (clazz.isArray()) { @@ -588,6 +620,82 @@ private static VectorValue convertVectorValue(Object o, DeserializeContext.Error } } + private static MinKey convertMinKey(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof MinKey) { + return (MinKey) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to MinKey"); + } + } + + private static MaxKey convertMaxKey(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof MaxKey) { + return (MaxKey) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to MaxKey"); + } + } + + private static RegexValue convertRegexValue(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof RegexValue) { + return (RegexValue) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to RegexValue"); + } + } + + private static Int32Value convertInt32Value(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Int32Value) { + return (Int32Value) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to Int32Value"); + } + } + + private static Decimal128Value convertDecimal128Value( + Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Decimal128Value) { + return (Decimal128Value) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to Decimal128Value"); + } + } + + private static BsonObjectId convertBsonObjectId( + Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof BsonObjectId) { + return (BsonObjectId) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to BsonObjectId"); + } + } + + private static BsonTimestamp convertBsonTimestamp( + Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof BsonTimestamp) { + return (BsonTimestamp) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to BsonTimestamp"); + } + } + + private static BsonBinaryData convertBsonBinaryData( + Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof BsonBinaryData) { + return (BsonBinaryData) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to BsonBinaryData"); + } + } + private static DocumentReference convertDocumentReference( Object o, DeserializeContext.ErrorPath errorPath) { if (o instanceof DocumentReference) { diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/DocumentReferenceTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/DocumentReferenceTest.java index b425e5879..9e3796339 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/DocumentReferenceTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/DocumentReferenceTest.java @@ -881,7 +881,15 @@ public void extractFieldMaskFromMerge() throws Exception { "second.timestampValue", "second.trueValue", "second.model.foo", - "second.vectorValue"); + "second.vectorValue", + "second.minKey", + "second.maxKey", + "second.regexValue", + "second.int32Value", + "second.decimal128Value", + "second.bsonObjectId", + "second.bsonTimestamp", + "second.bsonBinaryData"); CommitRequest expectedCommit = commit(set(nestedUpdate, updateMask)); assertCommitEquals(expectedCommit, commitCapture.getValue()); diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/ExtendedTypesTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/ExtendedTypesTest.java new file mode 100644 index 000000000..26f08e5ba --- /dev/null +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/ExtendedTypesTest.java @@ -0,0 +1,513 @@ +/* + * Copyright 2025 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.cloud.firestore; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.cloud.firestore.UserDataConverter.MapRepresentation; +import com.google.firestore.v1.MapValue; +import com.google.firestore.v1.Value; +import com.google.protobuf.ByteString; +import com.google.protobuf.NullValue; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class ExtendedTypesTest { + + Value encode(Object o) { + // The field path and encoding options are not used for encoding of extended types, + // so we'll use empty field path and `ARGUMENT` as encoding option. + return UserDataConverter.encodeValue(FieldPath.empty(), o, UserDataConverter.ARGUMENT); + } + + Object decode(Value value) { + // The RpcContext is not needed for decoding extended types, so we can use null. + return UserDataConverter.decodeValue(null, value); + } + + void assertEncodesAndDecodesCorrectly(Value proto, Object object) { + assertThat(proto).isEqualTo(encode(object)); + assertThat(object).isEqualTo(decode(proto)); + assertThat(decode(encode(object))).isEqualTo(object); + assertThat(encode(decode(proto))).isEqualTo(proto); + } + + @Test + public void minKeyIsSingleton() { + MinKey minKey1 = MinKey.instance(); + MinKey minKey2 = MinKey.instance(); + assertThat(minKey1).isEqualTo(minKey2); + assertThat(minKey1).isNotEqualTo(MaxKey.instance()); + } + + @Test + public void maxKeyIsSingleton() { + MaxKey maxKey1 = MaxKey.instance(); + MaxKey maxKey2 = MaxKey.instance(); + assertThat(maxKey1).isEqualTo(maxKey2); + assertThat(maxKey1).isNotEqualTo(MinKey.instance()); + } + + @Test + public void regexFieldsAndEquality() { + RegexValue regex1 = new RegexValue("^foo", "i"); + RegexValue regex2 = new RegexValue("^foo", "i"); + RegexValue regex3 = new RegexValue("^foo", "x"); + RegexValue regex4 = new RegexValue("^bar", "i"); + + assertThat(regex1.pattern).isEqualTo("^foo"); + assertThat(regex1.options).isEqualTo("i"); + assertThat(regex1).isEqualTo(regex2); + assertThat(regex1).isNotEqualTo(regex3); + assertThat(regex1).isNotEqualTo(regex4); + } + + @Test + public void Int32ValueAndEquality() { + Int32Value i1 = new Int32Value(123); + Int32Value i2 = new Int32Value(123); + Int32Value i3 = new Int32Value(456); + + assertThat(i1.value).isEqualTo(123); + assertThat(i1).isEqualTo(i2); + assertThat(i1).isNotEqualTo(i3); + } + + @Test + public void Decimal128ValueAndEquality() { + Decimal128Value i1 = new Decimal128Value("1.2e3"); + Decimal128Value i2 = new Decimal128Value("12e2"); + Decimal128Value i3 = new Decimal128Value("0.12e4"); + Decimal128Value i4 = new Decimal128Value("12000e-1"); + Decimal128Value i5 = new Decimal128Value("1.2"); + + assertThat(i1).isEqualTo(i2); + assertThat(i1).isEqualTo(i3); + assertThat(i1).isEqualTo(i4); + assertThat(i1).isNotEqualTo(i5); + + assertThat(i1.hashCode()).isEqualTo(i2.hashCode()); + assertThat(i1.hashCode()).isEqualTo(i3.hashCode()); + assertThat(i1.hashCode()).isEqualTo(i4.hashCode()); + assertThat(i1.hashCode()).isNotEqualTo(i5.hashCode()); + } + + @Test + public void Decimal128ValueZeros() { + Decimal128Value i1 = new Decimal128Value("0"); + Decimal128Value i2 = new Decimal128Value("+0"); + Decimal128Value i3 = new Decimal128Value("-0"); + Decimal128Value i4 = new Decimal128Value("0.0"); + Decimal128Value i5 = new Decimal128Value("+0.0"); + Decimal128Value i6 = new Decimal128Value("-0.0"); + + assertThat(i1).isEqualTo(i2); + assertThat(i1).isEqualTo(i3); + assertThat(i1).isEqualTo(i4); + assertThat(i1).isEqualTo(i5); + assertThat(i1).isEqualTo(i6); + + assertThat(i1.hashCode()).isEqualTo(i2.hashCode()); + assertThat(i1.hashCode()).isEqualTo(i3.hashCode()); + assertThat(i1.hashCode()).isEqualTo(i4.hashCode()); + assertThat(i1.hashCode()).isEqualTo(i5.hashCode()); + assertThat(i1.hashCode()).isEqualTo(i6.hashCode()); + } + + @Test + public void Decimal128ValueNaNs() { + Decimal128Value i1 = new Decimal128Value("NaN"); + Decimal128Value i2 = new Decimal128Value("NaN"); + assertThat(i1).isEqualTo(i2); + assertThat(i1.hashCode()).isEqualTo(i2.hashCode()); + } + + @Test + public void BsonObjectIdValueAndEquality() { + BsonObjectId oid1 = new BsonObjectId("foo"); + BsonObjectId oid2 = new BsonObjectId("foo"); + BsonObjectId oid3 = new BsonObjectId("bar"); + + assertThat(oid1.value).isEqualTo("foo"); + assertThat(oid1).isEqualTo(oid2); + assertThat(oid1).isNotEqualTo(oid3); + } + + @Test + public void BsonTimestampValuesAndEquality() { + BsonTimestamp t1 = new BsonTimestamp(123, 456); + BsonTimestamp t2 = new BsonTimestamp(123, 456); + BsonTimestamp t3 = new BsonTimestamp(124, 456); + BsonTimestamp t4 = new BsonTimestamp(123, 457); + + assertThat(t1.seconds).isEqualTo(123); + assertThat(t1.increment).isEqualTo(456); + assertThat(t1).isEqualTo(t2); + assertThat(t1).isNotEqualTo(t3); + assertThat(t1).isNotEqualTo(t4); + } + + @Test + public void BsonBinaryDataValuesAndEquality() { + BsonBinaryData b1 = BsonBinaryData.fromBytes(127, new byte[] {1, 2, 3}); + BsonBinaryData b2 = BsonBinaryData.fromBytes(127, new byte[] {1, 2, 3}); + BsonBinaryData b3 = BsonBinaryData.fromBytes(1, new byte[] {1, 2, 3}); + BsonBinaryData b4 = BsonBinaryData.fromBytes(127, new byte[] {1, 2, 4}); + + assertThat(b1.subtype()).isEqualTo(127); + assertThat(b1.dataAsBytes()).isEqualTo(new byte[] {1, 2, 3}); + assertThat(b1).isEqualTo(b2); + assertThat(b1).isNotEqualTo(b3); + assertThat(b1).isNotEqualTo(b4); + } + + @Test + public void BsonBinaryDataConvertsByteToIntAndIntToByteCorrectly() { + byte[] data = new byte[] {1, 2, 3}; + BsonBinaryData b1 = BsonBinaryData.fromBytes(127, data); // 0x7F - MSB:0 + BsonBinaryData b2 = BsonBinaryData.fromBytes(128, data); // 0x80 - MSB:1 + BsonBinaryData b3 = BsonBinaryData.fromBytes(255, data); // 0xFF - MSB:1 + + BsonBinaryData b4 = UserDataConverter.decodeBsonBinary(b1.toProto()); + BsonBinaryData b5 = UserDataConverter.decodeBsonBinary(b2.toProto()); + BsonBinaryData b6 = UserDataConverter.decodeBsonBinary(b3.toProto()); + + assertThat(b4.subtype()).isEqualTo(127); + assertThat(b4.dataAsBytes()).isEqualTo(data); + assertThat(b4).isEqualTo(b1); + + assertThat(b5.subtype()).isEqualTo(128); + assertThat(b5.dataAsBytes()).isEqualTo(data); + assertThat(b5).isEqualTo(b2); + + assertThat(b6.subtype()).isEqualTo(255); + assertThat(b6.dataAsBytes()).isEqualTo(data); + assertThat(b6).isEqualTo(b3); + } + + @Test + public void BsonBinaryDataConstructorsEncodeToTheSameValue() { + byte[] bytes = new byte[] {1, 2, 3}; + ByteString byteString = ByteString.copyFromUtf8("\01\02\03"); + BsonBinaryData b1 = BsonBinaryData.fromByteString(127, byteString); + BsonBinaryData b2 = BsonBinaryData.fromBytes(127, bytes); + assertThat(b1.toProto()).isEqualTo(b2.toProto()); + assertThat(b1).isEqualTo(b2); + + BsonBinaryData b3 = BsonBinaryData.fromByteString(128, byteString); + BsonBinaryData b4 = BsonBinaryData.fromBytes(128, bytes); + assertThat(b3.toProto()).isEqualTo(b4.toProto()); + assertThat(b3).isEqualTo(b4); + } + + @Test + public void DetectsBsonTypesCorrectly() { + MapValue minKeyMapValue = UserDataConverter.encodeMinKey(); + MapValue maxKeyMapValue = UserDataConverter.encodeMaxKey(); + MapValue int32MapValue = UserDataConverter.encodeInt32Value(5); + MapValue decimal128MapValue = UserDataConverter.encodeDecimal128Value("1.2e3"); + MapValue regexMapValue = UserDataConverter.encodeRegexValue("^foo", "i"); + MapValue bsonTimestamp = UserDataConverter.encodeBsonTimestamp(1, 2); + MapValue bsonObjectId = UserDataConverter.encodeBsonObjectId("foo"); + MapValue bsonBinaryData1 = UserDataConverter.encodeBsonBinaryData(128, ByteString.EMPTY); + MapValue bsonBinaryData2 = + UserDataConverter.encodeBsonBinaryData(128, ByteString.fromHex("010203")); + + assertTrue(UserDataConverter.isMinKey(minKeyMapValue)); + assertFalse(UserDataConverter.isMinKey(maxKeyMapValue)); + assertFalse(UserDataConverter.isMinKey(int32MapValue)); + assertFalse(UserDataConverter.isMinKey(decimal128MapValue)); + assertFalse(UserDataConverter.isMinKey(regexMapValue)); + assertFalse(UserDataConverter.isMinKey(bsonTimestamp)); + assertFalse(UserDataConverter.isMinKey(bsonObjectId)); + assertFalse(UserDataConverter.isMinKey(bsonBinaryData1)); + assertFalse(UserDataConverter.isMinKey(bsonBinaryData2)); + + assertFalse(UserDataConverter.isMaxKey(minKeyMapValue)); + assertTrue(UserDataConverter.isMaxKey(maxKeyMapValue)); + assertFalse(UserDataConverter.isMaxKey(int32MapValue)); + assertFalse(UserDataConverter.isMaxKey(decimal128MapValue)); + assertFalse(UserDataConverter.isMaxKey(regexMapValue)); + assertFalse(UserDataConverter.isMaxKey(bsonTimestamp)); + assertFalse(UserDataConverter.isMaxKey(bsonObjectId)); + assertFalse(UserDataConverter.isMaxKey(bsonBinaryData1)); + assertFalse(UserDataConverter.isMaxKey(bsonBinaryData2)); + + assertFalse(UserDataConverter.isInt32Value(minKeyMapValue)); + assertFalse(UserDataConverter.isInt32Value(maxKeyMapValue)); + assertTrue(UserDataConverter.isInt32Value(int32MapValue)); + assertFalse(UserDataConverter.isInt32Value(decimal128MapValue)); + assertFalse(UserDataConverter.isInt32Value(regexMapValue)); + assertFalse(UserDataConverter.isInt32Value(bsonTimestamp)); + assertFalse(UserDataConverter.isInt32Value(bsonObjectId)); + assertFalse(UserDataConverter.isInt32Value(bsonBinaryData1)); + assertFalse(UserDataConverter.isInt32Value(bsonBinaryData2)); + + assertFalse(UserDataConverter.isDecimal128Value(minKeyMapValue)); + assertFalse(UserDataConverter.isDecimal128Value(maxKeyMapValue)); + assertFalse(UserDataConverter.isDecimal128Value(int32MapValue)); + assertTrue(UserDataConverter.isDecimal128Value(decimal128MapValue)); + assertFalse(UserDataConverter.isDecimal128Value(regexMapValue)); + assertFalse(UserDataConverter.isDecimal128Value(bsonTimestamp)); + assertFalse(UserDataConverter.isDecimal128Value(bsonObjectId)); + assertFalse(UserDataConverter.isDecimal128Value(bsonBinaryData1)); + assertFalse(UserDataConverter.isDecimal128Value(bsonBinaryData2)); + + assertFalse(UserDataConverter.isRegexValue(minKeyMapValue)); + assertFalse(UserDataConverter.isRegexValue(maxKeyMapValue)); + assertFalse(UserDataConverter.isRegexValue(int32MapValue)); + assertFalse(UserDataConverter.isRegexValue(decimal128MapValue)); + assertTrue(UserDataConverter.isRegexValue(regexMapValue)); + assertFalse(UserDataConverter.isRegexValue(bsonTimestamp)); + assertFalse(UserDataConverter.isRegexValue(bsonObjectId)); + assertFalse(UserDataConverter.isRegexValue(bsonBinaryData1)); + assertFalse(UserDataConverter.isRegexValue(bsonBinaryData2)); + + assertFalse(UserDataConverter.isBsonTimestamp(minKeyMapValue)); + assertFalse(UserDataConverter.isBsonTimestamp(maxKeyMapValue)); + assertFalse(UserDataConverter.isBsonTimestamp(int32MapValue)); + assertFalse(UserDataConverter.isBsonTimestamp(decimal128MapValue)); + assertFalse(UserDataConverter.isBsonTimestamp(regexMapValue)); + assertTrue(UserDataConverter.isBsonTimestamp(bsonTimestamp)); + assertFalse(UserDataConverter.isBsonTimestamp(bsonObjectId)); + assertFalse(UserDataConverter.isBsonTimestamp(bsonBinaryData1)); + assertFalse(UserDataConverter.isBsonTimestamp(bsonBinaryData2)); + + assertFalse(UserDataConverter.isBsonObjectId(minKeyMapValue)); + assertFalse(UserDataConverter.isBsonObjectId(maxKeyMapValue)); + assertFalse(UserDataConverter.isBsonObjectId(int32MapValue)); + assertFalse(UserDataConverter.isBsonObjectId(decimal128MapValue)); + assertFalse(UserDataConverter.isBsonObjectId(regexMapValue)); + assertFalse(UserDataConverter.isBsonObjectId(bsonTimestamp)); + assertTrue(UserDataConverter.isBsonObjectId(bsonObjectId)); + assertFalse(UserDataConverter.isBsonObjectId(bsonBinaryData1)); + assertFalse(UserDataConverter.isBsonObjectId(bsonBinaryData2)); + + assertFalse(UserDataConverter.isBsonBinaryData(minKeyMapValue)); + assertFalse(UserDataConverter.isBsonBinaryData(maxKeyMapValue)); + assertFalse(UserDataConverter.isBsonBinaryData(int32MapValue)); + assertFalse(UserDataConverter.isBsonBinaryData(decimal128MapValue)); + assertFalse(UserDataConverter.isBsonBinaryData(regexMapValue)); + assertFalse(UserDataConverter.isBsonBinaryData(bsonTimestamp)); + assertFalse(UserDataConverter.isBsonBinaryData(bsonObjectId)); + assertTrue(UserDataConverter.isBsonBinaryData(bsonBinaryData1)); + assertTrue(UserDataConverter.isBsonBinaryData(bsonBinaryData2)); + + assertEquals( + UserDataConverter.detectMapRepresentation(minKeyMapValue), MapRepresentation.MIN_KEY); + assertEquals( + UserDataConverter.detectMapRepresentation(maxKeyMapValue), MapRepresentation.MAX_KEY); + assertEquals(UserDataConverter.detectMapRepresentation(int32MapValue), MapRepresentation.INT32); + assertEquals( + UserDataConverter.detectMapRepresentation(decimal128MapValue), + MapRepresentation.DECIMAL128); + assertEquals(UserDataConverter.detectMapRepresentation(regexMapValue), MapRepresentation.REGEX); + assertEquals( + UserDataConverter.detectMapRepresentation(bsonTimestamp), MapRepresentation.BSON_TIMESTAMP); + assertEquals( + UserDataConverter.detectMapRepresentation(bsonObjectId), MapRepresentation.BSON_OBJECT_ID); + assertEquals( + UserDataConverter.detectMapRepresentation(bsonBinaryData1), + MapRepresentation.BSON_BINARY_DATA); + assertEquals( + UserDataConverter.detectMapRepresentation(bsonBinaryData2), + MapRepresentation.BSON_BINARY_DATA); + } + + @Test + public void BsonTimestampValidation() { + // Negative seconds + IllegalArgumentException error1 = null; + try { + BsonTimestamp t1 = new BsonTimestamp(-1, 1); + } catch (IllegalArgumentException e) { + error1 = e; + } + assertThat(error1).isNotNull(); + assertThat(error1.getMessage()) + .isEqualTo("BsonTimestamp 'seconds' must be in the range of a 32-bit unsigned integer."); + + // Larger than 2^32-1 seconds + IllegalArgumentException error2 = null; + try { + BsonTimestamp t1 = new BsonTimestamp(4294967296L, 1); + } catch (IllegalArgumentException e) { + error2 = e; + } + assertThat(error2).isNotNull(); + assertThat(error2.getMessage()) + .isEqualTo("BsonTimestamp 'seconds' must be in the range of a 32-bit unsigned integer."); + + // Negative increment + IllegalArgumentException error3 = null; + try { + BsonTimestamp t1 = new BsonTimestamp(1234, -1); + } catch (IllegalArgumentException e) { + error3 = e; + } + assertThat(error3).isNotNull(); + assertThat(error3.getMessage()) + .isEqualTo("BsonTimestamp 'increment' must be in the range of a 32-bit unsigned integer."); + + // Larger than 2^32-1 increment + IllegalArgumentException error4 = null; + try { + BsonTimestamp t1 = new BsonTimestamp(123, 4294967296L); + } catch (IllegalArgumentException e) { + error4 = e; + } + assertThat(error4).isNotNull(); + assertThat(error4.getMessage()) + .isEqualTo("BsonTimestamp 'increment' must be in the range of a 32-bit unsigned integer."); + } + + @Test + public void canEncodeAndDecodeMinKey() { + MinKey minKey = MinKey.instance(); + Value proto = + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__min__", Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .build()) + .build(); + assertEncodesAndDecodesCorrectly(proto, minKey); + } + + @Test + public void canEncodeAndDecodeMaxKey() { + MaxKey maxKey = MaxKey.instance(); + Value proto = + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__max__", Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .build()) + .build(); + assertEncodesAndDecodesCorrectly(proto, maxKey); + } + + @Test + public void canEncodeAndDecodeRegexValue() { + RegexValue regexValue = new RegexValue("^foo", "i"); + Value proto = + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__regex__", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "pattern", + Value.newBuilder().setStringValue("^foo").build()) + .putFields( + "options", Value.newBuilder().setStringValue("i").build()) + .build()) + .build()) + .build()) + .build(); + assertEncodesAndDecodesCorrectly(proto, regexValue); + } + + @Test + public void canEncodeAndDecodeInt32Value() { + Int32Value int32Value = new Int32Value(12345); + Value proto = + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields("__int__", Value.newBuilder().setIntegerValue(12345).build()) + .build()) + .build(); + assertEncodesAndDecodesCorrectly(proto, int32Value); + } + + @Test + public void canEncodeAndDecodeDecimal128Value() { + Decimal128Value decimal128Value = new Decimal128Value("1.2e3"); + Value proto = + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields("__decimal128__", Value.newBuilder().setStringValue("1.2e3").build()) + .build()) + .build(); + assertEncodesAndDecodesCorrectly(proto, decimal128Value); + } + + @Test + public void canEncodeAndDecodeBsonObjectId() { + BsonObjectId bsonObjectId = new BsonObjectId("foo"); + Value proto = + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields("__oid__", Value.newBuilder().setStringValue("foo").build()) + .build()) + .build(); + assertEncodesAndDecodesCorrectly(proto, bsonObjectId); + } + + @Test + public void canEncodeAndDecodeBsonTimestamp() { + BsonTimestamp bsonTimestamp = new BsonTimestamp(12345, 67); + Value proto = + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__request_timestamp__", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "seconds", + Value.newBuilder().setIntegerValue(12345).build()) + .putFields( + "increment", Value.newBuilder().setIntegerValue(67).build()) + .build()) + .build()) + .build()) + .build(); + assertEncodesAndDecodesCorrectly(proto, bsonTimestamp); + } + + @Test + public void canEncodeAndDecodeBsonBinaryData() { + BsonBinaryData bsonBinaryData = BsonBinaryData.fromBytes(127, new byte[] {1, 2, 3}); + Value proto = + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__binary__", + Value.newBuilder() + .setBytesValue(ByteString.copyFrom(new byte[] {127, 1, 2, 3})) + .build()) + .build()) + .build(); + assertEncodesAndDecodesCorrectly(proto, bsonBinaryData); + } +} diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java index 500e35ff9..2211ad96e 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java @@ -670,7 +670,7 @@ public static StructuredQuery findNearest( Value.newBuilder() .setArrayValue(vectorArrayBuilder.build()) .build()))) - .setLimit(Int32Value.newBuilder().setValue(limit)) + .setLimit(com.google.protobuf.Int32Value.newBuilder().setValue(limit)) .setDistanceMeasure(measure); StructuredQuery.Builder structuredQuery = StructuredQuery.newBuilder(); @@ -980,6 +980,14 @@ public static class AllSupportedTypes { public GeoPoint geoPointValue = GEO_POINT; public Map model = ImmutableMap.of("foo", SINGLE_FIELD_OBJECT.foo); public VectorValue vectorValue = FieldValue.vector(new double[] {0.1, 0.2, 0.3}); + public MinKey minKey = MinKey.instance(); + public MaxKey maxKey = MaxKey.instance(); + public RegexValue regexValue = new RegexValue("^foo", "i"); + public Int32Value int32Value = new Int32Value(55); + public Decimal128Value decimal128Value = new Decimal128Value("1.2e3"); + public BsonObjectId bsonObjectId = new BsonObjectId("507f191e810c19729de860eb"); + public BsonTimestamp bsonTimestamp = new BsonTimestamp(100, 10); + public BsonBinaryData bsonBinaryData = BsonBinaryData.fromBytes(127, new byte[] {1, 2, 3}); @Override public boolean equals(Object o) { @@ -1007,7 +1015,15 @@ public boolean equals(Object o) { && Objects.equals(bytesValue, that.bytesValue) && Objects.equals(geoPointValue, that.geoPointValue) && Objects.equals(model, that.model) - && Objects.equals(vectorValue, that.vectorValue); + && Objects.equals(vectorValue, that.vectorValue) + && Objects.equals(minKey, that.minKey) + && Objects.equals(maxKey, that.maxKey) + && Objects.equals(regexValue, that.regexValue) + && Objects.equals(int32Value, that.int32Value) + && Objects.equals(decimal128Value, that.decimal128Value) + && Objects.equals(bsonObjectId, that.bsonObjectId) + && Objects.equals(bsonTimestamp, that.bsonTimestamp) + && Objects.equals(bsonBinaryData, that.bsonBinaryData); } } @@ -1129,6 +1145,15 @@ public boolean equals(Object o) { ALL_SUPPORTED_TYPES_MAP.put("geoPointValue", GEO_POINT); ALL_SUPPORTED_TYPES_MAP.put("model", map("foo", SINGLE_FIELD_OBJECT.foo)); ALL_SUPPORTED_TYPES_MAP.put("vectorValue", FieldValue.vector(new double[] {0.1, 0.2, 0.3})); + ALL_SUPPORTED_TYPES_MAP.put("minKey", MinKey.instance()); + ALL_SUPPORTED_TYPES_MAP.put("maxKey", MaxKey.instance()); + ALL_SUPPORTED_TYPES_MAP.put("regexValue", new RegexValue("^foo", "i")); + ALL_SUPPORTED_TYPES_MAP.put("int32Value", new Int32Value(55)); + ALL_SUPPORTED_TYPES_MAP.put("decimal128Value", new Decimal128Value("1.2e3")); + ALL_SUPPORTED_TYPES_MAP.put("bsonObjectId", new BsonObjectId("507f191e810c19729de860eb")); + ALL_SUPPORTED_TYPES_MAP.put("bsonTimestamp", new BsonTimestamp(100, 10)); + ALL_SUPPORTED_TYPES_MAP.put( + "bsonBinaryData", BsonBinaryData.fromBytes(127, new byte[] {1, 2, 3})); ALL_SUPPORTED_TYPES_PROTO = ImmutableMap.builder() .put("foo", Value.newBuilder().setStringValue("bar").build()) @@ -1162,6 +1187,110 @@ public boolean equals(Object o) { .addValues(Value.newBuilder().setDoubleValue(0.3))) .build()))) .build()) + .put( + "minKey", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putAllFields( + map( + "__min__", + Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())) + .build()) + .build()) + .put( + "maxKey", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putAllFields( + map( + "__max__", + Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build())) + .build()) + .build()) + .put( + "regexValue", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__regex__", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "pattern", + Value.newBuilder().setStringValue("^foo").build()) + .putFields( + "options", + Value.newBuilder().setStringValue("i").build()) + .build()) + .build()) + .build()) + .build()) + .put( + "int32Value", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields("__int__", Value.newBuilder().setIntegerValue(55).build()) + .build()) + .build()) + .put( + "decimal128Value", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__decimal128__", + Value.newBuilder().setStringValue("1.2e3").build()) + .build()) + .build()) + .put( + "bsonObjectId", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__oid__", + Value.newBuilder() + .setStringValue("507f191e810c19729de860eb") + .build()) + .build()) + .build()) + .put( + "bsonTimestamp", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__request_timestamp__", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "seconds", + Value.newBuilder().setIntegerValue(100).build()) + .putFields( + "increment", + Value.newBuilder().setIntegerValue(10).build()) + .build()) + .build()) + .build()) + .build()) + .put( + "bsonBinaryData", + Value.newBuilder() + .setMapValue( + MapValue.newBuilder() + .putFields( + "__binary__", + Value.newBuilder() + .setBytesValue(ByteString.copyFrom(new byte[] {127, 1, 2, 3})) + .build()) + .build()) + .build()) .put( "dateValue", Value.newBuilder() diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/OrderTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/OrderTest.java index 076cbf2db..e3fa584a2 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/OrderTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/OrderTest.java @@ -30,6 +30,7 @@ import com.google.type.LatLng; import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Random; import org.junit.Test; @@ -37,106 +38,168 @@ public class OrderTest { @Test public void verifyOrder() { - Value[][] groups = new Value[67][]; + List groups = new ArrayList<>(); - groups[0] = new Value[] {nullValue()}; + groups.add(new Value[] {nullValue()}); - groups[1] = new Value[] {booleanValue(false)}; - groups[2] = new Value[] {booleanValue(true)}; + groups.add(new Value[] {minKeyValue(), minKeyValue()}); + + groups.add(new Value[] {booleanValue(false)}); + groups.add(new Value[] {booleanValue(true)}); // numbers - groups[3] = new Value[] {doubleValue(Double.NaN), doubleValue(Double.NaN)}; - groups[4] = new Value[] {doubleValue(Double.NEGATIVE_INFINITY)}; - groups[5] = new Value[] {doubleValue((double) Long.MIN_VALUE - 100)}; - groups[6] = new Value[] {intValue((long) Integer.MIN_VALUE - 1)}; - groups[7] = new Value[] {intValue(Integer.MIN_VALUE)}; - groups[8] = new Value[] {doubleValue(-1.1)}; - // Integers and Doubles order the same. - groups[9] = new Value[] {intValue(-1), doubleValue(-1.0)}; - groups[10] = new Value[] {doubleValue(-Double.MIN_VALUE)}; + groups.add( + new Value[] {doubleValue(Double.NaN), doubleValue(Double.NaN), decimal128Value("NaN")}); + groups.add(new Value[] {doubleValue(Double.NEGATIVE_INFINITY), decimal128Value("-Infinity")}); + groups.add(new Value[] {doubleValue((double) Long.MIN_VALUE - 100)}); + groups.add( + new Value[] {intValue((long) Integer.MIN_VALUE - 1), decimal128Value("-2147483649")}); + // 64-bit and 32-bit integers order together numerically, so the same + // value (-2147483648) as int or long should order equally. + groups.add( + new Value[] { + intValue(Integer.MIN_VALUE), int32Value(Integer.MIN_VALUE), decimal128Value("-2147483648") + }); + groups.add(new Value[] {doubleValue(-1.1)}); + // Integers and Doubles and int32 order together numerically. + groups.add( + new Value[] {intValue(-1), doubleValue(-1.0), int32Value(-1), decimal128Value("-1")}); + groups.add(new Value[] {doubleValue(-Double.MIN_VALUE)}); // zeros all compare the same. - groups[11] = new Value[] {intValue(0), doubleValue(-0.0), doubleValue(0.0), doubleValue(+0.0)}; - groups[12] = new Value[] {doubleValue(Double.MIN_VALUE)}; - groups[13] = new Value[] {intValue(1), doubleValue(1.0)}; - groups[14] = new Value[] {doubleValue(1.1)}; - groups[15] = new Value[] {intValue(Integer.MAX_VALUE)}; - groups[16] = new Value[] {intValue((long) Integer.MAX_VALUE + 1)}; - groups[17] = new Value[] {doubleValue(((double) Long.MAX_VALUE) + 100)}; - groups[18] = new Value[] {doubleValue(Double.POSITIVE_INFINITY)}; - - groups[19] = new Value[] {timestampValue(123, 0)}; - groups[20] = new Value[] {timestampValue(123, 123)}; - groups[21] = new Value[] {timestampValue(345, 0)}; + groups.add( + new Value[] { + intValue(0), + doubleValue(-0.0), + doubleValue(0.0), + doubleValue(+0.0), + int32Value(0), + decimal128Value("+0"), + decimal128Value("0"), + decimal128Value("-0"), + decimal128Value("+0.0"), + decimal128Value("0.0"), + decimal128Value("-0.0"), + decimal128Value("+00.000"), + decimal128Value("00.000"), + decimal128Value("-00.000"), + decimal128Value("-00.000e-10"), + decimal128Value("-00.000e-0"), + decimal128Value("-00.000e10"), + }); + groups.add(new Value[] {doubleValue(Double.MIN_VALUE)}); + groups.add(new Value[] {intValue(1), doubleValue(1.0), int32Value(1)}); + groups.add(new Value[] {doubleValue(1.1)}); + groups.add(new Value[] {doubleValue(2.0), decimal128Value("2.0")}); + groups.add(new Value[] {int32Value(11), decimal128Value("11")}); + groups.add(new Value[] {int32Value(12), decimal128Value("12")}); + groups.add( + new Value[] { + intValue(Integer.MAX_VALUE), int32Value(Integer.MAX_VALUE), decimal128Value("2147483647") + }); + groups.add(new Value[] {intValue((long) Integer.MAX_VALUE + 1), decimal128Value("2147483648")}); + groups.add(new Value[] {doubleValue(((double) Long.MAX_VALUE) + 100)}); + groups.add(new Value[] {doubleValue(Double.POSITIVE_INFINITY), decimal128Value("Infinity")}); + + groups.add(new Value[] {timestampValue(123, 0)}); + groups.add(new Value[] {timestampValue(123, 123)}); + groups.add(new Value[] {timestampValue(345, 0)}); + + // BSON Timestamp + groups.add(new Value[] {bsonTimestampValue(123, 4)}); + groups.add(new Value[] {bsonTimestampValue(123, 5)}); + groups.add(new Value[] {bsonTimestampValue(124, 0)}); // strings - groups[22] = new Value[] {stringValue("")}; - groups[23] = new Value[] {stringValue("\u0000\ud7ff\ue000\uffff")}; - groups[24] = new Value[] {stringValue("(╯°□°)╯︵ ┻━┻")}; - groups[25] = new Value[] {stringValue("a")}; - groups[26] = new Value[] {stringValue("abc def")}; + groups.add(new Value[] {stringValue("")}); + groups.add(new Value[] {stringValue("\u0000\ud7ff\ue000\uffff")}); + groups.add(new Value[] {stringValue("(╯°□°)╯︵ ┻━┻")}); + groups.add(new Value[] {stringValue("a")}); + groups.add(new Value[] {stringValue("abc def")}); // latin small letter e + combining acute accent + latin small letter b - groups[27] = new Value[] {stringValue("e\u0301b")}; - groups[28] = new Value[] {stringValue("æ")}; + groups.add(new Value[] {stringValue("e\u0301b")}); + groups.add(new Value[] {stringValue("æ")}); // latin small letter e with acute accent + latin small letter a - groups[29] = new Value[] {stringValue("\u00e9a")}; + groups.add(new Value[] {stringValue("\u00e9a")}); // blobs - groups[30] = new Value[] {blobValue(new byte[] {})}; - groups[31] = new Value[] {blobValue(new byte[] {0})}; - groups[32] = new Value[] {blobValue(new byte[] {0, 1, 2, 3, 4})}; - groups[33] = new Value[] {blobValue(new byte[] {0, 1, 2, 4, 3})}; - groups[34] = new Value[] {blobValue(new byte[] {127})}; + groups.add(new Value[] {blobValue(new byte[] {})}); + groups.add(new Value[] {blobValue(new byte[] {0})}); + groups.add(new Value[] {blobValue(new byte[] {0, 1, 2, 3, 4})}); + groups.add(new Value[] {blobValue(new byte[] {0, 1, 2, 4, 3})}); + groups.add(new Value[] {blobValue(new byte[] {127})}); + + // BSON Binary Data + groups.add(new Value[] {bsonBinaryData(5, new byte[] {})}); + groups.add(new Value[] {bsonBinaryData(5, new byte[] {0}), bsonBinaryData(5, new byte[] {0})}); + groups.add(new Value[] {bsonBinaryData(7, new byte[] {0, 1, 2, 3, 4})}); + groups.add(new Value[] {bsonBinaryData(7, new byte[] {0, 1, 2, 4, 3})}); // resource names - groups[35] = new Value[] {referenceValue("projects/p1/databases/d1/documents/c1/doc1")}; - groups[36] = new Value[] {referenceValue("projects/p1/databases/d1/documents/c1/doc2")}; - groups[37] = new Value[] {referenceValue("projects/p1/databases/d1/documents/c1/doc2/c2/doc1")}; - groups[38] = new Value[] {referenceValue("projects/p1/databases/d1/documents/c1/doc2/c2/doc2")}; - groups[39] = new Value[] {referenceValue("projects/p1/databases/d1/documents/c10/doc1")}; - groups[40] = new Value[] {referenceValue("projects/p1/databases/d1/documents/c2/doc1")}; - groups[41] = new Value[] {referenceValue("projects/p2/databases/d2/documents/c1/doc1")}; - groups[42] = new Value[] {referenceValue("projects/p2/databases/d2/documents/c1-/doc1")}; - groups[43] = new Value[] {referenceValue("projects/p2/databases/d3/documents/c1-/doc1")}; + groups.add(new Value[] {referenceValue("projects/p1/databases/d1/documents/c1/doc1")}); + groups.add(new Value[] {referenceValue("projects/p1/databases/d1/documents/c1/doc2")}); + groups.add(new Value[] {referenceValue("projects/p1/databases/d1/documents/c1/doc2/c2/doc1")}); + groups.add(new Value[] {referenceValue("projects/p1/databases/d1/documents/c1/doc2/c2/doc2")}); + groups.add(new Value[] {referenceValue("projects/p1/databases/d1/documents/c10/doc1")}); + groups.add(new Value[] {referenceValue("projects/p1/databases/d1/documents/c2/doc1")}); + groups.add(new Value[] {referenceValue("projects/p2/databases/d2/documents/c1/doc1")}); + groups.add(new Value[] {referenceValue("projects/p2/databases/d2/documents/c1-/doc1")}); + groups.add(new Value[] {referenceValue("projects/p2/databases/d3/documents/c1-/doc1")}); + + // BSON ObjectId + groups.add(new Value[] {bsonObjectIdValue("foo"), bsonObjectIdValue("foo")}); + groups.add(new Value[] {bsonObjectIdValue("foo\\u0301")}); + groups.add(new Value[] {bsonObjectIdValue("xyz")}); + groups.add( + new Value[] {bsonObjectIdValue("Ḟoo")}); // with latin capital letter f with dot above // geo points - groups[44] = new Value[] {geoPointValue(-90, -180)}; - groups[45] = new Value[] {geoPointValue(-90, 0)}; - groups[46] = new Value[] {geoPointValue(-90, 180)}; - groups[47] = new Value[] {geoPointValue(0, -180)}; - groups[48] = new Value[] {geoPointValue(0, 0)}; - groups[49] = new Value[] {geoPointValue(0, 180)}; - groups[50] = new Value[] {geoPointValue(1, -180)}; - groups[51] = new Value[] {geoPointValue(1, 0)}; - groups[52] = new Value[] {geoPointValue(1, 180)}; - groups[53] = new Value[] {geoPointValue(90, -180)}; - groups[54] = new Value[] {geoPointValue(90, 0)}; - groups[55] = new Value[] {geoPointValue(90, 180)}; + groups.add(new Value[] {geoPointValue(-90, -180)}); + groups.add(new Value[] {geoPointValue(-90, 0)}); + groups.add(new Value[] {geoPointValue(-90, 180)}); + groups.add(new Value[] {geoPointValue(0, -180)}); + groups.add(new Value[] {geoPointValue(0, 0)}); + groups.add(new Value[] {geoPointValue(0, 180)}); + groups.add(new Value[] {geoPointValue(1, -180)}); + groups.add(new Value[] {geoPointValue(1, 0)}); + groups.add(new Value[] {geoPointValue(1, 180)}); + groups.add(new Value[] {geoPointValue(90, -180)}); + groups.add(new Value[] {geoPointValue(90, 0)}); + groups.add(new Value[] {geoPointValue(90, 180)}); + + // Regex + groups.add(new Value[] {regexValue("a", "bar1"), regexValue("a", "bar1")}); + groups.add(new Value[] {regexValue("foo", "bar1")}); + groups.add(new Value[] {regexValue("foo", "bar2")}); + groups.add(new Value[] {regexValue("go", "bar1")}); // arrays - groups[56] = new Value[] {arrayValue()}; - groups[57] = new Value[] {arrayValue(stringValue("bar"))}; - groups[58] = new Value[] {arrayValue(stringValue("foo"))}; - groups[59] = new Value[] {arrayValue(stringValue("foo"), intValue(0))}; - groups[60] = new Value[] {arrayValue(stringValue("foo"), intValue(1))}; - groups[61] = new Value[] {arrayValue(stringValue("foo"), stringValue("0"))}; + groups.add(new Value[] {arrayValue()}); + groups.add(new Value[] {arrayValue(stringValue("bar"))}); + groups.add(new Value[] {arrayValue(stringValue("foo"))}); + groups.add(new Value[] {arrayValue(stringValue("foo"), intValue(0))}); + groups.add(new Value[] {arrayValue(stringValue("foo"), intValue(1))}); + groups.add(new Value[] {arrayValue(stringValue("foo"), stringValue("0"))}); // objects - groups[62] = new Value[] {objectValue("bar", intValue(0))}; - groups[63] = new Value[] {objectValue("bar", intValue(0), "foo", intValue(1))}; - groups[64] = new Value[] {objectValue("bar", intValue(1))}; - groups[65] = new Value[] {objectValue("bar", intValue(2))}; - groups[66] = new Value[] {objectValue("bar", stringValue("0"))}; - - for (int left = 0; left < groups.length; left++) { - for (int right = 0; right < groups.length; right++) { - for (int i = 0; i < groups[left].length; i++) { - for (int j = 0; j < groups[right].length; j++) { + groups.add(new Value[] {objectValue("bar", intValue(0))}); + groups.add(new Value[] {objectValue("bar", intValue(0), "foo", intValue(1))}); + groups.add(new Value[] {objectValue("bar", intValue(1))}); + groups.add(new Value[] {objectValue("bar", intValue(2))}); + groups.add(new Value[] {objectValue("bar", stringValue("0"))}); + + groups.add(new Value[] {maxKeyValue(), maxKeyValue()}); + + for (int left = 0; left < groups.size(); left++) { + for (int right = 0; right < groups.size(); right++) { + for (int i = 0; i < groups.get(left).length; i++) { + for (int j = 0; j < groups.get(right).length; j++) { assertEquals( String.format( "Order does not match for: groups[%d][%d] and groups[%d][%d]", left, i, right, j), Integer.compare(left, right), - Integer.compare(Order.INSTANCE.compare(groups[left][i], groups[right][j]), 0)); + Integer.compare( + Order.INSTANCE.compare(groups.get(left)[i], groups.get(right)[j]), 0)); } } } @@ -171,6 +234,40 @@ private Value nullValue() { return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); } + private Value minKeyValue() { + return Value.newBuilder().setMapValue(MinKey.instance().toProto()).build(); + } + + private Value maxKeyValue() { + return Value.newBuilder().setMapValue(MaxKey.instance().toProto()).build(); + } + + private Value regexValue(String pattern, String options) { + return Value.newBuilder().setMapValue(new RegexValue(pattern, options).toProto()).build(); + } + + private Value int32Value(int value) { + return Value.newBuilder().setMapValue(new Int32Value(value).toProto()).build(); + } + + private Value decimal128Value(String value) { + return Value.newBuilder().setMapValue(new Decimal128Value(value).toProto()).build(); + } + + private Value bsonObjectIdValue(String oid) { + return Value.newBuilder().setMapValue(new BsonObjectId(oid).toProto()).build(); + } + + private Value bsonTimestampValue(long seconds, long increment) { + return Value.newBuilder().setMapValue(new BsonTimestamp(seconds, increment).toProto()).build(); + } + + private Value bsonBinaryData(int subtype, byte[] data) { + return Value.newBuilder() + .setMapValue(BsonBinaryData.fromBytes(subtype, data).toProto()) + .build(); + } + private Value timestampValue(long seconds, int nanos) { return Value.newBuilder() .setTimestampValue(Timestamp.newBuilder().setSeconds(seconds).setNanos(nanos).build()) diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITQueryWatchTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITQueryWatchTest.java index b65cc42d8..3f8314a68 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITQueryWatchTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITQueryWatchTest.java @@ -26,20 +26,35 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.cloud.Timestamp; +import com.google.cloud.firestore.Blob; +import com.google.cloud.firestore.BsonBinaryData; +import com.google.cloud.firestore.BsonObjectId; +import com.google.cloud.firestore.BsonTimestamp; import com.google.cloud.firestore.CollectionReference; +import com.google.cloud.firestore.Decimal128Value; import com.google.cloud.firestore.DocumentChange; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.DocumentSnapshot; import com.google.cloud.firestore.EventListener; import com.google.cloud.firestore.FieldPath; import com.google.cloud.firestore.FieldValue; +import com.google.cloud.firestore.Filter; import com.google.cloud.firestore.FirestoreException; +import com.google.cloud.firestore.GeoPoint; +import com.google.cloud.firestore.Int32Value; import com.google.cloud.firestore.ListenerRegistration; import com.google.cloud.firestore.LocalFirestoreHelper; +import com.google.cloud.firestore.MaxKey; +import com.google.cloud.firestore.MinKey; import com.google.cloud.firestore.Query; import com.google.cloud.firestore.Query.Direction; import com.google.cloud.firestore.QueryDocumentSnapshot; import com.google.cloud.firestore.QuerySnapshot; +import com.google.cloud.firestore.RegexValue; +import com.google.cloud.firestore.WriteResult; import com.google.cloud.firestore.it.ITQueryWatchTest.QuerySnapshotEventListener.ListenerAssertions; import com.google.common.base.Joiner; import com.google.common.base.Joiner.MapJoiner; @@ -60,6 +75,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import javax.annotation.Nullable; import org.junit.Before; @@ -759,6 +775,247 @@ public void snapshotListenerSortsFilteredQueryByDocumentIdInTheSameOrderAsServer assertEquals(expectedOrder, listenerOrder); // Assert order in the SDK } + private List> toDataArray(QuerySnapshot snapshot) { + return snapshot.getDocuments().stream() + .map(queryDocumentSnapshot -> queryDocumentSnapshot.getData()) + .collect(Collectors.toList()); + } + + private List toDocumentIdArray(QuerySnapshot snapshot) { + return snapshot.getDocuments().stream() + .map(queryDocumentSnapshot -> queryDocumentSnapshot.getId()) + .collect(Collectors.toList()); + } + + @Test + public void canFilterAndOrderObjectId() throws Exception { + Map> data = + map( + "doc1", map("key", new BsonObjectId("507f191e810c19729de860ea")), + "doc2", map("key", new BsonObjectId("507f191e810c19729de860eb")), + "doc3", map("key", new BsonObjectId("507f191e810c19729de860ec"))); + addDocs(data); + + QuerySnapshot snapshot = + getFirstSnapshot( + randomColl + .whereGreaterThan("key", new BsonObjectId("507f191e810c19729de860ea")) + .orderBy("key", Direction.DESCENDING)); + List> resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc3"), data.get("doc2"))); + + snapshot = + getFirstSnapshot( + randomColl + .whereIn( + "key", + Arrays.asList( + new BsonObjectId("507f191e810c19729de860ea"), + new BsonObjectId("507f191e810c19729de860eb"))) + .orderBy("key", Direction.DESCENDING)); + resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc2"), data.get("doc1"))); + } + + @Test + public void canFilterAndOrderInt32() throws Exception { + Map> data = + map( + "doc1", map("key", new Int32Value(-1)), + "doc2", map("key", new Int32Value(1)), + "doc3", map("key", new Int32Value(2))); + addDocs(data); + + QuerySnapshot snapshot = + getFirstSnapshot( + randomColl + .whereGreaterThanOrEqualTo("key", new Int32Value(1)) + .orderBy("key", Direction.DESCENDING)); + List> resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc3"), data.get("doc2"))); + + snapshot = + getFirstSnapshot( + randomColl + .whereNotIn("key", Arrays.asList(new Int32Value(1))) + .orderBy("key", Direction.DESCENDING)); + resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc3"), data.get("doc1"))); + } + + @Test + public void canFilterAndOrderDecimal128() throws Exception { + Map> data = + map( + "doc1", map("key", new Decimal128Value("-1.2e3")), + "doc2", map("key", new Decimal128Value("0")), + "doc3", map("key", new Decimal128Value("1.2e-3"))); + addDocs(data); + + QuerySnapshot snapshot = + getFirstSnapshot( + randomColl + .whereGreaterThanOrEqualTo("key", new Decimal128Value("-1.1")) + .orderBy("key", Direction.DESCENDING)); + List> resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc3"), data.get("doc2"))); + + snapshot = + getFirstSnapshot( + randomColl + .whereNotIn("key", Arrays.asList(new Decimal128Value("1.2e-3"))) + .orderBy("key", Direction.DESCENDING)); + resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc2"), data.get("doc1"))); + } + + @Test + public void canFilterAndOrderBsonTimestamp() throws Exception { + Map> data = + map( + "doc1", map("key", new BsonTimestamp(1, 1)), + "doc2", map("key", new BsonTimestamp(1, 2)), + "doc3", map("key", new BsonTimestamp(2, 1))); + addDocs(data); + + QuerySnapshot snapshot = + getFirstSnapshot( + randomColl + .whereGreaterThan("key", new BsonTimestamp(1, 1)) + .orderBy("key", Direction.DESCENDING)); + List> resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc3"), data.get("doc2"))); + + snapshot = + getFirstSnapshot( + randomColl + .whereNotEqualTo("key", new BsonTimestamp(1, 1)) + .orderBy("key", Direction.DESCENDING)); + resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc3"), data.get("doc2"))); + } + + @Test + public void canFilterAndOrderBsonBinaryData() throws Exception { + Map> data = + map( + "doc1", map("key", BsonBinaryData.fromBytes(1, new byte[] {1, 2, 3})), + "doc2", map("key", BsonBinaryData.fromBytes(1, new byte[] {1, 2, 4})), + "doc3", map("key", BsonBinaryData.fromBytes(2, new byte[] {1, 2, 3}))); + addDocs(data); + + QuerySnapshot snapshot = + getFirstSnapshot( + randomColl + .whereGreaterThan("key", BsonBinaryData.fromBytes(1, new byte[] {1, 2, 3})) + .orderBy("key", Direction.DESCENDING)); + List> resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc3"), data.get("doc2"))); + + snapshot = + getFirstSnapshot( + randomColl + .whereGreaterThanOrEqualTo("key", BsonBinaryData.fromBytes(1, new byte[] {1, 2, 3})) + .whereLessThan("key", BsonBinaryData.fromBytes(2, new byte[] {1, 2, 3})) + .orderBy("key", Direction.DESCENDING)); + resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc2"), data.get("doc1"))); + } + + @Test + public void canFilterAndOrderRegexValues() throws Exception { + Map> data = + map( + "doc1", map("key", new RegexValue("^bar", "i")), + "doc2", map("key", new RegexValue("^bar", "x")), + "doc3", map("key", new RegexValue("^baz", "i"))); + addDocs(data); + + QuerySnapshot snapshot = + getFirstSnapshot( + randomColl + .where( + Filter.or( + Filter.greaterThan("key", new RegexValue("^bar", "x")), + Filter.notEqualTo("key", new RegexValue("^bar", "x")))) + .orderBy("key", Direction.DESCENDING)); + List> resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc3"), data.get("doc1"))); + } + + @Test + public void canFilterAndOrderMinKeys() throws Exception { + Map> data = + map( + "doc1", map("key", MinKey.instance()), + "doc2", map("key", MinKey.instance()), + "doc3", map("key", MaxKey.instance())); + addDocs(data); + + // MinKeys are equal, would sort by documentId as secondary order + QuerySnapshot snapshot = + getFirstSnapshot( + randomColl.whereEqualTo("key", MinKey.instance()).orderBy("key", Direction.DESCENDING)); + List> resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc2"), data.get("doc1"))); + } + + @Test + public void canFilterAndOrderMaxKeys() throws Exception { + Map> data = + map( + "doc1", map("key", MinKey.instance()), + "doc2", map("key", MaxKey.instance()), + "doc3", map("key", MaxKey.instance())); + addDocs(data); + + // MaxKeys are equal, would sort by documentId as secondary order + QuerySnapshot snapshot = + getFirstSnapshot( + randomColl.whereEqualTo("key", MaxKey.instance()).orderBy("key", Direction.DESCENDING)); + List> resultData = toDataArray(snapshot); + assertThat(resultData).isEqualTo(Arrays.asList(data.get("doc3"), data.get("doc2"))); + } + + @Test + public void crossTypeOrder() throws Exception { + Map> data = + map( + "t", map("key", null), + "u", map("key", MinKey.instance()), + "c", map("key", true), + "d", map("key", Double.NaN), + "e", map("key", new Int32Value(1)), + "f", map("key", 2.0), + "g", map("key", new Decimal128Value("2.01e-5")), + "h", map("key", 3), + "i", map("key", Timestamp.ofTimeSecondsAndNanos(100, 123456000)), + "j", map("key", new BsonTimestamp(1, 2)), + "k", map("key", "string"), + "l", map("key", Blob.fromBytes(new byte[] {0, 1, 3})), + "m", map("key", BsonBinaryData.fromBytes(1, new byte[] {1, 2, 3})), + "n", map("key", randomColl.getFirestore().collection("c1").document("doc")), + "o", map("key", new BsonObjectId("507f191e810c19729de860ea")), + "p", map("key", new GeoPoint(0, 0)), + "q", map("key", new RegexValue("^foo", "i")), + "r", map("key", Arrays.asList(1, 2)), + "s", map("key", FieldValue.vector(new double[] {1, 2})), + "a", map("key", Collections.singletonMap("a", 1)), + "b", map("key", MaxKey.instance())); + addDocs(data); + + List expectedResult = + Arrays.asList( + "b", "a", "s", "r", "q", "p", "o", "n", "m", "l", "k", "j", "i", "h", "f", "e", "g", + "d", "c", "u", "t"); + + Query query = randomColl.orderBy("key", Direction.DESCENDING); + QuerySnapshot getResult = query.get().get(); + QuerySnapshot listenResult = getFirstSnapshot(query); + assertThat(toDocumentIdArray(getResult)).isEqualTo(expectedResult); + assertThat(toDocumentIdArray(listenResult)).isEqualTo(expectedResult); + } + /** * A tuple class used by {@code #queryWatch}. This class represents an event delivered to the * registered query listener. @@ -1095,4 +1352,30 @@ private ListenResponse filter(int documentCount) { response.setFilter(ExistenceFilter.newBuilder().setCount(documentCount).build()); return response.build(); } + + private void addDocs(Map> docToData) throws Exception { + List> futures = new ArrayList<>(); + for (Map.Entry> entry : docToData.entrySet()) { + futures.add(randomColl.document(entry.getKey()).set(entry.getValue())); + } + ApiFutures.allAsList(futures).get(); + } + + /** + * Initiates a snapshot listener for the given query, and returns the first snapshot it receives. + */ + private QuerySnapshot getFirstSnapshot(Query query) throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference snapshot = new AtomicReference<>(); + ListenerRegistration registration = + query.addSnapshotListener( + (value, error) -> { + snapshot.set(value); + latch.countDown(); + }); + + latch.await(); + registration.remove(); + return snapshot.get(); + } } diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java index 0281f58a9..94b44f619 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java @@ -45,8 +45,12 @@ import com.google.api.gax.retrying.RetrySettings; import com.google.api.gax.rpc.ApiStreamObserver; import com.google.cloud.Timestamp; +import com.google.cloud.firestore.BsonBinaryData; +import com.google.cloud.firestore.BsonObjectId; +import com.google.cloud.firestore.BsonTimestamp; import com.google.cloud.firestore.BulkWriter; import com.google.cloud.firestore.CollectionReference; +import com.google.cloud.firestore.Decimal128Value; import com.google.cloud.firestore.DocumentReference; import com.google.cloud.firestore.DocumentSnapshot; import com.google.cloud.firestore.FieldMask; @@ -56,16 +60,20 @@ import com.google.cloud.firestore.FirestoreBundle; import com.google.cloud.firestore.FirestoreException; import com.google.cloud.firestore.FirestoreOptions; +import com.google.cloud.firestore.Int32Value; import com.google.cloud.firestore.ListenerRegistration; import com.google.cloud.firestore.LocalFirestoreHelper; import com.google.cloud.firestore.LocalFirestoreHelper.AllSupportedTypes; import com.google.cloud.firestore.LocalFirestoreHelper.SingleField; +import com.google.cloud.firestore.MaxKey; +import com.google.cloud.firestore.MinKey; import com.google.cloud.firestore.Precondition; import com.google.cloud.firestore.Query; import com.google.cloud.firestore.Query.Direction; import com.google.cloud.firestore.QueryDocumentSnapshot; import com.google.cloud.firestore.QueryPartition; import com.google.cloud.firestore.QuerySnapshot; +import com.google.cloud.firestore.RegexValue; import com.google.cloud.firestore.SetOptions; import com.google.cloud.firestore.Transaction; import com.google.cloud.firestore.Transaction.Function; @@ -1332,6 +1340,17 @@ private Map getData() throws ExecutionException, InterruptedExce return randomDoc.get().get().getData(); } + private void checkRoundTrip(T value) throws Exception { + randomDoc.set(Collections.singletonMap("key", value)).get(); + DocumentSnapshot snapshot = randomDoc.get().get(); + Object fieldValue = snapshot.get("key"); + if (!value.getClass().isInstance(fieldValue)) { + throw new RuntimeException("Error: round trip value has a different type."); + } + T roundtripValue = (T) fieldValue; + assertThat(value).isEqualTo(roundtripValue); + } + @Test public void writeAndReadVectorEmbeddings() throws ExecutionException, InterruptedException { Map expected = new HashMap<>(); @@ -2311,4 +2330,126 @@ public void testEnforcesTimeouts() { FirestoreException.class, () -> collection.document().listCollections().iterator().hasNext()); } + + // Tests for non-native Firestore types. + + @Test + public void canWriteAndReadBackMinKey() throws Exception { + checkRoundTrip(MinKey.instance()); + } + + @Test + public void canWriteAndReadBackMaxKey() throws Exception { + checkRoundTrip(MaxKey.instance()); + } + + @Test + public void canWriteAndReadBackRegex() throws Exception { + checkRoundTrip(new RegexValue("^foo", "i")); + } + + @Test + public void canWriteAndReadBackInt32() throws Exception { + checkRoundTrip(new Int32Value(-57)); + checkRoundTrip(new Int32Value(0)); + checkRoundTrip(new Int32Value(57)); + } + + @Test + public void canWriteAndReadBackDecimal128() throws Exception { + checkRoundTrip(new Decimal128Value("NaN")); + checkRoundTrip(new Decimal128Value("-Infinity")); + checkRoundTrip(new Decimal128Value("-1.2e3")); + checkRoundTrip(new Decimal128Value("-4.2e+3")); + checkRoundTrip(new Decimal128Value("-1.2e-3")); + checkRoundTrip(new Decimal128Value("-4.2e-3")); + checkRoundTrip(new Decimal128Value("-1")); + checkRoundTrip(new Decimal128Value("-0")); + checkRoundTrip(new Decimal128Value("0")); + checkRoundTrip(new Decimal128Value("1")); + checkRoundTrip(new Decimal128Value("1.2e3")); + checkRoundTrip(new Decimal128Value("4.2e+3")); + checkRoundTrip(new Decimal128Value("1.2e-3")); + checkRoundTrip(new Decimal128Value("4.2e-3")); + checkRoundTrip(new Decimal128Value("Infinity")); + } + + @Test + public void canQueryNumericallyEqualNumbersOfDifferentTypes() throws Exception { + randomColl + .document("doc1") + .set(Collections.singletonMap("key", new Decimal128Value("1.0"))) + .get(); + randomColl.document("doc2").set(Collections.singletonMap("key", 1.0)).get(); + randomColl.document("doc3").set(Collections.singletonMap("key", 1)).get(); + randomColl + .document("doc4") + .set(Collections.singletonMap("key", new Decimal128Value("1.5"))) + .get(); + randomColl.document("doc5").set(Collections.singletonMap("key", 1.5)).get(); + + Query query1 = randomColl.whereEqualTo("key", 1); + assertEquals(asList("doc1", "doc2", "doc3"), querySnapshotToIds(query1.get().get())); + + Query query2 = randomColl.whereEqualTo("key", new Decimal128Value("1.5")); + assertEquals(asList("doc4", "doc5"), querySnapshotToIds(query2.get().get())); + } + + @Test + public void canWriteAndReadBackBsonObjectId() throws Exception { + checkRoundTrip(new BsonObjectId("507f191e810c19729de860ea")); + } + + @Test + public void canWriteAndReadBackBsonTimestamp() throws Exception { + checkRoundTrip(new BsonTimestamp(123, 45)); + } + + @Test + public void canWriteAndReadBackBsonBinaryData() throws Exception { + checkRoundTrip(BsonBinaryData.fromBytes(127, new byte[] {1, 2, 3})); + } + + @Test + public void invalidRegexGetsRejected() throws Exception { + Exception error = null; + try { + randomColl.document().set(Collections.singletonMap("key", new RegexValue("foo", "a"))).get(); + } catch (Exception e) { + error = e; + } + assertThat(error).isNotNull(); + assertThat(error.getMessage()) + .contains("Invalid regex option 'a'. Supported options are 'i', 'm', 's', 'u', and 'x'"); + } + + @Test + public void invalidBsonObjectIdGetsRejected() throws Exception { + Exception error = null; + try { + randomColl.document().set(Collections.singletonMap("key", new BsonObjectId("foobar"))).get(); + } catch (Exception e) { + error = e; + } + assertThat(error).isNotNull(); + assertThat(error.getMessage()).contains("Object ID hex string has incorrect length."); + } + + @Test + public void invalidBsonBinaryDataGetsRejected() throws Exception { + Exception error = null; + try { + randomColl + .document() + .set( + Collections.singletonMap("key", BsonBinaryData.fromBytes(1234, new byte[] {1, 2, 3}))) + .get(); + } catch (Exception e) { + error = e; + } + assertThat(error).isNotNull(); + assertThat(error.getMessage()) + .contains( + "The subtype for BsonBinaryData must be a value in the inclusive [0, 255] range."); + } }