com.google.gerrit.index.IndexedField Maven / Gradle / Ivy
// Copyright (C) 2022 The Android Open Source Project
//
// 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 com.google.gerrit.index;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.google.auto.value.AutoValue;
import com.google.common.base.CharMatcher;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.reflect.TypeToken;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.converter.ProtoConverter;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.index.SchemaFieldDefs.Getter;
import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.SchemaFieldDefs.Setter;
import com.google.gerrit.proto.Protos;
import com.google.protobuf.MessageLite;
import java.io.IOException;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.sql.Timestamp;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.StreamSupport;
/**
 * Definition of a field stored in the secondary index.
 *
 * Each IndexedField, stored in index, may have multiple {@link SearchSpec} which defines how it
 * can be searched and how the index tokens are generated.
 *
 * 
Index implementations may choose to store IndexedField and {@link SearchSpec} (search tokens)
 * separately, however {@link com.google.gerrit.index.query.IndexedQuery} always issues the queries
 * to {@link SearchSpec}.
 *
 * 
This allows index implementations to store IndexedField once, while enabling multiple
 * tokenization strategies on the same IndexedField with {@link SearchSpec}
 *
 * @param  input type from which documents are created and search results are returned.
 * @param  type that should be extracted from the input object when converting to an index
 *     document.
 */
// TODO(mariasavtchouk): revisit the class name after migration is done.
@SuppressWarnings("serial")
@AutoValue
public abstract class IndexedField {
  public static final TypeToken INTEGER_TYPE = new TypeToken<>() {};
  public static final TypeToken> ITERABLE_INTEGER_TYPE = new TypeToken<>() {};
  public static final TypeToken LONG_TYPE = new TypeToken<>() {};
  public static final TypeToken> ITERABLE_LONG_TYPE = new TypeToken<>() {};
  public static final TypeToken STRING_TYPE = new TypeToken<>() {};
  public static final TypeToken> ITERABLE_STRING_TYPE = new TypeToken<>() {};
  public static final TypeToken BYTE_ARRAY_TYPE = new TypeToken<>() {};
  public static final TypeToken> ITERABLE_BYTE_ARRAY_TYPE = new TypeToken<>() {};
  public static final TypeToken TIMESTAMP_TYPE = new TypeToken<>() {};
  // Should not be used directly, only used to check if the proto is stored
  private static final TypeToken MESSAGE_TYPE = new TypeToken<>() {};
  public static  Builder builder(String name, TypeToken fieldType) {
    return new AutoValue_IndexedField.Builder()
        .name(name)
        .fieldType(fieldType)
        .stored(false)
        .required(false);
  }
  public static  Builder> iterableStringBuilder(String name) {
    return builder(name, IndexedField.ITERABLE_STRING_TYPE);
  }
  public static  Builder stringBuilder(String name) {
    return builder(name, IndexedField.STRING_TYPE);
  }
  public static  Builder integerBuilder(String name) {
    return builder(name, IndexedField.INTEGER_TYPE);
  }
  public static  Builder longBuilder(String name) {
    return builder(name, IndexedField.LONG_TYPE);
  }
  public static  Builder> iterableIntegerBuilder(String name) {
    return builder(name, IndexedField.ITERABLE_INTEGER_TYPE);
  }
  public static  Builder timestampBuilder(String name) {
    return builder(name, IndexedField.TIMESTAMP_TYPE);
  }
  public static  Builder byteArrayBuilder(String name) {
    return builder(name, IndexedField.BYTE_ARRAY_TYPE);
  }
  public static  Builder> iterableByteArrayBuilder(String name) {
    return builder(name, IndexedField.ITERABLE_BYTE_ARRAY_TYPE);
  }
  /**
   * Defines how {@link IndexedField} can be searched and how the index tokens are generated.
   *
   * Multiple {@link SearchSpec} can be defined on a single {@link IndexedField}.
   *
   * 
Depending on the implementation, indexes can choose to store {@link IndexedField} and {@link
   * SearchSpec} separately. The searches are issues to {@link SearchSpec}.
   */
  public class SearchSpec implements SchemaField {
    private final String name;
    private final SearchOption searchOption;
    public SearchSpec(String name, SearchOption searchOption) {
      checkName(name);
      this.name = name;
      this.searchOption = searchOption;
    }
    @Override
    public boolean isStored() {
      return getField().stored();
    }
    @Override
    public boolean isRepeatable() {
      return getField().repeatable();
    }
    @Override
    @Nullable
    public T get(I obj) {
      return getField().get(obj);
    }
    @Override
    public String getName() {
      return name;
    }
    @Override
    public FieldType> getType() {
      SearchOption searchOption = getSearchOption();
      TypeToken> fieldType = getField().fieldType();
      if (searchOption.equals(SearchOption.STORE_ONLY)) {
        return FieldType.STORED_ONLY;
      } else if ((fieldType.equals(IndexedField.INTEGER_TYPE)
              || fieldType.equals(IndexedField.ITERABLE_INTEGER_TYPE))
          && searchOption.equals(SearchOption.EXACT)) {
        return FieldType.INTEGER;
      } else if (fieldType.equals(IndexedField.INTEGER_TYPE)
          && searchOption.equals(SearchOption.RANGE)) {
        return FieldType.INTEGER_RANGE;
      } else if (fieldType.equals(IndexedField.LONG_TYPE)) {
        return FieldType.LONG;
      } else if (fieldType.equals(IndexedField.TIMESTAMP_TYPE)) {
        return FieldType.TIMESTAMP;
      } else if (fieldType.equals(IndexedField.STRING_TYPE)
          || fieldType.equals(IndexedField.ITERABLE_STRING_TYPE)) {
        if (searchOption.equals(SearchOption.EXACT)) {
          return FieldType.EXACT;
        } else if (searchOption.equals(SearchOption.FULL_TEXT)) {
          return FieldType.FULL_TEXT;
        } else if (searchOption.equals(SearchOption.PREFIX)) {
          return FieldType.PREFIX;
        }
      }
      throw new IllegalArgumentException(
          String.format(
              "search spec [%s, %s] is not supported on field [%s, %s]",
              getName(), getSearchOption(), getField().name(), getField().fieldType()));
    }
    @Override
    public boolean setIfPossible(I object, StoredValue doc) {
      return getField().setIfPossible(object, doc);
    }
    /**
     * Returns {@link SearchOption} enabled on this field.
     *
     * @return {@link SearchOption}
     */
    public SearchOption getSearchOption() {
      return searchOption;
    }
    /**
     * Returns {@link IndexedField} on which this spec was created.
     *
     * @return original {@link IndexedField} of this spec.
     */
    public IndexedField getField() {
      return IndexedField.this;
    }
    @CanIgnoreReturnValue
    private String checkName(String name) {
      CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
      return name;
    }
  }
  /**
   * Adds {@link SearchSpec} to this {@link IndexedField}
   *
   * @param name the name to use for in the search.
   * @param searchOption the tokenization option, enabled by the new {@link SearchSpec}
   * @return the added {@link SearchSpec}.
   */
  public SearchSpec addSearchSpec(String name, SearchOption searchOption) {
    SearchSpec searchSpec = new SearchSpec(name, searchOption);
    checkArgument(
        !searchSpecs.containsKey(searchSpec.getName()),
        "Can not add search spec %s, because it is already defined on field %s",
        searchSpec.getName(),
        name());
    searchSpecs.put(searchSpec.getName(), searchSpec);
    return searchSpec;
  }
  public SearchSpec exact(String name) {
    return addSearchSpec(name, SearchOption.EXACT);
  }
  public SearchSpec fullText(String name) {
    return addSearchSpec(name, SearchOption.FULL_TEXT);
  }
  public SearchSpec range(String name) {
    return addSearchSpec(name, SearchOption.RANGE);
  }
  public SearchSpec integerRange(String name) {
    checkState(fieldType().equals(INTEGER_TYPE));
    // we currently store all integer range fields, this may change in the future
    checkState(stored());
    return addSearchSpec(name, SearchOption.RANGE);
  }
  public SearchSpec integer(String name) {
    checkState(fieldType().equals(INTEGER_TYPE) || fieldType().equals(ITERABLE_INTEGER_TYPE));
    return addSearchSpec(name, SearchOption.EXACT);
  }
  public SearchSpec longSearch(String name) {
    checkState(fieldType().equals(LONG_TYPE) || fieldType().equals(ITERABLE_LONG_TYPE));
    return addSearchSpec(name, SearchOption.EXACT);
  }
  public SearchSpec prefix(String name) {
    return addSearchSpec(name, SearchOption.PREFIX);
  }
  public SearchSpec storedOnly(String name) {
    checkState(stored());
    return addSearchSpec(name, SearchOption.STORE_ONLY);
  }
  public SearchSpec timestamp(String name) {
    checkState(fieldType().equals(TIMESTAMP_TYPE));
    return addSearchSpec(name, SearchOption.RANGE);
  }
  /** A builder for {@link IndexedField}. */
  @AutoValue.Builder
  public abstract static class Builder {
    public abstract IndexedField.Builder name(String name);
    public abstract IndexedField.Builder description(Optional description);
    public abstract IndexedField.Builder description(String description);
    public abstract Builder required(boolean required);
    public Builder required() {
      required(true);
      return this;
    }
    /** Allow reading the actual data from the index. */
    public abstract Builder stored(boolean stored);
    public Builder stored() {
      stored(true);
      return this;
    }
    abstract Builder repeatable(boolean repeatable);
    public abstract Builder size(Optional value);
    public abstract Builder size(Integer value);
    public abstract Builder getter(Getter getter);
    public abstract Builder fieldSetter(Optional> setter);
    abstract TypeToken fieldType();
    public abstract Builder fieldType(TypeToken type);
    public abstract Builder protoConverter(
        Optional> value);
    abstract IndexedField autoBuild(); // not public
    public final IndexedField build() {
      boolean isRepeatable = fieldType().isSubtypeOf(Iterable.class);
      repeatable(isRepeatable);
      IndexedField field = autoBuild();
      checkName(field.name());
      checkArgument(!field.size().isPresent() || field.size().get() > 0);
      return field;
    }
    public final IndexedField build(Getter getter, Setter setter) {
      return this.getter(getter).fieldSetter(Optional.of(setter)).build();
    }
    public final IndexedField build(
        Getter getter,
        Setter setter,
        ProtoConverter extends MessageLite, ?> protoConverter) {
      return this.getter(getter)
          .fieldSetter(Optional.of(setter))
          .protoConverter(Optional.of(protoConverter))
          .build();
    }
    public final IndexedField build(Getter getter) {
      return this.getter(getter).fieldSetter(Optional.empty()).build();
    }
    @CanIgnoreReturnValue
    private static String checkName(String name) {
      String allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_";
      CharMatcher m =
          CharMatcher.anyOf(allowedCharacters + allowedCharacters.toUpperCase(Locale.US));
      checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
      return name;
    }
  }
  private Map searchSpecs = new HashMap<>();
  /**
   * The name to store this field under.
   *
   * The name should use the UpperCamelCase format, see {@link Builder#checkName}.
   */
  public abstract String name();
  /** Optional description of the field data. */
  public abstract Optional description();
  /**
   * True if this field is mandatory. Default is false.
   *
   * This property is not enforced by the common indexing logic. It is up to the index
   * implementations to enforce that the field is required.
   */
  public abstract boolean required();
  /** Allow reading the actual data from the index. Default is false. */
  public abstract boolean stored();
  /** True if this field is repeatable. */
  public abstract boolean repeatable();
  /**
   * Optional size constrain on the field. The size is not constrained if this property is {@link
   * Optional#empty()}
   *
   * 
This property is not enforced by the common indexing logic. It is up to the index
   * implementations to enforce the size.
   *
   * 
If the field is {@link #repeatable()}, the constraint applies to each element separately.
   */
  public abstract Optional size();
  /** See {@link Getter} */
  public abstract Getter getter();
  /** See {@link Setter} */
  public abstract Optional> fieldSetter();
  /**
   * The {@link TypeToken} describing the contents of the field. See static constants for the common
   * supported types.
   *
   * @return {@link TypeToken} of this field.
   */
  public abstract TypeToken fieldType();
  /** If the {@link #fieldType()} is proto, the converter to use on byte/proto conversions. */
  public abstract Optional> protoConverter();
  /**
   * Returns all {@link SearchSpec}, enabled on this field.
   *
   * Note: weather or not a search is supported by the index depends on {@link Schema} version.
   */
  public ImmutableMap getSearchSpecs() {
    return ImmutableMap.copyOf(searchSpecs);
  }
  /**
   * Get the field contents from the input object.
   *
   * @param input input object.
   * @return the field value(s) to index.
   */
  @Nullable
  public T get(I input) {
    try {
      return getter().get(input);
    } catch (IOException e) {
      throw new StorageException(e);
    }
  }
  @SuppressWarnings("unchecked")
  public boolean setIfPossible(I object, StoredValue doc) {
    if (!fieldSetter().isPresent()) {
      return false;
    }
    if (this.fieldType().equals(STRING_TYPE)) {
      fieldSetter().get().set(object, (T) doc.asString());
      return true;
    } else if (this.fieldType().equals(ITERABLE_STRING_TYPE)) {
      fieldSetter().get().set(object, (T) doc.asStrings());
      return true;
    } else if (this.fieldType().equals(INTEGER_TYPE)) {
      fieldSetter().get().set(object, (T) doc.asInteger());
      return true;
    } else if (this.fieldType().equals(ITERABLE_INTEGER_TYPE)) {
      fieldSetter().get().set(object, (T) doc.asIntegers());
      return true;
    } else if (this.fieldType().equals(LONG_TYPE)) {
      fieldSetter().get().set(object, (T) doc.asLong());
      return true;
    } else if (this.fieldType().equals(ITERABLE_LONG_TYPE)) {
      fieldSetter().get().set(object, (T) doc.asLongs());
      return true;
    } else if (this.fieldType().equals(BYTE_ARRAY_TYPE)) {
      fieldSetter().get().set(object, (T) doc.asByteArray());
      return true;
    } else if (this.fieldType().equals(ITERABLE_BYTE_ARRAY_TYPE)) {
      fieldSetter().get().set(object, (T) doc.asByteArrays());
      return true;
    } else if (this.fieldType().equals(TIMESTAMP_TYPE)) {
      checkState(!repeatable(), "can't repeat timestamp values");
      fieldSetter().get().set(object, (T) doc.asTimestamp());
      return true;
    } else if (isProtoType()) {
      MessageLite proto = doc.asProto();
      if (proto != null) {
        fieldSetter().get().set(object, (T) proto);
        return true;
      }
      byte[] bytes = doc.asByteArray();
      if (bytes != null && protoConverter().isPresent()) {
        fieldSetter().get().set(object, (T) parseProtoFrom(bytes));
        return true;
      }
    } else if (isProtoIterableType()) {
      Iterable protos = doc.asProtos();
      if (protos != null) {
        fieldSetter().get().set(object, (T) protos);
        return true;
      }
      Iterable bytes = doc.asByteArrays();
      if (bytes != null && protoConverter().isPresent()) {
        fieldSetter().get().set(object, (T) decodeProtos(bytes));
        return true;
      }
    }
    return false;
  }
  /** Returns true if the {@link #fieldType} is a proto message. */
  public boolean isProtoType() {
    if (repeatable()) {
      return false;
    }
    return MESSAGE_TYPE.isSupertypeOf(fieldType());
  }
  /** Returns true if the {@link #fieldType} is a list of proto messages. */
  public boolean isProtoIterableType() {
    if (!repeatable()) {
      return false;
    }
    if (!(fieldType().getType() instanceof ParameterizedType)) {
      return false;
    }
    ParameterizedType parameterizedType = (ParameterizedType) fieldType().getType();
    if (parameterizedType.getActualTypeArguments().length != 1) {
      return false;
    }
    Type type = parameterizedType.getActualTypeArguments()[0];
    return MESSAGE_TYPE.isSupertypeOf(type);
  }
  private ImmutableList decodeProtos(Iterable raw) {
    return StreamSupport.stream(raw.spliterator(), false)
        .map(bytes -> parseProtoFrom(bytes))
        .collect(toImmutableList());
  }
  private MessageLite parseProtoFrom(byte[] bytes) {
    return Protos.parseUnchecked(protoConverter().get().getParser(), bytes);
  }
}