SortOrderItem.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 java.util.Objects.isNull;
import static java.util.Objects.nonNull;

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.io.Serial;
import java.io.Serializable;
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     | null        |
 * |              | path. The segments are separated by a dot (.):                 |             |
 * |              | field0.field1.field2                                           |             |
 * |              | It can be null. Then the object itself must be comparable.     |             |
 * |--------------|----------------------------------------------------------------|-------------|
 * | direction    | Defines ascending or descending ordering.                      | asc         |
 * |--------------|----------------------------------------------------------------|-------------|
 * | caseHandling | Makes a case ignoring comparison (only for strings).           | insensitive |
 * |--------------|----------------------------------------------------------------|-------------|
 * | nullHandling | Defines the ordering if one of the values is null.             | last        |
 *  ---------------------------------------------------------------------------------------------
 * </pre>
 *
 * <p>These values have a 'sort order text' representation. The values are concatenated with
 * comma ',' (default):
 * <pre>
 * fieldNameOrPath,direction,caseHandling,nullHandling
 * </pre>
 *
 * <p>For example:
 * <pre>
 * properties.customSettings.priority,asc,insensitive,nulls-first
 * </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 ';' (default):
 * <pre>
 * field0,desc;field1,desc
 * </pre>
 *
 * @author Christian Bremer
 */
@XmlRootElement(name = "sortOrderItem")
@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "sortOrderItemType", propOrder = {
    "field",
    "direction",
    "caseHandling",
    "nullHandling"
})
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonPropertyOrder(value = {
    "field",
    "direction",
    "caseHandling",
    "nullHandling"
})
@Schema(description = "A sort order defines how a field of an object is sorted.")
@Getter
@EqualsAndHashCode
public class SortOrderItem implements Serializable {

  @Serial
  private static final long serialVersionUID = 1;

  /**
   * The constant DEFAULT_SEPARATOR.
   */
  public static final String DEFAULT_SEPARATOR = ",";

  /**
   * The constant DEFAULT_DIRECTION.
   */
  protected static final Direction DEFAULT_DIRECTION = Direction.ASC;

  /**
   * The constant DEFAULT_CASE_HANDLING.
   */
  protected static final CaseHandling DEFAULT_CASE_HANDLING = CaseHandling.INSENSITIVE;

  /**
   * The constant DEFAULT_NULL_HANDLING.
   */
  protected static final NullHandling DEFAULT_NULL_HANDLING = NullHandling.NATIVE;

  /**
   * The field name or path. If it is {@code null}, the whole object will be compared.
   */
  @Schema(description = "The field name or path.")
  @XmlElement(name = "field")
  @JsonProperty("field")
  private final String field;

  /**
   * The direction. Default is {@link Direction#ASC}.
   */
  @Schema(description = "The direction.")
  @XmlElement(name = "direction", defaultValue = "ASC")
  @JsonProperty("direction")
  private final Direction direction;

  /**
   * The case-handling. Default is {@link CaseHandling#INSENSITIVE}.
   */
  @Schema(description = "The case-handling.")
  @XmlElement(name = "case-handling", defaultValue = "INSENSITIVE")
  @JsonProperty("case-handling")
  private final CaseHandling caseHandling;

  /**
   * The null-handling. Default is {@link NullHandling#NATIVE}.
   */
  @Schema(description = "The null-handling.")
  @XmlElement(name = "null-handling", defaultValue = "NATIVE")
  @JsonProperty("null-handling")
  private final NullHandling nullHandling;

  /**
   * Instantiates a new sort order item.
   */
  protected SortOrderItem() {
    this(null, DEFAULT_DIRECTION, DEFAULT_CASE_HANDLING, DEFAULT_NULL_HANDLING);
  }

  /**
   * Instantiates a new sort order item.
   *
   * @param field the field name or path (can be {@code null})
   * @param direction the direction
   * @param caseHandling the case-handling
   * @param nullHandling the null-handling
   */
  @JsonCreator
  public SortOrderItem(
      @JsonProperty("field") String field,
      @JsonProperty("direction") Direction direction,
      @JsonProperty("case-handling") CaseHandling caseHandling,
      @JsonProperty("null-handling") NullHandling nullHandling) {
    this.field = isNull(field) || field.isBlank() ? null : field;
    this.direction = Objects.requireNonNullElse(direction, DEFAULT_DIRECTION);
    this.caseHandling = Objects.requireNonNullElse(caseHandling, DEFAULT_CASE_HANDLING);
    this.nullHandling = Objects.requireNonNullElse(nullHandling, DEFAULT_NULL_HANDLING);
  }

  /**
   * Gets field (can be {@code null}).
   *
   * @return the field
   */
  public String getField() {
    return isNull(field) || field.isBlank() ? null : field;
  }

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

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

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

  /**
   * Creates the sort order text of this ordering description.
   *
   * <p>The syntax of the ordering description is
   * <pre>
   * fieldNameOrPath;direction;caseHandling;nullHandling
   * </pre>
   *
   * <p>For example
   * <pre>
   * person.lastName;asc;sensitive;nulls-first
   * </pre>
   *
   * @return the sort order text
   */
  @JsonIgnore
  @XmlTransient
  public String getSortOrderText() {
    return getSortOrderText(SortOrderTextSeparators.defaults());
  }

  /**
   * Creates the sort order text of this ordering description.
   *
   * <p>The syntax of the ordering description is
   * <pre>
   * fieldNameOrPath;direction;caseHandling;nullHandling
   * </pre>
   *
   * <p>For example
   * <pre>
   * person.lastName;asc;sensitive;nulls-first
   * </pre>
   *
   * @param separators the separators
   * @return the sort order text
   */
  public String getSortOrderText(SortOrderTextSeparators separators) {
    String separator = Optional.ofNullable(separators)
        .orElseGet(SortOrderTextSeparators::defaults)
        .getArgumentSeparator();
    StringBuilder sb = new StringBuilder();
    if (nonNull(getField())) {
      sb.append(getField());
    }
    boolean isNullHandlingDefault = getNullHandling() == DEFAULT_NULL_HANDLING;
    boolean isCaseHandlingDefault = getCaseHandling() == DEFAULT_CASE_HANDLING
        && isNullHandlingDefault;
    boolean isDirectionDefault = getDirection() == DEFAULT_DIRECTION && isCaseHandlingDefault;
    if (!isDirectionDefault) {
      sb.append(separator).append(getDirection().toString());
    }
    if (!isCaseHandlingDefault) {
      sb.append(separator).append(getCaseHandling().toString());
    }
    if (!isNullHandlingDefault) {
      sb.append(separator).append(getNullHandling().toString());
    }
    return sb.toString();
  }

  @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 SortOrderItem by(String field) {
    return new SortOrderItem(
        field, DEFAULT_DIRECTION, DEFAULT_CASE_HANDLING, DEFAULT_NULL_HANDLING);
  }

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

  /**
   * From sort order text.
   *
   * @param source the sort order text
   * @param separators the separators
   * @return the sort order
   */
  public static SortOrderItem fromSortOrderText(String source, SortOrderTextSeparators separators) {
    if (isNull(source)) {
      return null;
    }
    return Optional.of(source.trim())
        .map(text -> {
          String separator = Optional.ofNullable(separators)
              .orElseGet(SortOrderTextSeparators::defaults)
              .getArgumentSeparator();
          String field;
          Direction direction = DEFAULT_DIRECTION;
          CaseHandling caseHandling = DEFAULT_CASE_HANDLING;
          NullHandling nullHandling = DEFAULT_NULL_HANDLING;
          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) {
              direction = Direction.fromString(text.substring(from).trim());
            } else {
              direction = Direction.fromString(text.substring(from, index).trim());
              from = index + separator.length();
              index = text.indexOf(separator, from);
              if (index < 0) {
                caseHandling = CaseHandling.fromString(text.substring(from).trim());
              } else {
                caseHandling = CaseHandling.fromString(text.substring(from, index).trim());
                from = index + separator.length();
                nullHandling = NullHandling.fromString(text.substring(from).trim());
              }
            }
          }
          field = field.isEmpty() ? null : field;
          return new SortOrderItem(field, direction, caseHandling, nullHandling);
        })
        .orElseGet(SortOrderItem::new);
  }

  /**
   * The direction.
   */
  public enum Direction {

    /**
     * Ascending direction.
     */
    ASC,

    /**
     * Descending direction.
     */
    DESC;

    /**
     * Returns whether the direction is ascending.
     *
     * @return true if ascending, false otherwise.
     */
    public boolean isAscending() {
      return ASC.equals(this);
    }

    /**
     * Returns whether the direction is descending.
     *
     * @return true if descending, false otherwise.
     */
    public boolean isDescending() {
      return DESC.equals(this);
    }

    @Override
    public String toString() {
      return name().toLowerCase();
    }

    /**
     * From string.
     *
     * @param direction the direction
     * @return the direction
     */
    public static Direction fromString(String direction) {
      if ("DESC".equalsIgnoreCase(direction)) {
        return DESC;
      }
      return ASC;
    }
  }

  /**
   * The case-handling.
   */
  public enum CaseHandling {

    /**
     * Insensitive case-handling.
     */
    INSENSITIVE,

    /**
     * Sensitive case-handling.
     */
    SENSITIVE;

    /**
     * Returns whether the case-handling is insensitive.
     *
     * @return true if insensitive, false otherwise.
     */
    public boolean isInsensitive() {
      return INSENSITIVE.equals(this);
    }

    /**
     * Returns whether the case-handling is sensitive.
     *
     * @return true if sensitive, false otherwise.
     */
    public boolean isSensitive() {
      return SENSITIVE.equals(this);
    }

    @Override
    public String toString() {
      return name().toLowerCase();
    }

    /**
     * From string.
     *
     * @param caseHandling the case-handling
     * @return the case-handling
     */
    public static CaseHandling fromString(String caseHandling) {
      if ("SENSITIVE".equalsIgnoreCase(caseHandling)) {
        return SENSITIVE;
      }
      return INSENSITIVE;
    }
  }

  /**
   * The null-handling.
   */
  public enum NullHandling {

    /**
     * Nulls first handling.
     */
    NULLS_FIRST,

    /**
     * Nulls last handling.
     */
    NULLS_LAST,

    /**
     * Native null-handling.
     */
    NATIVE;

    @Override
    public String toString() {
      return name().replace("_", "-").toLowerCase();
    }

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

    /**
     * Is null last.
     *
     * @return the boolean
     */
    public boolean isNullLast() {
      return !isNullFirst();
    }

    /**
     * From string.
     *
     * @param nullHandling the null-handling
     * @return the null-handling
     */
    public static NullHandling fromString(String nullHandling) {
      if ("NULLS_FIRST".equalsIgnoreCase(nullHandling)
          || "NULLS-FIRST".equalsIgnoreCase(nullHandling)
          || "FIRST".equalsIgnoreCase(nullHandling)) {
        return NULLS_FIRST;
      }
      if ("NATIVE".equalsIgnoreCase(nullHandling)) {
        return NATIVE;
      }
      return NULLS_LAST;
    }

  }

}