diff --git a/firebase-firestore/firebase-firestore.gradle b/firebase-firestore/firebase-firestore.gradle index 12da631f2ec..e1a01282c4a 100644 --- a/firebase-firestore/firebase-firestore.gradle +++ b/firebase-firestore/firebase-firestore.gradle @@ -13,6 +13,7 @@ // limitations under the License. import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id 'firebase-library' @@ -107,19 +108,33 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + // Java 17 needed for record test cases + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } testOptions.unitTests.includeAndroidResources = true } -kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_1_8 } } +kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } } + +tasks.withType(KotlinCompile).configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} tasks.withType(Test) { maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 } +// Needed for testing RecordMapper's constructor selection +tasks.withType(JavaCompile).configureEach { + if (it.name.contains("UnitTest") || it.name.contains("AndroidTest")) { + options.compilerArgs.add("-parameters") + } +} + dependencies { javadocClasspath libs.autovalue.annotations diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/util/AndroidRecordMapperTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/util/AndroidRecordMapperTest.java new file mode 100644 index 00000000000..fcb7b1d1cbd --- /dev/null +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/util/AndroidRecordMapperTest.java @@ -0,0 +1,26 @@ +// Copyright 2018 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.firestore.util; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.runner.RunWith; + +/** + * Tests for {@link RecordMapper} using desugared java records. + * + * @author Eran Leshem + */ +@RunWith(AndroidJUnit4.class) +public class AndroidRecordMapperTest extends BaseRecordMapperTest {} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/util/BeanMapper.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/BeanMapper.java new file mode 100755 index 00000000000..9c74289e4dc --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/BeanMapper.java @@ -0,0 +1,188 @@ +/* + * 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.firebase.firestore.util; + +import android.os.Build; +import com.google.firebase.Timestamp; +import com.google.firebase.firestore.DocumentId; +import com.google.firebase.firestore.DocumentReference; +import com.google.firebase.firestore.IgnoreExtraProperties; +import com.google.firebase.firestore.PropertyName; +import com.google.firebase.firestore.ServerTimestamp; +import com.google.firebase.firestore.ThrowOnExtraProperties; +import java.lang.reflect.AnnotatedElement; +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; +import java.util.Set; + +/** 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 Set 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 Set 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); + + void applyFieldAnnotations(Field field) { + if (field.isAnnotationPresent(ServerTimestamp.class)) { + Class fieldType = field.getType(); + if (fieldType != Date.class + && fieldType != Timestamp.class + && !(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && 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)); + } + } + + static String propertyName(Field field) { + String annotatedName = annotatedName(field); + return annotatedName != null ? annotatedName : field.getName(); + } + + static String annotatedName(AnnotatedElement obj) { + if (obj.isAnnotationPresent(PropertyName.class)) { + PropertyName annotation = obj.getAnnotation(PropertyName.class); + return annotation.value(); + } + + return null; + } + + static 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."); + } + } + + 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); + } + } + + 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; + } + + 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); + } + } + + T deserialize(Map values, DeserializeContext context) { + return deserialize(values, Collections.emptyMap(), context); + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/util/CustomClassMapper.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/CustomClassMapper.java index 63d82821d82..9723685fef9 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/util/CustomClassMapper.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/CustomClassMapper.java @@ -27,12 +27,8 @@ import com.google.firebase.firestore.Exclude; import com.google.firebase.firestore.FieldValue; import com.google.firebase.firestore.GeoPoint; -import com.google.firebase.firestore.IgnoreExtraProperties; -import com.google.firebase.firestore.PropertyName; import com.google.firebase.firestore.ServerTimestamp; -import com.google.firebase.firestore.ThrowOnExtraProperties; import com.google.firebase.firestore.VectorValue; -import java.lang.reflect.AccessibleObject; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.GenericArrayType; @@ -47,13 +43,13 @@ 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.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -63,6 +59,8 @@ public class CustomClassMapper { private static final int MAX_DEPTH = 500; private static final ConcurrentMap, BeanMapper> mappers = new ConcurrentHashMap<>(); + private static final Set RECORD_BASE_CLASS_NAMES = + Set.of("java.lang.Record", "com.android.tools.r8.RecordTag"); private static void hardAssert(boolean assertion) { hardAssert(assertion, "Internal inconsistency"); @@ -104,15 +102,16 @@ public static Map convertToPlainJavaTypes(Map update) */ public static T convertToCustomClass( Object object, Class clazz, DocumentReference docRef) { - return deserializeToClass(object, clazz, new DeserializeContext(ErrorPath.EMPTY, docRef)); + return deserializeToClass( + object, clazz, new DeserializeContext(DeserializeContext.ErrorPath.EMPTY, docRef)); } private static Object serialize(T o) { - return serialize(o, ErrorPath.EMPTY); + return serialize(o, DeserializeContext.ErrorPath.EMPTY); } @SuppressWarnings("unchecked") - private static Object serialize(T o, ErrorPath path) { + static Object serialize(T o, DeserializeContext.ErrorPath path) { if (path.getLength() > MAX_DEPTH) { throw serializeError( path, @@ -193,7 +192,7 @@ private static Object serialize(T o, ErrorPath path) { } @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"}) - private static T deserializeToType(Object o, Type type, DeserializeContext context) { + static T deserializeToType(Object o, Type type, DeserializeContext context) { if (o == null) { return null; } else if (type instanceof ParameterizedType) { @@ -316,7 +315,7 @@ private static T deserializeToParameterizedType( Map map = expectMap(o, context); BeanMapper mapper = (BeanMapper) loadOrCreateBeanMapperForClass(rawType); HashMap>, Type> typeMapping = new HashMap<>(); - TypeVariable>[] typeVariables = mapper.clazz.getTypeParameters(); + 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"); @@ -389,7 +388,11 @@ private static BeanMapper loadOrCreateBeanMapperForClass(Class clazz) @SuppressWarnings("unchecked") BeanMapper mapper = (BeanMapper) mappers.get(clazz); if (mapper == null) { - mapper = new BeanMapper<>(clazz); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && 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); @@ -586,7 +589,8 @@ private static T convertBean(Object o, Class clazz, DeserializeContext co } } - private static IllegalArgumentException serializeError(ErrorPath path, String reason) { + private static IllegalArgumentException serializeError( + DeserializeContext.ErrorPath path, String reason) { reason = "Could not serialize object. " + reason; if (path.getLength() > 0) { reason = reason + " (found in field '" + path.toString() + "')"; @@ -594,7 +598,8 @@ private static IllegalArgumentException serializeError(ErrorPath path, String re return new IllegalArgumentException(reason); } - private static RuntimeException deserializeError(ErrorPath path, String reason) { + private static RuntimeException deserializeError( + DeserializeContext.ErrorPath path, String reason) { reason = "Could not deserialize object. " + reason; if (path.getLength() > 0) { reason = reason + " (found in field '" + path.toString() + "')"; @@ -602,16 +607,14 @@ private static RuntimeException deserializeError(ErrorPath path, String reason) return new RuntimeException(reason); } + private static boolean isRecordType(Class cls) { + Class parent = cls.getSuperclass(); + return parent != null && RECORD_BASE_CLASS_NAMES.contains(parent.getName()); + } + // Helper class to convert from maps to custom objects (Beans), and vice versa. - private static class BeanMapper { - private final Class clazz; + private static class PojoBeanMapper extends BeanMapper { 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; @@ -624,27 +627,14 @@ private static class BeanMapper { 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); + PojoBeanMapper(Class clazz) { + super(clazz); properties = new HashMap<>(); setters = new HashMap<>(); getters = new HashMap<>(); fields = new HashMap<>(); - serverTimestamps = new HashSet<>(); - documentIdPropertyNames = new HashSet<>(); - Constructor constructor; try { constructor = clazz.getDeclaredConstructor(); @@ -784,10 +774,7 @@ private void addProperty(String property) { } } - T deserialize(Map values, DeserializeContext context) { - return deserialize(values, Collections.emptyMap(), context); - } - + @Override T deserialize( Map values, Map>, Type> types, @@ -796,7 +783,7 @@ T deserialize( throw deserializeError( context.errorPath, "Class " - + clazz.getName() + + getClazz().getName() + " does not define a no-argument constructor. If you are using ProGuard, make " + "sure these constructors are not stripped"); } @@ -805,7 +792,7 @@ T deserialize( HashSet deserialzedProperties = new HashSet<>(); for (Map.Entry entry : values.entrySet()) { String propertyName = entry.getKey(); - ErrorPath childPath = context.errorPath.child(propertyName); + DeserializeContext.ErrorPath childPath = context.errorPath.child(propertyName); if (setters.containsKey(propertyName)) { Method setter = setters.get(propertyName); Type[] params = setter.getGenericParameterTypes(); @@ -832,13 +819,13 @@ T deserialize( deserialzedProperties.add(propertyName); } else { String message = - "No setter/field for " + propertyName + " found on class " + clazz.getName(); + "No setter/field for " + propertyName + " found on class " + getClazz().getName(); if (properties.containsKey(propertyName.toLowerCase(Locale.US))) { message += " (fields/setters are case sensitive!)"; } - if (throwOnUnknownProperties) { + if (isThrowOnUnknownProperties()) { throw new RuntimeException(message); - } else if (warnOnUnknownProperties) { + } else if (isWarnOnUnknownProperties()) { Logger.warn(CustomClassMapper.class.getSimpleName(), "%s", message); } } @@ -857,17 +844,8 @@ private void populateDocumentIdProperties( 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); + checkForDocIdConflict(docIdPropertyName, deserialzedProperties, context); + DeserializeContext.ErrorPath childPath = context.errorPath.child(docIdPropertyName); if (setters.containsKey(docIdPropertyName)) { Method setter = setters.get(docIdPropertyName); Type[] params = setter.getGenericParameterTypes(); @@ -895,28 +873,10 @@ private void populateDocumentIdProperties( } } - 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) { + @Override + Map serialize(T object, DeserializeContext.ErrorPath path) { + verifyValidType(object); // TODO(wuandy): Add logic to skip @DocumentId annotated fields in serialization. - 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; @@ -953,29 +913,6 @@ Map serialize(T object, ErrorPath path) { return result; } - private void applyFieldAnnotations(Field field) { - if (field.isAnnotationPresent(ServerTimestamp.class)) { - Class fieldType = field.getType(); - if (fieldType != Date.class - && fieldType != Timestamp.class - && !(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && 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(); @@ -1016,18 +953,6 @@ private void applySetterAnnotations(Method 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; @@ -1131,25 +1056,11 @@ private static boolean isSetterOverride(Method base, Method override) { && 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; @@ -1173,61 +1084,4 @@ private static String serializedName(String methodName) { 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. */ - 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/firebase-firestore/src/main/java/com/google/firebase/firestore/util/DeserializeContext.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/DeserializeContext.java new file mode 100755 index 00000000000..310db22e805 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/DeserializeContext.java @@ -0,0 +1,75 @@ +/* + * 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.firebase.firestore.util; + +import com.google.firebase.firestore.DocumentReference; + +/** 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; + } + } + + /** 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/firebase-firestore/src/main/java/com/google/firebase/firestore/util/RecordMapper.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/RecordMapper.java new file mode 100755 index 00000000000..9f49b9a2248 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/util/RecordMapper.java @@ -0,0 +1,219 @@ +/* + * 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.firebase.firestore.util; + +import android.os.Build; +import androidx.annotation.RequiresApi; +import com.google.firebase.firestore.FieldValue; +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.Parameter; +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; +import java.util.stream.Collectors; + +/** + * Serializes java records. Uses canonical record constructors and accessors only. Therefore, + * exclusion of fields is not supported. Supports {@code DocumentId}, {@code PropertyName}, + * and {@code ServerTimestamp} annotations on record components. + * Since java records may be desugared, and record component-related reflection methods may be missing, + * the canonical record constructor is identified through matching of parameter names and types with fields. + * Therefore, a mapped record must not have a custom constructor + * with the same set of parameter names and types as the canonical one + * (by default, only the canonical constructor's parameter names are preserved at runtime, + * and the others' get generic runtime names, + * but that can be changed with the {@code -parameters} compiler option). + * + * @author Eran Leshem + */ +@RequiresApi(api = Build.VERSION_CODES.O) +class RecordMapper extends BeanMapper { + private static final Logger LOGGER = Logger.getLogger(RecordMapper.class.getName()); + private static final Class[] CLASSES_ARRAY_TYPE = new Class[0]; + + // 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 = getConstructor(clazz); + Parameter[] recordComponents = constructor.getParameters(); + 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(recordComponents[i].getName()); + String propertyName = propertyName(field); + constructorParamIndexes.put(propertyName, i); + accessors.put(propertyName, getAccessor(clazz, field)); + applyFieldAnnotations(field); + } + } catch (NoSuchFieldException e) { + throw new RuntimeException(e); + } + } + + private static Constructor getConstructor(Class clazz) { + Map components = + Arrays.stream(clazz.getDeclaredFields()) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .collect(Collectors.toMap(Field::getName, Field::getGenericType)); + Constructor match = null; + //noinspection unchecked + for (Constructor ctor : (Constructor[]) clazz.getConstructors()) { + Parameter[] parameters = ctor.getParameters(); + Map parameterTypes = + Arrays.stream(parameters) + .collect(Collectors.toMap(Parameter::getName, Parameter::getParameterizedType)); + if (!parameterTypes.equals(components)) { + continue; + } + + if (match != null) { + throw new RuntimeException( + String.format( + "Multiple constructors match set of components for record %s", clazz.getName())); + } + + match = ctor; + } + + return match; + } + + @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; + 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; + } + + @Override + T deserialize( + Map values, + Map>, Type> types, + DeserializeContext context) { + Object[] constructorParams = new Object[constructor.getParameterTypes().length]; + 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); + } + } + + private static Method getAccessor(Class clazz, Field recordComponent) { + try { + Method accessor = clazz.getDeclaredMethod(recordComponent.getName(), CLASSES_ARRAY_TYPE); + accessor.setAccessible(true); + return accessor; + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("Failed to get record component accessor", 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 deserialzedProperties) { + for (String docIdPropertyName : documentIdPropertyNames) { + checkForDocIdConflict(docIdPropertyName, deserialzedProperties, 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; + } + } + } +} diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/util/RecordMapperTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/RecordMapperTest.java new file mode 100644 index 00000000000..7acdf7deed9 --- /dev/null +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/util/RecordMapperTest.java @@ -0,0 +1,103 @@ +// Copyright 2018 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.firebase.firestore.util; + +import static android.os.Build.VERSION_CODES.O; +import static org.junit.Assert.assertEquals; + +import com.google.firebase.firestore.DocumentReference; +import com.google.firebase.firestore.TestUtil; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; + +/** + * Tests for {@link RecordMapper} using non-desugared java records. + * + * @author Eran Leshem + */ +@RunWith(org.robolectric.RobolectricTestRunner.class) +@Config(manifest = Config.NONE, minSdk = O) +@SuppressWarnings({"unused", "WeakerAccess"}) +public class RecordMapperTest extends BaseRecordMapperTest { + @Test + public void documentIdsDeserialize() { + DocumentReference ref = TestUtil.documentReference("coll/doc123"); + + assertEquals("doc123", deserialize("{}", DocumentIdOnStringField.class, ref).docId()); + + assertEquals( + "doc123", + deserialize(Collections.singletonMap("property", 100), DocumentIdOnStringField.class, ref) + .docId()); + + var 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. + + DocumentReference ref = TestUtil.documentReference("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"; + DocumentReference ref = TestUtil.documentReference("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/firebase-firestore/src/testUtil/java/com/google/firebase/firestore/util/BaseRecordMapperTest.java b/firebase-firestore/src/testUtil/java/com/google/firebase/firestore/util/BaseRecordMapperTest.java new file mode 100644 index 00000000000..a1b2bf5bd04 --- /dev/null +++ b/firebase-firestore/src/testUtil/java/com/google/firebase/firestore/util/BaseRecordMapperTest.java @@ -0,0 +1,885 @@ +package com.google.firebase.firestore.util; + +import static com.google.firebase.firestore.testutil.TestUtil.fromSingleQuotedString; +import static com.google.firebase.firestore.testutil.TestUtil.map; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.firebase.firestore.DocumentId; +import com.google.firebase.firestore.DocumentReference; +import com.google.firebase.firestore.PropertyName; +import com.google.firebase.firestore.ThrowOnExtraProperties; +import java.io.Serializable; +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 org.junit.Assert; +import org.junit.Test; + +/** + * @author Eran Leshem + * + * @noinspection JUnitMalformedDeclaration*/ +class BaseRecordMapperTest { + public record StringBean(String value) {} + + public record DoubleBean(double 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 漢字) {} + + public record AllCapsDefaultHandlingBean(String UUID) {} + + public record AllCapsWithPropertyName(@PropertyName("uuid") String UUID) {} + + // Bean definitions with @DocumentId applied to wrong type. + public record FieldWithDocumentIdOnWrongTypeBean(@DocumentId Integer intField) {} + + public record PropertyWithDocumentIdOnWrongTypeBean( + @PropertyName("intField") @DocumentId int intField) {} + + 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) {} + + public record CustomConstructorBean(String value) { + public CustomConstructorBean() { + this("value"); + } + } + + public record ConflictingConstructorBean(String value, int i) { + public ConflictingConstructorBean(int i, String value) { + this(value, i); + } + } + + private static final double EPSILON = 0.0003; + + @Test + public void primitiveDeserializeString() { + var 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() { + var 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() { + var beanDouble = deserialize("{'value': 1.1}", DoubleBean.class); + assertEquals(1.1, beanDouble.value(), EPSILON); + + // Int + var beanInt = deserialize("{'value': 1}", DoubleBean.class); + assertEquals(1, beanInt.value(), EPSILON); + // Long + var 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 primitiveDeserializeFloat() { + var beanFloat = deserialize("{'value': 1.1}", FloatBean.class); + assertEquals(1.1, beanFloat.value(), EPSILON); + + // Int + var beanInt = deserialize(Collections.singletonMap("value", 1), FloatBean.class); + assertEquals(1, beanInt.value(), EPSILON); + // Long + var 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() { + var beanInt = deserialize("{'value': 1}", IntBean.class); + assertEquals(1, beanInt.value()); + + // Double + var 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() { + var beanLong = deserialize("{'value': 1234567890123}", LongBean.class); + assertEquals(1234567890123L, beanLong.value()); + + // Int + var beanInt = deserialize("{'value': 1}", LongBean.class); + assertEquals(1, beanInt.value()); + + // Double + var 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() { + var 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.firebase.firestore.util.BaseRecordMapperTest$NoFieldBean", + () -> deserialize("{'value': 'foo'}", NoFieldBean.class)); + } + + @Test + public void throwOnUnknownProperties() { + assertExceptionContains( + "No accessor for unknown found on class " + + "com.google.firebase.firestore.util.BaseRecordMapperTest$ThrowOnUnknownPropertiesBean", + () -> + deserialize("{'value': 'foo', 'unknown': 'bar'}", ThrowOnUnknownPropertiesBean.class)); + } + + @Test + public void XMLAndURLBean() { + var bean = deserialize("{'XMLAndURL': 'foo'}", XMLAndURLBean.class); + assertEquals("foo", bean.XMLAndURL()); + } + + @Test + public void allCapsSerializesToUppercaseByDefault() { + var bean = new AllCapsDefaultHandlingBean("value"); + assertJson("{'UUID': 'value'}", serialize(bean)); + var deserialized = deserialize("{'UUID': 'value'}", AllCapsDefaultHandlingBean.class); + assertEquals("value", deserialized.UUID()); + } + + @Test + public void allCapsWithPropertyNameSerializesToLowercase() { + var bean = new AllCapsWithPropertyName("value"); + assertJson("{'uuid': 'value'}", serialize(bean)); + var deserialized = deserialize("{'uuid': 'value'}", AllCapsWithPropertyName.class); + assertEquals("value", deserialized.UUID()); + } + + @Test + public void nestedParsingWorks() { + var bean = deserialize("{'bean': {'value': 'foo'}}", NestedBean.class); + assertEquals("foo", bean.bean().value()); + } + + @Test + public void beansCanContainLists() { + var bean = deserialize("{'values': ['foo', 'bar']}", ListBean.class); + assertEquals(Arrays.asList("foo", "bar"), bean.values()); + } + + @Test + public void beansCanContainMaps() { + var bean = deserialize("{'values': {'foo': 'bar'}}", MapBean.class); + var expected = fromSingleQuotedString("{'foo': 'bar'}"); + assertEquals(expected, bean.values()); + } + + @Test + public void beansCanContainUpperBoundedMaps() { + var date = new Date(1491847082123L); + var source = map("values", map("foo", date)); + var bean = convertToCustomClass(source, UpperBoundedMapBean.class); + var expected = map("foo", date); + assertEquals(expected, bean.values()); + } + + @Test + public void beansCanContainMultiBoundedMaps() { + var date = new Date(1491847082123L); + var source = map("map", map("values", map("foo", date))); + var bean = convertToCustomClass(source, MultiBoundedMapHolderBean.class); + + var expected = map("foo", date); + assertEquals(expected, bean.map().values()); + } + + @Test + public void beansCanContainUnboundedMaps() { + var bean = deserialize("{'values': {'foo': 'bar'}}", UnboundedMapBean.class); + var expected = map("foo", "bar"); + assertEquals(expected, bean.values()); + } + + @Test + public void beansCanContainUnboundedTypeVariableMaps() { + var source = map("map", map("values", map("foo", "bar"))); + var bean = convertToCustomClass(source, UnboundedTypeVariableMapHolderBean.class); + + var expected = map("foo", "bar"); + assertEquals(expected, bean.map().values()); + } + + @Test + public void beansCanContainNestedUnboundedMaps() { + var bean = deserialize("{'values': {'foo': {'bar': 'baz'}}}", UnboundedMapBean.class); + var expected = map("foo", map("bar", "baz")); + assertEquals(expected, bean.values()); + } + + @Test + public void beansCanContainBeanLists() { + var bean = deserialize("{'values': [{'value': 'foo'}]}", NestedListBean.class); + assertEquals(1, bean.values().size()); + assertEquals("foo", bean.values().get(0).value()); + } + + @Test + public void beansCanContainBeanMaps() { + var 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() { + var bean = new StringBean("foo"); + assertJson("{'value': 'foo'}", serialize(bean)); + } + + @Test + public void serializeDoubleBean() { + var bean = new DoubleBean(1.1); + assertJson("{'value': 1.1}", serialize(bean)); + } + + @Test + public void serializeIntBean() { + var bean = new IntBean(1); + assertJson("{'value': 1}", serialize(Collections.singletonMap("value", 1))); + } + + @Test + public void serializeLongBean() { + var bean = new LongBean(1234567890123L); + assertJson( + "{'value': 1.234567890123E12}", + serialize(Collections.singletonMap("value", 1.234567890123E12))); + } + + @Test + public void serializeBooleanBean() { + var bean = new BooleanBean(true); + assertJson("{'value': true}", serialize(bean)); + } + + @Test + public void serializeFloatBean() { + var bean = new FloatBean(0.5f); + + // We don't use assertJson as it converts all floating point numbers to Double. + Assert.assertEquals(map("value", 0.5f), serialize(bean)); + } + + @Test + public void serializePrivateFieldBean() { + final var bean = new NoFieldBean(); + assertExceptionContains( + "No properties to serialize found on class " + + "com.google.firebase.firestore.util.BaseRecordMapperTest$NoFieldBean", + () -> serialize(bean)); + } + + @Test + public void nestedSerializingWorks() { + var bean = new NestedBean(new StringBean("foo")); + assertJson("{'bean': {'value': 'foo'}}", serialize(bean)); + } + + @Test + public void serializingListsWorks() { + var bean = new ListBean(Arrays.asList("foo", "bar")); + assertJson("{'values': ['foo', 'bar']}", serialize(bean)); + } + + @Test + public void serializingMapsWorks() { + var bean = new MapBean(new HashMap<>()); + bean.values().put("foo", "bar"); + assertJson("{'values': {'foo': 'bar'}}", serialize(bean)); + } + + @Test + public void serializingUpperBoundedMapsWorks() { + var date = new Date(1491847082123L); + var bean = new UpperBoundedMapBean(Map.of("foo", date)); + var expected = map("values", map("foo", new Date(date.getTime()))); + assertEquals(expected, serialize(bean)); + } + + @Test + public void serializingMultiBoundedObjectsWorks() { + var date = new Date(1491847082123L); + + var values = new HashMap(); + values.put("foo", date); + + var holder = new MultiBoundedMapHolderBean(new MultiBoundedMapBean<>(values)); + + var expected = map("map", map("values", map("foo", new Date(date.getTime())))); + assertEquals(expected, serialize(holder)); + } + + @Test + public void serializeListOfBeansWorks() { + var stringBean = new StringBean("foo"); + + var bean = new NestedListBean(new ArrayList<>()); + bean.values().add(stringBean); + + assertJson("{'values': [{'value': 'foo'}]}", serialize(bean)); + } + + @Test + public void serializeMapOfBeansWorks() { + var stringBean = new StringBean("foo"); + + var bean = new NestedMapBean(new HashMap<>()); + bean.values().put("key", stringBean); + + assertJson("{'values': {'key': {'value': 'foo'}}}", serialize(bean)); + } + + @Test + public void beanMapsMustHaveStringKeysForSerializing() { + var stringBean = new StringBean("foo"); + + final var 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() { + var bean = new XMLAndURLBean("foo"); + assertJson("{'XMLAndURL': 'foo'}", serialize(bean)); + } + + @Test + public void roundTripCaseSensitiveFieldBean1() { + var bean = new CaseSensitiveFieldBean1("foo"); + assertJson("{'VALUE': 'foo'}", serialize(bean)); + var deserialized = deserialize("{'VALUE': 'foo'}", CaseSensitiveFieldBean1.class); + assertEquals("foo", deserialized.VALUE()); + } + + @Test + public void roundTripCaseSensitiveFieldBean2() { + var bean = new CaseSensitiveFieldBean2("foo"); + assertJson("{'value': 'foo'}", serialize(bean)); + var deserialized = deserialize("{'value': 'foo'}", CaseSensitiveFieldBean2.class); + assertEquals("foo", deserialized.value()); + } + + @Test + public void roundTripCaseSensitiveFieldBean3() { + var bean = new CaseSensitiveFieldBean3("foo"); + assertJson("{'Value': 'foo'}", serialize(bean)); + var deserialized = deserialize("{'Value': 'foo'}", CaseSensitiveFieldBean3.class); + assertEquals("foo", deserialized.Value()); + } + + @Test + public void roundTripCaseSensitiveFieldBean4() { + var bean = new CaseSensitiveFieldBean4("foo"); + assertJson("{'valUE': 'foo'}", serialize(bean)); + var deserialized = deserialize("{'valUE': 'foo'}", CaseSensitiveFieldBean4.class); + assertEquals("foo", deserialized.valUE()); + } + + @Test + public void roundTripUnicodeBean() { + var bean = new UnicodeBean("foo"); + assertJson("{'漢字': 'foo'}", serialize(bean)); + var deserialized = deserialize("{'漢字': 'foo'}", UnicodeBean.class); + assertEquals("foo", deserialized.漢字()); + } + + @Test + public void shortsCantBeSerialized() { + final var bean = new ShortBean((short) 1); + assertExceptionContains( + "Numbers of type Short are not supported, please use an int, long, float or double (found in field 'value')", + () -> serialize(bean)); + } + + @Test + public void bytesCantBeSerialized() { + final var bean = new ByteBean((byte) 1); + assertExceptionContains( + "Numbers of type Byte are not supported, please use an int, long, float or double (found in field 'value')", + () -> serialize(bean)); + } + + @Test + public void charsCantBeSerialized() { + final var bean = new CharBean((char) 1); + assertExceptionContains( + "Characters are not supported, please use Strings (found in field 'value')", + () -> serialize(bean)); + } + + @Test + public void intArraysCantBeSerialized() { + final var 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 var 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() { + var stringValue = deserialize("{'value': 'foo'}", ObjectBean.class); + assertEquals("foo", stringValue.value()); + var listValue = deserialize("{'value': ['foo']}", ObjectBean.class); + assertEquals(Collections.singletonList("foo"), listValue.value()); + var mapValue = deserialize("{'value': {'foo':'bar'}}", ObjectBean.class); + Assert.assertEquals(fromSingleQuotedString("{'foo':'bar'}"), mapValue.value()); + var complex = "{'value': {'foo':['bar', ['baz'], {'bam': 'qux'}]}, 'other':{'a': ['b']}}"; + var complexValue = deserialize(complex, ObjectBean.class); + Assert.assertEquals(fromSingleQuotedString(complex).get("value"), complexValue.value()); + } + + @Test + public void passingInGenericBeanTopLevelThrows() { + assertExceptionContains( + "Class com.google.firebase.firestore.util.BaseRecordMapperTest$GenericBean has generic type parameters", + () -> deserialize("{'value': 'foo'}", GenericBean.class)); + } + + @Test + public void collectionsCanBeSerializedWhenList() { + var bean = new CollectionBean(Collections.singletonList("foo")); + assertJson("{'values': ['foo']}", serialize(bean)); + } + + @Test + public void collectionsCantBeSerializedWhenSet() { + final var 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() { + var stringBean = new GenericBean("foo"); + assertJson("{'value': 'foo'}", serialize(stringBean)); + + var mapBean = new GenericBean>(Collections.singletonMap("foo", "bar")); + assertJson("{'value': {'foo': 'bar'}}", serialize(mapBean)); + + var listBean = new GenericBean>(Collections.singletonList("foo")); + assertJson("{'value': ['foo']}", serialize(listBean)); + + var recursiveBean = new GenericBean>(new GenericBean<>("foo")); + assertJson("{'value': {'value': 'foo'}}", serialize(recursiveBean)); + + var doubleBean = new DoubleGenericBean("foo", 1.0); + assertJson("{'valueB': 1.0, 'valueA': 'foo'}", serialize(doubleBean)); + } + + @Test + public void propertyNamesAreSerialized() { + var bean = new PropertyNameBean("foo", "bar"); + + assertJson("{'my_key': 'foo', 'my_value': 'bar'}", serialize(bean)); + } + + @Test + public void propertyNamesAreParsed() { + var bean = deserialize("{'my_key': 'foo', 'my_value': 'bar'}", PropertyNameBean.class); + assertEquals("foo", bean.key()); + assertEquals("bar", bean.value()); + } + + @Test + public void documentIdAnnotateWrongTypeThrows() { + final var 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)); + } + + @Test + public void customConstructorRoundTrip() { + final var bean = new CustomConstructorBean("foo"); + assertEquals( + bean, + CustomClassMapper.convertToCustomClass(serialize(bean), CustomConstructorBean.class, null)); + } + + @Test + public void conflictingConstructorCantBeSerialized() { + final var bean = new ConflictingConstructorBean("foo", 5); + assertExceptionContains( + "Multiple constructors match set of components for record " + + "com.google.firebase.firestore.util.BaseRecordMapperTest$ConflictingConstructorBean", + () -> serialize(bean)); + } + + @Test + public void conflictingConstructorCantBeDeserialized() { + assertExceptionContains( + "Multiple constructors match set of components for record " + + "com.google.firebase.firestore.util.BaseRecordMapperTest$ConflictingConstructorBean", + () -> deserialize(map("foo", 5), ConflictingConstructorBean.class)); + } + + static T deserialize(String jsonString, Class clazz) { + return deserialize(jsonString, clazz, /* docRef= */ null); + } + + static T deserialize(Map json, Class clazz) { + return deserialize(json, clazz, /* docRef= */ null); + } + + static T deserialize(String jsonString, Class clazz, DocumentReference docRef) { + var json = fromSingleQuotedString(jsonString); + return CustomClassMapper.convertToCustomClass(json, clazz, docRef); + } + + static T deserialize(Map json, Class clazz, DocumentReference docRef) { + return CustomClassMapper.convertToCustomClass(json, clazz, docRef); + } + + static Object serialize(Object object) { + return CustomClassMapper.convertToPlainJavaTypes(object); + } + + private static void assertJson(String expected, Object actual) { + Assert.assertEquals(fromSingleQuotedString(expected), actual); + } + + 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); + } + + static T convertToCustomClass(Object object, Class clazz) { + return CustomClassMapper.convertToCustomClass(object, clazz, null); + } +} diff --git a/gradle.properties b/gradle.properties index c1aa7f84562..23405247bdc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,4 @@ firebase.checks.lintProjects=:tools:lint systemProp.illegal-access=warn -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true