/* * Copyright 2017 the original author or authors. * * 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 org.springframework.data.mongodb.core; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.RequiredArgsConstructor; import java.util.Locale; import java.util.Optional; import org.bson.Document; import org.springframework.core.convert.converter.Converter; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import com.mongodb.client.model.Collation.Builder; import com.mongodb.client.model.CollationAlternate; import com.mongodb.client.model.CollationCaseFirst; import com.mongodb.client.model.CollationMaxVariable; import com.mongodb.client.model.CollationStrength; /** * Central abstraction for MongoDB collation support. <br /> * Allows fluent creation of a collation {@link Document} that can be used for creating collections & indexes as well as * querying data. * <p /> * <strong>NOTE:</strong> Please keep in mind that queries will only make use of an index with collation settings if the * query itself specifies the same collation. * * @author Christoph Strobl * @author Mark Paluch * @since 2.0 * @see <a href="https://docs.mongodb.com/manual/reference/collation/">MongoDB Reference - Collation</a> */ public class Collation { private static final Collation SIMPLE = of("simple"); private final CollationLocale locale; private Optional<ComparisonLevel> strength = Optional.empty(); private Optional<Boolean> numericOrdering = Optional.empty(); private Optional<Alternate> alternate = Optional.empty(); private Optional<Boolean> backwards = Optional.empty(); private Optional<Boolean> normalization = Optional.empty(); private Optional<String> version = Optional.empty(); private Collation(CollationLocale locale) { Assert.notNull(locale, "ICULocale must not be null!"); this.locale = locale; } /** * Create a {@link Collation} using {@literal simple} binary comparison. * * @return a {@link Collation} for {@literal simple} binary comparison. */ public static Collation simple() { return SIMPLE; } /** * Create new {@link Collation} with locale set to {{@link java.util.Locale#getLanguage()}} and * {@link java.util.Locale#getVariant()}. * * @param locale must not be {@literal null}. * @return */ public static Collation of(Locale locale) { Assert.notNull(locale, "Locale must not be null!"); String format; if (StringUtils.hasText(locale.getCountry())) { format = String.format("%s_%s", locale.getLanguage(), locale.getCountry()); } else { format = locale.getLanguage(); } return of(CollationLocale.of(format).variant(locale.getVariant())); } /** * Create new {@link Collation} with locale set to the given ICU language. * * @param language must not be {@literal null}. * @return */ public static Collation of(String language) { return of(CollationLocale.of(language)); } /** * Create new {@link Collation} with locale set to the given {@link CollationLocale}. * * @param locale must not be {@literal null}. * @return */ public static Collation of(CollationLocale locale) { return new Collation(locale); } /** * Create new {@link Collation} from values in {@link Document}. * * @param source must not be {@literal null}. * @return * @see <a href="https://docs.mongodb.com/manual/reference/collation/#collation-document">MongoDB Reference - * Collation Document</a> */ public static Collation from(Document source) { Assert.notNull(source, "Source must not be null!"); Collation collation = Collation.of(source.getString("locale")); if (source.containsKey("strength")) { collation = collation.strength(source.getInteger("strength")); } if (source.containsKey("caseLevel")) { collation = collation.caseLevel(source.getBoolean("caseLevel")); } if (source.containsKey("caseFirst")) { collation = collation.caseFirst(source.getString("caseFirst")); } if (source.containsKey("numericOrdering")) { collation = collation.numericOrdering(source.getBoolean("numericOrdering")); } if (source.containsKey("alternate")) { collation = collation.alternate(source.getString("alternate")); } if (source.containsKey("maxVariable")) { collation = collation.maxVariable(source.getString("maxVariable")); } if (source.containsKey("backwards")) { collation = collation.backwards(source.getBoolean("backwards")); } if (source.containsKey("normalization")) { collation = collation.normalization(source.getBoolean("normalization")); } if (source.containsKey("version")) { collation.version = Optional.of(source.get("version").toString()); } return collation; } /** * Set the level of comparison to perform. * * @param strength * @return new {@link Collation}. */ public Collation strength(int strength) { ComparisonLevel current = this.strength.orElseGet(() -> new ICUComparisonLevel(strength)); return strength(new ICUComparisonLevel(strength, current.getCaseFirst(), current.getCaseLevel())); } /** * Set the level of comparison to perform. * * @param comparisonLevel must not be {@literal null}. * @return new {@link Collation} */ public Collation strength(ComparisonLevel comparisonLevel) { Collation newInstance = copy(); newInstance.strength = Optional.of(comparisonLevel); return newInstance; } /** * Set whether to include {@code caseLevel} comparison. <br /> * * @param caseLevel * @return new {@link Collation}. */ public Collation caseLevel(boolean caseLevel) { ComparisonLevel strengthValue = strength.orElseGet(ComparisonLevel::primary); return strength( new ICUComparisonLevel(strengthValue.getLevel(), strengthValue.getCaseFirst(), Optional.of(caseLevel))); } /** * Set the flag that determines sort order of case differences during tertiary level comparisons. * * @param caseFirst must not be {@literal null}. * @return */ public Collation caseFirst(String caseFirst) { return caseFirst(new CaseFirst(caseFirst)); } /** * Set the flag that determines sort order of case differences during tertiary level comparisons. * * @param caseFirst must not be {@literal null}. * @return */ public Collation caseFirst(CaseFirst sort) { ComparisonLevel strengthValue = strength.orElseGet(ComparisonLevel::tertiary); return strength(new ICUComparisonLevel(strengthValue.getLevel(), Optional.of(sort), strengthValue.getCaseLevel())); } /** * Treat numeric strings as numbers for comparison. * * @return new {@link Collation}. */ public Collation numericOrderingEnabled() { return numericOrdering(true); } /** * Treat numeric strings as string for comparison. * * @return new {@link Collation}. */ public Collation numericOrderingDisabled() { return numericOrdering(false); } /** * Set the flag that determines whether to compare numeric strings as numbers or as strings. * * @return new {@link Collation}. */ public Collation numericOrdering(boolean flag) { Collation newInstance = copy(); newInstance.numericOrdering = Optional.of(flag); return newInstance; } /** * Set the Field that determines whether collation should consider whitespace and punctuation as base characters for * purposes of comparison. * * @param alternate must not be {@literal null}. * @return new {@link Collation}. */ public Collation alternate(String alternate) { Alternate instance = this.alternate.orElseGet(() -> new Alternate(alternate, Optional.empty())); return alternate(new Alternate(alternate, instance.maxVariable)); } /** * Set the Field that determines whether collation should consider whitespace and punctuation as base characters for * purposes of comparison. * * @param alternate must not be {@literal null}. * @return new {@link Collation}. */ public Collation alternate(Alternate alternate) { Collation newInstance = copy(); newInstance.alternate = Optional.ofNullable(alternate); return newInstance; } /** * Sort string with diacritics sort from back of the string. * * @return new {@link Collation}. */ public Collation backwardDiacriticSort() { return backwards(true); } /** * Do not sort string with diacritics sort from back of the string. * * @return new {@link Collation}. */ public Collation forwardDiacriticSort() { return backwards(false); } /** * Set the flag that determines whether strings with diacritics sort from back of the string. * * @param backwards must not be {@literal null}. * @return new {@link Collation}. */ public Collation backwards(Boolean backwards) { Collation newInstance = copy(); newInstance.backwards = Optional.ofNullable(backwards); return newInstance; } /** * Enable text normalization. * * @return new {@link Collation}. */ public Collation normalizationEnabled() { return normalization(true); } /** * Disable text normalization. * * @return new {@link Collation}. */ public Collation normalizationDisabled() { return normalization(false); } /** * Set the flag that determines whether to check if text require normalization and to perform normalization. * * @param normalization must not be {@literal null}. * @return new {@link Collation}. */ public Collation normalization(Boolean normalization) { Collation newInstance = copy(); newInstance.normalization = Optional.ofNullable(normalization); return newInstance; } /** * Set the field that determines up to which characters are considered ignorable when alternate is {@code shifted}. * * @param maxVariable must not be {@literal null}. * @return new {@link Collation}. */ public Collation maxVariable(String maxVariable) { Alternate alternateValue = alternate.orElseGet(Alternate::shifted); return alternate(new AlternateWithMaxVariable(alternateValue.alternate, maxVariable)); } /** * Get the {@link Document} representation of the {@link Collation}. * * @return */ public Document toDocument() { return map(toMongoDocumentConverter()); } /** * Get the {@link com.mongodb.client.model.Collation} representation of the {@link Collation}. * * @return */ public com.mongodb.client.model.Collation toMongoCollation() { return map(toMongoCollationConverter()); } /** * Transform {@code this} {@link Collation} by applying a {@link Converter}. * * @param mapper * @param <R> * @return */ public <R> R map(Converter<? super Collation, ? extends R> mapper) { return mapper.convert(this); } @Override public String toString() { return toDocument().toJson(); } private Collation copy() { Collation collation = new Collation(locale); collation.strength = this.strength; collation.normalization = this.normalization; collation.numericOrdering = this.numericOrdering; collation.alternate = this.alternate; collation.backwards = this.backwards; return collation; } /** * Abstraction for the ICU Comparison Levels. * * @since 2.0 */ public interface ComparisonLevel { /** * Primary level of comparison. Collation performs comparisons of the base characters only, ignoring other * differences such as diacritics and case. <br /> * The {@code caseLevel} can be set via {@link PrimaryICUComparisonLevel#includeCase()} and * {@link PrimaryICUComparisonLevel#excludeCase()}. * * @return new {@link SecondaryICUComparisonLevel}. */ static PrimaryICUComparisonLevel primary() { return PrimaryICUComparisonLevel.DEFAULT; } /** * Secondary level of comparison. Collation performs comparisons up to secondary differences, such as * diacritics.<br /> * The {@code caseLevel} can be set via {@link SecondaryICUComparisonLevel#includeCase()} and * {@link SecondaryICUComparisonLevel#excludeCase()}. * * @return new {@link SecondaryICUComparisonLevel}. */ static SecondaryICUComparisonLevel secondary() { return SecondaryICUComparisonLevel.DEFAULT; } /** * Tertiary level of comparison. Collation performs comparisons up to tertiary differences, such as case and letter * variants. <br /> * The {@code caseLevel} cannot be set for {@link ICUComparisonLevel} above {@code secondary}. * * @return new {@link ICUComparisonLevel}. */ static TertiaryICUComparisonLevel tertiary() { return TertiaryICUComparisonLevel.DEFAULT; } /** * Quaternary Level. Limited for specific use case to consider punctuation. <br /> * The {@code caseLevel} cannot be set for {@link ICUComparisonLevel} above {@code secondary}. * * @return new {@link ComparisonLevel}. */ static ComparisonLevel quaternary() { return ComparisonLevels.QUATERNARY; } /** * Identical Level. Limited for specific use case of tie breaker. <br /> * The {@code caseLevel} cannot be set for {@link ICUComparisonLevel} above {@code secondary}. * * @return new {@link ComparisonLevel}. */ static ComparisonLevel identical() { return ComparisonLevels.IDENTICAL; } /** * @return collation strength, {@literal 1} for primary, {@literal 2} for secondary and so on. */ int getLevel(); default Optional<CaseFirst> getCaseFirst() { return Optional.empty(); } default Optional<Boolean> getCaseLevel() { return Optional.empty(); } } /** * Abstraction for the ICU Comparison Levels. * * @since 2.0 */ @AllArgsConstructor(access = AccessLevel.PACKAGE) @Getter static class ICUComparisonLevel implements ComparisonLevel { private final int level; private final Optional<CaseFirst> caseFirst; private final Optional<Boolean> caseLevel; ICUComparisonLevel(int level) { this(level, Optional.empty(), Optional.empty()); } } /** * Simple comparison levels. */ enum ComparisonLevels implements ComparisonLevel { QUATERNARY(4), IDENTICAL(5); private final int level; ComparisonLevels(int level) { this.level = level; } @Override public int getLevel() { return level; } } /** * Primary-strength {@link ICUComparisonLevel}. */ public static class PrimaryICUComparisonLevel extends ICUComparisonLevel { static final PrimaryICUComparisonLevel DEFAULT = new PrimaryICUComparisonLevel(); static final PrimaryICUComparisonLevel WITH_CASE_LEVEL = new PrimaryICUComparisonLevel(true); static final PrimaryICUComparisonLevel WITHOUT_CASE_LEVEL = new PrimaryICUComparisonLevel(false); private PrimaryICUComparisonLevel() { super(1); } private PrimaryICUComparisonLevel(boolean caseLevel) { super(1, Optional.empty(), Optional.of(caseLevel)); } /** * Include case comparison. * * @return new {@link ICUComparisonLevel} */ public ComparisonLevel includeCase() { return WITH_CASE_LEVEL; } /** * Exclude case comparison. * * @return new {@link ICUComparisonLevel} */ public ComparisonLevel excludeCase() { return WITHOUT_CASE_LEVEL; } } /** * Secondary-strength {@link ICUComparisonLevel}. */ public static class SecondaryICUComparisonLevel extends ICUComparisonLevel { static final SecondaryICUComparisonLevel DEFAULT = new SecondaryICUComparisonLevel(); static final SecondaryICUComparisonLevel WITH_CASE_LEVEL = new SecondaryICUComparisonLevel(true); static final SecondaryICUComparisonLevel WITHOUT_CASE_LEVEL = new SecondaryICUComparisonLevel(false); private SecondaryICUComparisonLevel() { super(2); } private SecondaryICUComparisonLevel(boolean caseLevel) { super(2, Optional.empty(), Optional.of(caseLevel)); } /** * Include case comparison. * * @return new {@link SecondaryICUComparisonLevel} */ public ComparisonLevel includeCase() { return WITH_CASE_LEVEL; } /** * Exclude case comparison. * * @return new {@link SecondaryICUComparisonLevel} */ public ComparisonLevel excludeCase() { return WITHOUT_CASE_LEVEL; } } /** * Tertiary-strength {@link ICUComparisonLevel}. */ public static class TertiaryICUComparisonLevel extends ICUComparisonLevel { static final TertiaryICUComparisonLevel DEFAULT = new TertiaryICUComparisonLevel(); private TertiaryICUComparisonLevel() { super(3); } private TertiaryICUComparisonLevel(CaseFirst caseFirst) { super(3, Optional.of(caseFirst), Optional.empty()); } /** * Set the flag that determines sort order of case differences. * * @param caseFirst must not be {@literal null}. * @return new {@link ICUComparisonLevel} */ public ComparisonLevel caseFirst(CaseFirst caseFirst) { Assert.notNull(caseFirst, "CaseFirst must not be null!"); return new TertiaryICUComparisonLevel(caseFirst); } } /** * @since 2.0 */ @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public static class CaseFirst { private static final CaseFirst UPPER = new CaseFirst("upper"); private static final CaseFirst LOWER = new CaseFirst("lower"); private static final CaseFirst OFF = new CaseFirst("off"); private final String state; /** * Sort uppercase before lowercase. * * @return new {@link CaseFirst}. */ public static CaseFirst upper() { return UPPER; } /** * Sort lowercase before uppercase. * * @return new {@link CaseFirst}. */ public static CaseFirst lower() { return LOWER; } /** * Use the default. * * @return new {@link CaseFirst}. */ public static CaseFirst off() { return OFF; } } /** * @since 2.0 */ @RequiredArgsConstructor(access = AccessLevel.PACKAGE) public static class Alternate { private static final Alternate NON_IGNORABLE = new Alternate("non-ignorable", Optional.empty()); final String alternate; final Optional<String> maxVariable; /** * Consider Whitespace and punctuation as base characters. * * @return new {@link Alternate}. */ public static Alternate nonIgnorable() { return NON_IGNORABLE; } /** * Whitespace and punctuation are <strong>not</strong> considered base characters and are only distinguished at * strength. <br /> * <strong>NOTE:</strong> Only works for {@link ICUComparisonLevel} above {@link ComparisonLevel#tertiary()}. * * @return new {@link AlternateWithMaxVariable}. */ public static AlternateWithMaxVariable shifted() { return AlternateWithMaxVariable.DEFAULT; } } /** * @since 2.0 */ public static class AlternateWithMaxVariable extends Alternate { static final AlternateWithMaxVariable DEFAULT = new AlternateWithMaxVariable("shifted"); static final Alternate SHIFTED_PUNCT = new AlternateWithMaxVariable("shifted", "punct"); static final Alternate SHIFTED_SPACE = new AlternateWithMaxVariable("shifted", "space"); private AlternateWithMaxVariable(String alternate) { super(alternate, Optional.empty()); } private AlternateWithMaxVariable(String alternate, String maxVariable) { super(alternate, Optional.of(maxVariable)); } /** * Consider both whitespaces and punctuation as ignorable. * * @return new {@link AlternateWithMaxVariable}. */ public Alternate punct() { return SHIFTED_PUNCT; } /** * Only consider whitespaces as ignorable. * * @return new {@link AlternateWithMaxVariable}. */ public Alternate space() { return SHIFTED_SPACE; } } /** * ICU locale abstraction for usage with MongoDB {@link Collation}. * * @since 2.0 * @see <a href="http://site.icu-project.org">ICU - International Components for Unicode</a> */ @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public static class CollationLocale { private final String language; private final Optional<String> variant; /** * Create new {@link CollationLocale} for given language. * * @param language must not be {@literal null}. * @return */ public static CollationLocale of(String language) { Assert.notNull(language, "Code must not be null!"); return new CollationLocale(language, Optional.empty()); } /** * Define language variant. * * @param variant must not be {@literal null}. * @return new {@link CollationLocale}. */ public CollationLocale variant(String variant) { Assert.notNull(variant, "Variant must not be null!"); return new CollationLocale(language, Optional.of(variant)); } /** * Get the string representation. * * @return */ public String asString() { StringBuilder sb = new StringBuilder(language); variant.filter(it -> !it.isEmpty()).ifPresent(val -> { // Mongo requires variant rendered as ICU keyword (@key=value;key=value…) sb.append("@collation=").append(val); }); return sb.toString(); } } private static Converter<Collation, Document> toMongoDocumentConverter() { return source -> { Document document = new Document(); document.append("locale", source.locale.asString()); source.strength.ifPresent(strength -> { document.append("strength", strength.getLevel()); strength.getCaseLevel().ifPresent(it -> document.append("caseLevel", it)); strength.getCaseFirst().ifPresent(it -> document.append("caseFirst", it.state)); }); source.numericOrdering.ifPresent(val -> document.append("numericOrdering", val)); source.alternate.ifPresent(it -> { document.append("alternate", it.alternate); it.maxVariable.ifPresent(maxVariable -> document.append("maxVariable", maxVariable)); }); source.backwards.ifPresent(it -> document.append("backwards", it)); source.normalization.ifPresent(it -> document.append("normalization", it)); source.version.ifPresent(it -> document.append("version", it)); return document; }; } private static Converter<Collation, com.mongodb.client.model.Collation> toMongoCollationConverter() { return source -> { Builder builder = com.mongodb.client.model.Collation.builder(); builder.locale(source.locale.asString()); source.strength.ifPresent(strength -> { builder.collationStrength(CollationStrength.fromInt(strength.getLevel())); strength.getCaseLevel().ifPresent(builder::caseLevel); strength.getCaseFirst().ifPresent(it -> builder.collationCaseFirst(CollationCaseFirst.fromString(it.state))); }); source.numericOrdering.ifPresent(builder::numericOrdering); source.alternate.ifPresent(it -> { builder.collationAlternate(CollationAlternate.fromString(it.alternate)); it.maxVariable .ifPresent(maxVariable -> builder.collationMaxVariable(CollationMaxVariable.fromString(maxVariable))); }); source.backwards.ifPresent(builder::backwards); source.normalization.ifPresent(builder::normalization); return builder.build(); }; } }