blob: 8cb18f6e5ef51cf34ffed5a60b162baf0f9fa7ef [file] [log] [blame]
// Copyright (C) 2019 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.plugins.checks;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.index.query.QueryParser.AND;
import static com.google.gerrit.index.query.QueryParser.DEFAULT_FIELD;
import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
import static com.google.gerrit.index.query.QueryParser.NOT;
import static com.google.gerrit.index.query.QueryParser.OR;
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.index.query.IndexPredicate;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.index.query.QueryParser;
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.index.change.ChangeField;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangePredicates;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ChangeQueryProcessor;
import com.google.gerrit.server.query.change.ChangeStatusPredicate;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.update.RetryableAction.ActionType;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import org.antlr.runtime.tree.Tree;
import org.eclipse.jgit.errors.ConfigInvalidException;
/**
* Utility for validating and executing relevancy queries for checkers.
*
* <p>Instances are not threadsafe and should not be reused across requests. However, they may be
* reused within a single request.
*/
public class CheckerQuery {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
// Note that this list contains *operators*, not predicates. If there are multiple operators
// aliased together to the same predicate ("f:", "file:"), they all need to be listed explicitly.
//
// We chose to list operators instead of predicates because:
// * It insulates us from changes in implementation details of the query system, such as
// predicate classes being renamed, or additional predicates being ORed into existing
// operators.
// * It's easier to keep in sync with the documentation.
//
// This doesn't rule out switching to predicates in the future, particularly if the predicate
// classes gain some informative methods like "boolean matchMethodNeedsToQueryIndex()".
//
// Predicates that definitely cannot be allowed:
// * Anything where match() needs to query the index, i.e. any full-text fields. This includes
// the default field.
// * Anything where post-filtering is unreasonably expensive.
// * Any predicate over projects, since that may conflict with the projects field.
//
// Beyond that, this set is mostly based on what we subjectively consider useful for limiting the
// changes that a checker runs on. It will probably grow, based on user feedback.
private static final ImmutableSortedSet<String> ALLOWED_OPERATORS =
ImmutableSortedSet.of(
"added",
"after",
"age",
"author",
"before",
"branch",
"committer",
"deleted",
"delta",
"destination",
"dir",
"directory",
"ext",
"extension",
"f",
"file",
"footer",
"hashtag",
"intopic",
// TODO(dborowitz): Support some is: operators but not others. Definitely not is:starred.
"label",
"onlyextensions",
"onlyexts",
"ownerin",
"path",
"r",
"ref",
"reviewer",
"reviewerin",
"size",
"status",
"submittable",
"topic",
"unresolved",
"wip");
@VisibleForTesting
public static String clean(String query) throws ConfigInvalidException {
String trimmed = requireNonNull(query).trim();
if (trimmed.isEmpty()) {
return trimmed;
}
try {
checkOperators(QueryParser.parse(query));
} catch (QueryParseException e) {
throw new ConfigInvalidException("Invalid query: " + query + "\n" + e.getMessage(), e);
}
return trimmed;
}
private static void checkOperators(Tree node) throws ConfigInvalidException {
switch (node.getType()) {
case AND:
case OR:
case NOT:
for (int i = 0; i < node.getChildCount(); i++) {
checkOperators(node.getChild(i));
}
break;
case FIELD_NAME:
if (!ALLOWED_OPERATORS.contains(node.getText())) {
throw new ConfigInvalidException("Unsupported operator: " + node);
}
break;
case DEFAULT_FIELD:
throw new ConfigInvalidException(
"Specific search operator required: " + getOnlyChild(node));
default:
throw new ConfigInvalidException("Unsupported query: " + node);
}
}
private static Tree getOnlyChild(Tree node) {
checkState(node.getChildCount() == 1, "expected 1 child: %s", node);
return node.getChild(0);
}
private final RetryHelper retryHelper;
private final Provider<ChangeQueryProcessor> changeQueryProcessorProvider;
private final ChangeQueryBuilder queryBuilder;
@Inject
CheckerQuery(
RetryHelper retryHelper,
Provider<AnonymousUser> anonymousUserProvider,
Provider<ChangeQueryBuilder> queryBuilderProvider,
Provider<ChangeQueryProcessor> changeQueryProcessorProvider) {
this.retryHelper = retryHelper;
this.changeQueryProcessorProvider = changeQueryProcessorProvider;
// The user passed to the ChangeQueryBuilder just controls how it parses "self". Anonymous means
// "self" is disallowed, which is correct for checker queries, since the results should not
// depend on the calling user. However, note that results are still filtered by visibility, but
// visibility is controlled by ChangeQueryProcessor, which always uses the current user and
// can't be overridden.
this.queryBuilder = queryBuilderProvider.get().asUser(anonymousUserProvider.get());
}
public boolean isCheckerRelevant(Checker checker, ChangeData cd) throws StorageException {
if (!checker.getQuery().isPresent()) {
return cd.change().isNew();
}
Predicate<ChangeData> predicate;
try {
predicate =
createQueryPredicate(checker.getUuid(), checker.getRepository(), checker.getQuery());
} catch (ConfigInvalidException e) {
logger.atWarning().withCause(e).log(
"skipping invalid query for checker %s", checker.getUuid());
return false;
}
return predicate.asMatchable().match(cd);
}
/**
* Cleans and validates a query string for storage in the checker configuration.
*
* <p>The query string is interpreted as a change query. Only a subset of query operators are
* supported, as listed in the REST API documentation and {@link #ALLOWED_OPERATORS}.
*
* <p>In addition to syntactic validation and checking for allowed operators, this method actually
* performs a query against the index, to ensure it passes any restrictions imposed by the index
* implementation, such as length limits.
*
* @param checkerUuid the checker UUID.
* @param repository the checker repository.
* @param query a change query string, either from the checker in storage or a proposed new value
* provided by a user.
* @return the query string, trimmed. May be empty, which indicates all changes match.
* @throws ConfigInvalidException if the query is not a valid query, or it uses operators outside
* of the allowed set.
*/
public String validate(CheckerUuid checkerUuid, Project.NameKey repository, String query)
throws ConfigInvalidException, StorageException {
// This parses the query string twice, which is unavoidable since there is currently no
// QueryProcessor API which takes an Antlr Tree. That's ok; the parse cost is vastly outweighed
// by the actual query execution.
query = clean(query);
queryMatchingChanges(
checkerUuid,
repository,
Optional.ofNullable(Strings.emptyToNull(query)),
qp -> qp.setUserProvidedLimit(1));
return query;
}
public ImmutableList<ImmutableList<ChangeData>> queryMatchingChanges(List<Checker> checkers)
throws ConfigInvalidException {
try {
List<Predicate<ChangeData>> predicateList = new ArrayList<>();
for (Checker checker : checkers) {
predicateList.add(
createQueryPredicate(checker.getUuid(), checker.getRepository(), checker.getQuery()));
}
return executeIndexQueryWithRetry("queryMatchingChangesForCheckers", qp -> {}, predicateList);
} catch (QueryParseException e) {
throw new ConfigInvalidException(
String.format(
"A checker in scheme %s has an invalid query (%s)",
checkers.get(0).getUuid().scheme(), e.getMessage()));
}
}
public ImmutableList<ChangeData> queryMatchingChanges(Checker checker)
throws ConfigInvalidException, StorageException {
return queryMatchingChanges(
checker.getUuid(), checker.getRepository(), checker.getQuery(), qp -> {});
}
private ImmutableList<ChangeData> queryMatchingChanges(
CheckerUuid checkerUuid,
Project.NameKey repository,
Optional<String> optionalQuery,
Consumer<ChangeQueryProcessor> queryProcessorSetup)
throws ConfigInvalidException, StorageException {
try {
return executeIndexQueryWithRetry(
"queryMatchingChangesForChecker",
queryProcessorSetup,
createQueryPredicate(checkerUuid, repository, optionalQuery));
} catch (QueryParseException e) {
throw invalidQueryException(checkerUuid, optionalQuery, e);
}
}
private Predicate<ChangeData> createQueryPredicate(
CheckerUuid checkerUuid, Project.NameKey repository, Optional<String> optionalQuery)
throws ConfigInvalidException {
Predicate<ChangeData> predicate = ChangePredicates.project(Project.nameKey(repository.get()));
if (optionalQuery.isPresent()) {
String query = optionalQuery.get();
Predicate<ChangeData> predicateForQuery;
try {
predicateForQuery = queryBuilder.parse(query);
} catch (QueryParseException e) {
throw invalidQueryException(checkerUuid, optionalQuery, e);
}
if (!predicateForQuery.isMatchable()) {
// Assuming nobody modified the query behind Gerrit's back, this is programmer error:
// CheckerQuery should not be able to produce non-matchable queries.
logger.atWarning().log(
"change query of checker %s is not matchable: %s", checkerUuid, optionalQuery.get());
throw invalidQueryException(checkerUuid, optionalQuery, null);
}
predicate = Predicate.and(predicate, predicateForQuery);
}
if (!hasStatusPredicate(predicate)) {
predicate = Predicate.and(ChangeStatusPredicate.open(), predicate);
}
return predicate;
}
private static boolean hasStatusPredicate(Predicate<ChangeData> predicate) {
if (predicate instanceof IndexPredicate) {
return ((IndexPredicate<ChangeData>) predicate)
.getField()
.getName()
.equals(ChangeField.STATUS_SPEC.getName());
}
return predicate.getChildren().stream().anyMatch(CheckerQuery::hasStatusPredicate);
}
// TODO(ekempin): Retrying the query should be done by ChangeQueryProcessor.
private ImmutableList<ChangeData> executeIndexQueryWithRetry(
String actionName,
Consumer<ChangeQueryProcessor> queryProcessorSetup,
Predicate<ChangeData> predicate)
throws StorageException, QueryParseException {
return executeIndexQueryWithRetry(actionName, queryProcessorSetup, ImmutableList.of(predicate))
.get(0);
}
private ImmutableList<ImmutableList<ChangeData>> executeIndexQueryWithRetry(
String actionName,
Consumer<ChangeQueryProcessor> queryProcessorSetup,
List<Predicate<ChangeData>> predicateList)
throws StorageException, QueryParseException {
try {
return retryHelper
.action(
ActionType.INDEX_QUERY,
actionName,
() -> {
ChangeQueryProcessor qp = changeQueryProcessorProvider.get();
queryProcessorSetup.accept(qp);
return qp.query(predicateList).stream()
.map(predicate -> predicate.entities())
.collect(toImmutableList());
})
.call();
} catch (Exception e) {
Throwables.throwIfUnchecked(e);
Throwables.throwIfInstanceOf(e, QueryParseException.class);
Throwables.throwIfInstanceOf(e, StorageException.class);
throw new StorageException(e);
}
}
private static ConfigInvalidException invalidQueryException(
CheckerUuid checkerUuid,
Optional<String> optionalQuery,
@Nullable QueryParseException parseException) {
String msg =
String.format(
"change query of checker %s is invalid: %s", checkerUuid, optionalQuery.orElse(""));
if (parseException != null) {
msg += " (" + parseException.getMessage() + ")";
}
return new ConfigInvalidException(msg, parseException);
}
}