diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ca6f52a97..d8b90da03 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,6 +36,7 @@ jobs: - run: .kokoro/build.sh env: JOB_TYPE: test + JAVA_VERSION: ${{matrix.java}} windows: runs-on: windows-latest steps: diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 205347243..f9b99579a 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -48,7 +48,12 @@ set +e case ${JOB_TYPE} in test) echo "SUREFIRE_JVM_OPT: ${SUREFIRE_JVM_OPT}" - mvn test -B -ntp -Dclirr.skip=true -Denforcer.skip=true ${SUREFIRE_JVM_OPT} + if [[ "${JAVA_VERSION}" == "17" ]]; then + # Activate the runTestsWithJava17 profile inside google-cloud-firestore + mvn test -B -ntp -Dclirr.skip=true -Denforcer.skip=true ${SUREFIRE_JVM_OPT} -DrunTestsWithJava17=true + else + mvn test -B -ntp -Dclirr.skip=true -Denforcer.skip=true ${SUREFIRE_JVM_OPT} + fi RETURN_CODE=$? ;; lint) diff --git a/google-cloud-firestore/pom.xml b/google-cloud-firestore/pom.xml index fe57dc63f..27fa5c8d8 100644 --- a/google-cloud-firestore/pom.xml +++ b/google-cloud-firestore/pom.xml @@ -321,5 +321,54 @@ + + + java17-test + + + + runTestsWithJava17 + true + + false + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-test-source + generate-test-sources + + add-test-source + + + + src/test-jdk17/java + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + true + + + 17 + 17 + + -parameters + --add-opens=java.base/java.lang=ALL-UNNAMED + --add-opens=java.base/java.util=ALL-UNNAMED + + + + + + diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CollectionReference.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CollectionReference.java index c736d7028..e1c2841d6 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CollectionReference.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CollectionReference.java @@ -22,6 +22,7 @@ import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.ApiExceptions; import com.google.api.gax.rpc.UnaryCallable; +import com.google.cloud.firestore.encoding.CustomClassMapper; import com.google.cloud.firestore.spi.v1.FirestoreRpc; import com.google.cloud.firestore.telemetry.TraceUtil; import com.google.cloud.firestore.telemetry.TraceUtil.Scope; diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CustomClassMapper.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CustomClassMapper.java deleted file mode 100644 index 1369091e4..000000000 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CustomClassMapper.java +++ /dev/null @@ -1,1279 +0,0 @@ -/* - * Copyright 2017 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.cloud.Timestamp; -import com.google.cloud.firestore.annotation.DocumentId; -import com.google.cloud.firestore.annotation.Exclude; -import com.google.cloud.firestore.annotation.IgnoreExtraProperties; -import com.google.cloud.firestore.annotation.PropertyName; -import com.google.cloud.firestore.annotation.ServerTimestamp; -import com.google.cloud.firestore.annotation.ThrowOnExtraProperties; -import com.google.firestore.v1.Value; -import java.lang.reflect.AccessibleObject; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.GenericArrayType; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.lang.reflect.TypeVariable; -import java.lang.reflect.WildcardType; -import java.math.BigDecimal; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.logging.Logger; - -/** Helper class to convert to/from custom POJO classes and plain Java types. */ -class CustomClassMapper { - private static final Logger LOGGER = Logger.getLogger(CustomClassMapper.class.getName()); - - /** Maximum depth before we give up and assume it's a recursive object graph. */ - private static final int MAX_DEPTH = 500; - - private static final ConcurrentMap, BeanMapper> mappers = new ConcurrentHashMap<>(); - - private static void hardAssert(boolean assertion) { - hardAssert(assertion, "Internal inconsistency"); - } - - private static void hardAssert(boolean assertion, String message) { - if (!assertion) { - throw new RuntimeException("Hard assert failed: " + message); - } - } - - /** - * Converts a Java representation of JSON data to standard library Java data types: Map, Array, - * String, Double, Integer and Boolean. POJOs are converted to Java Maps. - * - * @param object The representation of the JSON data - * @return JSON representation containing only standard library Java types - */ - static Object convertToPlainJavaTypes(Object object) { - return serialize(object); - } - - public static Map convertToPlainJavaTypes(Map update) { - Object converted = serialize(update); - hardAssert(converted instanceof Map); - @SuppressWarnings("unchecked") - Map convertedMap = (Map) converted; - return convertedMap; - } - - /** - * Converts a standard library Java representation of JSON data to an object of the provided - * class. - * - * @param object The representation of the JSON data - * @param clazz The class of the object to convert to - * @param docRef The value to set to {@link DocumentId} annotated fields in the custom class. - * @return The POJO object. - */ - static T convertToCustomClass(Object object, Class clazz, DocumentReference docRef) { - return deserializeToClass(object, clazz, new DeserializeContext(ErrorPath.EMPTY, docRef)); - } - - static Object serialize(T o) { - return serialize(o, ErrorPath.EMPTY); - } - - @SuppressWarnings("unchecked") - private static Object serialize(T o, ErrorPath path) { - if (path.getLength() > MAX_DEPTH) { - throw serializeError( - path, - "Exceeded maximum depth of " - + MAX_DEPTH - + ", which likely indicates there's an object cycle"); - } - if (o == null) { - return null; - } else if (o instanceof Number) { - if (o instanceof Long || o instanceof Integer || o instanceof Double || o instanceof Float) { - return o; - } else if (o instanceof BigDecimal) { - return String.valueOf(o); - } else { - throw serializeError( - path, - String.format( - "Numbers of type %s are not supported, please use an int, long, float, double or BigDecimal", - o.getClass().getSimpleName())); - } - } else if (o instanceof String) { - return o; - } else if (o instanceof Boolean) { - return o; - } else if (o instanceof Character) { - throw serializeError(path, "Characters are not supported, please use Strings"); - } else if (o instanceof Map) { - Map result = new HashMap<>(); - for (Map.Entry entry : ((Map) o).entrySet()) { - Object key = entry.getKey(); - if (key instanceof String) { - String keyString = (String) key; - result.put(keyString, serialize(entry.getValue(), path.child(keyString))); - } else { - throw serializeError(path, "Maps with non-string keys are not supported"); - } - } - return result; - } else if (o instanceof Collection) { - if (o instanceof List) { - List list = (List) o; - List result = new ArrayList<>(list.size()); - for (int i = 0; i < list.size(); i++) { - result.add(serialize(list.get(i), path.child("[" + i + "]"))); - } - return result; - } else { - throw serializeError( - path, "Serializing Collections is not supported, please use Lists instead"); - } - } else if (o.getClass().isArray()) { - throw serializeError(path, "Serializing Arrays is not supported, please use Lists instead"); - } else if (o instanceof Enum) { - String enumName = ((Enum) o).name(); - try { - Field enumField = o.getClass().getField(enumName); - return BeanMapper.propertyName(enumField); - } catch (NoSuchFieldException ex) { - return enumName; - } - } else if (o instanceof Date - || o instanceof Timestamp - || o instanceof GeoPoint - || o instanceof Blob - || o instanceof DocumentReference - || o instanceof FieldValue - || o instanceof Value - || o instanceof VectorValue) { - return o; - } else if (o instanceof Instant) { - Instant instant = (Instant) o; - return Timestamp.ofTimeSecondsAndNanos(instant.getEpochSecond(), instant.getNano()); - } else { - Class clazz = (Class) o.getClass(); - BeanMapper mapper = loadOrCreateBeanMapperForClass(clazz); - return mapper.serialize(o, path); - } - } - - @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) - private static T deserializeToType(Object o, Type type, DeserializeContext context) { - if (o == null) { - return null; - } else if (type instanceof ParameterizedType) { - return deserializeToParameterizedType(o, (ParameterizedType) type, context); - } else if (type instanceof Class) { - return deserializeToClass(o, (Class) type, context); - } else if (type instanceof WildcardType) { - Type[] lowerBounds = ((WildcardType) type).getLowerBounds(); - if (lowerBounds.length > 0) { - throw deserializeError( - context.errorPath, "Generic lower-bounded wildcard types are not supported"); - } - - // Upper bounded wildcards are of the form . Multiple upper bounds are allowed - // but if any of the bounds are of class type, that bound must come first in this array. Note - // that this array always has at least one element, since the unbounded wildcard always - // has at least an upper bound of Object. - Type[] upperBounds = ((WildcardType) type).getUpperBounds(); - hardAssert(upperBounds.length > 0, "Unexpected type bounds on wildcard " + type); - return deserializeToType(o, upperBounds[0], context); - } else if (type instanceof TypeVariable) { - // As above, TypeVariables always have at least one upper bound of Object. - Type[] upperBounds = ((TypeVariable) type).getBounds(); - hardAssert(upperBounds.length > 0, "Unexpected type bounds on type variable " + type); - return deserializeToType(o, upperBounds[0], context); - - } else if (type instanceof GenericArrayType) { - throw deserializeError( - context.errorPath, "Generic Arrays are not supported, please use Lists instead"); - } else { - throw deserializeError(context.errorPath, "Unknown type encountered: " + type); - } - } - - @SuppressWarnings("unchecked") - private static T deserializeToClass(Object o, Class clazz, DeserializeContext context) { - if (o == null) { - return null; - } else if (clazz.isPrimitive() - || Number.class.isAssignableFrom(clazz) - || Boolean.class.isAssignableFrom(clazz) - || Character.class.isAssignableFrom(clazz)) { - return deserializeToPrimitive(o, clazz, context); - } else if (String.class.isAssignableFrom(clazz)) { - return (T) convertString(o, context); - } else if (Date.class.isAssignableFrom(clazz)) { - return (T) convertDate(o, context); - } else if (Timestamp.class.isAssignableFrom(clazz)) { - return (T) convertTimestamp(o, context); - } else if (Instant.class.isAssignableFrom(clazz)) { - return (T) convertInstant(o, context); - } else if (Blob.class.isAssignableFrom(clazz)) { - return (T) convertBlob(o, context); - } else if (GeoPoint.class.isAssignableFrom(clazz)) { - return (T) convertGeoPoint(o, context); - } else if (VectorValue.class.isAssignableFrom(clazz)) { - return (T) convertVectorValue(o, context); - } else if (DocumentReference.class.isAssignableFrom(clazz)) { - return (T) convertDocumentReference(o, context); - } else if (clazz.isArray()) { - throw deserializeError( - context.errorPath, "Converting to Arrays is not supported, please use Lists instead"); - } else if (clazz.getTypeParameters().length > 0) { - throw deserializeError( - context.errorPath, - "Class " - + clazz.getName() - + " has generic type parameters, please use GenericTypeIndicator instead"); - } else if (clazz.equals(Object.class)) { - return (T) o; - } else if (clazz.isEnum()) { - return deserializeToEnum(o, clazz, context); - } else { - return convertBean(o, clazz, context); - } - } - - @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) - private static T deserializeToParameterizedType( - Object o, ParameterizedType type, DeserializeContext context) { - // getRawType should always return a Class - Class rawType = (Class) type.getRawType(); - if (List.class.isAssignableFrom(rawType)) { - Type genericType = type.getActualTypeArguments()[0]; - if (o instanceof List) { - List list = (List) o; - List result; - try { - result = - (rawType == List.class) - ? new ArrayList<>(list.size()) - : (List) rawType.getDeclaredConstructor().newInstance(); - } catch (InstantiationException - | IllegalAccessException - | NoSuchMethodException - | InvocationTargetException e) { - throw deserializeError( - context.errorPath, - String.format( - "Unable to deserialize to %s: %s", rawType.getSimpleName(), e.toString())); - } - for (int i = 0; i < list.size(); i++) { - result.add( - deserializeToType( - list.get(i), - genericType, - context.newInstanceWithErrorPath(context.errorPath.child("[" + i + "]")))); - } - return (T) result; - } else { - throw deserializeError(context.errorPath, "Expected a List, but got a " + o.getClass()); - } - } else if (Map.class.isAssignableFrom(rawType)) { - Type keyType = type.getActualTypeArguments()[0]; - Type valueType = type.getActualTypeArguments()[1]; - if (!keyType.equals(String.class)) { - throw deserializeError( - context.errorPath, - "Only Maps with string keys are supported, but found Map with key type " + keyType); - } - Map map = expectMap(o, context); - HashMap result; - try { - result = - (rawType == Map.class) - ? new HashMap<>() - : (HashMap) rawType.getDeclaredConstructor().newInstance(); - } catch (InstantiationException - | IllegalAccessException - | NoSuchMethodException - | InvocationTargetException e) { - throw deserializeError( - context.errorPath, - String.format( - "Unable to deserialize to %s: %s", rawType.getSimpleName(), e.toString())); - } - for (Map.Entry entry : map.entrySet()) { - result.put( - entry.getKey(), - deserializeToType( - entry.getValue(), - valueType, - context.newInstanceWithErrorPath(context.errorPath.child(entry.getKey())))); - } - return (T) result; - } else if (Collection.class.isAssignableFrom(rawType)) { - throw deserializeError( - context.errorPath, "Collections are not supported, please use Lists instead"); - } else { - Map map = expectMap(o, context); - BeanMapper mapper = (BeanMapper) loadOrCreateBeanMapperForClass(rawType); - HashMap>, Type> typeMapping = new HashMap<>(); - TypeVariable>[] typeVariables = mapper.clazz.getTypeParameters(); - Type[] types = type.getActualTypeArguments(); - if (types.length != typeVariables.length) { - throw new IllegalStateException("Mismatched lengths for type variables and actual types"); - } - for (int i = 0; i < typeVariables.length; i++) { - typeMapping.put(typeVariables[i], types[i]); - } - return mapper.deserialize(map, typeMapping, context); - } - } - - @SuppressWarnings("unchecked") - private static T deserializeToPrimitive( - Object o, Class clazz, DeserializeContext context) { - if (Integer.class.isAssignableFrom(clazz) || int.class.isAssignableFrom(clazz)) { - return (T) convertInteger(o, context); - } else if (Boolean.class.isAssignableFrom(clazz) || boolean.class.isAssignableFrom(clazz)) { - return (T) convertBoolean(o, context); - } else if (Double.class.isAssignableFrom(clazz) || double.class.isAssignableFrom(clazz)) { - return (T) convertDouble(o, context); - } else if (Long.class.isAssignableFrom(clazz) || long.class.isAssignableFrom(clazz)) { - return (T) convertLong(o, context); - } else if (BigDecimal.class.isAssignableFrom(clazz)) { - return (T) convertBigDecimal(o, context); - } else if (Float.class.isAssignableFrom(clazz) || float.class.isAssignableFrom(clazz)) { - return (T) (Float) convertDouble(o, context).floatValue(); - } else { - throw deserializeError( - context.errorPath, - String.format("Deserializing values to %s is not supported", clazz.getSimpleName())); - } - } - - @SuppressWarnings("unchecked") - private static T deserializeToEnum( - Object object, Class clazz, DeserializeContext context) { - if (object instanceof String) { - String value = (String) object; - // We cast to Class without generics here since we can't prove the bound - // T extends Enum statically - - // try to use PropertyName if exist - Field[] enumFields = clazz.getFields(); - for (Field field : enumFields) { - if (field.isEnumConstant()) { - String propertyName = BeanMapper.propertyName(field); - if (value.equals(propertyName)) { - value = field.getName(); - break; - } - } - } - - try { - return (T) Enum.valueOf((Class) clazz, value); - } catch (IllegalArgumentException e) { - throw deserializeError( - context.errorPath, - "Could not find enum value of " + clazz.getName() + " for value \"" + value + "\""); - } - } else { - throw deserializeError( - context.errorPath, - "Expected a String while deserializing to enum " - + clazz - + " but got a " - + object.getClass()); - } - } - - private static BeanMapper loadOrCreateBeanMapperForClass(Class clazz) { - @SuppressWarnings("unchecked") - BeanMapper mapper = (BeanMapper) mappers.get(clazz); - if (mapper == null) { - mapper = new BeanMapper<>(clazz); - // Inserting without checking is fine because mappers are "pure" and it's okay - // if we create and use multiple by different threads temporarily - mappers.put(clazz, mapper); - } - return mapper; - } - - @SuppressWarnings("unchecked") - private static Map expectMap(Object object, DeserializeContext context) { - if (object instanceof Map) { - // TODO: runtime validation of keys? - return (Map) object; - } else { - throw deserializeError( - context.errorPath, "Expected a Map while deserializing, but got a " + object.getClass()); - } - } - - private static Integer convertInteger(Object o, DeserializeContext context) { - if (o instanceof Integer) { - return (Integer) o; - } else if (o instanceof Long || o instanceof Double) { - double value = ((Number) o).doubleValue(); - if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { - return ((Number) o).intValue(); - } else { - throw deserializeError( - context.errorPath, - "Numeric value out of 32-bit integer range: " - + value - + ". Did you mean to use a long or double instead of an int?"); - } - } else { - throw deserializeError( - context.errorPath, - "Failed to convert a value of type " + o.getClass().getName() + " to int"); - } - } - - private static Long convertLong(Object o, DeserializeContext context) { - if (o instanceof Integer) { - return ((Integer) o).longValue(); - } else if (o instanceof Long) { - return (Long) o; - } else if (o instanceof Double) { - Double value = (Double) o; - if (value >= Long.MIN_VALUE && value <= Long.MAX_VALUE) { - return value.longValue(); - } else { - throw deserializeError( - context.errorPath, - "Numeric value out of 64-bit long range: " - + value - + ". Did you mean to use a double instead of a long?"); - } - } else { - throw deserializeError( - context.errorPath, - "Failed to convert a value of type " + o.getClass().getName() + " to long"); - } - } - - private static Double convertDouble(Object o, DeserializeContext context) { - if (o instanceof Integer) { - return ((Integer) o).doubleValue(); - } else if (o instanceof Long) { - Long value = (Long) o; - Double doubleValue = ((Long) o).doubleValue(); - if (doubleValue.longValue() == value) { - return doubleValue; - } else { - throw deserializeError( - context.errorPath, - "Loss of precision while converting number to " - + "double: " - + o - + ". Did you mean to use a 64-bit long instead?"); - } - } else if (o instanceof Double) { - return (Double) o; - } else { - throw deserializeError( - context.errorPath, - "Failed to convert a value of type " + o.getClass().getName() + " to double"); - } - } - - private static BigDecimal convertBigDecimal(Object o, DeserializeContext context) { - if (o instanceof Integer) { - return BigDecimal.valueOf(((Integer) o).intValue()); - } else if (o instanceof Long) { - return BigDecimal.valueOf(((Long) o).longValue()); - } else if (o instanceof Double) { - return BigDecimal.valueOf(((Double) o).doubleValue()).abs(); - } else if (o instanceof BigDecimal) { - return (BigDecimal) o; - } else if (o instanceof String) { - return new BigDecimal((String) o); - } else { - throw deserializeError( - context.errorPath, - "Failed to convert a value of type " + o.getClass().getName() + " to BigDecimal"); - } - } - - private static Boolean convertBoolean(Object o, DeserializeContext context) { - if (o instanceof Boolean) { - return (Boolean) o; - } else { - throw deserializeError( - context.errorPath, - "Failed to convert value of type " + o.getClass().getName() + " to boolean"); - } - } - - private static String convertString(Object o, DeserializeContext context) { - if (o instanceof String) { - return (String) o; - } else { - throw deserializeError( - context.errorPath, - "Failed to convert value of type " + o.getClass().getName() + " to String"); - } - } - - private static Date convertDate(Object o, DeserializeContext context) { - if (o instanceof Date) { - return (Date) o; - } else if (o instanceof Timestamp) { - return ((Timestamp) o).toDate(); - } else { - throw deserializeError( - context.errorPath, - "Failed to convert value of type " + o.getClass().getName() + " to Date"); - } - } - - private static Timestamp convertTimestamp(Object o, DeserializeContext context) { - if (o instanceof Timestamp) { - return (Timestamp) o; - } else if (o instanceof Date) { - return Timestamp.of((Date) o); - } else { - throw deserializeError( - context.errorPath, - "Failed to convert value of type " + o.getClass().getName() + " to Timestamp"); - } - } - - private static Instant convertInstant(Object o, DeserializeContext context) { - if (o instanceof Timestamp) { - Timestamp timestamp = (Timestamp) o; - return Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos()); - } else if (o instanceof Date) { - return Instant.ofEpochMilli(((Date) o).getTime()); - } else { - throw deserializeError( - context.errorPath, - "Failed to convert value of type " + o.getClass().getName() + " to Instant"); - } - } - - private static Blob convertBlob(Object o, DeserializeContext context) { - if (o instanceof Blob) { - return (Blob) o; - } else { - throw deserializeError( - context.errorPath, - "Failed to convert value of type " + o.getClass().getName() + " to Blob"); - } - } - - private static GeoPoint convertGeoPoint(Object o, DeserializeContext context) { - if (o instanceof GeoPoint) { - return (GeoPoint) o; - } else { - throw deserializeError( - context.errorPath, - "Failed to convert value of type " + o.getClass().getName() + " to GeoPoint"); - } - } - - private static VectorValue convertVectorValue(Object o, DeserializeContext context) { - if (o instanceof VectorValue) { - return (VectorValue) o; - } else { - throw deserializeError( - context.errorPath, - "Failed to convert value of type " + o.getClass().getName() + " to VectorValue"); - } - } - - private static DocumentReference convertDocumentReference(Object o, DeserializeContext context) { - if (o instanceof DocumentReference) { - return (DocumentReference) o; - } else { - throw deserializeError( - context.errorPath, - "Failed to convert value of type " + o.getClass().getName() + " to DocumentReference"); - } - } - - private static T convertBean(Object o, Class clazz, DeserializeContext context) { - BeanMapper mapper = loadOrCreateBeanMapperForClass(clazz); - if (o instanceof Map) { - return mapper.deserialize(expectMap(o, context), context); - } else { - throw deserializeError( - context.errorPath, - "Can't convert object of type " + o.getClass().getName() + " to type " + clazz.getName()); - } - } - - private static IllegalArgumentException serializeError(ErrorPath path, String reason) { - reason = "Could not serialize object. " + reason; - if (path.getLength() > 0) { - reason = reason + " (found in field '" + path.toString() + "')"; - } - return new IllegalArgumentException(reason); - } - - private static RuntimeException deserializeError(ErrorPath path, String reason) { - reason = "Could not deserialize object. " + reason; - if (path.getLength() > 0) { - reason = reason + " (found in field '" + path.toString() + "')"; - } - return new RuntimeException(reason); - } - - // Helper class to convert from maps to custom objects (Beans), and vice versa. - private static class BeanMapper { - private final Class clazz; - private final Constructor constructor; - // Whether to throw exception if there are properties we don't know how to set to - // custom object fields/setters during deserialization. - private final boolean throwOnUnknownProperties; - // Whether to log a message if there are properties we don't know how to set to - // custom object fields/setters during deserialization. - private final boolean warnOnUnknownProperties; - - // Case insensitive mapping of properties to their case sensitive versions - private final Map properties; - - // Below are maps to find getter/setter/field from a given property name. - // A property name is the name annotated by @PropertyName, if exists; or their property name - // following the Java Bean convention: field name is kept as-is while getters/setters will have - // their prefixes removed. See method propertyName for details. - private final Map getters; - private final Map setters; - private final Map fields; - - // A set of property names that were annotated with @ServerTimestamp. - private final HashSet serverTimestamps; - - // A set of property names that were annotated with @DocumentId. These properties will be - // populated with document ID values during deserialization, and be skipped during - // serialization. - private final HashSet documentIdPropertyNames; - - BeanMapper(Class clazz) { - this.clazz = clazz; - throwOnUnknownProperties = clazz.isAnnotationPresent(ThrowOnExtraProperties.class); - warnOnUnknownProperties = !clazz.isAnnotationPresent(IgnoreExtraProperties.class); - properties = new HashMap<>(); - - setters = new HashMap<>(); - getters = new HashMap<>(); - fields = new HashMap<>(); - - serverTimestamps = new HashSet<>(); - documentIdPropertyNames = new HashSet<>(); - - Constructor constructor; - try { - constructor = clazz.getDeclaredConstructor(); - constructor.setAccessible(true); - } catch (NoSuchMethodException e) { - // We will only fail at deserialization time if no constructor is present - constructor = null; - } - this.constructor = constructor; - // Add any public getters to properties (including isXyz()) - for (Method method : clazz.getMethods()) { - if (shouldIncludeGetter(method)) { - String propertyName = propertyName(method); - addProperty(propertyName); - method.setAccessible(true); - if (getters.containsKey(propertyName)) { - throw new RuntimeException( - "Found conflicting getters for name " - + method.getName() - + " on class " - + clazz.getName()); - } - getters.put(propertyName, method); - applyGetterAnnotations(method); - } - } - - // Add any public fields to properties - for (Field field : clazz.getFields()) { - if (shouldIncludeField(field)) { - String propertyName = propertyName(field); - addProperty(propertyName); - applyFieldAnnotations(field); - } - } - - // We can use private setters and fields for known (public) properties/getters. Since - // getMethods/getFields only returns public methods/fields we need to traverse the - // class hierarchy to find the appropriate setter or field. - Class currentClass = clazz; - do { - // Add any setters - for (Method method : currentClass.getDeclaredMethods()) { - if (shouldIncludeSetter(method)) { - String propertyName = propertyName(method); - String existingPropertyName = properties.get(propertyName.toLowerCase(Locale.US)); - if (existingPropertyName != null) { - if (!existingPropertyName.equals(propertyName)) { - throw new RuntimeException( - "Found setter on " - + currentClass.getName() - + " with invalid case-sensitive name: " - + method.getName()); - } else { - Method existingSetter = setters.get(propertyName); - if (existingSetter == null) { - method.setAccessible(true); - setters.put(propertyName, method); - applySetterAnnotations(method); - } else if (!isSetterOverride(method, existingSetter)) { - // We require that setters with conflicting property names are - // overrides from a base class - if (currentClass == clazz) { - // TODO: Should we support overloads? - throw new RuntimeException( - "Class " - + clazz.getName() - + " has multiple setter overloads with name " - + method.getName()); - } else { - throw new RuntimeException( - "Found conflicting setters " - + "with name: " - + method.getName() - + " (conflicts with " - + existingSetter.getName() - + " defined on " - + existingSetter.getDeclaringClass().getName() - + ")"); - } - } - } - } - } - } - - for (Field field : currentClass.getDeclaredFields()) { - String propertyName = propertyName(field); - - // Case sensitivity is checked at deserialization time - // Fields are only added if they don't exist on a subclass - if (properties.containsKey(propertyName.toLowerCase(Locale.US)) - && !fields.containsKey(propertyName)) { - field.setAccessible(true); - fields.put(propertyName, field); - applyFieldAnnotations(field); - } - } - - // Traverse class hierarchy until we reach java.lang.Object which contains a bunch - // of fields/getters we don't want to serialize - currentClass = currentClass.getSuperclass(); - } while (currentClass != null && !currentClass.equals(Object.class)); - - if (properties.isEmpty()) { - throw new RuntimeException("No properties to serialize found on class " + clazz.getName()); - } - - // Make sure we can write to @DocumentId annotated properties before proceeding. - for (String docIdProperty : documentIdPropertyNames) { - if (!setters.containsKey(docIdProperty) && !fields.containsKey(docIdProperty)) { - throw new RuntimeException( - "@DocumentId is annotated on property " - + docIdProperty - + " of class " - + clazz.getName() - + " but no field or public setter was found"); - } - } - } - - private void addProperty(String property) { - String oldValue = properties.put(property.toLowerCase(Locale.US), property); - if (oldValue != null && !property.equals(oldValue)) { - throw new RuntimeException( - "Found two getters or fields with conflicting case " - + "sensitivity for property: " - + property.toLowerCase(Locale.US)); - } - } - - T deserialize(Map values, DeserializeContext context) { - return deserialize(values, Collections.emptyMap(), context); - } - - T deserialize( - Map values, - Map>, Type> types, - DeserializeContext context) { - if (constructor == null) { - throw deserializeError( - context.errorPath, - "Class " - + clazz.getName() - + " does not define a no-argument constructor. If you are using ProGuard, make " - + "sure these constructors are not stripped"); - } - - T instance; - try { - instance = constructor.newInstance(); - } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException(e); - } - HashSet deserialzedProperties = new HashSet<>(); - for (Map.Entry entry : values.entrySet()) { - String propertyName = entry.getKey(); - ErrorPath childPath = context.errorPath.child(propertyName); - if (setters.containsKey(propertyName)) { - Method setter = setters.get(propertyName); - Type[] params = setter.getGenericParameterTypes(); - if (params.length != 1) { - throw deserializeError(childPath, "Setter does not have exactly one parameter"); - } - Type resolvedType = resolveType(params[0], types); - Object value = - CustomClassMapper.deserializeToType( - entry.getValue(), resolvedType, context.newInstanceWithErrorPath(childPath)); - try { - setter.invoke(instance, value); - } catch (IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException(e); - } - deserialzedProperties.add(propertyName); - } else if (fields.containsKey(propertyName)) { - Field field = fields.get(propertyName); - Type resolvedType = resolveType(field.getGenericType(), types); - Object value = - CustomClassMapper.deserializeToType( - entry.getValue(), resolvedType, context.newInstanceWithErrorPath(childPath)); - try { - field.set(instance, value); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } - deserialzedProperties.add(propertyName); - } else { - String message = - "No setter/field for " + propertyName + " found on class " + clazz.getName(); - if (properties.containsKey(propertyName.toLowerCase(Locale.US))) { - message += " (fields/setters are case sensitive!)"; - } - if (throwOnUnknownProperties) { - throw new RuntimeException(message); - } else if (warnOnUnknownProperties) { - LOGGER.warning(message); - } - } - } - populateDocumentIdProperties(types, context, instance, deserialzedProperties); - - return instance; - } - - // Populate @DocumentId annotated fields. If there is a conflict (@DocumentId annotation is - // applied to a property that is already deserialized from the firestore document) - // a runtime exception will be thrown. - private void populateDocumentIdProperties( - Map>, Type> types, - DeserializeContext context, - T instance, - HashSet deserialzedProperties) { - for (String docIdPropertyName : documentIdPropertyNames) { - if (deserialzedProperties.contains(docIdPropertyName)) { - String message = - "'" - + docIdPropertyName - + "' was found from document " - + context.documentRef.getPath() - + ", cannot apply @DocumentId on this property for class " - + clazz.getName(); - throw new RuntimeException(message); - } - ErrorPath childPath = context.errorPath.child(docIdPropertyName); - if (setters.containsKey(docIdPropertyName)) { - Method setter = setters.get(docIdPropertyName); - Type[] params = setter.getGenericParameterTypes(); - if (params.length != 1) { - throw deserializeError(childPath, "Setter does not have exactly one parameter"); - } - Type resolvedType = resolveType(params[0], types); - try { - if (resolvedType == String.class) { - setter.invoke(instance, context.documentRef.getId()); - } else { - setter.invoke(instance, context.documentRef); - } - } catch (IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException(e); - } - } else { - Field docIdField = fields.get(docIdPropertyName); - try { - if (docIdField.getType() == String.class) { - docIdField.set(instance, context.documentRef.getId()); - } else { - docIdField.set(instance, context.documentRef); - } - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } - } - } - } - - private Type resolveType(Type type, Map>, Type> types) { - if (type instanceof TypeVariable) { - Type resolvedType = types.get(type); - if (resolvedType == null) { - throw new IllegalStateException("Could not resolve type " + type); - } else { - return resolvedType; - } - } else { - return type; - } - } - - Map serialize(T object, ErrorPath path) { - if (!clazz.isAssignableFrom(object.getClass())) { - throw new IllegalArgumentException( - "Can't serialize object of class " - + object.getClass() - + " with BeanMapper for class " - + clazz); - } - Map result = new HashMap<>(); - for (String property : properties.values()) { - // Skip @DocumentId annotated properties; - if (documentIdPropertyNames.contains(property)) { - continue; - } - - Object propertyValue; - if (getters.containsKey(property)) { - Method getter = getters.get(property); - try { - propertyValue = getter.invoke(object); - } catch (IllegalAccessException | InvocationTargetException e) { - throw new RuntimeException(e); - } - } else { - // Must be a field - Field field = fields.get(property); - if (field == null) { - throw new IllegalStateException("Bean property without field or getter: " + property); - } - try { - propertyValue = field.get(object); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } - } - - Object serializedValue; - if (serverTimestamps.contains(property) && propertyValue == null) { - // Replace null ServerTimestamp-annotated fields with the sentinel. - serializedValue = FieldValue.serverTimestamp(); - } else { - serializedValue = CustomClassMapper.serialize(propertyValue, path.child(property)); - } - result.put(property, serializedValue); - } - return result; - } - - private void applyFieldAnnotations(Field field) { - if (field.isAnnotationPresent(ServerTimestamp.class)) { - Class fieldType = field.getType(); - if (fieldType != Date.class && fieldType != Timestamp.class && fieldType != Instant.class) { - throw new IllegalArgumentException( - "Field " - + field.getName() - + " is annotated with @ServerTimestamp but is " - + fieldType - + " instead of Date, Timestamp, or Instant."); - } - serverTimestamps.add(propertyName(field)); - } - - if (field.isAnnotationPresent(DocumentId.class)) { - Class fieldType = field.getType(); - ensureValidDocumentIdType("Field", "is", fieldType); - documentIdPropertyNames.add(propertyName(field)); - } - } - - private void applyGetterAnnotations(Method method) { - if (method.isAnnotationPresent(ServerTimestamp.class)) { - Class returnType = method.getReturnType(); - if (returnType != Date.class - && returnType != Timestamp.class - && returnType != Instant.class) { - throw new IllegalArgumentException( - "Method " - + method.getName() - + " is annotated with @ServerTimestamp but returns " - + returnType - + " instead of Date, Timestamp, or Instant."); - } - serverTimestamps.add(propertyName(method)); - } - - // Even though the value will be skipped, we still check for type matching for consistency. - if (method.isAnnotationPresent(DocumentId.class)) { - Class returnType = method.getReturnType(); - ensureValidDocumentIdType("Method", "returns", returnType); - documentIdPropertyNames.add(propertyName(method)); - } - } - - private void applySetterAnnotations(Method method) { - if (method.isAnnotationPresent(ServerTimestamp.class)) { - throw new IllegalArgumentException( - "Method " - + method.getName() - + " is annotated with @ServerTimestamp but should not be. @ServerTimestamp can" - + " only be applied to fields and getters, not setters."); - } - - if (method.isAnnotationPresent(DocumentId.class)) { - Class paramType = method.getParameterTypes()[0]; - ensureValidDocumentIdType("Method", "accepts", paramType); - documentIdPropertyNames.add(propertyName(method)); - } - } - - private void ensureValidDocumentIdType(String fieldDescription, String operation, Type type) { - if (type != String.class && type != DocumentReference.class) { - throw new IllegalArgumentException( - fieldDescription - + " is annotated with @DocumentId but " - + operation - + " " - + type - + " instead of String or DocumentReference."); - } - } - - private static boolean shouldIncludeGetter(Method method) { - if (!method.getName().startsWith("get") && !method.getName().startsWith("is")) { - return false; - } - // Exclude methods from Object.class - if (method.getDeclaringClass().equals(Object.class)) { - return false; - } - // Non-public methods - if (!Modifier.isPublic(method.getModifiers())) { - return false; - } - // Static methods - if (Modifier.isStatic(method.getModifiers())) { - return false; - } - // No return type - if (method.getReturnType().equals(Void.TYPE)) { - return false; - } - // Non-zero parameters - if (method.getParameterTypes().length != 0) { - return false; - } - // Excluded methods - if (method.isAnnotationPresent(Exclude.class)) { - return false; - } - return true; - } - - private static boolean shouldIncludeSetter(Method method) { - if (!method.getName().startsWith("set")) { - return false; - } - // Exclude methods from Object.class - if (method.getDeclaringClass().equals(Object.class)) { - return false; - } - // Static methods - if (Modifier.isStatic(method.getModifiers())) { - return false; - } - // Has a return type - if (!method.getReturnType().equals(Void.TYPE)) { - return false; - } - // Methods without exactly one parameters - if (method.getParameterTypes().length != 1) { - return false; - } - // Excluded methods - if (method.isAnnotationPresent(Exclude.class)) { - return false; - } - return true; - } - - private static boolean shouldIncludeField(Field field) { - // Exclude methods from Object.class - if (field.getDeclaringClass().equals(Object.class)) { - return false; - } - // Non-public fields - if (!Modifier.isPublic(field.getModifiers())) { - return false; - } - // Static fields - if (Modifier.isStatic(field.getModifiers())) { - return false; - } - // Transient fields - if (Modifier.isTransient(field.getModifiers())) { - return false; - } - // Excluded fields - if (field.isAnnotationPresent(Exclude.class)) { - return false; - } - return true; - } - - private static boolean isSetterOverride(Method base, Method override) { - // We expect an overridden setter here - hardAssert( - base.getDeclaringClass().isAssignableFrom(override.getDeclaringClass()), - "Expected override from a base class"); - hardAssert(base.getReturnType().equals(Void.TYPE), "Expected void return type"); - hardAssert(override.getReturnType().equals(Void.TYPE), "Expected void return type"); - - Type[] baseParameterTypes = base.getParameterTypes(); - Type[] overrideParameterTypes = override.getParameterTypes(); - hardAssert(baseParameterTypes.length == 1, "Expected exactly one parameter"); - hardAssert(overrideParameterTypes.length == 1, "Expected exactly one parameter"); - - return base.getName().equals(override.getName()) - && baseParameterTypes[0].equals(overrideParameterTypes[0]); - } - - private static String propertyName(Field field) { - String annotatedName = annotatedName(field); - return annotatedName != null ? annotatedName : field.getName(); - } - - private static String propertyName(Method method) { - String annotatedName = annotatedName(method); - return annotatedName != null ? annotatedName : serializedName(method.getName()); - } - - private static String annotatedName(AccessibleObject obj) { - if (obj.isAnnotationPresent(PropertyName.class)) { - PropertyName annotation = obj.getAnnotation(PropertyName.class); - return annotation.value(); - } - - return null; - } - - private static String serializedName(String methodName) { - String[] prefixes = new String[] {"get", "set", "is"}; - String methodPrefix = null; - for (String prefix : prefixes) { - if (methodName.startsWith(prefix)) { - methodPrefix = prefix; - } - } - if (methodPrefix == null) { - throw new IllegalArgumentException("Unknown Bean prefix for method: " + methodName); - } - String strippedName = methodName.substring(methodPrefix.length()); - - // Make sure the first word or upper-case prefix is converted to lower-case - char[] chars = strippedName.toCharArray(); - int pos = 0; - while (pos < chars.length && Character.isUpperCase(chars[pos])) { - chars[pos] = Character.toLowerCase(chars[pos]); - pos++; - } - return new String(chars); - } - } - - /** - * Immutable class representing the path to a specific field in an object. Used to provide better - * error messages. - */ - static class ErrorPath { - private final int length; - private final ErrorPath parent; - private final String name; - - static final ErrorPath EMPTY = new ErrorPath(null, null, 0); - - ErrorPath(ErrorPath parent, String name, int length) { - this.parent = parent; - this.name = name; - this.length = length; - } - - int getLength() { - return length; - } - - ErrorPath child(String name) { - return new ErrorPath(this, name, length + 1); - } - - @Override - public String toString() { - if (length == 0) { - return ""; - } else if (length == 1) { - return name; - } else { - // This is not very efficient, but it's only hit if there's an error. - return parent.toString() + "." + name; - } - } - } - - /** Holds information a deserialization operation needs to complete the job. */ - private static class DeserializeContext { - - /** Current path to the field being deserialized, used for better error messages. */ - final ErrorPath errorPath; - - /** Value used to set to {@link DocumentId} annotated fields during deserialization, if any. */ - final DocumentReference documentRef; - - DeserializeContext(ErrorPath path, DocumentReference docRef) { - errorPath = path; - documentRef = docRef; - } - - DeserializeContext newInstanceWithErrorPath(ErrorPath newPath) { - return new DeserializeContext(newPath, documentRef); - } - } -} 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 3f27040ad..e1aab1cac 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 @@ -19,6 +19,7 @@ import com.google.api.core.InternalExtensionOnly; import com.google.cloud.Timestamp; import com.google.cloud.firestore.UserDataConverter.EncodingOptions; +import com.google.cloud.firestore.encoding.CustomClassMapper; import com.google.common.base.Preconditions; import com.google.firestore.v1.Document; import com.google.firestore.v1.Value; diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldValue.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldValue.java index 5f9e406da..2cfc41acf 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldValue.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldValue.java @@ -16,6 +16,7 @@ package com.google.cloud.firestore; +import com.google.cloud.firestore.encoding.CustomClassMapper; import com.google.common.base.Preconditions; import com.google.firestore.v1.ArrayValue; import com.google.firestore.v1.DocumentTransform.FieldTransform; diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Internal.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Internal.java index 46f16f5f3..b25701075 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Internal.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Internal.java @@ -20,6 +20,7 @@ import com.google.api.core.InternalApi; import com.google.cloud.Timestamp; +import com.google.cloud.firestore.encoding.CustomClassMapper; import com.google.cloud.firestore.spi.v1.FirestoreRpc; import com.google.common.base.Preconditions; import com.google.firestore.v1.Document; diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java index 4721ba93d..a794b6a63 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java @@ -39,6 +39,7 @@ import com.google.auto.value.AutoValue; import com.google.cloud.Timestamp; import com.google.cloud.firestore.Query.QueryOptions.Builder; +import com.google.cloud.firestore.encoding.CustomClassMapper; import com.google.cloud.firestore.telemetry.TraceUtil; import com.google.cloud.firestore.telemetry.TraceUtil.Scope; import com.google.cloud.firestore.v1.FirestoreSettings; diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QuerySnapshot.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QuerySnapshot.java index 4f51cd583..494a298e4 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QuerySnapshot.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QuerySnapshot.java @@ -18,6 +18,7 @@ import com.google.cloud.Timestamp; import com.google.cloud.firestore.DocumentChange.Type; +import com.google.cloud.firestore.encoding.CustomClassMapper; import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Collections; diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java index e93fe8310..31434667b 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java @@ -25,6 +25,7 @@ import com.google.api.core.ApiFutures; import com.google.api.core.InternalExtensionOnly; import com.google.cloud.firestore.UserDataConverter.EncodingOptions; +import com.google.cloud.firestore.encoding.CustomClassMapper; import com.google.cloud.firestore.telemetry.TraceUtil; import com.google.cloud.firestore.telemetry.TraceUtil.Scope; import com.google.common.base.Preconditions; diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/BeanMapper.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/BeanMapper.java new file mode 100644 index 000000000..fd5067b08 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/BeanMapper.java @@ -0,0 +1,204 @@ +/* + * Copyright 2024 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.encoding; + +import com.google.cloud.Timestamp; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.FieldValue; +import com.google.cloud.firestore.annotation.DocumentId; +import com.google.cloud.firestore.annotation.IgnoreExtraProperties; +import com.google.cloud.firestore.annotation.PropertyName; +import com.google.cloud.firestore.annotation.ServerTimestamp; +import com.google.cloud.firestore.annotation.ThrowOnExtraProperties; +import java.lang.reflect.AccessibleObject; +import java.lang.reflect.Field; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.Map; + +/** Base bean mapper class, providing common functionality for class and record serialization. */ +abstract class BeanMapper { + private final Class clazz; + // Whether to throw exception if there are properties we don't know how to set to + // custom object fields/setters or record components during deserialization. + private final boolean throwOnUnknownProperties; + // Whether to log a message if there are properties we don't know how to set to + // custom object fields/setters or record components during deserialization. + private final boolean warnOnUnknownProperties; + // A set of property names that were annotated with @ServerTimestamp. + final HashSet serverTimestamps; + // A set of property names that were annotated with @DocumentId. These properties will be + // populated with document ID values during deserialization, and be skipped during + // serialization. + final HashSet documentIdPropertyNames; + + BeanMapper(Class clazz) { + this.clazz = clazz; + throwOnUnknownProperties = clazz.isAnnotationPresent(ThrowOnExtraProperties.class); + warnOnUnknownProperties = !clazz.isAnnotationPresent(IgnoreExtraProperties.class); + serverTimestamps = new HashSet<>(); + documentIdPropertyNames = new HashSet<>(); + } + + Class getClazz() { + return clazz; + } + + boolean isThrowOnUnknownProperties() { + return throwOnUnknownProperties; + } + + boolean isWarnOnUnknownProperties() { + return warnOnUnknownProperties; + } + + /** + * Serialize an object to a map. + * + * @param object the object to serialize + * @param path the path to a specific field/component in an object, for use in error messages + * @return the map + */ + abstract Map serialize(T object, DeserializeContext.ErrorPath path); + + /** + * Deserialize a map to an object. + * + * @param values the map to deserialize + * @param types generic type mappings + * @param context context information about the deserialization operation + * @return the deserialized object + */ + abstract T deserialize( + Map values, + Map>, Type> types, + DeserializeContext context); + + T deserialize(Map values, DeserializeContext context) { + return deserialize(values, Collections.emptyMap(), context); + } + + protected void verifyValidType(T object) { + if (!clazz.isAssignableFrom(object.getClass())) { + throw new IllegalArgumentException( + "Can't serialize object of class " + + object.getClass() + + " with BeanMapper for class " + + clazz); + } + } + + protected Type resolveType(Type type, Map>, Type> types) { + if (type instanceof TypeVariable) { + Type resolvedType = types.get(type); + if (resolvedType == null) { + throw new IllegalStateException("Could not resolve type " + type); + } + + return resolvedType; + } + + return type; + } + + protected void checkForDocIdConflict( + String docIdPropertyName, + Collection deserializedProperties, + DeserializeContext context) { + if (deserializedProperties.contains(docIdPropertyName)) { + String message = + "'" + + docIdPropertyName + + "' was found from document " + + context.documentRef.getPath() + + ", cannot apply @DocumentId on this property for class " + + clazz.getName(); + throw new RuntimeException(message); + } + } + + protected Object getSerializedValue( + String property, Object propertyValue, DeserializeContext.ErrorPath path) { + if (serverTimestamps.contains(property) && propertyValue == null) { + // Replace null ServerTimestamp-annotated fields with the sentinel. + return FieldValue.serverTimestamp(); + } else { + return CustomClassMapper.serialize(propertyValue, path.child(property)); + } + } + + protected void applyFieldAnnotations(Field field) { + Class fieldType = field.getType(); + if (field.isAnnotationPresent(ServerTimestamp.class)) { + validateServerTimestampType("Field", "is", fieldType); + serverTimestamps.add(propertyName(field)); + } + if (field.isAnnotationPresent(DocumentId.class)) { + validateDocumentIdType("Field", "is", fieldType); + documentIdPropertyNames.add(propertyName(field)); + } + } + + protected void validateDocumentIdType(String fieldDescription, String operation, Type type) { + if (type != String.class && type != DocumentReference.class) { + throw new IllegalArgumentException( + fieldDescription + + " is annotated with @DocumentId but " + + operation + + " " + + type + + " instead of String or DocumentReference."); + } + } + + protected void validateServerTimestampType(String fieldDescription, String operation, Type type) { + if (type != Date.class && type != Timestamp.class && type != Instant.class) { + throw new IllegalArgumentException( + fieldDescription + + " is annotated with @ServerTimestamp but " + + operation + + " " + + type + + " instead of Date, Timestamp, or Instant."); + } + } + + protected String propertyName(Field field) { + String annotatedName = annotatedName(field); + return annotatedName != null ? annotatedName : field.getName(); + } + + protected String annotatedName(AccessibleObject obj) { + if (obj.isAnnotationPresent(PropertyName.class)) { + PropertyName annotation = obj.getAnnotation(PropertyName.class); + return annotation.value(); + } + + return null; + } + + protected void hardAssert(boolean assertion, String message) { + if (!assertion) { + throw new RuntimeException("Hard assert failed: " + message); + } + } +} 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 new file mode 100644 index 000000000..321ead3e6 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/CustomClassMapper.java @@ -0,0 +1,628 @@ +/* + * Copyright 2017 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.encoding; + +import com.google.api.core.InternalApi; +import com.google.cloud.Timestamp; +import com.google.cloud.firestore.Blob; +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.FieldValue; +import com.google.cloud.firestore.GeoPoint; +import com.google.cloud.firestore.VectorValue; +import com.google.cloud.firestore.annotation.DocumentId; +import com.google.cloud.firestore.annotation.PropertyName; +import com.google.firestore.v1.Value; +import java.lang.reflect.AccessibleObject; +import java.lang.reflect.Field; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** Helper class to convert to/from custom POJO classes and plain Java types. */ +@InternalApi +public class CustomClassMapper { + + /** Maximum depth before we give up and assume it's a recursive object graph. */ + private static final int MAX_DEPTH = 500; + + private static final ConcurrentMap, BeanMapper> mappers = new ConcurrentHashMap<>(); + + /** + * Converts a Java representation of JSON data to standard library Java data types: Map, Array, + * String, Double, Integer and Boolean. POJOs are converted to Java Maps. + * + * @param object The representation of the JSON data + * @return JSON representation containing only standard library Java types + */ + public static Object convertToPlainJavaTypes(Object object) { + return serialize(object); + } + + public static Map convertToPlainJavaTypes(Map update) { + Object converted = serialize(update); + hardAssert(converted instanceof Map); + @SuppressWarnings("unchecked") + Map convertedMap = (Map) converted; + return convertedMap; + } + + /** + * Converts a standard library Java representation of JSON data to an object of the provided + * class. + * + * @param object The representation of the JSON data + * @param clazz The class of the object to convert to + * @param docRef The value to set to {@link DocumentId} annotated fields in the custom class. + * @return The POJO object. + */ + public static T convertToCustomClass( + Object object, Class clazz, DocumentReference docRef) { + return deserializeToClass( + object, clazz, new DeserializeContext(DeserializeContext.ErrorPath.EMPTY, docRef)); + } + + public static Object serialize(T o) { + return serialize(o, DeserializeContext.ErrorPath.EMPTY); + } + + @SuppressWarnings("unchecked") + static Object serialize(T o, DeserializeContext.ErrorPath path) { + if (path.getLength() > MAX_DEPTH) { + throw path.serializeError( + "Exceeded maximum depth of " + + MAX_DEPTH + + ", which likely indicates there's an object cycle"); + } + if (o == null) { + return null; + } else if (o instanceof Number) { + if (o instanceof Long || o instanceof Integer || o instanceof Double || o instanceof Float) { + return o; + } else if (o instanceof BigDecimal) { + return String.valueOf(o); + } else { + throw path.serializeError( + String.format( + "Numbers of type %s are not supported, please use an int, long, float, double or BigDecimal", + o.getClass().getSimpleName())); + } + } else if (o instanceof String) { + return o; + } else if (o instanceof Boolean) { + return o; + } else if (o instanceof Character) { + throw path.serializeError("Characters are not supported, please use Strings"); + } else if (o instanceof Map) { + Map result = new HashMap<>(); + for (Map.Entry entry : ((Map) o).entrySet()) { + Object key = entry.getKey(); + if (key instanceof String) { + String keyString = (String) key; + result.put(keyString, serialize(entry.getValue(), path.child(keyString))); + } else { + throw path.serializeError("Maps with non-string keys are not supported"); + } + } + return result; + } else if (o instanceof Collection) { + if (o instanceof List) { + List list = (List) o; + List result = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + result.add(serialize(list.get(i), path.child("[" + i + "]"))); + } + return result; + } else { + throw path.serializeError( + "Serializing Collections is not supported, please use Lists instead"); + } + } else if (o.getClass().isArray()) { + throw path.serializeError("Serializing Arrays is not supported, please use Lists instead"); + } else if (o instanceof Enum) { + String enumName = ((Enum) o).name(); + try { + Field enumField = o.getClass().getField(enumName); + return propertyName(enumField); + } catch (NoSuchFieldException ex) { + return enumName; + } + } else if (o instanceof Date + || o instanceof Timestamp + || o instanceof GeoPoint + || o instanceof Blob + || o instanceof DocumentReference + || o instanceof FieldValue + || o instanceof Value + || o instanceof VectorValue) { + return o; + } else if (o instanceof Instant) { + Instant instant = (Instant) o; + return Timestamp.ofTimeSecondsAndNanos(instant.getEpochSecond(), instant.getNano()); + } else { + Class clazz = (Class) o.getClass(); + BeanMapper mapper = loadOrCreateBeanMapperForClass(clazz); + return mapper.serialize(o, path); + } + } + + @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) + static T deserializeToType(Object o, Type type, DeserializeContext context) { + if (o == null) { + return null; + } else if (type instanceof ParameterizedType) { + return deserializeToParameterizedType(o, (ParameterizedType) type, context); + } else if (type instanceof Class) { + return deserializeToClass(o, (Class) type, context); + } else if (type instanceof WildcardType) { + Type[] lowerBounds = ((WildcardType) type).getLowerBounds(); + if (lowerBounds.length > 0) { + throw context.errorPath.deserializeError( + "Generic lower-bounded wildcard types are not supported"); + } + + // Upper bounded wildcards are of the form . Multiple upper bounds are allowed + // but if any of the bounds are of class type, that bound must come first in this array. Note + // that this array always has at least one element, since the unbounded wildcard always + // has at least an upper bound of Object. + Type[] upperBounds = ((WildcardType) type).getUpperBounds(); + hardAssert(upperBounds.length > 0, "Unexpected type bounds on wildcard " + type); + return deserializeToType(o, upperBounds[0], context); + } else if (type instanceof TypeVariable) { + // As above, TypeVariables always have at least one upper bound of Object. + Type[] upperBounds = ((TypeVariable) type).getBounds(); + hardAssert(upperBounds.length > 0, "Unexpected type bounds on type variable " + type); + return deserializeToType(o, upperBounds[0], context); + + } else if (type instanceof GenericArrayType) { + throw context.errorPath.deserializeError( + "Generic Arrays are not supported, please use Lists instead"); + } else { + throw context.errorPath.deserializeError("Unknown type encountered: " + type); + } + } + + @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) + private static T deserializeToParameterizedType( + Object o, ParameterizedType type, DeserializeContext context) { + // getRawType should always return a Class + Class rawType = (Class) type.getRawType(); + if (List.class.isAssignableFrom(rawType)) { + Type genericType = type.getActualTypeArguments()[0]; + if (o instanceof List) { + List list = (List) o; + List result; + try { + result = + (rawType == List.class) + ? new ArrayList<>(list.size()) + : (List) rawType.getDeclaredConstructor().newInstance(); + } catch (InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e) { + throw context.errorPath.deserializeError( + String.format( + "Unable to deserialize to %s: %s", rawType.getSimpleName(), e.toString())); + } + for (int i = 0; i < list.size(); i++) { + result.add( + deserializeToType( + list.get(i), + genericType, + context.newInstanceWithErrorPath(context.errorPath.child("[" + i + "]")))); + } + return (T) result; + } else { + throw context.errorPath.deserializeError("Expected a List, but got a " + o.getClass()); + } + } else if (Map.class.isAssignableFrom(rawType)) { + Type keyType = type.getActualTypeArguments()[0]; + Type valueType = type.getActualTypeArguments()[1]; + if (!keyType.equals(String.class)) { + throw context.errorPath.deserializeError( + "Only Maps with string keys are supported, but found Map with key type " + keyType); + } + Map map = expectMap(o, context.errorPath); + HashMap result; + try { + result = + (rawType == Map.class) + ? new HashMap<>() + : (HashMap) rawType.getDeclaredConstructor().newInstance(); + } catch (InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e) { + throw context.errorPath.deserializeError( + String.format( + "Unable to deserialize to %s: %s", rawType.getSimpleName(), e.toString())); + } + for (Map.Entry entry : map.entrySet()) { + result.put( + entry.getKey(), + deserializeToType( + entry.getValue(), + valueType, + context.newInstanceWithErrorPath(context.errorPath.child(entry.getKey())))); + } + return (T) result; + } else if (Collection.class.isAssignableFrom(rawType)) { + throw context.errorPath.deserializeError( + "Collections are not supported, please use Lists instead"); + } else { + Map map = expectMap(o, context.errorPath); + BeanMapper mapper = (BeanMapper) loadOrCreateBeanMapperForClass(rawType); + HashMap>, Type> typeMapping = new HashMap<>(); + TypeVariable>[] typeVariables = mapper.getClazz().getTypeParameters(); + Type[] types = type.getActualTypeArguments(); + if (types.length != typeVariables.length) { + throw new IllegalStateException("Mismatched lengths for type variables and actual types"); + } + for (int i = 0; i < typeVariables.length; i++) { + typeMapping.put(typeVariables[i], types[i]); + } + return mapper.deserialize(map, typeMapping, context); + } + } + + @SuppressWarnings("unchecked") + private static T deserializeToClass(Object o, Class clazz, DeserializeContext context) { + if (o == null) { + return null; + } else if (clazz.isPrimitive() + || Number.class.isAssignableFrom(clazz) + || Boolean.class.isAssignableFrom(clazz) + || Character.class.isAssignableFrom(clazz)) { + return deserializeToPrimitive(o, clazz, context.errorPath); + } else if (String.class.isAssignableFrom(clazz)) { + return (T) convertString(o, context.errorPath); + } else if (Date.class.isAssignableFrom(clazz)) { + return (T) convertDate(o, context.errorPath); + } else if (Timestamp.class.isAssignableFrom(clazz)) { + return (T) convertTimestamp(o, context.errorPath); + } else if (Instant.class.isAssignableFrom(clazz)) { + return (T) convertInstant(o, context.errorPath); + } else if (Blob.class.isAssignableFrom(clazz)) { + return (T) convertBlob(o, context.errorPath); + } else if (GeoPoint.class.isAssignableFrom(clazz)) { + return (T) convertGeoPoint(o, context.errorPath); + } else if (VectorValue.class.isAssignableFrom(clazz)) { + return (T) convertVectorValue(o, context.errorPath); + } else if (DocumentReference.class.isAssignableFrom(clazz)) { + return (T) convertDocumentReference(o, context.errorPath); + } else if (clazz.isArray()) { + throw context.errorPath.deserializeError( + "Converting to Arrays is not supported, please use Lists instead"); + } else if (clazz.getTypeParameters().length > 0) { + throw context.errorPath.deserializeError( + "Class " + + clazz.getName() + + " has generic type parameters, please use GenericTypeIndicator instead"); + } else if (clazz.equals(Object.class)) { + return (T) o; + } else if (clazz.isEnum()) { + return deserializeToEnum(o, clazz, context.errorPath); + } else { + return convertBean(o, clazz, context); + } + } + + private static T convertBean(Object o, Class clazz, DeserializeContext context) { + BeanMapper mapper = loadOrCreateBeanMapperForClass(clazz); + if (o instanceof Map) { + return mapper.deserialize(expectMap(o, context.errorPath), context); + } else { + throw context.errorPath.deserializeError( + "Can't convert object of type " + o.getClass().getName() + " to type " + clazz.getName()); + } + } + + private static BeanMapper loadOrCreateBeanMapperForClass(Class clazz) { + @SuppressWarnings("unchecked") + BeanMapper mapper = (BeanMapper) mappers.get(clazz); + if (mapper == null) { + if (isRecordType(clazz)) { + mapper = new RecordMapper<>(clazz); + } else { + mapper = new PojoBeanMapper<>(clazz); + } + // Inserting without checking is fine because mappers are "pure" and it's okay + // if we create and use multiple by different threads temporarily + mappers.put(clazz, mapper); + } + return mapper; + } + + @SuppressWarnings("unchecked") + private static Map expectMap( + Object object, DeserializeContext.ErrorPath errorPath) { + if (object instanceof Map) { + // TODO: runtime validation of keys? + return (Map) object; + } else { + throw errorPath.deserializeError( + "Expected a Map while deserializing, but got a " + object.getClass()); + } + } + + @SuppressWarnings("unchecked") + private static T deserializeToPrimitive( + Object o, Class clazz, DeserializeContext.ErrorPath errorPath) { + if (Integer.class.isAssignableFrom(clazz) || int.class.isAssignableFrom(clazz)) { + return (T) convertInteger(o, errorPath); + } else if (Boolean.class.isAssignableFrom(clazz) || boolean.class.isAssignableFrom(clazz)) { + return (T) convertBoolean(o, errorPath); + } else if (Double.class.isAssignableFrom(clazz) || double.class.isAssignableFrom(clazz)) { + return (T) convertDouble(o, errorPath); + } else if (Long.class.isAssignableFrom(clazz) || long.class.isAssignableFrom(clazz)) { + return (T) convertLong(o, errorPath); + } else if (BigDecimal.class.isAssignableFrom(clazz)) { + return (T) convertBigDecimal(o, errorPath); + } else if (Float.class.isAssignableFrom(clazz) || float.class.isAssignableFrom(clazz)) { + return (T) (Float) convertDouble(o, errorPath).floatValue(); + } else { + throw errorPath.deserializeError( + String.format("Deserializing values to %s is not supported", clazz.getSimpleName())); + } + } + + @SuppressWarnings("unchecked") + private static T deserializeToEnum( + Object object, Class clazz, DeserializeContext.ErrorPath errorPath) { + if (object instanceof String) { + String value = (String) object; + // We cast to Class without generics here since we can't prove the bound + // T extends Enum statically + + // try to use PropertyName if exist + Field[] enumFields = clazz.getFields(); + for (Field field : enumFields) { + if (field.isEnumConstant()) { + String propertyName = propertyName(field); + if (value.equals(propertyName)) { + value = field.getName(); + break; + } + } + } + + try { + return (T) Enum.valueOf((Class) clazz, value); + } catch (IllegalArgumentException e) { + throw errorPath.deserializeError( + "Could not find enum value of " + clazz.getName() + " for value \"" + value + "\""); + } + } else { + throw errorPath.deserializeError( + "Expected a String while deserializing to enum " + + clazz + + " but got a " + + object.getClass()); + } + } + + private static Integer convertInteger(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Integer) { + return (Integer) o; + } else if (o instanceof Long || o instanceof Double) { + double value = ((Number) o).doubleValue(); + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { + return ((Number) o).intValue(); + } else { + throw errorPath.deserializeError( + "Numeric value out of 32-bit integer range: " + + value + + ". Did you mean to use a long or double instead of an int?"); + } + } else { + throw errorPath.deserializeError( + "Failed to convert a value of type " + o.getClass().getName() + " to int"); + } + } + + private static Long convertLong(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Integer) { + return ((Integer) o).longValue(); + } else if (o instanceof Long) { + return (Long) o; + } else if (o instanceof Double) { + Double value = (Double) o; + if (value >= Long.MIN_VALUE && value <= Long.MAX_VALUE) { + return value.longValue(); + } else { + throw errorPath.deserializeError( + "Numeric value out of 64-bit long range: " + + value + + ". Did you mean to use a double instead of a long?"); + } + } else { + throw errorPath.deserializeError( + "Failed to convert a value of type " + o.getClass().getName() + " to long"); + } + } + + private static Double convertDouble(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Integer) { + return ((Integer) o).doubleValue(); + } else if (o instanceof Long) { + Long value = (Long) o; + Double doubleValue = ((Long) o).doubleValue(); + if (doubleValue.longValue() == value) { + return doubleValue; + } else { + throw errorPath.deserializeError( + "Loss of precision while converting number to " + + "double: " + + o + + ". Did you mean to use a 64-bit long instead?"); + } + } else if (o instanceof Double) { + return (Double) o; + } else { + throw errorPath.deserializeError( + "Failed to convert a value of type " + o.getClass().getName() + " to double"); + } + } + + private static BigDecimal convertBigDecimal(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Integer) { + return BigDecimal.valueOf(((Integer) o).intValue()); + } else if (o instanceof Long) { + return BigDecimal.valueOf(((Long) o).longValue()); + } else if (o instanceof Double) { + return BigDecimal.valueOf(((Double) o).doubleValue()).abs(); + } else if (o instanceof BigDecimal) { + return (BigDecimal) o; + } else if (o instanceof String) { + return new BigDecimal((String) o); + } else { + throw errorPath.deserializeError( + "Failed to convert a value of type " + o.getClass().getName() + " to BigDecimal"); + } + } + + private static Boolean convertBoolean(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Boolean) { + return (Boolean) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to boolean"); + } + } + + private static String convertString(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof String) { + return (String) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to String"); + } + } + + private static Date convertDate(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Date) { + return (Date) o; + } else if (o instanceof Timestamp) { + return ((Timestamp) o).toDate(); + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to Date"); + } + } + + private static Timestamp convertTimestamp(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Timestamp) { + return (Timestamp) o; + } else if (o instanceof Date) { + return Timestamp.of((Date) o); + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to Timestamp"); + } + } + + private static Instant convertInstant(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Timestamp) { + Timestamp timestamp = (Timestamp) o; + return Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos()); + } else if (o instanceof Date) { + return Instant.ofEpochMilli(((Date) o).getTime()); + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to Instant"); + } + } + + private static Blob convertBlob(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof Blob) { + return (Blob) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to Blob"); + } + } + + private static GeoPoint convertGeoPoint(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof GeoPoint) { + return (GeoPoint) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to GeoPoint"); + } + } + + private static VectorValue convertVectorValue(Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof VectorValue) { + return (VectorValue) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to VectorValue"); + } + } + + private static DocumentReference convertDocumentReference( + Object o, DeserializeContext.ErrorPath errorPath) { + if (o instanceof DocumentReference) { + return (DocumentReference) o; + } else { + throw errorPath.deserializeError( + "Failed to convert value of type " + o.getClass().getName() + " to DocumentReference"); + } + } + + private static boolean isRecordType(Class cls) { + Class parent = cls.getSuperclass(); + return parent != null && "java.lang.Record".equals(parent.getName()); + } + + private static String propertyName(Field field) { + String annotatedName = annotatedName(field); + return annotatedName != null ? annotatedName : field.getName(); + } + + private static String annotatedName(AccessibleObject obj) { + if (obj.isAnnotationPresent(PropertyName.class)) { + PropertyName annotation = obj.getAnnotation(PropertyName.class); + return annotation.value(); + } + + return null; + } + + private static void hardAssert(boolean assertion) { + hardAssert(assertion, "Internal inconsistency"); + } + + private static void hardAssert(boolean assertion, String message) { + if (!assertion) { + throw new RuntimeException("Hard assert failed: " + message); + } + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/DeserializeContext.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/DeserializeContext.java new file mode 100644 index 000000000..df85f5817 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/DeserializeContext.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024 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.encoding; + +import com.google.cloud.firestore.DocumentReference; +import com.google.cloud.firestore.annotation.DocumentId; + +/** Holds information a deserialization operation needs to complete the job. */ +class DeserializeContext { + /** + * Immutable class representing the path to a specific field in an object. Used to provide better + * error messages. + */ + static class ErrorPath { + static final ErrorPath EMPTY = new ErrorPath(null, null, 0); + + private final int length; + private final ErrorPath parent; + private final String name; + + ErrorPath child(String name) { + return new ErrorPath(this, name, length + 1); + } + + @Override + public String toString() { + if (length == 0) { + return ""; + } else if (length == 1) { + return name; + } else { + // This is not very efficient, but it's only hit if there's an error. + return parent.toString() + "." + name; + } + } + + ErrorPath(ErrorPath parent, String name, int length) { + this.parent = parent; + this.name = name; + this.length = length; + } + + int getLength() { + return length; + } + + IllegalArgumentException serializeError(String reason) { + reason = "Could not serialize object. " + reason; + if (getLength() > 0) { + reason = reason + " (found in field '" + toString() + "')"; + } + return new IllegalArgumentException(reason); + } + + RuntimeException deserializeError(String reason) { + reason = "Could not deserialize object. " + reason; + if (getLength() > 0) { + reason = reason + " (found in field '" + toString() + "')"; + } + return new RuntimeException(reason); + } + } + + /** Current path to the field being deserialized, used for better error messages. */ + final ErrorPath errorPath; + + /** Value used to set to {@link DocumentId} annotated fields during deserialization, if any. */ + final DocumentReference documentRef; + + DeserializeContext newInstanceWithErrorPath(ErrorPath newPath) { + return new DeserializeContext(newPath, documentRef); + } + + DeserializeContext(ErrorPath path, DocumentReference docRef) { + errorPath = path; + documentRef = docRef; + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/PojoBeanMapper.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/PojoBeanMapper.java new file mode 100644 index 000000000..adf7ef229 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/PojoBeanMapper.java @@ -0,0 +1,494 @@ +/* + * Copyright 2024 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.encoding; + +import com.google.cloud.firestore.annotation.DocumentId; +import com.google.cloud.firestore.annotation.Exclude; +import com.google.cloud.firestore.annotation.ServerTimestamp; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Logger; + +// Helper class to convert from maps to custom objects (Beans), and vice versa. +class PojoBeanMapper extends BeanMapper { + private static final Logger LOGGER = Logger.getLogger(PojoBeanMapper.class.getName()); + + private final Constructor constructor; + + // Case insensitive mapping of properties to their case sensitive versions + private final Map properties; + + // Below are maps to find getter/setter/field from a given property name. + // A property name is the name annotated by @PropertyName, if exists; or their property name + // following the Java Bean convention: field name is kept as-is while getters/setters will have + // their prefixes removed. See method propertyName for details. + private final Map getters; + private final Map setters; + private final Map fields; + + PojoBeanMapper(Class clazz) { + super(clazz); + properties = new HashMap<>(); + + setters = new HashMap<>(); + getters = new HashMap<>(); + fields = new HashMap<>(); + + Constructor constructor; + try { + constructor = clazz.getDeclaredConstructor(); + constructor.setAccessible(true); + } catch (NoSuchMethodException e) { + // We will only fail at deserialization time if no constructor is present + constructor = null; + } + this.constructor = constructor; + // Add any public getters to properties (including isXyz()) + for (Method method : clazz.getMethods()) { + if (shouldIncludeGetter(method)) { + String propertyName = propertyName(method); + addProperty(propertyName); + method.setAccessible(true); + if (getters.containsKey(propertyName)) { + throw new RuntimeException( + "Found conflicting getters for name " + + method.getName() + + " on class " + + clazz.getName()); + } + getters.put(propertyName, method); + applyGetterAnnotations(method); + } + } + + // Add any public fields to properties + for (Field field : clazz.getFields()) { + if (shouldIncludeField(field)) { + String propertyName = propertyName(field); + addProperty(propertyName); + applyFieldAnnotations(field); + } + } + + // We can use private setters and fields for known (public) properties/getters. Since + // getMethods/getFields only returns public methods/fields we need to traverse the + // class hierarchy to find the appropriate setter or field. + Class currentClass = clazz; + do { + // Add any setters + for (Method method : currentClass.getDeclaredMethods()) { + if (shouldIncludeSetter(method)) { + String propertyName = propertyName(method); + String existingPropertyName = properties.get(propertyName.toLowerCase(Locale.US)); + if (existingPropertyName != null) { + if (!existingPropertyName.equals(propertyName)) { + throw new RuntimeException( + "Found setter on " + + currentClass.getName() + + " with invalid case-sensitive name: " + + method.getName()); + } else { + Method existingSetter = setters.get(propertyName); + if (existingSetter == null) { + method.setAccessible(true); + setters.put(propertyName, method); + applySetterAnnotations(method); + } else if (!isSetterOverride(method, existingSetter)) { + // We require that setters with conflicting property names are + // overrides from a base class + if (currentClass == clazz) { + // TODO: Should we support overloads? + throw new RuntimeException( + "Class " + + clazz.getName() + + " has multiple setter overloads with name " + + method.getName()); + } else { + throw new RuntimeException( + "Found conflicting setters " + + "with name: " + + method.getName() + + " (conflicts with " + + existingSetter.getName() + + " defined on " + + existingSetter.getDeclaringClass().getName() + + ")"); + } + } + } + } + } + } + + for (Field field : currentClass.getDeclaredFields()) { + String propertyName = propertyName(field); + + // Case sensitivity is checked at deserialization time + // Fields are only added if they don't exist on a subclass + if (properties.containsKey(propertyName.toLowerCase(Locale.US)) + && !fields.containsKey(propertyName)) { + field.setAccessible(true); + fields.put(propertyName, field); + applyFieldAnnotations(field); + } + } + + // Traverse class hierarchy until we reach java.lang.Object which contains a bunch + // of fields/getters we don't want to serialize + currentClass = currentClass.getSuperclass(); + } while (currentClass != null && !currentClass.equals(Object.class)); + + if (properties.isEmpty()) { + throw new RuntimeException("No properties to serialize found on class " + clazz.getName()); + } + + // Make sure we can write to @DocumentId annotated properties before proceeding. + for (String docIdProperty : documentIdPropertyNames) { + if (!setters.containsKey(docIdProperty) && !fields.containsKey(docIdProperty)) { + throw new RuntimeException( + "@DocumentId is annotated on property " + + docIdProperty + + " of class " + + clazz.getName() + + " but no field or public setter was found"); + } + } + } + + @Override + Map serialize(T object, DeserializeContext.ErrorPath path) { + verifyValidType(object); + Map result = new HashMap<>(); + for (String property : properties.values()) { + // Skip @DocumentId annotated properties; + if (documentIdPropertyNames.contains(property)) { + continue; + } + + Object propertyValue; + if (getters.containsKey(property)) { + Method getter = getters.get(property); + try { + propertyValue = getter.invoke(object); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } else { + // Must be a field + Field field = fields.get(property); + if (field == null) { + throw new IllegalStateException("Bean property without field or getter: " + property); + } + try { + propertyValue = field.get(object); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + Object serializedValue = getSerializedValue(property, propertyValue, path); + + result.put(property, serializedValue); + } + return result; + } + + @Override + T deserialize( + Map values, + Map>, Type> types, + DeserializeContext context) { + if (constructor == null) { + throw context.errorPath.deserializeError( + "Class " + + getClazz().getName() + + " does not define a no-argument constructor. If you are using ProGuard, make " + + "sure these constructors are not stripped"); + } + + T instance; + try { + instance = constructor.newInstance(); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + HashSet deserializedProperties = new HashSet<>(); + for (Map.Entry entry : values.entrySet()) { + String propertyName = entry.getKey(); + DeserializeContext.ErrorPath childPath = context.errorPath.child(propertyName); + if (setters.containsKey(propertyName)) { + Method setter = setters.get(propertyName); + Type[] params = setter.getGenericParameterTypes(); + if (params.length != 1) { + throw childPath.deserializeError("Setter does not have exactly one parameter"); + } + Type resolvedType = resolveType(params[0], types); + Object value = + CustomClassMapper.deserializeToType( + entry.getValue(), resolvedType, context.newInstanceWithErrorPath(childPath)); + try { + setter.invoke(instance, value); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + deserializedProperties.add(propertyName); + } else if (fields.containsKey(propertyName)) { + Field field = fields.get(propertyName); + Type resolvedType = resolveType(field.getGenericType(), types); + Object value = + CustomClassMapper.deserializeToType( + entry.getValue(), resolvedType, context.newInstanceWithErrorPath(childPath)); + try { + field.set(instance, value); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + deserializedProperties.add(propertyName); + } else { + String message = + "No setter/field for " + propertyName + " found on class " + getClazz().getName(); + if (properties.containsKey(propertyName.toLowerCase(Locale.US))) { + message += " (fields/setters are case sensitive!)"; + } + if (isThrowOnUnknownProperties()) { + throw new RuntimeException(message); + } else if (isWarnOnUnknownProperties()) { + LOGGER.warning(message); + } + } + } + populateDocumentIdProperties(types, context, instance, deserializedProperties); + + return instance; + } + + private void addProperty(String property) { + String oldValue = properties.put(property.toLowerCase(Locale.US), property); + if (oldValue != null && !property.equals(oldValue)) { + throw new RuntimeException( + "Found two getters or fields with conflicting case " + + "sensitivity for property: " + + property.toLowerCase(Locale.US)); + } + } + + // Populate @DocumentId annotated fields. If there is a conflict (@DocumentId annotation is + // applied to a property that is already deserialized from the firestore document) + // a runtime exception will be thrown. + private void populateDocumentIdProperties( + Map>, Type> types, + DeserializeContext context, + T instance, + HashSet deserializedProperties) { + for (String docIdPropertyName : documentIdPropertyNames) { + checkForDocIdConflict(docIdPropertyName, deserializedProperties, context); + DeserializeContext.ErrorPath childPath = context.errorPath.child(docIdPropertyName); + if (setters.containsKey(docIdPropertyName)) { + Method setter = setters.get(docIdPropertyName); + Type[] params = setter.getGenericParameterTypes(); + if (params.length != 1) { + throw childPath.deserializeError("Setter does not have exactly one parameter"); + } + Type resolvedType = resolveType(params[0], types); + try { + if (resolvedType == String.class) { + setter.invoke(instance, context.documentRef.getId()); + } else { + setter.invoke(instance, context.documentRef); + } + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } else { + Field docIdField = fields.get(docIdPropertyName); + try { + if (docIdField.getType() == String.class) { + docIdField.set(instance, context.documentRef.getId()); + } else { + docIdField.set(instance, context.documentRef); + } + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } + } + + private void applyGetterAnnotations(Method method) { + Class returnType = method.getReturnType(); + if (method.isAnnotationPresent(ServerTimestamp.class)) { + validateServerTimestampType("Method", "returns", returnType); + serverTimestamps.add(propertyName(method)); + } + // Even though the value will be skipped, we still check for type matching for consistency. + if (method.isAnnotationPresent(DocumentId.class)) { + validateDocumentIdType("Method", "returns", returnType); + documentIdPropertyNames.add(propertyName(method)); + } + } + + private void applySetterAnnotations(Method method) { + if (method.isAnnotationPresent(ServerTimestamp.class)) { + throw new IllegalArgumentException( + "Method " + + method.getName() + + " is annotated with @ServerTimestamp but should not be. @ServerTimestamp can" + + " only be applied to fields and getters, not setters."); + } + if (method.isAnnotationPresent(DocumentId.class)) { + Class paramType = method.getParameterTypes()[0]; + validateDocumentIdType("Method", "accepts", paramType); + documentIdPropertyNames.add(propertyName(method)); + } + } + + private boolean shouldIncludeGetter(Method method) { + if (!method.getName().startsWith("get") && !method.getName().startsWith("is")) { + return false; + } + // Exclude methods from Object.class + if (method.getDeclaringClass().equals(Object.class)) { + return false; + } + // Non-public methods + if (!Modifier.isPublic(method.getModifiers())) { + return false; + } + // Static methods + if (Modifier.isStatic(method.getModifiers())) { + return false; + } + // No return type + if (method.getReturnType().equals(Void.TYPE)) { + return false; + } + // Non-zero parameters + if (method.getParameterTypes().length != 0) { + return false; + } + // Excluded methods + if (method.isAnnotationPresent(Exclude.class)) { + return false; + } + return true; + } + + private boolean shouldIncludeSetter(Method method) { + if (!method.getName().startsWith("set")) { + return false; + } + // Exclude methods from Object.class + if (method.getDeclaringClass().equals(Object.class)) { + return false; + } + // Static methods + if (Modifier.isStatic(method.getModifiers())) { + return false; + } + // Has a return type + if (!method.getReturnType().equals(Void.TYPE)) { + return false; + } + // Methods without exactly one parameters + if (method.getParameterTypes().length != 1) { + return false; + } + // Excluded methods + if (method.isAnnotationPresent(Exclude.class)) { + return false; + } + return true; + } + + private boolean shouldIncludeField(Field field) { + // Exclude methods from Object.class + if (field.getDeclaringClass().equals(Object.class)) { + return false; + } + // Non-public fields + if (!Modifier.isPublic(field.getModifiers())) { + return false; + } + // Static fields + if (Modifier.isStatic(field.getModifiers())) { + return false; + } + // Transient fields + if (Modifier.isTransient(field.getModifiers())) { + return false; + } + // Excluded fields + if (field.isAnnotationPresent(Exclude.class)) { + return false; + } + return true; + } + + private boolean isSetterOverride(Method base, Method override) { + // We expect an overridden setter here + hardAssert( + base.getDeclaringClass().isAssignableFrom(override.getDeclaringClass()), + "Expected override from a base class"); + hardAssert(base.getReturnType().equals(Void.TYPE), "Expected void return type"); + hardAssert(override.getReturnType().equals(Void.TYPE), "Expected void return type"); + + Type[] baseParameterTypes = base.getParameterTypes(); + Type[] overrideParameterTypes = override.getParameterTypes(); + hardAssert(baseParameterTypes.length == 1, "Expected exactly one parameter"); + hardAssert(overrideParameterTypes.length == 1, "Expected exactly one parameter"); + + return base.getName().equals(override.getName()) + && baseParameterTypes[0].equals(overrideParameterTypes[0]); + } + + private String propertyName(Method method) { + String annotatedName = annotatedName(method); + return annotatedName != null ? annotatedName : serializedName(method.getName()); + } + + private String serializedName(String methodName) { + String[] prefixes = new String[] {"get", "set", "is"}; + String methodPrefix = null; + for (String prefix : prefixes) { + if (methodName.startsWith(prefix)) { + methodPrefix = prefix; + } + } + if (methodPrefix == null) { + throw new IllegalArgumentException("Unknown Bean prefix for method: " + methodName); + } + String strippedName = methodName.substring(methodPrefix.length()); + + // Make sure the first word or upper-case prefix is converted to lower-case + char[] chars = strippedName.toCharArray(); + int pos = 0; + while (pos < chars.length && Character.isUpperCase(chars[pos])) { + chars[pos] = Character.toLowerCase(chars[pos]); + pos++; + } + return new String(chars); + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/RecordMapper.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/RecordMapper.java new file mode 100644 index 000000000..f15f65823 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/encoding/RecordMapper.java @@ -0,0 +1,230 @@ +/* + * Copyright 2024 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.encoding; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +/** + * Serializes java records. Uses automatic record constructors and accessors only. Therefore, + * exclusion of fields is not supported. Supports DocumentId, PropertyName, and ServerTimestamp + * annotations on record components. Since records are not supported in JDK versions < 16, + * reflection is used for inspecting record metadata. + */ +class RecordMapper extends BeanMapper { + private static final Logger LOGGER = Logger.getLogger(RecordMapper.class.getName()); + private static final RecordInspector RECORD_INSPECTOR = new RecordInspector(); + + // Below are maps to find an accessor and constructor parameter index from a given property name. + // A property name is the name annotated by @PropertyName, if exists; or the component name. + // See method propertyName for details. + private final Map accessors = new HashMap<>(); + private final Constructor constructor; + private final Map constructorParamIndexes = new HashMap<>(); + + RecordMapper(Class clazz) { + super(clazz); + + constructor = RECORD_INSPECTOR.getCanonicalConstructor(clazz); + + AnnotatedElement[] recordComponents = RECORD_INSPECTOR.getRecordComponents(clazz); + if (recordComponents.length == 0) { + throw new RuntimeException("No properties to serialize found on class " + clazz.getName()); + } + + try { + for (int i = 0; i < recordComponents.length; i++) { + Field field = clazz.getDeclaredField(RECORD_INSPECTOR.getName(recordComponents[i])); + String propertyName = propertyName(field); + constructorParamIndexes.put(propertyName, i); + accessors.put(propertyName, RECORD_INSPECTOR.getAccessor(recordComponents[i])); + applyFieldAnnotations(field); + } + } catch (NoSuchFieldException e) { + throw new RuntimeException(e); + } + } + + @Override + Map serialize(T object, DeserializeContext.ErrorPath path) { + verifyValidType(object); + Map result = new HashMap<>(); + for (Map.Entry entry : accessors.entrySet()) { + String property = entry.getKey(); + // Skip @DocumentId annotated properties; + if (documentIdPropertyNames.contains(property)) { + continue; + } + + Object propertyValue; + Method accessor = entry.getValue(); + try { + propertyValue = accessor.invoke(object); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + + Object serializedValue = getSerializedValue(property, propertyValue, path); + + result.put(property, serializedValue); + } + return result; + } + + @Override + T deserialize( + Map values, + Map>, Type> types, + DeserializeContext context) { + Object[] constructorParams = new Object[constructor.getParameterCount()]; + Set deserializedProperties = new HashSet<>(values.size()); + for (Map.Entry entry : values.entrySet()) { + String propertyName = entry.getKey(); + if (accessors.containsKey(propertyName)) { + Method accessor = accessors.get(propertyName); + Type resolvedType = resolveType(accessor.getGenericReturnType(), types); + DeserializeContext.ErrorPath childPath = context.errorPath.child(propertyName); + Object value = + CustomClassMapper.deserializeToType( + entry.getValue(), resolvedType, context.newInstanceWithErrorPath(childPath)); + constructorParams[constructorParamIndexes.get(propertyName).intValue()] = value; + deserializedProperties.add(propertyName); + } else { + String message = + "No accessor for " + propertyName + " found on class " + getClazz().getName(); + if (isThrowOnUnknownProperties()) { + throw new RuntimeException(message); + } + if (isWarnOnUnknownProperties()) { + LOGGER.warning(message); + } + } + } + + populateDocumentIdProperties(types, context, constructorParams, deserializedProperties); + + try { + return constructor.newInstance(constructorParams); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + + // Populate @DocumentId annotated components. If there is a conflict (@DocumentId annotation is + // applied to a property that is already deserialized from the firestore document) + // a runtime exception will be thrown. + private void populateDocumentIdProperties( + Map>, Type> types, + DeserializeContext context, + Object[] params, + Set deserializedProperties) { + for (String docIdPropertyName : documentIdPropertyNames) { + checkForDocIdConflict(docIdPropertyName, deserializedProperties, context); + + if (accessors.containsKey(docIdPropertyName)) { + Object id; + Type resolvedType = + resolveType(accessors.get(docIdPropertyName).getGenericReturnType(), types); + if (resolvedType == String.class) { + id = context.documentRef.getId(); + } else { + id = context.documentRef; + } + params[constructorParamIndexes.get(docIdPropertyName).intValue()] = id; + } + } + } + + private static final class RecordInspector { + private final Method _getRecordComponents; + private final Method _getName; + private final Method _getType; + private final Method _getAccessor; + + @SuppressWarnings("JavaReflectionMemberAccess") + private RecordInspector() { + try { + _getRecordComponents = Class.class.getMethod("getRecordComponents"); + Class recordComponentClass = Class.forName("java.lang.reflect.RecordComponent"); + _getName = recordComponentClass.getMethod("getName"); + _getType = recordComponentClass.getMethod("getType"); + _getAccessor = recordComponentClass.getMethod("getAccessor"); + } catch (ClassNotFoundException | NoSuchMethodException e) { + throw new IllegalStateException( + "Failed to access class or methods needed to support record serialization", e); + } + } + + private Constructor getCanonicalConstructor(Class cls) { + try { + Class[] paramTypes = + Arrays.stream(getRecordComponents(cls)).map(this::getType).toArray(Class[]::new); + Constructor constructor = cls.getDeclaredConstructor(paramTypes); + constructor.setAccessible(true); + return constructor; + } catch (NoSuchMethodException e) { + throw new IllegalStateException(e); + } + } + + private AnnotatedElement[] getRecordComponents(Class recordType) { + try { + return (AnnotatedElement[]) _getRecordComponents.invoke(recordType); + } catch (InvocationTargetException | IllegalAccessException e) { + throw new IllegalArgumentException( + "Failed to load components of record " + recordType.getName(), e); + } + } + + private Class getType(AnnotatedElement recordComponent) { + try { + return (Class) _getType.invoke(recordComponent); + } catch (InvocationTargetException | IllegalAccessException e) { + throw new IllegalArgumentException("Failed to get record component type", e); + } + } + + private String getName(AnnotatedElement recordComponent) { + try { + return (String) _getName.invoke(recordComponent); + } catch (InvocationTargetException | IllegalAccessException e) { + throw new IllegalArgumentException("Failed to get record component name", e); + } + } + + private Method getAccessor(AnnotatedElement recordComponent) { + try { + Method accessor = (Method) _getAccessor.invoke(recordComponent); + accessor.setAccessible(true); + return accessor; + } catch (InvocationTargetException | IllegalAccessException e) { + throw new IllegalArgumentException("Failed to get record component accessor", e); + } + } + } +} diff --git a/google-cloud-firestore/src/test-jdk17/java/com/google/cloud/firestore/RecordDocumentReferenceTest.java b/google-cloud-firestore/src/test-jdk17/java/com/google/cloud/firestore/RecordDocumentReferenceTest.java new file mode 100644 index 000000000..21baf69cc --- /dev/null +++ b/google-cloud-firestore/src/test-jdk17/java/com/google/cloud/firestore/RecordDocumentReferenceTest.java @@ -0,0 +1,407 @@ +/* + * Copyright 2024 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.LocalFirestoreHelper.assertCommitEquals; +import static com.google.cloud.firestore.LocalFirestoreHelper.commit; +import static com.google.cloud.firestore.LocalFirestoreHelper.create; +import static com.google.cloud.firestore.LocalFirestoreHelper.getAllResponse; +import static com.google.cloud.firestore.LocalFirestoreHelper.map; +import static com.google.cloud.firestore.LocalFirestoreHelper.serverTimestamp; +import static com.google.cloud.firestore.LocalFirestoreHelper.set; +import static com.google.cloud.firestore.LocalFirestoreHelper.transform; +import static com.google.cloud.firestore.RecordTestHelper.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; + +import com.google.api.gax.rpc.ResponseObserver; +import com.google.api.gax.rpc.ServerStreamingCallable; +import com.google.api.gax.rpc.UnaryCallable; +import com.google.cloud.firestore.spi.v1.FirestoreRpc; +import com.google.firestore.v1.BatchGetDocumentsRequest; +import com.google.firestore.v1.CommitRequest; +import com.google.firestore.v1.CommitResponse; +import com.google.firestore.v1.Value; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Captor; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + + +@RunWith(MockitoJUnitRunner.class) +public class RecordDocumentReferenceTest { + + @Spy + private final FirestoreImpl firestoreMock = + new FirestoreImpl( + FirestoreOptions.newBuilder().setProjectId("test-project").build(), + Mockito.mock(FirestoreRpc.class)); + + @Captor private ArgumentCaptor commitCapture; + + @Captor private ArgumentCaptor getAllCapture; + + @Captor private ArgumentCaptor> streamObserverCapture; + + private DocumentReference documentReference; + + @Before + public void before() { + documentReference = firestoreMock.document("coll/doc"); + } + + @Test + public void serializeBasicTypes() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference.set(ALL_SUPPORTED_TYPES_OBJECT).get(); + + CommitRequest expectedCommit = commit(set(ALL_SUPPORTED_TYPES_PROTO)); + assertCommitEquals(expectedCommit, commitCapture.getAllValues().get(0)); + } + + @Test + public void doesNotSerializeAdvancedNumberTypes() { + Map expectedErrorMessages = new HashMap<>(); + + InvalidRecord record = new InvalidRecord(new BigInteger("0"), null, null); + expectedErrorMessages.put( + record, + "Could not serialize object. Numbers of type BigInteger are not supported, please use an int, long, float, double or BigDecimal (found in field 'bigIntegerValue')"); + + record = new InvalidRecord(null, (byte) 0, null); + expectedErrorMessages.put( + record, + "Could not serialize object. Numbers of type Byte are not supported, please use an int, long, float, double or BigDecimal (found in field 'byteValue')"); + + record = new InvalidRecord(null, null, (short) 0); + expectedErrorMessages.put( + record, + "Could not serialize object. Numbers of type Short are not supported, please use an int, long, float, double or BigDecimal (found in field 'shortValue')"); + + for (Map.Entry testCase : expectedErrorMessages.entrySet()) { + try { + documentReference.set(testCase.getKey()); + fail(); + } catch (IllegalArgumentException e) { + assertEquals(testCase.getValue(), e.getMessage()); + } + } + } + + @Test + public void doesNotDeserializeAdvancedNumberTypes() throws Exception { + Map fieldNamesToTypeNames = + map("bigIntegerValue", "BigInteger", "shortValue", "Short", "byteValue", "Byte"); + + for (Map.Entry testCase : fieldNamesToTypeNames.entrySet()) { + String fieldName = testCase.getKey(); + String typeName = testCase.getValue(); + Map response = map(fieldName, Value.newBuilder().setIntegerValue(0).build()); + + doAnswer(getAllResponse(response)) + .when(firestoreMock) + .streamRequest( + getAllCapture.capture(), + streamObserverCapture.capture(), + ArgumentMatchers.any()); + + DocumentSnapshot snapshot = documentReference.get().get(); + try { + snapshot.toObject(InvalidRecord.class); + fail(); + } catch (RuntimeException e) { + assertEquals( + String.format( + "Could not deserialize object. Deserializing values to %s is not supported (found in field '%s')", + typeName, fieldName), + e.getMessage()); + } + } + } + + @Test + public void createDocument() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference.create(SINGLE_COMPONENT_OBJECT).get(); + + CommitRequest expectedCommit = commit(create(SINGLE_COMPONENT_PROTO)); + + List commitRequests = commitCapture.getAllValues(); + assertCommitEquals(expectedCommit, commitRequests.get(0)); + } + + @Test + public void createWithServerTimestamp() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference.create(SERVER_TIMESTAMP_OBJECT).get(); + + CommitRequest create = + commit( + create(Collections.emptyMap()), + transform("foo", serverTimestamp(), "inner.bar", serverTimestamp())); + + List commitRequests = commitCapture.getAllValues(); + assertCommitEquals(create, commitRequests.get(0)); + } + + @Test + public void setWithServerTimestamp() throws Exception { + doReturn(FIELD_TRANSFORM_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference.set(SERVER_TIMESTAMP_OBJECT).get(); + + CommitRequest set = + commit( + set(SERVER_TIMESTAMP_PROTO), + transform("foo", serverTimestamp(), "inner.bar", serverTimestamp())); + + List commitRequests = commitCapture.getAllValues(); + assertCommitEquals(set, commitRequests.get(0)); + } + + @Test + public void mergeWithServerTimestamps() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference + .set(SERVER_TIMESTAMP_OBJECT, SetOptions.mergeFields("inner.bar")) + .get(); + + CommitRequest set = + commit( + set(SERVER_TIMESTAMP_PROTO, new ArrayList<>()), + transform("inner.bar", serverTimestamp())); + + List commitRequests = commitCapture.getAllValues(); + assertCommitEquals(set, commitRequests.get(0)); + } + + @Test + public void setDocumentWithMerge() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference.set(SINGLE_COMPONENT_OBJECT, SetOptions.merge()).get(); + documentReference.set(ALL_SUPPORTED_TYPES_OBJECT, SetOptions.mergeFields("foo")).get(); + documentReference + .set(ALL_SUPPORTED_TYPES_OBJECT, SetOptions.mergeFields(Arrays.asList("foo"))) + .get(); + documentReference + .set( + ALL_SUPPORTED_TYPES_OBJECT, + SetOptions.mergeFieldPaths(Arrays.asList(FieldPath.of("foo")))) + .get(); + + CommitRequest expectedCommit = commit(set(SINGLE_COMPONENT_PROTO, Arrays.asList("foo"))); + + for (int i = 0; i < 4; ++i) { + assertCommitEquals(expectedCommit, commitCapture.getAllValues().get(i)); + } + } + + @Test + public void setDocumentWithNestedMerge() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference.set(NESTED_RECORD_OBJECT, SetOptions.mergeFields("first.foo")).get(); + documentReference + .set(NESTED_RECORD_OBJECT, SetOptions.mergeFields(Arrays.asList("first.foo"))) + .get(); + documentReference + .set( + NESTED_RECORD_OBJECT, + SetOptions.mergeFieldPaths(Arrays.asList(FieldPath.of("first", "foo")))) + .get(); + + Map nestedUpdate = new HashMap<>(); + Value.Builder nestedProto = Value.newBuilder(); + nestedProto.getMapValueBuilder().putAllFields(SINGLE_COMPONENT_PROTO); + nestedUpdate.put("first", nestedProto.build()); + + CommitRequest expectedCommit = commit(set(nestedUpdate, Arrays.asList("first.foo"))); + + for (int i = 0; i < 3; ++i) { + assertCommitEquals(expectedCommit, commitCapture.getAllValues().get(i)); + } + } + + @Test + public void setMultipleFieldsWithMerge() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference + .set( + NESTED_RECORD_OBJECT, + SetOptions.mergeFields("first.foo", "second.foo", "second.trueValue")) + .get(); + + Map nestedUpdate = new HashMap<>(); + Value.Builder nestedProto = Value.newBuilder(); + nestedProto.getMapValueBuilder().putAllFields(SINGLE_COMPONENT_PROTO); + nestedUpdate.put("first", nestedProto.build()); + nestedProto + .getMapValueBuilder() + .putFields("trueValue", Value.newBuilder().setBooleanValue(true).build()); + nestedUpdate.put("second", nestedProto.build()); + + CommitRequest expectedCommit = + commit(set(nestedUpdate, Arrays.asList("first.foo", "second.foo", "second.trueValue"))); + + assertCommitEquals(expectedCommit, commitCapture.getValue()); + } + + @Test + public void setNestedMapWithMerge() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference.set(NESTED_RECORD_OBJECT, SetOptions.mergeFields("first", "second")).get(); + + Map nestedUpdate = new HashMap<>(); + Value.Builder nestedProto = Value.newBuilder(); + nestedProto.getMapValueBuilder().putAllFields(SINGLE_COMPONENT_PROTO); + nestedUpdate.put("first", nestedProto.build()); + nestedProto.getMapValueBuilder().putAllFields(ALL_SUPPORTED_TYPES_PROTO); + nestedUpdate.put("second", nestedProto.build()); + + CommitRequest expectedCommit = commit(set(nestedUpdate, Arrays.asList("first", "second"))); + assertCommitEquals(expectedCommit, commitCapture.getValue()); + } + + @Test + public void extractFieldMaskFromMerge() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference.set(NESTED_RECORD_OBJECT, SetOptions.merge()).get(); + + Map nestedUpdate = new HashMap<>(); + Value.Builder nestedProto = Value.newBuilder(); + nestedProto.getMapValueBuilder().putAllFields(SINGLE_COMPONENT_PROTO); + nestedUpdate.put("first", nestedProto.build()); + nestedProto.getMapValueBuilder().putAllFields(ALL_SUPPORTED_TYPES_PROTO); + nestedUpdate.put("second", nestedProto.build()); + + List updateMask = Arrays.asList( + "first.foo", + "second.arrayValue", + "second.bytesValue", + "second.dateValue", + "second.doubleValue", + "second.falseValue", + "second.foo", + "second.geoPointValue", + "second.infValue", + "second.longValue", + "second.nanValue", + "second.negInfValue", + "second.nullValue", + "second.objectValue.foo", + "second.timestampValue", + "second.trueValue", + "second.model.foo"); + + CommitRequest expectedCommit = commit(set(nestedUpdate, updateMask)); + assertCommitEquals(expectedCommit, commitCapture.getValue()); + } + + @Test + public void setNestedRecordWithPojoMapWithMerge() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), ArgumentMatchers.>any()); + + documentReference.set(NESTED_RECORD_WITH_POJO_OBJECT, SetOptions.mergeFields("first", "second")).get(); + + Map nestedUpdate = new HashMap<>(); + Value.Builder nestedProto = Value.newBuilder(); + nestedProto.getMapValueBuilder().putAllFields(SINGLE_COMPONENT_PROTO); + nestedUpdate.put("first", nestedProto.build()); + nestedProto.getMapValueBuilder().putAllFields(ALL_SUPPORTED_TYPES_PROTO); + nestedUpdate.put("second", nestedProto.build()); + + CommitRequest expectedCommit = commit(set(nestedUpdate, Arrays.asList("first", "second"))); + assertCommitEquals(expectedCommit, commitCapture.getValue()); + } + + @Test + public void setNestedPojoWithRecordMapWithMerge() throws Exception { + doReturn(SINGLE_WRITE_COMMIT_RESPONSE) + .when(firestoreMock) + .sendRequest( + commitCapture.capture(), + ArgumentMatchers.>any()); + + documentReference.set(NESTED_POJO_WITH_RECORD_OBJECT, SetOptions.mergeFields("first", "second")).get(); + + Map nestedUpdate = new HashMap<>(); + Value.Builder nestedProto = Value.newBuilder(); + nestedProto.getMapValueBuilder().putAllFields(SINGLE_COMPONENT_PROTO); + nestedUpdate.put("first", nestedProto.build()); + nestedProto.getMapValueBuilder().putAllFields(ALL_SUPPORTED_TYPES_PROTO); + nestedUpdate.put("second", nestedProto.build()); + + CommitRequest expectedCommit = commit(set(nestedUpdate, Arrays.asList("first", "second"))); + assertCommitEquals(expectedCommit, commitCapture.getValue()); + } +} diff --git a/google-cloud-firestore/src/test-jdk17/java/com/google/cloud/firestore/RecordMapperTest.java b/google-cloud-firestore/src/test-jdk17/java/com/google/cloud/firestore/RecordMapperTest.java new file mode 100644 index 000000000..daa5df99d --- /dev/null +++ b/google-cloud-firestore/src/test-jdk17/java/com/google/cloud/firestore/RecordMapperTest.java @@ -0,0 +1,1134 @@ +/* + * Copyright 2024 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.cloud.firestore.annotation.DocumentId; +import com.google.cloud.firestore.annotation.PropertyName; +import com.google.cloud.firestore.annotation.ThrowOnExtraProperties; +import com.google.cloud.firestore.encoding.CustomClassMapper; +import com.google.cloud.firestore.spi.v1.FirestoreRpc; +import com.google.common.collect.ImmutableList; +import com.google.firestore.v1.DatabaseRootName; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.google.cloud.firestore.LocalFirestoreHelper.fromSingleQuotedString; +import static com.google.cloud.firestore.LocalFirestoreHelper.mapAnyType; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings({"unused", "WeakerAccess", "SpellCheckingInspection"}) +@RunWith(MockitoJUnitRunner.class) +public class RecordMapperTest { + + @Spy + private final FirestoreImpl firestoreMock = + new FirestoreImpl( + FirestoreOptions.newBuilder().setProjectId("test-project").build(), + Mockito.mock(FirestoreRpc.class)); + + private static final double EPSILON = 0.0003; + + public record StringBean ( + String value + ){} + + public record DoubleBean ( + double value + ){} + + public record BigDecimalBean ( + BigDecimal value + ){} + + public record FloatBean ( + float value + ){} + + public record LongBean ( + long value + ){} + + public record IntBean ( + int value + ){} + + public record BooleanBean ( + boolean value + ){} + + public record ShortBean ( + short value + ){} + + public record ByteBean ( + byte value + ){} + + public record CharBean ( + char value + ){} + + public record IntArrayBean ( + int[] values + ){} + + public record StringArrayBean ( + String[] values + ){} + + public record XMLAndURLBean ( + String XMLAndURL + ){} + + public record CaseSensitiveFieldBean1 ( + String VALUE + ){} + + public record CaseSensitiveFieldBean2 ( + String value + ){} + + public record CaseSensitiveFieldBean3 ( + String Value + ){} + + public record CaseSensitiveFieldBean4 ( + String valUE + ){} + + public record NestedBean ( + StringBean bean + ){} + + public record ObjectBean ( + Object value + ){} + + public record GenericBean ( + B value + ){} + + public record DoubleGenericBean ( + A valueA, + B valueB + ){} + + public record ListBean ( + List values + ){} + + public record SetBean ( + Set values + ){} + + public record CollectionBean ( + Collection values + ){} + + public record MapBean ( + Map values + ){} + + /** + * This form is not terribly useful in Java, but Kotlin Maps are immutable and are rewritten into + * this form (b/67470108 has more details). + */ + public record UpperBoundedMapBean ( + Map values + ){} + + public record MultiBoundedMapBean ( + Map values + ){} + + public record MultiBoundedMapHolderBean ( + MultiBoundedMapBean map + ){} + + public record UnboundedMapBean ( + Map values + ){} + + public record UnboundedTypeVariableMapBean ( + Map values + ){} + + public record UnboundedTypeVariableMapHolderBean ( + UnboundedTypeVariableMapBean map + ){} + + public record NestedListBean ( + List values + ){} + + public record NestedMapBean ( + Map values + ){} + + public record IllegalKeyMapBean ( + Map values + ){} + + @ThrowOnExtraProperties + public record ThrowOnUnknownPropertiesBean ( + String value + ){} + + @ThrowOnExtraProperties + public record NoFieldBean( + ){} + + public record PropertyNameBean ( + @PropertyName("my_key") + String key, + + @PropertyName("my_value") + String value + ){} + + @SuppressWarnings({"NonAsciiCharacters"}) + public record UnicodeBean ( + String 漢字 + ){} + + private static T deserialize(String jsonString, Class clazz) { + return deserialize(jsonString, clazz, /*docRef=*/ null); + } + + private static T deserialize(Map json, Class clazz) { + return deserialize(json, clazz, /*docRef=*/ null); + } + + private static T deserialize(String jsonString, Class clazz, DocumentReference docRef) { + Map json = fromSingleQuotedString(jsonString); + return CustomClassMapper.convertToCustomClass(json, clazz, docRef); + } + + private static T deserialize( + Map json, Class clazz, DocumentReference docRef) { + return CustomClassMapper.convertToCustomClass(json, clazz, docRef); + } + + private static Object serialize(Object object) { + return CustomClassMapper.convertToPlainJavaTypes(object); + } + + private static void assertJson(String expected, Object actual) { + assertEquals(fromSingleQuotedString(expected), actual); + } + + private static void assertExceptionContains(String partialMessage, Runnable run) { + try { + run.run(); + fail("Expected exception not thrown"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains(partialMessage)); + } + } + + private static T convertToCustomClass( + Object object, Class clazz, DocumentReference docRef) { + return CustomClassMapper.convertToCustomClass(object, clazz, docRef); + } + + private static T convertToCustomClass(Object object, Class clazz) { + return CustomClassMapper.convertToCustomClass(object, clazz, null); + } + + @Test + public void primitiveDeserializeString() { + StringBean bean = deserialize("{'value': 'foo'}", StringBean.class); + assertEquals("foo", bean.value()); + + // Double + try { + deserialize("{'value': 1.1}", StringBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // Int + try { + deserialize("{'value': 1}", StringBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // Long + try { + deserialize("{'value': 1234567890123}", StringBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // Boolean + try { + deserialize("{'value': true}", StringBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + } + + @Test + public void primitiveDeserializeBoolean() { + BooleanBean beanBoolean = deserialize("{'value': true}", BooleanBean.class); + assertEquals(true, beanBoolean.value()); + + // Double + try { + deserialize("{'value': 1.1}", BooleanBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // Long + try { + deserialize("{'value': 1234567890123}", BooleanBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // Int + try { + deserialize("{'value': 1}", BooleanBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // String + try { + deserialize("{'value': 'foo'}", BooleanBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + } + + @Test + public void primitiveDeserializeDouble() { + DoubleBean beanDouble = deserialize("{'value': 1.1}", DoubleBean.class); + assertEquals(1.1, beanDouble.value(), EPSILON); + + // Int + DoubleBean beanInt = deserialize("{'value': 1}", DoubleBean.class); + assertEquals(1, beanInt.value(), EPSILON); + // Long + DoubleBean beanLong = deserialize("{'value': 1234567890123}", DoubleBean.class); + assertEquals(1234567890123L, beanLong.value(), EPSILON); + + // Boolean + try { + deserialize("{'value': true}", DoubleBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // String + try { + deserialize("{'value': 'foo'}", DoubleBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + } + + @Test + public void primitiveDeserializeBigDecimal() { + BigDecimalBean beanBigdecimal = deserialize("{'value': 123}", BigDecimalBean.class); + assertEquals(BigDecimal.valueOf(123.0), beanBigdecimal.value()); + + beanBigdecimal = deserialize("{'value': '123'}", BigDecimalBean.class); + assertEquals(BigDecimal.valueOf(123), beanBigdecimal.value()); + + // Int + BigDecimalBean beanInt = + deserialize(Collections.singletonMap("value", 1), BigDecimalBean.class); + assertEquals(BigDecimal.valueOf(1), beanInt.value()); + + // Long + BigDecimalBean beanLong = + deserialize(Collections.singletonMap("value", 1234567890123L), BigDecimalBean.class); + assertEquals(BigDecimal.valueOf(1234567890123L), beanLong.value()); + + // Double + BigDecimalBean beanDouble = + deserialize(Collections.singletonMap("value", 1.1), BigDecimalBean.class); + assertEquals(BigDecimal.valueOf(1.1), beanDouble.value()); + + // BigDecimal + BigDecimalBean beanBigDecimal = + deserialize( + Collections.singletonMap("value", BigDecimal.valueOf(1.2)), BigDecimalBean.class); + assertEquals(BigDecimal.valueOf(1.2), beanBigDecimal.value()); + + // Boolean + try { + deserialize("{'value': true}", BigDecimalBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // String + try { + deserialize("{'value': 'foo'}", BigDecimalBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + } + + @Test + public void primitiveDeserializeFloat() { + FloatBean beanFloat = deserialize("{'value': 1.1}", FloatBean.class); + assertEquals(1.1, beanFloat.value(), EPSILON); + + // Int + FloatBean beanInt = deserialize(Collections.singletonMap("value", 1), FloatBean.class); + assertEquals(1, beanInt.value(), EPSILON); + // Long + FloatBean beanLong = + deserialize(Collections.singletonMap("value", 1234567890123L), FloatBean.class); + assertEquals((float) 1234567890123L, beanLong.value(), EPSILON); + + // Boolean + try { + deserialize("{'value': true}", FloatBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // String + try { + deserialize("{'value': 'foo'}", FloatBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + } + + @Test + public void primitiveDeserializeInt() { + IntBean beanInt = deserialize("{'value': 1}", IntBean.class); + assertEquals(1, beanInt.value()); + + // Double + IntBean beanDouble = deserialize("{'value': 1.1}", IntBean.class); + assertEquals(1, beanDouble.value()); + + // Large doubles + try { + deserialize("{'value': 1e10}", IntBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // Long + try { + deserialize("{'value': 1234567890123}", IntBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // Boolean + try { + deserialize("{'value': true}", IntBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // String + try { + deserialize("{'value': 'foo'}", IntBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + } + + @Test + public void primitiveDeserializeLong() { + LongBean beanLong = deserialize("{'value': 1234567890123}", LongBean.class); + assertEquals(1234567890123L, beanLong.value()); + + // Int + LongBean beanInt = deserialize("{'value': 1}", LongBean.class); + assertEquals(1, beanInt.value()); + + // Double + LongBean beanDouble = deserialize("{'value': 1.1}", LongBean.class); + assertEquals(1, beanDouble.value()); + + // Large doubles + try { + deserialize("{'value': 1e300}", LongBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // Boolean + try { + deserialize("{'value': true}", LongBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + + // String + try { + deserialize("{'value': 'foo'}", LongBean.class); + fail("Should throw"); + } catch (RuntimeException e) { // ignore + } + } + + @Test + public void primitiveDeserializeWrongTypeMap() { + String expectedExceptionMessage = + ".* Failed to convert value of type .*Map to String \\(found in field 'value'\\).*"; + Throwable exception = + assertThrows( + RuntimeException.class, + () -> deserialize("{'value': {'foo': 'bar'}}", StringBean.class)); + assertTrue(exception.getMessage().matches(expectedExceptionMessage)); + } + + @Test + public void primitiveDeserializeWrongTypeList() { + assertExceptionContains( + "Failed to convert value of type java.util.ArrayList to String" + + " (found in field 'value')", + () -> deserialize("{'value': ['foo']}", StringBean.class)); + } + + @Test + public void noFieldDeserialize() { + assertExceptionContains( + "No properties to serialize found on class " + + "com.google.cloud.firestore.RecordMapperTest$NoFieldBean", + () -> deserialize("{'value': 'foo'}", NoFieldBean.class)); + } + + @Test + public void throwOnUnknownProperties() { + assertExceptionContains( + "No accessor for unknown found on class " + + "com.google.cloud.firestore.RecordMapperTest$ThrowOnUnknownPropertiesBean", + () -> + deserialize("{'value': 'foo', 'unknown': 'bar'}", ThrowOnUnknownPropertiesBean.class)); + } + + @Test + public void XMLAndURLBean() { + XMLAndURLBean bean = + deserialize("{'XMLAndURL': 'foo'}", XMLAndURLBean.class); + assertEquals("foo", bean.XMLAndURL()); + } + + public record AllCapsDefaultHandlingBean ( + String UUID + ){} + + @Test + public void allCapsSerializesToUppercaseByDefault() { + AllCapsDefaultHandlingBean bean = new AllCapsDefaultHandlingBean("value"); + assertJson("{'UUID': 'value'}", serialize(bean)); + AllCapsDefaultHandlingBean deserialized = + deserialize("{'UUID': 'value'}", AllCapsDefaultHandlingBean.class); + assertEquals("value", deserialized.UUID()); + } + + public record AllCapsWithPropertyName ( + @PropertyName("uuid") + String UUID + ){} + + @Test + public void allCapsWithPropertyNameSerializesToLowercase() { + AllCapsWithPropertyName bean = new AllCapsWithPropertyName("value"); + assertJson("{'uuid': 'value'}", serialize(bean)); + AllCapsWithPropertyName deserialized = + deserialize("{'uuid': 'value'}", AllCapsWithPropertyName.class); + assertEquals("value", deserialized.UUID()); + } + + @Test + public void nestedParsingWorks() { + NestedBean bean = deserialize("{'bean': {'value': 'foo'}}", NestedBean.class); + assertEquals("foo", bean.bean().value()); + } + + @Test + public void beansCanContainLists() { + ListBean bean = deserialize("{'values': ['foo', 'bar']}", ListBean.class); + assertEquals(Arrays.asList("foo", "bar"), bean.values()); + } + + @Test + public void beansCanContainMaps() { + MapBean bean = deserialize("{'values': {'foo': 'bar'}}", MapBean.class); + Map expected = fromSingleQuotedString("{'foo': 'bar'}"); + assertEquals(expected, bean.values()); + } + + @Test + public void beansCanContainUpperBoundedMaps() { + Date date = new Date(1491847082123L); + Map source = mapAnyType("values", mapAnyType("foo", date)); + UpperBoundedMapBean bean = convertToCustomClass(source, UpperBoundedMapBean.class); + Map expected = mapAnyType("foo", date); + assertEquals(expected, bean.values()); + } + + @Test + public void beansCanContainMultiBoundedMaps() { + Date date = new Date(1491847082123L); + Map source = mapAnyType("map", mapAnyType("values", mapAnyType("foo", date))); + MultiBoundedMapHolderBean bean = convertToCustomClass(source, MultiBoundedMapHolderBean.class); + + Map expected = mapAnyType("foo", date); + assertEquals(expected, bean.map().values()); + } + + @Test + public void beansCanContainUnboundedMaps() { + UnboundedMapBean bean = deserialize("{'values': {'foo': 'bar'}}", UnboundedMapBean.class); + Map expected = mapAnyType("foo", "bar"); + assertEquals(expected, bean.values()); + } + + @Test + public void beansCanContainUnboundedTypeVariableMaps() { + Map source = mapAnyType("map", mapAnyType("values", mapAnyType("foo", "bar"))); + UnboundedTypeVariableMapHolderBean bean = + convertToCustomClass(source, UnboundedTypeVariableMapHolderBean.class); + + Map expected = mapAnyType("foo", "bar"); + assertEquals(expected, bean.map().values()); + } + + @Test + public void beansCanContainNestedUnboundedMaps() { + UnboundedMapBean bean = + deserialize("{'values': {'foo': {'bar': 'baz'}}}", UnboundedMapBean.class); + Map expected = mapAnyType("foo", mapAnyType("bar", "baz")); + assertEquals(expected, bean.values()); + } + + @Test + public void beansCanContainBeanLists() { + NestedListBean bean = deserialize("{'values': [{'value': 'foo'}]}", NestedListBean.class); + assertEquals(1, bean.values().size()); + assertEquals("foo", bean.values().get(0).value()); + } + + @Test + public void beansCanContainBeanMaps() { + NestedMapBean bean = deserialize("{'values': {'key': {'value': 'foo'}}}", NestedMapBean.class); + assertEquals(1, bean.values().size()); + assertEquals("foo", bean.values().get("key").value()); + } + + @Test + public void beanMapsMustHaveStringKeys() { + assertExceptionContains( + "Only Maps with string keys are supported, but found Map with key type class " + + "java.lang.Integer (found in field 'values')", + () -> deserialize("{'values': {'1': 'bar'}}", IllegalKeyMapBean.class)); + } + + @Test + public void serializeStringBean() { + StringBean bean = new StringBean("foo"); + assertJson("{'value': 'foo'}", serialize(bean)); + } + + @Test + public void serializeDoubleBean() { + DoubleBean bean = new DoubleBean(1.1); + assertJson("{'value': 1.1}", serialize(bean)); + } + + @Test + public void serializeIntBean() { + IntBean bean = new IntBean(1); + assertJson("{'value': 1}", serialize(Collections.singletonMap("value", 1.0))); + } + + @Test + public void serializeLongBean() { + LongBean bean = new LongBean(1234567890123L); + assertJson( + "{'value': 1.234567890123E12}", + serialize(Collections.singletonMap("value", 1.234567890123E12))); + } + + @Test + public void serializeBigDecimalBean() { + BigDecimalBean bean = new BigDecimalBean(BigDecimal.valueOf(1.1)); + assertEquals(mapAnyType("value", "1.1"), serialize(bean)); + } + + @Test + public void bigDecimalRoundTrip() { + BigDecimal doubleMaxPlusOne = BigDecimal.valueOf(Double.MAX_VALUE).add(BigDecimal.ONE); + BigDecimalBean a = new BigDecimalBean(doubleMaxPlusOne); + Map serialized = (Map) serialize(a); + BigDecimalBean b = convertToCustomClass(serialized, BigDecimalBean.class); + assertEquals(a, b); + } + + @Test + public void serializeBooleanBean() { + BooleanBean bean = new BooleanBean(true); + assertJson("{'value': true}", serialize(bean)); + } + + @Test + public void serializeFloatBean() { + FloatBean bean = new FloatBean(0.5f); + + // We don't use assertJson as it converts all floating point numbers to Double. + assertEquals(mapAnyType("value", 0.5f), serialize(bean)); + } + + @Test + public void serializePrivateFieldBean() { + final NoFieldBean bean = new NoFieldBean(); + assertExceptionContains( + "No properties to serialize found on class " + + "com.google.cloud.firestore.RecordMapperTest$NoFieldBean", + () -> serialize(bean)); + } + + @Test + public void nestedSerializingWorks() { + NestedBean bean = new NestedBean(new StringBean("foo")); + assertJson("{'bean': {'value': 'foo'}}", serialize(bean)); + } + + @Test + public void serializingListsWorks() { + ListBean bean = new ListBean(Arrays.asList("foo", "bar")); + assertJson("{'values': ['foo', 'bar']}", serialize(bean)); + } + + @Test + public void serializingMapsWorks() { + MapBean bean = new MapBean(new HashMap<>()); + bean.values().put("foo", "bar"); + assertJson("{'values': {'foo': 'bar'}}", serialize(bean)); + } + + @Test + public void serializingUpperBoundedMapsWorks() { + Date date = new Date(1491847082123L); + UpperBoundedMapBean bean = new UpperBoundedMapBean(Map.of("foo", date)); + Map expected = + mapAnyType("values", mapAnyType("foo", new Date(date.getTime()))); + assertEquals(expected, serialize(bean)); + } + + @Test + public void serializingMultiBoundedObjectsWorks() { + Date date = new Date(1491847082123L); + + HashMap values = new HashMap(); + values.put("foo", date); + + MultiBoundedMapHolderBean holder = new MultiBoundedMapHolderBean(new MultiBoundedMapBean<>(values)); + + Map expected = + mapAnyType("map", mapAnyType("values", mapAnyType("foo", new Date(date.getTime())))); + assertEquals(expected, serialize(holder)); + } + + @Test + public void serializeListOfBeansWorks() { + StringBean stringBean = new StringBean("foo"); + + NestedListBean bean = new NestedListBean(new ArrayList<>()); + bean.values().add(stringBean); + + assertJson("{'values': [{'value': 'foo'}]}", serialize(bean)); + } + + @Test + public void serializeMapOfBeansWorks() { + StringBean stringBean = new StringBean("foo"); + + NestedMapBean bean = new NestedMapBean(new HashMap<>()); + bean.values().put("key", stringBean); + + assertJson("{'values': {'key': {'value': 'foo'}}}", serialize(bean)); + } + + @Test + public void beanMapsMustHaveStringKeysForSerializing() { + StringBean stringBean = new StringBean("foo"); + + final IllegalKeyMapBean bean = new IllegalKeyMapBean(new HashMap<>()); + bean.values().put(1, stringBean); + + assertExceptionContains( + "Maps with non-string keys are not supported (found in field 'values')", + () -> serialize(bean)); + } + + @Test + public void serializeUPPERCASE() { + XMLAndURLBean bean = new XMLAndURLBean("foo"); + assertJson("{'XMLAndURL': 'foo'}", serialize(bean)); + } + + @Test + public void roundTripCaseSensitiveFieldBean1() { + CaseSensitiveFieldBean1 bean = new CaseSensitiveFieldBean1("foo"); + assertJson("{'VALUE': 'foo'}", serialize(bean)); + CaseSensitiveFieldBean1 deserialized = + deserialize("{'VALUE': 'foo'}", CaseSensitiveFieldBean1.class); + assertEquals("foo", deserialized.VALUE()); + } + + @Test + public void roundTripCaseSensitiveFieldBean2() { + CaseSensitiveFieldBean2 bean = new CaseSensitiveFieldBean2("foo"); + assertJson("{'value': 'foo'}", serialize(bean)); + CaseSensitiveFieldBean2 deserialized = + deserialize("{'value': 'foo'}", CaseSensitiveFieldBean2.class); + assertEquals("foo", deserialized.value()); + } + + @Test + public void roundTripCaseSensitiveFieldBean3() { + CaseSensitiveFieldBean3 bean = new CaseSensitiveFieldBean3("foo"); + assertJson("{'Value': 'foo'}", serialize(bean)); + CaseSensitiveFieldBean3 deserialized = + deserialize("{'Value': 'foo'}", CaseSensitiveFieldBean3.class); + assertEquals("foo", deserialized.Value()); + } + + @Test + public void roundTripCaseSensitiveFieldBean4() { + CaseSensitiveFieldBean4 bean = new CaseSensitiveFieldBean4("foo"); + assertJson("{'valUE': 'foo'}", serialize(bean)); + CaseSensitiveFieldBean4 deserialized = + deserialize("{'valUE': 'foo'}", CaseSensitiveFieldBean4.class); + assertEquals("foo", deserialized.valUE()); + } + + @Test + public void roundTripUnicodeBean() { + UnicodeBean bean = new UnicodeBean("foo"); + assertJson("{'漢字': 'foo'}", serialize(bean)); + UnicodeBean deserialized = deserialize("{'漢字': 'foo'}", UnicodeBean.class); + assertEquals("foo", deserialized.漢字()); + } + + @Test + public void shortsCantBeSerialized() { + final ShortBean bean = new ShortBean((short) 1); + assertExceptionContains( + "Numbers of type Short are not supported, please use an int, long, float, double or BigDecimal (found in field 'value')", + () -> serialize(bean)); + } + + @Test + public void bytesCantBeSerialized() { + final ByteBean bean = new ByteBean((byte) 1); + assertExceptionContains( + "Numbers of type Byte are not supported, please use an int, long, float, double or BigDecimal (found in field 'value')", + () -> serialize(bean)); + } + + @Test + public void charsCantBeSerialized() { + final CharBean bean = new CharBean((char) 1); + assertExceptionContains( + "Characters are not supported, please use Strings (found in field 'value')", + () -> serialize(bean)); + } + + @Test + public void intArraysCantBeSerialized() { + final IntArrayBean bean = new IntArrayBean(new int[] {1}); + assertExceptionContains( + "Serializing Arrays is not supported, please use Lists instead " + + "(found in field 'values')", + () -> serialize(bean)); + } + + @Test + public void objectArraysCantBeSerialized() { + final StringArrayBean bean = new StringArrayBean(new String[] {"foo"}); + assertExceptionContains( + "Serializing Arrays is not supported, please use Lists instead " + + "(found in field 'values')", + () -> serialize(bean)); + } + + @Test + public void shortsCantBeDeserialized() { + assertExceptionContains( + "Deserializing values to short is not supported (found in field 'value')", + () -> deserialize("{'value': 1}", ShortBean.class)); + } + + @Test + public void bytesCantBeDeserialized() { + assertExceptionContains( + "Deserializing values to byte is not supported (found in field 'value')", + () -> deserialize("{'value': 1}", ByteBean.class)); + } + + @Test + public void charsCantBeDeserialized() { + assertExceptionContains( + "Deserializing values to char is not supported (found in field 'value')", + () -> deserialize("{'value': '1'}", CharBean.class)); + } + + @Test + public void intArraysCantBeDeserialized() { + assertExceptionContains( + "Converting to Arrays is not supported, please use Lists instead (found in field 'values')", + () -> deserialize("{'values': [1]}", IntArrayBean.class)); + } + + @Test + public void objectArraysCantBeDeserialized() { + assertExceptionContains( + "Could not deserialize object. Converting to Arrays is not supported, please use Lists " + + "instead (found in field 'values')", + () -> deserialize("{'values': ['foo']}", StringArrayBean.class)); + } + + @Test + public void objectAcceptsAnyObject() { + ObjectBean stringValue = deserialize("{'value': 'foo'}", ObjectBean.class); + assertEquals("foo", stringValue.value()); + ObjectBean listValue = deserialize("{'value': ['foo']}", ObjectBean.class); + assertEquals(Collections.singletonList("foo"), listValue.value()); + ObjectBean mapValue = deserialize("{'value': {'foo':'bar'}}", ObjectBean.class); + assertEquals(fromSingleQuotedString("{'foo':'bar'}"), mapValue.value()); + String complex = "{'value': {'foo':['bar', ['baz'], {'bam': 'qux'}]}, 'other':{'a': ['b']}}"; + ObjectBean complexValue = deserialize(complex, ObjectBean.class); + assertEquals(fromSingleQuotedString(complex).get("value"), complexValue.value()); + } + + @Test + public void passingInGenericBeanTopLevelThrows() { + assertExceptionContains( + "Class com.google.cloud.firestore.RecordMapperTest$GenericBean has generic type " + + "parameters, please use GenericTypeIndicator instead", + () -> deserialize("{'value': 'foo'}", GenericBean.class)); + } + + @Test + public void collectionsCanBeSerializedWhenList() { + CollectionBean bean = new CollectionBean(Collections.singletonList("foo")); + assertJson("{'values': ['foo']}", serialize(bean)); + } + + @Test + public void collectionsCantBeSerializedWhenSet() { + final CollectionBean bean = new CollectionBean(Collections.singleton("foo")); + assertExceptionContains( + "Serializing Collections is not supported, please use Lists instead " + + "(found in field 'values')", + () -> serialize(bean)); + } + + @Test + public void collectionsCantBeDeserialized() { + assertExceptionContains( + "Collections are not supported, please use Lists instead (found in field 'values')", + () -> deserialize("{'values': ['foo']}", CollectionBean.class)); + } + + @Test + public void serializingGenericBeansSupported() { + GenericBean stringBean = new GenericBean("foo"); + assertJson("{'value': 'foo'}", serialize(stringBean)); + + GenericBean> mapBean = new GenericBean>(Collections.singletonMap("foo", "bar")); + assertJson("{'value': {'foo': 'bar'}}", serialize(mapBean)); + + GenericBean> listBean = new GenericBean>(Collections.singletonList("foo")); + assertJson("{'value': ['foo']}", serialize(listBean)); + + GenericBean> recursiveBean = new GenericBean>(new GenericBean<>("foo")); + assertJson("{'value': {'value': 'foo'}}", serialize(recursiveBean)); + + DoubleGenericBean doubleBean = new DoubleGenericBean("foo", 1.0); + assertJson("{'valueB': 1, 'valueA': 'foo'}", serialize(doubleBean)); + } + + @Test + public void propertyNamesAreSerialized() { + PropertyNameBean bean = new PropertyNameBean("foo", "bar"); + + assertJson("{'my_key': 'foo', 'my_value': 'bar'}", serialize(bean)); + } + + @Test + public void propertyNamesAreParsed() { + PropertyNameBean bean = + deserialize("{'my_key': 'foo', 'my_value': 'bar'}", PropertyNameBean.class); + assertEquals("foo", bean.key()); + assertEquals("bar", bean.value()); + } + + // Bean definitions with @DocumentId applied to wrong type. + public record FieldWithDocumentIdOnWrongTypeBean ( + @DocumentId Integer intField + ){} + + public record PropertyWithDocumentIdOnWrongTypeBean ( + @PropertyName("intField") + @DocumentId + int intField + ){} + + @Test + public void documentIdAnnotateWrongTypeThrows() { + final String expectedErrorMessage = "instead of String or DocumentReference"; + assertExceptionContains( + expectedErrorMessage, () -> serialize(new FieldWithDocumentIdOnWrongTypeBean(100))); + assertExceptionContains( + expectedErrorMessage, + () -> deserialize("{'intField': 1}", FieldWithDocumentIdOnWrongTypeBean.class)); + + assertExceptionContains( + expectedErrorMessage, () -> serialize(new PropertyWithDocumentIdOnWrongTypeBean(100))); + assertExceptionContains( + expectedErrorMessage, + () -> deserialize("{'intField': 1}", PropertyWithDocumentIdOnWrongTypeBean.class)); + } + + public record DocumentIdOnStringField ( + @DocumentId String docId + ){} + + public record DocumentIdOnStringFieldAsProperty ( + @PropertyName("docIdProperty") + @DocumentId + String docId, + + @PropertyName("anotherProperty") + int someOtherProperty + ){} + + public record DocumentIdOnNestedObjects ( + @PropertyName("nestedDocIdHolder") + DocumentIdOnStringField nestedDocIdHolder + ){} + + @Test + public void documentIdsDeserialize() { + DocumentReference ref = + new DocumentReference( + firestoreMock, + ResourcePath.create( + DatabaseRootName.of("test-project", "(default)"), + ImmutableList.of("coll", "doc123"))); + + assertEquals("doc123", deserialize("{}", DocumentIdOnStringField.class, ref).docId()); + + assertEquals( + "doc123", + deserialize(Collections.singletonMap("property", 100), DocumentIdOnStringField.class, ref) + .docId()); + + DocumentIdOnStringFieldAsProperty target = + deserialize("{'anotherProperty': 100}", DocumentIdOnStringFieldAsProperty.class, ref); + assertEquals("doc123", target.docId()); + assertEquals(100, target.someOtherProperty()); + + assertEquals( + "doc123", + deserialize("{'nestedDocIdHolder': {}}", DocumentIdOnNestedObjects.class, ref) + .nestedDocIdHolder() + .docId()); + } + + @Test + public void documentIdsRoundTrip() { + // Implicitly verifies @DocumentId is ignored during serialization. + + final DocumentReference ref = + new DocumentReference( + firestoreMock, + ResourcePath.create( + DatabaseRootName.of("test-project", "(default)"), + ImmutableList.of("coll", "doc123"))); + + assertEquals( + Collections.emptyMap(), serialize(deserialize("{}", DocumentIdOnStringField.class, ref))); + + assertEquals( + Collections.singletonMap("anotherProperty", 100), + serialize( + deserialize("{'anotherProperty': 100}", DocumentIdOnStringFieldAsProperty.class, ref))); + + assertEquals( + Collections.singletonMap("nestedDocIdHolder", Collections.emptyMap()), + serialize(deserialize("{'nestedDocIdHolder': {}}", DocumentIdOnNestedObjects.class, ref))); + } + + @Test + public void documentIdsDeserializeConflictThrows() { + final String expectedErrorMessage = "cannot apply @DocumentId on this property"; + final DocumentReference ref = + new DocumentReference( + firestoreMock, + ResourcePath.create( + DatabaseRootName.of("test-project", "(default)"), + ImmutableList.of("coll", "doc123"))); + + assertExceptionContains( + expectedErrorMessage, + () -> deserialize("{'docId': 'toBeOverwritten'}", DocumentIdOnStringField.class, ref)); + + assertExceptionContains( + expectedErrorMessage, + () -> + deserialize( + "{'docIdProperty': 'toBeOverwritten', 'anotherProperty': 100}", + DocumentIdOnStringFieldAsProperty.class, + ref)); + + assertExceptionContains( + expectedErrorMessage, + () -> + deserialize( + "{'nestedDocIdHolder': {'docId': 'toBeOverwritten'}}", + DocumentIdOnNestedObjects.class, + ref)); + } +} diff --git a/google-cloud-firestore/src/test-jdk17/java/com/google/cloud/firestore/RecordTestHelper.java b/google-cloud-firestore/src/test-jdk17/java/com/google/cloud/firestore/RecordTestHelper.java new file mode 100644 index 000000000..6bfe845be --- /dev/null +++ b/google-cloud-firestore/src/test-jdk17/java/com/google/cloud/firestore/RecordTestHelper.java @@ -0,0 +1,224 @@ +/* + * Copyright 2024 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.api.core.ApiFuture; +import com.google.cloud.Timestamp; +import com.google.cloud.firestore.LocalFirestoreHelper.SingleField; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firestore.v1.ArrayValue; +import com.google.firestore.v1.CommitResponse; +import com.google.firestore.v1.MapValue; +import com.google.firestore.v1.Value; +import com.google.protobuf.NullValue; +import com.google.type.LatLng; + +import static com.google.cloud.firestore.LocalFirestoreHelper.commitResponse; +import static com.google.cloud.firestore.LocalFirestoreHelper.map; + +import java.math.BigInteger; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + + +public final class RecordTestHelper { + + public static final String DATABASE_NAME; + public static final String DOCUMENT_PATH; + public static final String DOCUMENT_NAME; + public static final String DOCUMENT_ROOT; + + public static final SingleComponent SINGLE_COMPONENT_OBJECT; + public static final Map SINGLE_COMPONENT_PROTO; + + public static final NestedRecord NESTED_RECORD_OBJECT; + public static final NestedRecordWithPOJO NESTED_RECORD_WITH_POJO_OBJECT; + public static final NestedPOJOWithRecord NESTED_POJO_WITH_RECORD_OBJECT; + + public static final ServerTimestamp SERVER_TIMESTAMP_OBJECT; + public static final Map SERVER_TIMESTAMP_PROTO; + + public static final AllSupportedTypes ALL_SUPPORTED_TYPES_OBJECT; + public static final Map ALL_SUPPORTED_TYPES_PROTO; + + public static final ApiFuture SINGLE_WRITE_COMMIT_RESPONSE; + + public static final ApiFuture FIELD_TRANSFORM_COMMIT_RESPONSE; + + public static final Date DATE; + public static final Timestamp TIMESTAMP; + public static final GeoPoint GEO_POINT; + public static final Blob BLOB; + + + public record SingleComponent( + String foo) { + } + + public record NestedRecord( + SingleComponent first, + AllSupportedTypes second) { + } + + public record NestedRecordWithPOJO( + SingleField first, + AllSupportedTypes second) { + } + + public static class NestedPOJOWithRecord { + public SingleField first = new SingleField(); + public AllSupportedTypes second = ALL_SUPPORTED_TYPES_OBJECT; + } + + public record ServerTimestamp ( + + @com.google.cloud.firestore.annotation.ServerTimestamp Date foo, + Inner inner + + ){ + record Inner ( + + @com.google.cloud.firestore.annotation.ServerTimestamp Date bar + ){} + } + + public record InvalidRecord ( + BigInteger bigIntegerValue, + Byte byteValue, + Short shortValue + ){} + + + public record AllSupportedTypes ( + + String foo, + Double doubleValue, + long longValue, + double nanValue, + double infValue, + double negInfValue, + boolean trueValue, + boolean falseValue, + SingleComponent objectValue, + Date dateValue, + Timestamp timestampValue, + List arrayValue, + String nullValue, + Blob bytesValue, + GeoPoint geoPointValue, + Map model + ){} + + static { + try { + DATE = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.S z").parse("1985-03-18 08:20:00.123 CET"); + } catch (ParseException e) { + throw new RuntimeException("Failed to parse date", e); + } + + TIMESTAMP = + Timestamp.ofTimeSecondsAndNanos( + TimeUnit.MILLISECONDS.toSeconds(DATE.getTime()), + 123000); // Firestore truncates to microsecond precision. + GEO_POINT = new GeoPoint(50.1430847, -122.9477780); + BLOB = Blob.fromBytes(new byte[] {1, 2, 3}); + + DATABASE_NAME = "projects/test-project/databases/(default)"; + DOCUMENT_PATH = "coll/doc"; + DOCUMENT_NAME = DATABASE_NAME + "/documents/" + DOCUMENT_PATH; + DOCUMENT_ROOT = DATABASE_NAME + "/documents/"; + + SINGLE_COMPONENT_OBJECT = new SingleComponent("bar"); + SINGLE_COMPONENT_PROTO = map("foo", Value.newBuilder().setStringValue("bar").build()); + + SERVER_TIMESTAMP_PROTO = Collections.emptyMap(); + SERVER_TIMESTAMP_OBJECT = new ServerTimestamp(null, new ServerTimestamp.Inner(null)); + + ALL_SUPPORTED_TYPES_OBJECT = new AllSupportedTypes("bar", 0.0, 0L, Double.NaN, Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, true, false, + new SingleComponent("bar"), DATE, + TIMESTAMP, ImmutableList.of("foo"), null, BLOB, GEO_POINT, + ImmutableMap.of("foo", SINGLE_COMPONENT_OBJECT.foo())); + ALL_SUPPORTED_TYPES_PROTO = + ImmutableMap.builder() + .put("foo", Value.newBuilder().setStringValue("bar").build()) + .put("doubleValue", Value.newBuilder().setDoubleValue(0.0).build()) + .put("longValue", Value.newBuilder().setIntegerValue(0L).build()) + .put("nanValue", Value.newBuilder().setDoubleValue(Double.NaN).build()) + .put("infValue", Value.newBuilder().setDoubleValue(Double.POSITIVE_INFINITY).build()) + .put("negInfValue", Value.newBuilder().setDoubleValue(Double.NEGATIVE_INFINITY).build()) + .put("trueValue", Value.newBuilder().setBooleanValue(true).build()) + .put("falseValue", Value.newBuilder().setBooleanValue(false).build()) + .put( + "objectValue", + Value.newBuilder() + .setMapValue(MapValue.newBuilder().putAllFields(SINGLE_COMPONENT_PROTO)) + .build()) + .put( + "dateValue", + Value.newBuilder() + .setTimestampValue( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(479978400) + .setNanos(123000000)) // Dates only support millisecond precision. + .build()) + .put( + "timestampValue", + Value.newBuilder() + .setTimestampValue( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(479978400) + .setNanos(123000)) // Timestamps supports microsecond precision. + .build()) + .put( + "arrayValue", + Value.newBuilder() + .setArrayValue( + ArrayValue.newBuilder().addValues(Value.newBuilder().setStringValue("foo"))) + .build()) + .put("nullValue", Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build()) + .put("bytesValue", Value.newBuilder().setBytesValue(BLOB.toByteString()).build()) + .put( + "geoPointValue", + Value.newBuilder() + .setGeoPointValue( + LatLng.newBuilder().setLatitude(50.1430847).setLongitude(-122.9477780)) + .build()) + .put( + "model", + Value.newBuilder() + .setMapValue(MapValue.newBuilder().putAllFields(SINGLE_COMPONENT_PROTO)) + .build()) + .build(); + SINGLE_WRITE_COMMIT_RESPONSE = commitResponse(/* adds= */ 1, /* deletes= */ 0); + + FIELD_TRANSFORM_COMMIT_RESPONSE = commitResponse(/* adds= */ 2, /* deletes= */ 0); + + NESTED_RECORD_OBJECT = new NestedRecord(SINGLE_COMPONENT_OBJECT, ALL_SUPPORTED_TYPES_OBJECT); + + NESTED_RECORD_WITH_POJO_OBJECT = new NestedRecordWithPOJO(new SingleField(), ALL_SUPPORTED_TYPES_OBJECT); + + NESTED_POJO_WITH_RECORD_OBJECT = new NestedPOJOWithRecord(); + } + +} diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/MapperTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/MapperTest.java index b8508b787..cf5a55c4e 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/MapperTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/MapperTest.java @@ -29,6 +29,7 @@ import com.google.cloud.firestore.annotation.Exclude; import com.google.cloud.firestore.annotation.PropertyName; import com.google.cloud.firestore.annotation.ThrowOnExtraProperties; +import com.google.cloud.firestore.encoding.CustomClassMapper; import com.google.cloud.firestore.spi.v1.FirestoreRpc; import com.google.common.collect.ImmutableList; import com.google.firestore.v1.DatabaseRootName; diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/ToStringTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/ToStringTest.java index 6779edd18..8b5a7e64e 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/ToStringTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/ToStringTest.java @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import com.google.cloud.Timestamp; +import com.google.cloud.firestore.encoding.CustomClassMapper; import com.google.cloud.firestore.spi.v1.FirestoreRpc; import com.google.firestore.v1.Value; import java.util.Collections;