FieldBuilder.java

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package com.azure.search.documents.implementation.util;

import com.azure.core.models.GeoPoint;
import com.azure.core.util.logging.ClientLogger;
import com.azure.core.util.serializer.MemberNameConverter;
import com.azure.core.util.serializer.MemberNameConverterProviders;
import com.azure.core.util.serializer.ObjectSerializer;
import com.azure.search.documents.indexes.FieldBuilderIgnore;
import com.azure.search.documents.indexes.SearchableField;
import com.azure.search.documents.indexes.SimpleField;
import com.azure.search.documents.indexes.models.FieldBuilderOptions;
import com.azure.search.documents.indexes.models.LexicalAnalyzerName;
import com.azure.search.documents.indexes.models.SearchField;
import com.azure.search.documents.indexes.models.SearchFieldDataType;
import reactor.util.annotation.Nullable;

import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.Stack;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Helper to convert model class to Search {@link SearchField fields}.
 * <p>
 * {@link FieldBuilder} currently only read fields of Java model class. If passed a custom {@link ObjectSerializer} in
 * API, please remember the helper class is only able to read the rename annotation on the field instead of
 * getter/setter methods.
 */
public final class FieldBuilder {
    private static final ClientLogger LOGGER = new ClientLogger(FieldBuilder.class);

    private static final int MAX_DEPTH = 10000;
    private static final Map<Type, SearchFieldDataType> SUPPORTED_NONE_PARAMETERIZED_TYPE = new HashMap<>();
    private static final Set<Type> UNSUPPORTED_TYPES = new HashSet<>();

    static {
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(Integer.class, SearchFieldDataType.INT32);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(int.class, SearchFieldDataType.INT32);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(Long.class, SearchFieldDataType.INT64);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(long.class, SearchFieldDataType.INT64);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(Double.class, SearchFieldDataType.DOUBLE);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(double.class, SearchFieldDataType.DOUBLE);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(Boolean.class, SearchFieldDataType.BOOLEAN);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(boolean.class, SearchFieldDataType.BOOLEAN);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(String.class, SearchFieldDataType.STRING);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(CharSequence.class, SearchFieldDataType.STRING);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(Character.class, SearchFieldDataType.STRING);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(char.class, SearchFieldDataType.STRING);
        //noinspection UseOfObsoleteDateTimeApi
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(Date.class, SearchFieldDataType.DATE_TIME_OFFSET);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(OffsetDateTime.class, SearchFieldDataType.DATE_TIME_OFFSET);
        SUPPORTED_NONE_PARAMETERIZED_TYPE.put(GeoPoint.class, SearchFieldDataType.GEOGRAPHY_POINT);

        UNSUPPORTED_TYPES.add(byte.class);
        UNSUPPORTED_TYPES.add(Byte.class);
        UNSUPPORTED_TYPES.add(float.class);
        UNSUPPORTED_TYPES.add(Float.class);
        UNSUPPORTED_TYPES.add(short.class);
        UNSUPPORTED_TYPES.add(Short.class);
    }

    /**
     * Creates a collection of {@link SearchField} objects corresponding to the properties of the type supplied.
     *
     * @param modelClass The class for which fields will be created, based on its properties.
     * @param options Configuration used to determine generation of the {@link SearchField SearchFields}.
     * @param <T> The generic type of the model class.
     * @return A collection of fields.
     */
    public static <T> List<SearchField> build(Class<T> modelClass, FieldBuilderOptions options) {
        MemberNameConverter converter;
        if (options == null || options.getJsonSerializer() == null) {
            converter = MemberNameConverterProviders.createInstance();
        } else if (!(options.getJsonSerializer() instanceof MemberNameConverter)) {
            converter = MemberNameConverterProviders.createInstance();
        } else {
            converter = (MemberNameConverter) options.getJsonSerializer();
        }

        return build(modelClass, new Stack<>(), converter);
    }

    /**
     * Recursive class to build complex data type.
     *
     * @param currentClass Current class to be built.
     * @param classChain A class chain from {@code modelClass} to prior of {@code currentClass}.
     * @return A list of {@link SearchField} that currentClass is built to.
     */
    private static List<SearchField> build(Class<?> currentClass, Stack<Class<?>> classChain,
        MemberNameConverter serializer) {
        if (classChain.contains(currentClass)) {
            LOGGER.warning("There is circular dependencies {}, {}", classChain, currentClass);
            return null;
        }

        if (classChain.size() > MAX_DEPTH) {
            throw LOGGER.logExceptionAsError(new RuntimeException(
                "The dependency graph is too deep. Please review your schema."));
        }

        classChain.push(currentClass);
        List<SearchField> searchFields = getDeclaredFieldsAndMethods(currentClass)
            .filter(FieldBuilder::fieldOrMethodIgnored)
            .map(classField -> buildSearchField(classField, classChain, serializer))
            .filter(Objects::nonNull)
            .collect(Collectors.toList());
        classChain.pop();
        return searchFields;
    }

    /*
     * Retrieves all declared fields and methods from the passed Class.
     */
    private static Stream<Member> getDeclaredFieldsAndMethods(Class<?> model) {
        List<Member> fieldsAndMethods = new ArrayList<>(Arrays.asList(model.getDeclaredFields()));
        fieldsAndMethods.addAll(Arrays.asList(model.getDeclaredMethods()));

        return fieldsAndMethods.stream();
    }

    /*
     * Indicates if the Member, should be a Field or Method, is annotated with FieldBuilderIgnore indicating that it
     * shouldn't have a SearchField created for it.
     */
    private static boolean fieldOrMethodIgnored(Member member) {
        if (member instanceof Field) {
            return !((Field) member).isAnnotationPresent(FieldBuilderIgnore.class);
        } else if (member instanceof Method) {
            return !((Method) member).isAnnotationPresent(FieldBuilderIgnore.class);
        } else {
            return false;
        }
    }

    private static SearchField buildSearchField(Member member, Stack<Class<?>> classChain,
        MemberNameConverter serializer) {
        String fieldName = serializer.convertMemberName(member);
        if (fieldName == null) {
            return null;
        }

        Type type = getFieldOrMethodReturnType(member);
        if (SUPPORTED_NONE_PARAMETERIZED_TYPE.containsKey(type)) {
            return buildNoneParameterizedType(fieldName, member, type);
        }

        if (isArrayOrList(type)) {
            return buildCollectionField(fieldName, member, type, classChain, serializer);
        }

        return getSearchField(type, classChain, serializer, fieldName, (Class<?>) type);
    }

    private static Type getFieldOrMethodReturnType(Member member) {
        if (member instanceof Field) {
            return ((Field) member).getGenericType();
        } else if (member instanceof Method) {
            return ((Method) member).getGenericReturnType();
        } else {
            throw LOGGER.logExceptionAsError(new IllegalStateException("Member isn't instance of Field or Method."));
        }
    }

    @Nullable
    private static SearchField getSearchField(Type type, Stack<Class<?>> classChain, MemberNameConverter serializer,
        String fieldName, Class<?> clazz) {
        SearchField searchField = convertToBasicSearchField(fieldName, type);
        if (searchField == null) {
            return null;
        }

        return searchField.setFields(build(clazz, classChain, serializer));
    }

    private static SearchField buildNoneParameterizedType(String fieldName, Member member, Type type) {
        SearchField searchField = convertToBasicSearchField(fieldName, type);

        return (searchField == null) ? null : enrichWithAnnotation(searchField, member);
    }


    private static boolean isArrayOrList(Type type) {
        return isList(type) || ((Class<?>) type).isArray();
    }

    private static boolean isList(Type type) {
        if (!(type instanceof ParameterizedType)) {
            return false;
        }

        Type rawType = ((ParameterizedType) type).getRawType();

        return List.class.isAssignableFrom((Class<?>) rawType);
    }

    private static SearchField buildCollectionField(String fieldName, Member member, Type type,
        Stack<Class<?>> classChain, MemberNameConverter serializer) {
        Type componentOrElementType = getComponentOrElementType(type);

        validateType(componentOrElementType, true);
        if (SUPPORTED_NONE_PARAMETERIZED_TYPE.containsKey(componentOrElementType)) {
            SearchField searchField = convertToBasicSearchField(fieldName, type);
            if (searchField == null) {
                return null;
            }
            return enrichWithAnnotation(searchField, member);
        }
        return getSearchField(type, classChain, serializer, fieldName, (Class<?>) componentOrElementType);
    }

    private static Type getComponentOrElementType(Type arrayOrListType) {
        if (isList(arrayOrListType)) {
            ParameterizedType pt = (ParameterizedType) arrayOrListType;
            return pt.getActualTypeArguments()[0];
        }

        if (((Class<?>) arrayOrListType).isArray()) {
            return ((Class<?>) arrayOrListType).getComponentType();
        }

        throw LOGGER.logExceptionAsError(new RuntimeException(String.format(
            "Collection type %s is not supported.", arrayOrListType.getTypeName())));
    }

    private static SearchField convertToBasicSearchField(String fieldName, Type type) {
        SearchFieldDataType dataType = covertToSearchFieldDataType(type, false);

        return (dataType == null) ? null : new SearchField(fieldName, dataType);
    }

    private static SearchField enrichWithAnnotation(SearchField searchField, Member member) {
        SimpleField simpleField = getDeclaredAnnotation(member, SimpleField.class);
        SearchableField searchableField = getDeclaredAnnotation(member, SearchableField.class);

        if (simpleField != null && searchableField != null) {
            throw LOGGER.logExceptionAsError(new IllegalArgumentException(String.format(
                "@SimpleField and @SearchableField cannot be present simultaneously for %s", member.getName())));
        }
        if (simpleField != null) {
            searchField.setSearchable(false)
                .setSortable(simpleField.isSortable())
                .setFilterable(simpleField.isFilterable())
                .setFacetable(simpleField.isFacetable())
                .setKey(simpleField.isKey())
                .setHidden(simpleField.isHidden());
        } else if (searchableField != null) {
            if (!searchField.getType().equals(SearchFieldDataType.STRING)
                && !searchField.getType().equals(SearchFieldDataType.collection(SearchFieldDataType.STRING))) {
                throw LOGGER.logExceptionAsError(new RuntimeException(String.format("SearchField can only be used on "
                        + "string properties. Property %s returns a %s value.", member.getName(),
                    searchField.getType())));
            }

            searchField.setSearchable(true)
                .setSortable(searchableField.isSortable())
                .setFilterable(searchableField.isFilterable())
                .setFacetable(searchableField.isFacetable())
                .setKey(searchableField.isKey())
                .setHidden(searchableField.isHidden());
            String analyzer = searchableField.analyzerName();
            String searchAnalyzer = searchableField.searchAnalyzerName();
            String indexAnalyzer = searchableField.indexAnalyzerName();
            if (!analyzer.isEmpty() && (!searchAnalyzer.isEmpty() || !indexAnalyzer.isEmpty())) {
                throw LOGGER.logExceptionAsError(new RuntimeException(
                    "Please specify either analyzer or both searchAnalyzer and indexAnalyzer."));
            }
            if (!searchableField.analyzerName().isEmpty()) {
                searchField.setAnalyzerName(LexicalAnalyzerName.fromString(
                    searchableField.analyzerName()));
            }
            if (!searchableField.searchAnalyzerName().isEmpty()) {
                searchField.setAnalyzerName(LexicalAnalyzerName.fromString(
                    searchableField.searchAnalyzerName()));
            }
            if (!searchableField.indexAnalyzerName().isEmpty()) {
                searchField.setAnalyzerName(LexicalAnalyzerName.fromString(
                    searchableField.indexAnalyzerName()));
            }
            if (searchableField.synonymMapNames().length != 0) {
                List<String> synonymMaps = Arrays.stream(searchableField.synonymMapNames())
                    .filter(synonym -> !synonym.trim().isEmpty()).collect(Collectors.toList());
                searchField.setSynonymMapNames(synonymMaps);
            }
        }
        return searchField;
    }

    private static <T extends Annotation> T getDeclaredAnnotation(Member member, Class<T> annotationType) {
        if (member instanceof Field) {
            return ((Field) member).getAnnotation(annotationType);
        } else if (member instanceof Method) {
            return ((Method) member).getAnnotation(annotationType);
        } else {
            return null;
        }
    }

    private static void validateType(Type type, boolean hasArrayOrCollectionWrapped) {
        if (!(type instanceof ParameterizedType)) {
            if (UNSUPPORTED_TYPES.contains(type)) {
                throw LOGGER.logExceptionAsError(new IllegalArgumentException(
                    String.format("Type '%s' is not supported. "
                            + "Please use @FieldIgnore to exclude the field "
                            + "and manually build SearchField to the list if the field is needed. %n"
                            + "For more information, refer to link: aka.ms/azsdk/java/search/fieldbuilder",
                        type.getTypeName())));
            }
            return;
        }

        ParameterizedType parameterizedType = (ParameterizedType) type;
        if (Map.class.isAssignableFrom((Class<?>) parameterizedType.getRawType())) {
            throw LOGGER.logExceptionAsError(new IllegalArgumentException("Map and its subclasses are not supported"));
        }

        if (hasArrayOrCollectionWrapped) {
            throw LOGGER.logExceptionAsError(new IllegalArgumentException(
                "Only single-dimensional array is supported."));
        }

        if (!List.class.isAssignableFrom((Class<?>) parameterizedType.getRawType())) {
            throw LOGGER.logExceptionAsError(new IllegalArgumentException(
                String.format("Collection type %s is not supported", type.getTypeName())));
        }
    }

    private static SearchFieldDataType covertToSearchFieldDataType(Type type, boolean hasArrayOrCollectionWrapped) {
        validateType(type, hasArrayOrCollectionWrapped);

        if (SUPPORTED_NONE_PARAMETERIZED_TYPE.containsKey(type)) {
            return SUPPORTED_NONE_PARAMETERIZED_TYPE.get(type);
        }

        if (isArrayOrList(type)) {
            Type componentOrElementType = getComponentOrElementType(type);
            return SearchFieldDataType.collection(covertToSearchFieldDataType(componentOrElementType, true));
        }

        return SearchFieldDataType.COMPLEX;
    }
}