SortOrder.java

/*
 * Copyright 2019-2022 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.bremersee.comparator.model;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
import jakarta.xml.bind.annotation.XmlTransient;
import jakarta.xml.bind.annotation.XmlType;
import java.util.Objects;
import java.util.Optional;
import lombok.EqualsAndHashCode;
import lombok.Getter;

/**
 * This class defines the sort order of a field.
 *
 * <pre>
 *  ---------------------------------------------------------------------------------------------
 * | Attribute    | Description                                                       | Default  |
 * |--------------|-------------------------------------------------------------------|----------|
 * | field        | The field name (or method name) of the object. It can be a path.  | null     |
 * |              | The segments are separated by a dot (.): field0.field1.field2     |          |
 * |              | It can be null. Then the object itself must be comparable.        |          |
 * |--------------|-------------------------------------------------------------------|----------|
 * | asc or desc  | Defines ascending or descending ordering.                         | asc      |
 * |--------------|-------------------------------------------------------------------|----------|
 * | ignoreCase   | Makes a case ignoring comparison (only for strings).              | true     |
 * |--------------|-------------------------------------------------------------------|----------|
 * | nullIsFirst  | Defines the ordering if one of the values is null.                | false    |
 *  ---------------------------------------------------------------------------------------------
 * </pre>
 *
 * <p>These values have a 'sort oder text' representation. The values are concatenated with comma
 * (,):
 * <pre>
 * fieldNameOrPath,asc,ignoreCase,nullIsFirst
 * </pre>
 *
 * <p>For example:
 * <pre>
 * properties.customSettings.priority,asc,true,false
 * </pre>
 *
 * <p>Defaults can be omitted. This is the same:
 * <pre>
 * properties.customSettings.priority
 * </pre>
 *
 * <p>The building of a chain is done by concatenate the fields with a semicolon (;):
 * <pre>
 * field0,asc,ignoreCase,nullIsFirst;field1,asc,ignoreCase,nullIsFirst
 * </pre>
 *
 * @author Christian Bremer
 */
@XmlRootElement(name = "sortOrder")
@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "sortOrderType", propOrder = {
    "field",
    "asc",
    "ignoreCase",
    "nullIsFirst"
})
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonPropertyOrder(value = {
    "field",
    "asc",
    "ignoreCase",
    "nullIsFirst"
})
@Schema(description = "A sort order defines how a field of an object is sorted.")
@Getter
@EqualsAndHashCode
public class SortOrder {

  @Schema(description = "The field name or path.")
  @XmlElement(name = "field")
  private final String field;

  @Schema(description = "Is ascending or descending order.", requiredMode = REQUIRED)
  @XmlElement(name = "asc", defaultValue = "true")
  private final boolean asc;

  @Schema(description = "Is case insensitive or sensitive order.", requiredMode = REQUIRED)
  @XmlElement(name = "ignoreCase", defaultValue = "true")
  private final boolean ignoreCase;

  @Schema(description = "Is null is first.", requiredMode = REQUIRED)
  @XmlElement(name = "nullIsFirst", defaultValue = "false")
  private final boolean nullIsFirst;

  /**
   * Instantiates a new sort order.
   */
  protected SortOrder() {
    this(null, true, true, false);
  }

  /**
   * Instantiates a new sort order.
   *
   * @param field the field name or path (can be {@code null})
   * @param asc {@code true} for an ascending order, {@code false} for a descending order
   * @param ignoreCase {@code true} for a case-insensitive order,  {@code false} for a
   *     case-sensitive order
   * @param nullIsFirst specifies the order of {@code null} values
   */
  @JsonCreator
  public SortOrder(
      @JsonProperty("field") String field,
      @JsonProperty(value = "asc", required = true) boolean asc,
      @JsonProperty(value = "ignoreCase", required = true) boolean ignoreCase,
      @JsonProperty(value = "nullIsFirst", required = true) boolean nullIsFirst) {
    this.field = field;
    this.asc = asc;
    this.ignoreCase = ignoreCase;
    this.nullIsFirst = nullIsFirst;
  }

  /**
   * With given direction.
   *
   * @param direction the direction
   * @return the new sort order
   */
  public SortOrder with(Direction direction) {
    return Optional.ofNullable(direction)
        .map(dir -> new SortOrder(getField(), dir.isAsc(), isIgnoreCase(), isNullIsFirst()))
        .orElse(this);
  }

  /**
   * With given case handling.
   *
   * @param caseHandling the case handling
   * @return the new sort order
   */
  public SortOrder with(CaseHandling caseHandling) {
    return Optional.ofNullable(caseHandling)
        .map(ch -> new SortOrder(getField(), isAsc(), ch.isIgnoreCase(), isNullIsFirst()))
        .orElse(this);
  }

  /**
   * With given null handling.
   *
   * @param nullHandling the null handling
   * @return the new sort order
   */
  public SortOrder with(NullHandling nullHandling) {
    return Optional.ofNullable(nullHandling)
        .map(nh -> new SortOrder(getField(), isAsc(), isIgnoreCase(), nh.isNullIsFirst()))
        .orElse(this);
  }

  /**
   * Creates the sort order text of this ordering description.
   *
   * <p>The syntax of the ordering description is
   * <pre>
   * fieldNameOrPath,asc,ignoreCase,nullIsFirst
   * </pre>
   *
   * <p>For example
   * <pre>
   * person.lastName,asc,true,false
   * </pre>
   *
   * @return the sort order text
   */
  @JsonIgnore
  @XmlTransient
  public String getSortOrderText() {
    return getSortOrderText(null);
  }

  /**
   * Creates the sort order text of this ordering description.
   *
   * <p>The syntax of the ordering description is
   * <pre>
   * fieldNameOrPath,asc,ignoreCase,nullIsFirst
   * </pre>
   *
   * <p>For example
   * <pre>
   * person.lastName,asc,true,false
   * </pre>
   *
   * @return the sort order text
   * @deprecated Use {@link #getSortOrderText()} instead.
   */
  @Deprecated
  public String toSortOrderText() {
    return getSortOrderText();
  }

  /**
   * Creates the sort order text of this ordering description.
   *
   * <p>The syntax of the ordering description is
   * <pre>
   * fieldNameOrPath,asc,ignoreCase,nullIsFirst
   * </pre>
   *
   * <p>The separator (',') and the values of {@code direction}, {@code case-handling} and {@code
   * null-handling}** depend on the given {@link SortOrdersTextProperties}.
   *
   * <p>For example with default properties:
   * <pre>
   * person.lastName,asc,true,false
   * </pre>
   *
   * @param properties the properties (can be {@code null}
   * @return the sort order text
   */
  public String getSortOrderText(SortOrdersTextProperties properties) {
    SortOrdersTextProperties props = Objects.requireNonNullElse(properties,
        SortOrdersTextProperties.defaults());
    return (field != null ? field : "") + props.getSortOrderArgsSeparator()
        + props.getDirectionValue(asc) + props.getSortOrderArgsSeparator()
        + props.getIgnoreCaseValue(ignoreCase) + props.getSortOrderArgsSeparator()
        + props.getNullIsFirstValue(nullIsFirst);
  }

  /**
   * Creates the sort order text of this ordering description.
   *
   * <p>The syntax of the ordering description is
   * <pre>
   * fieldNameOrPath,asc,ignoreCase,nullIsFirst
   * </pre>
   *
   * <p>The separator (',') and the values of {@code direction}, {@code case-handling} and {@code
   * null-handling}** depend on the given {@link SortOrdersTextProperties}.
   *
   * <p>For example with default properties:
   * <pre>
   * person.lastName,asc,true,false
   * </pre>
   *
   * @param properties the properties (can be {@code null}
   * @return the sort order text
   * @deprecated Use {@link #getSortOrderText(SortOrdersTextProperties)} instead.
   */
  @Deprecated
  public String toSortOrderText(SortOrdersTextProperties properties) {
    return getSortOrderText(properties);
  }

  @Override
  public String toString() {
    return getSortOrderText();
  }

  /**
   * Creates a new sort order for the given field.
   *
   * @param field the field
   * @return the sort order
   */
  public static SortOrder by(String field) {
    return new SortOrder(field, true, true, false);
  }

  /**
   * From sort order text.
   *
   * @param source the sort order text
   * @return the sort order
   */
  public static SortOrder fromSortOrderText(String source) {
    return fromSortOrderText(source, SortOrdersTextProperties.defaults());
  }

  /**
   * From sort order text.
   *
   * @param source the sort order text
   * @param properties the properties
   * @return the sort order
   */
  public static SortOrder fromSortOrderText(String source, SortOrdersTextProperties properties) {
    return Optional.ofNullable(source)
        .map(text -> {
          SortOrdersTextProperties props = Objects
              .requireNonNullElse(properties, SortOrdersTextProperties.defaults());
          String field;
          boolean asc = props.isAsc(null);
          boolean ignoreCase = props.isCaseIgnored(null);
          boolean nullIsFirst = props.isNullFirst(null);
          String separator = props.getSortOrderArgsSeparator();
          int index = text.indexOf(separator);
          if (index < 0) {
            field = text.trim();
          } else {
            field = text.substring(0, index).trim();
            int from = index + separator.length();
            index = text.indexOf(separator, from);
            if (index < 0) {
              asc = props.isAsc(text.substring(from).trim());
            } else {
              asc = props.isAsc(text.substring(from, index).trim());
              from = index + separator.length();
              index = text.indexOf(separator, from);
              if (index < 0) {
                ignoreCase = props.isCaseIgnored(text.substring(from).trim());
              } else {
                ignoreCase = props.isCaseIgnored(text.substring(from, index).trim());
                from = index + separator.length();
                nullIsFirst = props.isNullFirst(text.substring(from).trim());
              }
            }
          }
          field = field.isEmpty() ? null : field;
          return new SortOrder(field, asc, ignoreCase, nullIsFirst);
        })
        .orElseGet(() -> new SortOrder(null, true, true, false));
  }

  /**
   * The direction.
   */
  public enum Direction {
    /**
     * Asc direction.
     */
    ASC,
    /**
     * Desc direction.
     */
    DESC;

    /**
     * Is asc.
     *
     * @return the boolean
     */
    public boolean isAsc() {
      return ASC.equals(this);
    }
  }

  /**
   * The case handling.
   */
  public enum CaseHandling {
    /**
     * Insensitive case handling.
     */
    INSENSITIVE,
    /**
     * Sensitive case handling.
     */
    SENSITIVE;

    /**
     * Is ignore case.
     *
     * @return the boolean
     */
    public boolean isIgnoreCase() {
      return INSENSITIVE.equals(this);
    }
  }

  /**
   * The null handling.
   */
  public enum NullHandling {
    /**
     * Nulls first handling.
     */
    NULLS_FIRST,
    /**
     * Nulls last handling.
     */
    NULLS_LAST;

    /**
     * Is null is first.
     *
     * @return the boolean
     */
    public boolean isNullIsFirst() {
      return NULLS_FIRST.equals(this);
    }
  }

}