diff --git a/eng/code-quality-reports/src/main/resources/checkstyle/checkstyle-suppressions.xml b/eng/code-quality-reports/src/main/resources/checkstyle/checkstyle-suppressions.xml index 53f8c0f3258c6..7891958977f89 100755 --- a/eng/code-quality-reports/src/main/resources/checkstyle/checkstyle-suppressions.xml +++ b/eng/code-quality-reports/src/main/resources/checkstyle/checkstyle-suppressions.xml @@ -485,9 +485,10 @@ the main ServiceBusClientBuilder. --> - + + diff --git a/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/implementation/util/FieldBuilder.java b/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/implementation/util/FieldBuilder.java index 133066c9bcd3b..c1fdc3208baa5 100644 --- a/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/implementation/util/FieldBuilder.java +++ b/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/implementation/util/FieldBuilder.java @@ -4,6 +4,7 @@ package com.azure.search.documents.implementation.util; import com.azure.core.models.GeoPoint; +import com.azure.core.util.CoreUtils; import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.serializer.MemberNameConverter; import com.azure.core.util.serializer.MemberNameConverterProviders; @@ -13,6 +14,7 @@ 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.LexicalNormalizerName; import com.azure.search.documents.indexes.models.SearchField; import com.azure.search.documents.indexes.models.SearchFieldDataType; import reactor.util.annotation.Nullable; @@ -51,6 +53,9 @@ public final class FieldBuilder { private static final Map SUPPORTED_NONE_PARAMETERIZED_TYPE = new HashMap<>(); private static final Set UNSUPPORTED_TYPES = new HashSet<>(); + private static final SearchFieldDataType COLLECTION_STRING + = SearchFieldDataType.collection(SearchFieldDataType.STRING); + static { SUPPORTED_NONE_PARAMETERIZED_TYPE.put(Integer.class, SearchFieldDataType.INT32); SUPPORTED_NONE_PARAMETERIZED_TYPE.put(int.class, SearchFieldDataType.INT32); @@ -237,8 +242,8 @@ private static Type getComponentOrElementType(Type arrayOrListType) { return ((Class) arrayOrListType).getComponentType(); } - throw LOGGER.logExceptionAsError(new RuntimeException(String.format( - "Collection type %s is not supported.", arrayOrListType.getTypeName()))); + throw LOGGER.logExceptionAsError( + new RuntimeException("Collection type '" + arrayOrListType.getTypeName() + "' is not supported.")); } private static SearchField convertToBasicSearchField(String fieldName, Type type) { @@ -252,55 +257,100 @@ private static SearchField enrichWithAnnotation(SearchField searchField, Member 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()))); + throw LOGGER.logExceptionAsError(new IllegalArgumentException( + "@SimpleField and @SearchableField cannot be present simultaneously for " + member.getName())); + } + + if (simpleField == null && searchableField == null) { + return searchField; } + + boolean key, hidden, filterable, sortable, facetable; + boolean searchable = searchableField != null; + String analyzerName = null; + String searchAnalyzerName = null; + String indexAnalyzerName = null; + String normalizerName; + String[] synonymMapNames = null; + 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()))); - } + key = simpleField.isKey(); + hidden = simpleField.isHidden(); + filterable = simpleField.isFilterable(); + sortable = simpleField.isSortable(); + facetable = simpleField.isFacetable(); + normalizerName = simpleField.normalizerName(); + } else { + key = searchableField.isKey(); + hidden = searchableField.isHidden(); + filterable = searchableField.isFilterable(); + sortable = searchableField.isSortable(); + facetable = searchableField.isFacetable(); + analyzerName = searchableField.analyzerName(); + searchAnalyzerName = searchableField.searchAnalyzerName(); + indexAnalyzerName = searchableField.indexAnalyzerName(); + normalizerName = searchableField.normalizerName(); + synonymMapNames = searchableField.synonymMapNames(); + } - 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())); + StringBuilder errorMessage = new StringBuilder(); + boolean isStringOrCollectionString = searchField.getType() == SearchFieldDataType.STRING + || searchField.getType() == COLLECTION_STRING; + boolean hasAnalyzerName = !CoreUtils.isNullOrEmpty(analyzerName); + boolean hasSearchAnalyzerName = !CoreUtils.isNullOrEmpty(searchAnalyzerName); + boolean hasIndexAnalyzerName = !CoreUtils.isNullOrEmpty(indexAnalyzerName); + boolean hasNormalizerName = !CoreUtils.isNullOrEmpty(normalizerName); + if (searchable) { + if (!isStringOrCollectionString) { + errorMessage.append("SearchField can only be used on string properties. Property '") + .append(member.getName()).append("' returns a '").append(searchField.getType()).append("' value. "); } - 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 synonymMaps = Arrays.stream(searchableField.synonymMapNames()) - .filter(synonym -> !synonym.trim().isEmpty()).collect(Collectors.toList()); - searchField.setSynonymMapNames(synonymMaps); + + // Searchable fields are allowed to have either no analyzer names configure or one of the following + // analyzerName is set and searchAnalyzerName and indexAnalyzerName are not set + // searchAnalyzerName and indexAnalyzerName are set and analyzerName is not set + if ((!hasAnalyzerName && (hasSearchAnalyzerName != hasIndexAnalyzerName)) + || (hasAnalyzerName && (hasSearchAnalyzerName || hasIndexAnalyzerName))) { + errorMessage.append("Please specify either analyzer or both searchAnalyzer and indexAnalyzer. "); } } + + // Any field is allowed to have a normalizer but it must be either a STRING or Collection(STRING) and have one + // of filterable, sortable, or facetable set to true. + if (hasNormalizerName && (!isStringOrCollectionString || !(filterable || sortable || facetable))) { + errorMessage.append("A field with a normalizer name can only be used on string properties and must have ") + .append("one of filterable, sortable, or facetable set to true. "); + } + + if (errorMessage.length() > 0) { + throw LOGGER.logExceptionAsError(new RuntimeException(errorMessage.toString())); + } + + searchField.setKey(key) + .setHidden(hidden) + .setSearchable(searchable) + .setFilterable(filterable) + .setSortable(sortable) + .setFacetable(facetable); + + if (hasAnalyzerName) { + searchField.setAnalyzerName(LexicalAnalyzerName.fromString(analyzerName)); + } else if (hasSearchAnalyzerName || hasIndexAnalyzerName) { + searchField.setAnalyzerName(LexicalAnalyzerName.fromString(searchAnalyzerName)); + searchField.setAnalyzerName(LexicalAnalyzerName.fromString(indexAnalyzerName)); + } + + if (hasNormalizerName) { + searchField.setNormalizerName(LexicalNormalizerName.fromString(normalizerName)); + } + + if (!CoreUtils.isNullOrEmpty(synonymMapNames)) { + List synonymMaps = Arrays.stream(searchableField.synonymMapNames()) + .filter(synonym -> !synonym.trim().isEmpty()) + .collect(Collectors.toList()); + searchField.setSynonymMapNames(synonymMaps); + } + return searchField; } @@ -318,11 +368,9 @@ 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()))); + "Type '" + type.getTypeName() + "' is not supported. Please use @FieldIgnore to exclude the field " + + "and manually build SearchField to the list if the field is needed. For more information, " + + "refer to link: aka.ms/azsdk/java/search/fieldbuilder")); } return; } @@ -333,13 +381,13 @@ private static void validateType(Type type, boolean hasArrayOrCollectionWrapped) } if (hasArrayOrCollectionWrapped) { - throw LOGGER.logExceptionAsError(new IllegalArgumentException( - "Only single-dimensional array is supported.")); + 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()))); + throw LOGGER.logExceptionAsError( + new IllegalArgumentException("Collection type '" + type.getTypeName() + "' is not supported")); } } diff --git a/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/indexes/SearchableField.java b/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/indexes/SearchableField.java index cec17d813d595..b5ee7018909c4 100644 --- a/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/indexes/SearchableField.java +++ b/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/indexes/SearchableField.java @@ -5,6 +5,7 @@ import com.azure.search.documents.indexes.models.FieldBuilderOptions; import com.azure.search.documents.indexes.models.LexicalAnalyzerName; +import com.azure.search.documents.indexes.models.LexicalNormalizerName; import com.azure.search.documents.indexes.models.SearchField; import com.azure.search.documents.indexes.models.SynonymMap; @@ -58,27 +59,35 @@ /** * A {@link LexicalAnalyzerName} to associate as the search and index analyzer for the {@link SearchField field}. * - * @return The {@link LexicalAnalyzerName} that will be associated as the search and index analyzer for the {@link - * SearchField field}. + * @return The {@link LexicalAnalyzerName} that will be associated as the search and index analyzer for the + * {@link SearchField field}. */ String analyzerName() default ""; /** * A {@link LexicalAnalyzerName} to associate as the search analyzer for the {@link SearchField field}. * - * @return The {@link LexicalAnalyzerName} that will be associated as the search analyzer for the {@link SearchField - * field}. + * @return The {@link LexicalAnalyzerName} that will be associated as the search analyzer for the + * {@link SearchField field}. */ String searchAnalyzerName() default ""; /** * A {@link LexicalAnalyzerName} to associate as the index analyzer for the {@link SearchField field}. * - * @return The {@link LexicalAnalyzerName} that will be associated as the index analyzer for the {@link SearchField - * field}. + * @return The {@link LexicalAnalyzerName} that will be associated as the index analyzer for the + * {@link SearchField field}. */ String indexAnalyzerName() default ""; + /** + * A {@link LexicalNormalizerName} to associate as the normalizer for the {@link SearchField field}. + * + * @return The {@link LexicalNormalizerName} that will be associated as the normalizer for the + * {@link SearchField field}. + */ + String normalizerName() default ""; + /** * A list of {@link SynonymMap} names to be associated with the {@link SearchField field}. *

diff --git a/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/indexes/SimpleField.java b/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/indexes/SimpleField.java index 98d4609465747..aa2f43edf7f0c 100644 --- a/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/indexes/SimpleField.java +++ b/sdk/search/azure-search-documents/src/main/java/com/azure/search/documents/indexes/SimpleField.java @@ -4,6 +4,7 @@ package com.azure.search.documents.indexes; import com.azure.search.documents.indexes.models.FieldBuilderOptions; +import com.azure.search.documents.indexes.models.LexicalNormalizerName; import com.azure.search.documents.indexes.models.SearchField; import java.lang.annotation.ElementType; @@ -52,4 +53,12 @@ * @return A flag indicating if the field or method should generate as a filterable {@link SearchField field}. */ boolean isFilterable() default false; + + /** + * A {@link LexicalNormalizerName} to associate as the normalizer for the {@link SearchField field}. + * + * @return The {@link LexicalNormalizerName} that will be associated as the normalizer for the + * {@link SearchField field}. + */ + String normalizerName() default ""; } diff --git a/sdk/search/azure-search-documents/src/test/java/com/azure/search/documents/indexes/FieldBuilderTests.java b/sdk/search/azure-search-documents/src/test/java/com/azure/search/documents/indexes/FieldBuilderTests.java index c6ddf835a390f..309fed997dead 100644 --- a/sdk/search/azure-search-documents/src/test/java/com/azure/search/documents/indexes/FieldBuilderTests.java +++ b/sdk/search/azure-search-documents/src/test/java/com/azure/search/documents/indexes/FieldBuilderTests.java @@ -5,6 +5,7 @@ import com.azure.core.models.GeoPoint; import com.azure.search.documents.TestHelpers; +import com.azure.search.documents.indexes.models.LexicalNormalizerName; import com.azure.search.documents.indexes.models.SearchField; import com.azure.search.documents.indexes.models.SearchFieldDataType; import com.azure.search.documents.test.environment.models.HotelAnalyzerException; @@ -18,6 +19,8 @@ import com.azure.search.documents.test.environment.models.HotelWithIgnoredFields; import com.azure.search.documents.test.environment.models.HotelWithUnsupportedField; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.time.OffsetDateTime; import java.util.Arrays; @@ -255,6 +258,43 @@ public void unsupportedFields() { assertExceptionMassageAndDataType(exception, null, "is not supported"); } + @Test + public void validNormalizerField() { + List fields = SearchIndexClient.buildSearchFields(ValidNormalizer.class, null); + + assertEquals(1, fields.size()); + + SearchField normalizerField = fields.get(0); + assertEquals(LexicalNormalizerName.STANDARD, normalizerField.getNormalizerName()); + } + + @SuppressWarnings("unused") + public static final class ValidNormalizer { + @SimpleField(normalizerName = "standard", isFilterable = true) + public String validNormalizer; + } + + @ParameterizedTest + @ValueSource(classes = { NonStringNormalizer.class, MissingFunctionalityNormalizer.class }) + public void invalidNormalizerField(Class type) { + RuntimeException ex = assertThrows(RuntimeException.class, + () -> SearchIndexClient.buildSearchFields(type, null)); + + assertTrue(ex.getMessage().contains("A field with a normalizer name")); + } + + @SuppressWarnings("unused") + public static final class NonStringNormalizer { + @SimpleField(normalizerName = "standard") + public int wrongTypeForNormalizer; + } + + @SuppressWarnings("unused") + public static final class MissingFunctionalityNormalizer { + @SimpleField(normalizerName = "standard") + public String rightTypeWrongFunctionality; + } + private void assertListFieldEquals(List expected, List actual) { assertEquals(expected.size(), actual.size()); for (int i = 0; i < expected.size(); i++) {