Change List Pending Checks REST endpoint to accept a query as input

At the moment the List Pending Checks REST endpoint supports 2 URL
parameters:

1. checker: checker UUID (requried)
2. state: check state (optional, can be specified multiple times)

Later we would need another URL parameter for the checker scheme.

In the future the List Pending Checks REST endpoint should be
implemented on top of a new check index. This means the List Pending
Checks REST endpoint will execute a query on the check index.

Keeping this in mind we want to make the List Pending Checks REST
endpoint already now consistent with other Query REST endpoints (e.g.
QueryAccounts, QueryChanges) so that we don't break callers when we
migrate this REST endpoint on top of a check index.

All existing query REST endpoints take a query string as input (via a
'query' URL parameter). To be consistent the List Pending REST endpoint
should also expect a query string as input.

The current URL parameters are transitioned to operators in the input
query. This means instead of

  GET /plugins/checks~checks.pending/?checker=<checker-uuid>&state=<state>

one would now call

  GET /plugins/checks~checks.pending/?query=checker:<checker-uuid>+state:<state>

With this syntax we allow users to make full use of the flexibility that
an index provides, once we have a check index.

While there is no check index yet there are some limitations for the
query:
1. exactly 1 checker predicate must be used
2. the checker predicate must either be the root predicate
   (query = "checker:<checker-uuid>") or the root predicate must be an
   'and' predicate that has a checker predicate as immediate child
   (query = "checker:<checker-uuid> AND ...")

These limitations will be removed when we implement a check index.

For now we implement the List Pending Checks REST endpoint like this:
1. implement a CheckQueryBuilder which supports a 'checker' and a
   'state' operator, use the CheckQueryBuilder to parse the query
2. validate the query against the limitations mentioned above (expect
   exactly 1 checker predicate at root or as immediate child of an 'and'
   predicate at root)
3. add 'AND state:NOT_STARTED' if no state predicate was used
4. get the checker UUID from the 1 checker predicate in the query and
   load the checker config
5. use the query of the checker to query for matching changes via the
   change index
6. For each change:
   a. load the check for the checker
   b. create a backfilled check if no such check exists (the checker's
      query matched the change hence it is relevant and backfilling
      should be done)
   c. check if the input query matches the check (by converting the
      predicate to Matchable and invoking the match(Check) method)
   d. create PendingChecksInfo if the check was matched

The extension API and the tests have been adapted to the new REST API.

The behavior of the List Pending REST endpoint is unchanges, except for
listing pending checks of a non-existing checker. We now return an empty
list in this case (the non-existing checker doesn't match any check),
before we failed with a UnprocessableEntityException ("checker
<checker-uuid> not found"). The new behavior is better since it doesn't
allow callers to probe whether a checker exists.

Change-Id: Ide4cb6507571f9159f39ca1dc0f505944f48b5e4
Signed-off-by: Edwin Kempin <ekempin@google.com>
diff --git a/java/com/google/gerrit/plugins/checks/Check.java b/java/com/google/gerrit/plugins/checks/Check.java
index b7af0f8..bd46191 100644
--- a/java/com/google/gerrit/plugins/checks/Check.java
+++ b/java/com/google/gerrit/plugins/checks/Check.java
@@ -16,11 +16,21 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.plugins.checks.api.CheckState;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import java.sql.Timestamp;
 import java.util.Optional;
 
 @AutoValue
 public abstract class Check {
+  public static Check newBackfilledCheck(Project.NameKey project, PatchSet ps, Checker checker) {
+    return Check.builder(CheckKey.create(project, ps.getId(), checker.getUuid()))
+        .setState(CheckState.NOT_STARTED)
+        .setCreated(ps.getCreatedOn())
+        .setUpdated(ps.getCreatedOn())
+        .build();
+  }
+
   /** The key of the Check. */
   public abstract CheckKey key();
 
diff --git a/java/com/google/gerrit/plugins/checks/Checker.java b/java/com/google/gerrit/plugins/checks/Checker.java
index aa43340..b0852e4 100644
--- a/java/com/google/gerrit/plugins/checks/Checker.java
+++ b/java/com/google/gerrit/plugins/checks/Checker.java
@@ -145,6 +145,10 @@
     return builder().setUuid(uuid);
   }
 
+  public boolean isDisabled() {
+    return CheckerStatus.DISABLED == getStatus();
+  }
+
   public boolean isCheckerRelevant(ChangeData cd, ChangeQueryBuilder changeQueryBuilder)
       throws OrmException {
     if (!getQuery().isPresent()) {
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckerUuidHandler.java b/java/com/google/gerrit/plugins/checks/api/CheckerUuidHandler.java
deleted file mode 100644
index 5e563ff..0000000
--- a/java/com/google/gerrit/plugins/checks/api/CheckerUuidHandler.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// 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.api;
-
-import static com.google.gerrit.util.cli.Localizable.localizable;
-
-import com.google.gerrit.plugins.checks.CheckerUuid;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.CmdLineParser;
-import org.kohsuke.args4j.OptionDef;
-import org.kohsuke.args4j.spi.OptionHandler;
-import org.kohsuke.args4j.spi.Parameters;
-import org.kohsuke.args4j.spi.Setter;
-
-public class CheckerUuidHandler extends OptionHandler<CheckerUuid> {
-  @Inject
-  public CheckerUuidHandler(
-      @Assisted CmdLineParser parser,
-      @Assisted OptionDef option,
-      @Assisted Setter<CheckerUuid> setter) {
-    super(parser, option, setter);
-  }
-
-  @Override
-  public int parseArguments(Parameters params) throws CmdLineException {
-    String token = params.getParameter(0);
-
-    if (!CheckerUuid.isUuid(token)) {
-      throw new CmdLineException(owner, localizable("Invalid checker UUID: %s"), token);
-    }
-
-    CheckerUuid checkerUuid = CheckerUuid.parse(token);
-    setter.addValue(checkerUuid);
-    return 1;
-  }
-
-  @Override
-  public String getDefaultMetaVariable() {
-    return "UUID";
-  }
-}
diff --git a/java/com/google/gerrit/plugins/checks/api/ListPendingChecks.java b/java/com/google/gerrit/plugins/checks/api/ListPendingChecks.java
index 18e2f8c..a4f3cbc 100644
--- a/java/com/google/gerrit/plugins/checks/api/ListPendingChecks.java
+++ b/java/com/google/gerrit/plugins/checks/api/ListPendingChecks.java
@@ -14,13 +14,17 @@
 
 package com.google.gerrit.plugins.checks.api;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.index.query.AndPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.plugins.checks.Check;
 import com.google.gerrit.plugins.checks.CheckKey;
 import com.google.gerrit.plugins.checks.Checker;
@@ -28,6 +32,9 @@
 import com.google.gerrit.plugins.checks.Checkers;
 import com.google.gerrit.plugins.checks.Checks;
 import com.google.gerrit.plugins.checks.Checks.GetCheckOptions;
+import com.google.gerrit.plugins.checks.index.CheckQueryBuilder;
+import com.google.gerrit.plugins.checks.index.CheckStatePredicate;
+import com.google.gerrit.plugins.checks.index.CheckerPredicate;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -45,36 +52,33 @@
 import org.kohsuke.args4j.Option;
 
 public class ListPendingChecks implements RestReadView<TopLevelResource> {
+  private final CheckQueryBuilder checkQueryBuilder;
   private final Checkers checkers;
   private final Checks checks;
   private final RetryHelper retryHelper;
   private final Provider<ChangeQueryBuilder> queryBuilderProvider;
   private final Provider<ChangeQueryProcessor> changeQueryProcessorProvider;
 
-  private CheckerUuid checkerUuid;
-  private List<CheckState> states = new ArrayList<>(CheckState.values().length);
+  private String queryString;
 
   @Option(
-      name = "--checker",
-      metaVar = "UUID",
-      usage = "checker UUID formatted as '<scheme>:<id>'",
-      handler = CheckerUuidHandler.class)
-  public void setChecker(CheckerUuid checkerUuid) {
-    this.checkerUuid = checkerUuid;
-  }
-
-  @Option(name = "--state", metaVar = "STATE", usage = "check state")
-  public void addState(CheckState state) {
-    this.states.add(state);
+      name = "--query",
+      aliases = {"-q"},
+      metaVar = "QUERY",
+      usage = "check query")
+  public void setQuery(String queryString) {
+    this.queryString = queryString;
   }
 
   @Inject
   public ListPendingChecks(
+      CheckQueryBuilder checkQueryBuilder,
       Checkers checkers,
       Checks checks,
       RetryHelper retryHelper,
       Provider<ChangeQueryBuilder> queryBuilderProvider,
       Provider<ChangeQueryProcessor> changeQueryProcessorProvider) {
+    this.checkQueryBuilder = checkQueryBuilder;
     this.checkers = checkers;
     this.checks = checks;
     this.retryHelper = retryHelper;
@@ -85,77 +89,148 @@
   @Override
   public List<PendingChecksInfo> apply(TopLevelResource resource)
       throws RestApiException, IOException, ConfigInvalidException, OrmException {
-    if (states.isEmpty()) {
-      // If no state was specified, assume NOT_STARTED by default.
-      states.add(CheckState.NOT_STARTED);
+    if (queryString == null) {
+      throw new BadRequestException("query is required");
     }
 
-    if (checkerUuid == null) {
-      throw new BadRequestException("checker UUID is required");
+    Predicate<Check> query = validateQuery(parseQuery(queryString));
+    if (!hasStatePredicate(query)) {
+      query = Predicate.and(new CheckStatePredicate(CheckState.NOT_STARTED), query);
     }
 
-    Checker checker =
-        checkers
-            .getChecker(checkerUuid)
-            .orElseThrow(
-                () ->
-                    new UnprocessableEntityException(
-                        String.format("checker %s not found", checkerUuid)));
-
-    if (checker.getStatus() == CheckerStatus.DISABLED) {
+    Optional<Checker> checker = checkers.getChecker(getCheckerUuidFromQuery(query));
+    if (!checker.isPresent() || checker.get().isDisabled()) {
       return ImmutableList.of();
     }
 
     // The query system can only match against the current patch set; ignore non-current patch sets
     // for now.
     List<ChangeData> changes =
-        checker.queryMatchingChanges(
-            retryHelper, queryBuilderProvider.get(), changeQueryProcessorProvider);
+        checker
+            .get()
+            .queryMatchingChanges(
+                retryHelper, queryBuilderProvider.get(), changeQueryProcessorProvider);
+    CheckerUuid checkerUuid = checker.get().getUuid();
     List<PendingChecksInfo> pendingChecks = new ArrayList<>(changes.size());
     for (ChangeData cd : changes) {
-      getPostFilteredPendingChecks(cd.project(), cd.currentPatchSet().getId())
-          .ifPresent(pendingChecks::add);
+      PatchSet patchSet = cd.currentPatchSet();
+      CheckKey checkKey = CheckKey.create(cd.project(), patchSet.getId(), checkerUuid);
+
+      // Backfill if check is not present.
+      // Backfilling is only done for relevant checkers (checkers where the repository and the query
+      // matches the change). Since the change was found by executing the query of the checker we
+      // know that the checker is relevant for this patch set and hence backfilling should be done.
+      Check check =
+          checks
+              .getCheck(checkKey, GetCheckOptions.defaults())
+              .orElseGet(() -> Check.newBackfilledCheck(cd.project(), patchSet, checker.get()));
+
+      if (query.asMatchable().match(check)) {
+        pendingChecks.add(createPendingChecksInfo(cd.project(), patchSet, checkerUuid, check));
+      }
     }
     return pendingChecks;
   }
 
-  private Optional<PendingChecksInfo> getPostFilteredPendingChecks(
-      Project.NameKey repositoryName, PatchSet.Id patchSetId) throws OrmException, IOException {
-    CheckState checkState = getCheckState(repositoryName, patchSetId);
-    if (!states.contains(checkState)) {
-      return Optional.empty();
+  private Predicate<Check> parseQuery(String query) throws BadRequestException {
+    try {
+      return checkQueryBuilder.parse(query.trim());
+    } catch (QueryParseException e) {
+      throw new BadRequestException(e.getMessage());
     }
-    return Optional.of(
-        createPendingChecksInfo(repositoryName, patchSetId, checkerUuid, checkState));
   }
 
-  private CheckState getCheckState(Project.NameKey project, PatchSet.Id patchSetId)
-      throws OrmException, IOException {
-    Optional<Check> check =
-        checks.getCheck(
-            CheckKey.create(project, patchSetId, checkerUuid), GetCheckOptions.defaults());
+  private static Predicate<Check> validateQuery(Predicate<Check> predicate)
+      throws BadRequestException {
+    if (countCheckerPredicates(predicate) != 1)
+      throw new BadRequestException(
+          String.format(
+              "query must contain exactly 1 '%s' operator", CheckQueryBuilder.FIELD_CHECKER));
 
-    // Backfill if check is not present.
-    // Backfilling is only done for relevant checkers (checkers where the repository and the query
-    // matches the change). Since the change was found by executing the query of the checker we know
-    // that the checker is relevant for this patch set and hence backfilling should be done.
-    return check.map(Check::state).orElse(CheckState.NOT_STARTED);
+    // the root predicate must either be an AndPredicate ....
+    if (predicate instanceof AndPredicate) {
+      // if the root predicate is an AndPredicate, any of its direct children must be a
+      // CheckerPredicate, the other child predicates can be anything (including any combination of
+      // AndPredicate, OrPredicate and NotPredicate).
+      if (!predicate.getChildren().stream().anyMatch(CheckerPredicate.class::isInstance)) {
+        throw new BadRequestException(
+            String.format(
+                "query must be '%s:<checker-uuid>' or '%s:<checker-uuid> AND <other-operators>'",
+                CheckQueryBuilder.FIELD_CHECKER, CheckQueryBuilder.FIELD_CHECKER));
+      }
+      // ... or a CheckerPredicate
+    } else if (!(predicate instanceof CheckerPredicate)) {
+      throw new BadRequestException(
+          String.format(
+              "query must be '%s:<checker-uuid>' or '%s:<checker-uuid> AND <other-operators>'",
+              CheckQueryBuilder.FIELD_CHECKER, CheckQueryBuilder.FIELD_CHECKER));
+    }
+    return predicate;
+  }
+
+  private static boolean hasStatePredicate(Predicate<Check> predicate) {
+    if (predicate instanceof CheckStatePredicate) {
+      return true;
+    }
+    if (predicate.getChildCount() == 0) {
+      return false;
+    }
+    return predicate.getChildren().stream().anyMatch(ListPendingChecks::hasStatePredicate);
+  }
+
+  /**
+   * Counts the number of {@link CheckerPredicate}s in the given predicate.
+   *
+   * <p>This method doesn't validate that the checker predicates appear in any particular location.
+   *
+   * @param predicate the predicate in which the checker predicates should be counted
+   * @return the number of checker predicates in the given predicate
+   */
+  private static int countCheckerPredicates(Predicate<Check> predicate) {
+    if (predicate instanceof CheckerPredicate) {
+      return 1;
+    }
+    if (predicate.getChildCount() == 0) {
+      return 0;
+    }
+    return predicate.getChildren().stream()
+        .mapToInt(ListPendingChecks::countCheckerPredicates)
+        .sum();
+  }
+
+  private static CheckerUuid getCheckerUuidFromQuery(Predicate<Check> predicate) {
+    // the query validation (see #validateQuery(Predicate<Check>)) ensures that there is exactly 1
+    // CheckerPredicate and that it is on the first or second level of the predicate tree.
+
+    if (predicate instanceof CheckerPredicate) {
+      return ((CheckerPredicate) predicate).getCheckerUuid();
+    }
+
+    checkState(predicate.getChildCount() > 0, "no checker predicate found: %s", predicate);
+    Optional<CheckerPredicate> checkerPredicate =
+        predicate.getChildren().stream()
+            .filter(CheckerPredicate.class::isInstance)
+            .map(p -> (CheckerPredicate) p)
+            .findAny();
+    return checkerPredicate
+        .map(CheckerPredicate::getCheckerUuid)
+        .orElseThrow(
+            () ->
+                new IllegalStateException(
+                    String.format("no checker predicate found: %s", predicate)));
   }
 
   private static PendingChecksInfo createPendingChecksInfo(
-      Project.NameKey repositoryName,
-      PatchSet.Id patchSetId,
-      CheckerUuid checkerUuid,
-      CheckState checkState) {
+      Project.NameKey repositoryName, PatchSet patchSet, CheckerUuid checkerUuid, Check check) {
     PendingChecksInfo pendingChecksInfo = new PendingChecksInfo();
 
     pendingChecksInfo.patchSet = new CheckablePatchSetInfo();
     pendingChecksInfo.patchSet.repository = repositoryName.get();
-    pendingChecksInfo.patchSet.changeNumber = patchSetId.getParentKey().get();
-    pendingChecksInfo.patchSet.patchSetId = patchSetId.get();
+    pendingChecksInfo.patchSet.changeNumber = patchSet.getId().getParentKey().get();
+    pendingChecksInfo.patchSet.patchSetId = patchSet.getPatchSetId();
 
     pendingChecksInfo.pendingChecks =
-        ImmutableMap.of(checkerUuid.get(), new PendingCheckInfo(checkState));
+        ImmutableMap.of(checkerUuid.get(), new PendingCheckInfo(check.state()));
 
     return pendingChecksInfo;
   }
diff --git a/java/com/google/gerrit/plugins/checks/api/PendingChecks.java b/java/com/google/gerrit/plugins/checks/api/PendingChecks.java
index 361472b..458f4ec 100644
--- a/java/com/google/gerrit/plugins/checks/api/PendingChecks.java
+++ b/java/com/google/gerrit/plugins/checks/api/PendingChecks.java
@@ -14,41 +14,35 @@
 
 package com.google.gerrit.plugins.checks.api;
 
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.plugins.checks.CheckerUuid;
 import java.util.List;
 
 public interface PendingChecks {
-  /**
-   * Lists the pending checks for the specified checker.
-   *
-   * @param checkerUuid the UUID of the checker for which pending checks should be listed
-   * @param checkStates the states that should be considered as pending, if not specified {@link
-   *     CheckState#NOT_STARTED} is assumed.
-   * @return the pending checks
-   */
-  List<PendingChecksInfo> list(CheckerUuid checkerUuid, CheckState... checkStates)
-      throws RestApiException;
+  QueryRequest query();
 
-  /**
-   * Lists the pending checks for the specified checker.
-   *
-   * @param checkerUuidString the UUID of the checker for which pending checks should be listed
-   * @param checkStates the states that should be considered as pending, if not specified {@link
-   *     CheckState#NOT_STARTED} is assumed.
-   * @return the pending checks
-   */
-  default List<PendingChecksInfo> list(String checkerUuidString, CheckState... checkStates)
-      throws RestApiException {
-    return list(
-        CheckerUuid.tryParse(checkerUuidString)
-            .orElseThrow(
-                () ->
-                    new BadRequestException(
-                        String.format("invalid checker UUID: %s", checkerUuidString))),
-        checkStates);
+  default QueryRequest query(String query) {
+    return query().withQuery(query);
+  }
+
+  abstract class QueryRequest {
+    private String query;
+
+    public abstract List<PendingChecksInfo> get() throws RestApiException;
+
+    public QueryRequest withQuery(String query) {
+      this.query = query;
+      return this;
+    }
+
+    public String getQuery() {
+      return query;
+    }
+
+    @Override
+    public String toString() {
+      return query;
+    }
   }
 
   /**
@@ -57,7 +51,7 @@
    */
   class NotImplemented implements PendingChecks {
     @Override
-    public List<PendingChecksInfo> list(CheckerUuid checkerUuid, CheckState... checkStates) {
+    public QueryRequest query() {
       throw new NotImplementedException();
     }
   }
diff --git a/java/com/google/gerrit/plugins/checks/api/PendingChecksImpl.java b/java/com/google/gerrit/plugins/checks/api/PendingChecksImpl.java
index ca4cf18..badfeb4 100644
--- a/java/com/google/gerrit/plugins/checks/api/PendingChecksImpl.java
+++ b/java/com/google/gerrit/plugins/checks/api/PendingChecksImpl.java
@@ -18,12 +18,10 @@
 
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.plugins.checks.CheckerUuid;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.List;
-import java.util.stream.Stream;
 
 @Singleton
 public class PendingChecksImpl implements PendingChecks {
@@ -35,15 +33,22 @@
   }
 
   @Override
-  public List<PendingChecksInfo> list(CheckerUuid checkerUuid, CheckState... checkStates)
-      throws RestApiException {
+  public QueryRequest query() {
+    return new QueryRequest() {
+      @Override
+      public List<PendingChecksInfo> get() throws RestApiException {
+        return PendingChecksImpl.this.query(this);
+      }
+    };
+  }
+
+  private List<PendingChecksInfo> query(QueryRequest queryRequest) throws RestApiException {
     try {
       ListPendingChecks listPendingChecks = listPendingChecksProvider.get();
-      listPendingChecks.setChecker(checkerUuid);
-      Stream.of(checkStates).forEach(listPendingChecks::addState);
+      listPendingChecks.setQuery(queryRequest.getQuery());
       return listPendingChecks.apply(TopLevelResource.INSTANCE);
     } catch (Exception e) {
-      throw asRestApiException("Cannot list pending checks", e);
+      throw asRestApiException("Cannot query pending checks", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/plugins/checks/db/CheckBackfiller.java b/java/com/google/gerrit/plugins/checks/db/CheckBackfiller.java
index dfd3892..2b1a26a 100644
--- a/java/com/google/gerrit/plugins/checks/db/CheckBackfiller.java
+++ b/java/com/google/gerrit/plugins/checks/db/CheckBackfiller.java
@@ -16,11 +16,9 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.plugins.checks.Check;
-import com.google.gerrit.plugins.checks.CheckKey;
 import com.google.gerrit.plugins.checks.Checker;
 import com.google.gerrit.plugins.checks.CheckerUuid;
 import com.google.gerrit.plugins.checks.Checkers;
-import com.google.gerrit.plugins.checks.api.CheckState;
 import com.google.gerrit.plugins.checks.api.CheckerStatus;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.AnonymousUser;
@@ -69,7 +67,7 @@
     for (Checker checker : candidates) {
       if (checker.isCheckerRelevant(cd, queryBuilder)) {
         // Add synthetic check at the creation time of the patch set.
-        result.add(newBackfilledCheck(cd, ps, checker));
+        result.add(Check.newBackfilledCheck(cd.project(), ps, checker));
       }
     }
     return result.build();
@@ -95,15 +93,7 @@
         || !checker.get().isCheckerRelevant(cd, newQueryBuilder())) {
       return Optional.empty();
     }
-    return Optional.of(newBackfilledCheck(cd, cd.patchSet(psId), checker.get()));
-  }
-
-  private Check newBackfilledCheck(ChangeData cd, PatchSet ps, Checker checker) {
-    return Check.builder(CheckKey.create(cd.project(), ps.getId(), checker.getUuid()))
-        .setState(CheckState.NOT_STARTED)
-        .setCreated(ps.getCreatedOn())
-        .setUpdated(ps.getCreatedOn())
-        .build();
+    return Optional.of(Check.newBackfilledCheck(cd.project(), cd.patchSet(psId), checker.get()));
   }
 
   private ChangeQueryBuilder newQueryBuilder() {
diff --git a/java/com/google/gerrit/plugins/checks/index/CheckPredicate.java b/java/com/google/gerrit/plugins/checks/index/CheckPredicate.java
new file mode 100644
index 0000000..562df5f
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/index/CheckPredicate.java
@@ -0,0 +1,30 @@
+// 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.index;
+
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.plugins.checks.Check;
+
+public abstract class CheckPredicate extends OperatorPredicate<Check> implements Matchable<Check> {
+  protected CheckPredicate(String name, String value) {
+    super(name, value);
+  }
+
+  @Override
+  public int getCost() {
+    return 0;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/index/CheckQueryBuilder.java b/java/com/google/gerrit/plugins/checks/index/CheckQueryBuilder.java
new file mode 100644
index 0000000..bc512ca
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/index/CheckQueryBuilder.java
@@ -0,0 +1,44 @@
+// 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.index;
+
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.plugins.checks.Check;
+import com.google.inject.Inject;
+
+public class CheckQueryBuilder extends QueryBuilder<Check> {
+  public static final String FIELD_CHECKER = "checker";
+  public static final String FIELD_STATE = "state";
+
+  private static final QueryBuilder.Definition<Check, CheckQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(CheckQueryBuilder.class);
+
+  @Inject
+  CheckQueryBuilder() {
+    super(mydef);
+  }
+
+  @Operator
+  public Predicate<Check> checker(String checkerUuid) throws QueryParseException {
+    return CheckerPredicate.parse(checkerUuid);
+  }
+
+  @Operator
+  public Predicate<Check> state(String state) throws QueryParseException {
+    return CheckStatePredicate.parse(state);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/index/CheckStatePredicate.java b/java/com/google/gerrit/plugins/checks/index/CheckStatePredicate.java
new file mode 100644
index 0000000..69a919c
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/index/CheckStatePredicate.java
@@ -0,0 +1,45 @@
+// 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.index;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.Enums;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.plugins.checks.Check;
+import com.google.gerrit.plugins.checks.api.CheckState;
+import com.google.gwtorm.server.OrmException;
+
+public class CheckStatePredicate extends CheckPredicate {
+  public static CheckStatePredicate parse(String value) throws QueryParseException {
+    return new CheckStatePredicate(
+        Enums.getIfPresent(CheckState.class, value)
+            .toJavaUtil()
+            .orElseThrow(
+                () -> new QueryParseException(String.format("invalid check state: %s", value))));
+  }
+
+  private final CheckState checkState;
+
+  public CheckStatePredicate(CheckState checkState) {
+    super(CheckQueryBuilder.FIELD_STATE, checkState.name());
+    this.checkState = requireNonNull(checkState, "checkState");
+  }
+
+  @Override
+  public boolean match(Check check) throws OrmException {
+    return checkState.equals(check.state());
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/index/CheckerPredicate.java b/java/com/google/gerrit/plugins/checks/index/CheckerPredicate.java
new file mode 100644
index 0000000..0050ce9
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/index/CheckerPredicate.java
@@ -0,0 +1,47 @@
+// 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.index;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.plugins.checks.Check;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gwtorm.server.OrmException;
+
+public class CheckerPredicate extends CheckPredicate {
+  public static CheckerPredicate parse(String value) throws QueryParseException {
+    return new CheckerPredicate(
+        CheckerUuid.tryParse(value)
+            .orElseThrow(
+                () -> new QueryParseException(String.format("invalid checker UUID: %s", value))));
+  }
+
+  private final CheckerUuid checkerUuid;
+
+  public CheckerPredicate(CheckerUuid checkerUuid) {
+    super(CheckQueryBuilder.FIELD_CHECKER, checkerUuid.toString());
+    this.checkerUuid = requireNonNull(checkerUuid, "checkerUuid");
+  }
+
+  @Override
+  public boolean match(Check check) throws OrmException {
+    return checkerUuid.equals(check.key().checkerUuid());
+  }
+
+  public CheckerUuid getCheckerUuid() {
+    return checkerUuid;
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/api/ListPendingChecksIT.java b/javatests/com/google/gerrit/plugins/checks/acceptance/api/ListPendingChecksIT.java
index 41ddec3..d10d61c 100644
--- a/javatests/com/google/gerrit/plugins/checks/acceptance/api/ListPendingChecksIT.java
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/api/ListPendingChecksIT.java
@@ -15,17 +15,16 @@
 package com.google.gerrit.plugins.checks.acceptance.api;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
 import static com.google.gerrit.plugins.checks.testing.PendingChecksInfoSubject.assertThat;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.hamcrest.CoreMatchers.instanceOf;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.plugins.checks.CheckKey;
 import com.google.gerrit.plugins.checks.CheckerUuid;
 import com.google.gerrit.plugins.checks.acceptance.AbstractCheckersTest;
@@ -40,7 +39,9 @@
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.List;
+import java.util.StringJoiner;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.junit.After;
 import org.junit.Before;
@@ -65,26 +66,108 @@
   }
 
   @Test
-  public void specifyingCheckerUuidIsRequired() throws Exception {
-    // The extension API doesn't allow to not specify a checker UUID. Call the endpoint over REST to
-    // test this.
-    RestResponse response = adminRestSession.get("/plugins/checks/checks.pending/");
-    response.assertBadRequest();
-    assertThat(response.getEntityContent()).isEqualTo("checker UUID is required");
+  public void specifyingQueryIsRequired() throws Exception {
+    assertInvalidQuery(null, "query is required");
+  }
+
+  @Test
+  public void queryCannotBeEmpty() throws Exception {
+    assertInvalidQuery("", "query is empty");
+  }
+
+  @Test
+  public void queryCannotBeEmptyAfterTrim() throws Exception {
+    assertInvalidQuery(" ", "query is empty");
+  }
+
+  @Test
+  public void specifyingCheckerIsRequired() throws Exception {
+    assertInvalidQuery("state:NOT_STARTED", "query must contain exactly 1 'checker' operator");
   }
 
   @Test
   public void cannotListPendingChecksForInvalidCheckerUuid() throws Exception {
-    exception.expect(BadRequestException.class);
-    exception.expectMessage("invalid checker UUID: " + CheckerTestData.INVALID_UUID);
-    pendingChecksApi.list(CheckerTestData.INVALID_UUID);
+    assertInvalidQuery(
+        "checker:" + CheckerTestData.INVALID_UUID,
+        "invalid checker UUID: " + CheckerTestData.INVALID_UUID);
   }
 
   @Test
-  public void cannotListPendingChecksForNonExistingChecker() throws Exception {
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("checker non:existing not found");
-    pendingChecksApi.list("non:existing");
+  public void cannotSpecifyingMultipleCheckers() throws Exception {
+    CheckerUuid checkerUuid1 = checkerOperations.newChecker().repository(project).create();
+    CheckerUuid checkerUuid2 = checkerOperations.newChecker().repository(project).create();
+
+    String expectedMessage = "query must contain exactly 1 'checker' operator";
+    assertInvalidQuery(
+        String.format("checker:\"%s\" checker:\"%s\"", checkerUuid1, checkerUuid2),
+        expectedMessage);
+    assertInvalidQuery(
+        String.format("checker:\"%s\" OR checker:\"%s\"", checkerUuid1, checkerUuid2),
+        expectedMessage);
+    assertInvalidQuery(
+        String.format(
+            "checker:\"%s\" (state:NOT_STARTED checker:\"%s\")", checkerUuid1, checkerUuid2),
+        expectedMessage);
+  }
+
+  @Test
+  public void canSpecifyCheckersAsRootPredicate() throws Exception {
+    CheckerUuid checkerUuid = checkerOperations.newChecker().repository(project).create();
+    assertThat(queryPendingChecks(String.format("checker:\"%s\"", checkerUuid))).hasSize(1);
+  }
+
+  @Test
+  public void canSpecifyCheckersInAndCondition() throws Exception {
+    CheckerUuid checkerUuid = checkerOperations.newChecker().repository(project).create();
+    assertThat(
+            queryPendingChecks(String.format("checker:\"%s\" AND state:NOT_STARTED", checkerUuid)))
+        .hasSize(1);
+  }
+
+  @Test
+  public void andConditionAtRootCanContainAnyCombinationOfOtherPredicates() throws Exception {
+    CheckerUuid checkerUuid = checkerOperations.newChecker().repository(project).create();
+
+    assertThat(
+            queryPendingChecks(
+                String.format(
+                    "checker:\"%s\" AND (state:NOT_STARTED OR state:RUNNING)", checkerUuid)))
+        .hasSize(1);
+    assertThat(
+            queryPendingChecks(
+                String.format("checker:\"%s\" AND NOT state:NOT_STARTED)", checkerUuid)))
+        .isEmpty();
+    assertThat(
+            queryPendingChecks(
+                String.format(
+                    "checker:\"%s\" AND (NOT state:FAILED AND NOT (state:RUNNING OR state:SUCCESSFUL))",
+                    checkerUuid)))
+        .hasSize(1);
+  }
+
+  @Test
+  public void cannotSpecifyCheckersInOrCondition() throws Exception {
+    CheckerUuid checkerUuid = checkerOperations.newChecker().repository(project).create();
+
+    String expectedMessage =
+        "query must be 'checker:<checker-uuid>' or 'checker:<checker-uuid> AND <other-operators>'";
+    assertInvalidQuery(
+        String.format("checker:\"%s\" OR state:NOT_STARTED", checkerUuid), expectedMessage);
+    assertInvalidQuery(
+        String.format("state:NOT_STARTED OR checker:\"%s\"", checkerUuid), expectedMessage);
+  }
+
+  @Test
+  public void cannotSpecifyCheckersInNotCondition() throws Exception {
+    CheckerUuid checkerUuid = checkerOperations.newChecker().repository(project).create();
+    assertInvalidQuery(
+        String.format("NOT checker:\"%s\"", checkerUuid),
+        "query must be 'checker:<checker-uuid>' or 'checker:<checker-uuid> AND <other-operators>'");
+  }
+
+  @Test
+  public void listPendingChecksForNonExistingChecker() throws Exception {
+    assertThat(pendingChecksApi.query("checker:\"non:existing\"").get()).isEmpty();
   }
 
   @Test
@@ -111,7 +194,7 @@
         .setState(CheckState.NOT_STARTED)
         .upsert();
 
-    List<PendingChecksInfo> pendingChecksList = pendingChecksApi.list(checkerUuid);
+    List<PendingChecksInfo> pendingChecksList = queryPendingChecks(checkerUuid);
     assertThat(pendingChecksList).hasSize(1);
     PendingChecksInfo pendingChecks = Iterables.getOnlyElement(pendingChecksList);
     assertThat(pendingChecks).hasRepository(project);
@@ -145,8 +228,7 @@
         .setState(CheckState.FAILED)
         .upsert();
 
-    List<PendingChecksInfo> pendingChecksList =
-        pendingChecksApi.list(checkerUuid, CheckState.FAILED);
+    List<PendingChecksInfo> pendingChecksList = queryPendingChecks(checkerUuid, CheckState.FAILED);
     assertThat(pendingChecksList).hasSize(1);
     PendingChecksInfo pendingChecks = Iterables.getOnlyElement(pendingChecksList);
     assertThat(pendingChecks).hasRepository(project);
@@ -188,7 +270,7 @@
         .upsert();
 
     List<PendingChecksInfo> pendingChecksList =
-        pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED, CheckState.SCHEDULED);
+        queryPendingChecks(checkerUuid, CheckState.NOT_STARTED, CheckState.SCHEDULED);
     assertThat(pendingChecksList).hasSize(2);
 
     // The sorting of the pendingChecksList matches the sorting in which the matching changes are
@@ -210,9 +292,18 @@
   }
 
   @Test
+  public void listPendingChecksForSpecifiedStateDifferentCases() throws Exception {
+    CheckerUuid checkerUuid = checkerOperations.newChecker().repository(project).create();
+
+    assertThat(queryPendingChecks(buildQueryString(checkerUuid) + " state:NOT_STARTED")).hasSize(1);
+    assertThat(queryPendingChecks(buildQueryString(checkerUuid) + " state:not_started")).hasSize(1);
+    assertThat(queryPendingChecks(buildQueryString(checkerUuid) + " state:NoT_StArTeD")).hasSize(1);
+  }
+
+  @Test
   public void backfillForApplyingChecker() throws Exception {
     CheckerUuid checkerUuid = checkerOperations.newChecker().repository(project).create();
-    List<PendingChecksInfo> pendingChecksList = pendingChecksApi.list(checkerUuid);
+    List<PendingChecksInfo> pendingChecksList = queryPendingChecks(checkerUuid);
     assertThat(pendingChecksList).hasSize(1);
     PendingChecksInfo pendingChecks = Iterables.getOnlyElement(pendingChecksList);
     assertThat(pendingChecks).hasRepository(project);
@@ -225,14 +316,14 @@
   @Test
   public void noBackfillForCheckerThatDoesNotApplyToTheProject() throws Exception {
     CheckerUuid checkerUuid = checkerOperations.newChecker().repository(allProjects).create();
-    assertThat(pendingChecksApi.list(checkerUuid)).isEmpty();
+    assertThat(queryPendingChecks(checkerUuid)).isEmpty();
   }
 
   @Test
   public void noBackfillForCheckerThatDoesNotApplyToTheChange() throws Exception {
     CheckerUuid checkerUuid =
         checkerOperations.newChecker().repository(project).query("message:not-matching").create();
-    assertThat(pendingChecksApi.list(checkerUuid)).isEmpty();
+    assertThat(queryPendingChecks(checkerUuid)).isEmpty();
   }
 
   @Test
@@ -244,11 +335,11 @@
         .upsert();
 
     List<PendingChecksInfo> pendingChecksList =
-        pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+        queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).isNotEmpty();
 
     checkerOperations.checker(checkerUuid).forUpdate().disable().update();
-    pendingChecksList = pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+    pendingChecksList = queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).isEmpty();
   }
 
@@ -270,12 +361,12 @@
         .upsert();
 
     List<PendingChecksInfo> pendingChecksList =
-        pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+        queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).hasSize(2);
 
     gApi.changes().id(patchSetId2.getParentKey().toString()).abandon();
 
-    pendingChecksList = pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+    pendingChecksList = queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).hasSize(1);
   }
 
@@ -297,12 +388,12 @@
         .upsert();
 
     List<PendingChecksInfo> pendingChecksList =
-        pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+        queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).hasSize(2);
 
     gApi.changes().id(patchSetId2.getParentKey().toString()).abandon();
 
-    pendingChecksList = pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+    pendingChecksList = queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).hasSize(2);
   }
 
@@ -312,9 +403,9 @@
     checkerOperations.checker(checkerUuid).forUpdate().forceInvalidConfig().update();
 
     exception.expect(RestApiException.class);
-    exception.expectMessage("Cannot list pending checks");
+    exception.expectMessage("Cannot query pending checks");
     exception.expectCause(instanceOf(ConfigInvalidException.class));
-    pendingChecksApi.list(checkerUuid);
+    queryPendingChecks(checkerUuid);
   }
 
   @Test
@@ -327,9 +418,9 @@
             .create();
 
     exception.expect(RestApiException.class);
-    exception.expectMessage("Cannot list pending checks");
+    exception.expectMessage("Cannot query pending checks");
     exception.expectCause(instanceOf(ConfigInvalidException.class));
-    pendingChecksApi.list(checkerUuid);
+    queryPendingChecks(checkerUuid);
   }
 
   @Test
@@ -342,7 +433,7 @@
 
     requestScopeOperations.setApiUser(user.getId());
     List<PendingChecksInfo> pendingChecksList =
-        pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+        queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).hasSize(1);
     PendingChecksInfo pendingChecks = Iterables.getOnlyElement(pendingChecksList);
     assertThat(pendingChecks).hasRepository(project);
@@ -369,12 +460,12 @@
 
     // Check is returned for admin user.
     List<PendingChecksInfo> pendingChecksList =
-        pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+        queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).isNotEmpty();
 
     // Check is not returned for non-admin user.
     requestScopeOperations.setApiUser(user.getId());
-    pendingChecksList = pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+    pendingChecksList = queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).isEmpty();
   }
 
@@ -391,12 +482,41 @@
 
     // Check is returned for admin user.
     List<PendingChecksInfo> pendingChecksList =
-        pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+        queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).isNotEmpty();
 
     // Check is not returned for non-admin user.
     requestScopeOperations.setApiUser(user.getId());
-    pendingChecksList = pendingChecksApi.list(checkerUuid, CheckState.NOT_STARTED);
+    pendingChecksList = queryPendingChecks(checkerUuid, CheckState.NOT_STARTED);
     assertThat(pendingChecksList).isEmpty();
   }
+
+  private void assertInvalidQuery(String query, String expectedMessage) throws RestApiException {
+    try {
+      pendingChecksApi.query(query).get();
+      assert_().fail("expected BadRequestException");
+    } catch (BadRequestException e) {
+      assertThat(e).hasMessageThat().isEqualTo(expectedMessage);
+    }
+  }
+
+  private List<PendingChecksInfo> queryPendingChecks(String queryString) throws RestApiException {
+    return pendingChecksApi.query(queryString).get();
+  }
+
+  private List<PendingChecksInfo> queryPendingChecks(
+      CheckerUuid checkerUuid, CheckState... checkStates) throws RestApiException {
+    return pendingChecksApi.query(buildQueryString(checkerUuid, checkStates)).get();
+  }
+
+  private String buildQueryString(CheckerUuid checkerUuid, CheckState... checkStates) {
+    StringBuilder queryString = new StringBuilder();
+    queryString.append(String.format("checker:%s", checkerUuid));
+
+    StringJoiner stateJoiner = new StringJoiner(" OR state:", " (state:", ")");
+    Stream.of(checkStates).map(CheckState::name).forEach(stateJoiner::add);
+    queryString.append(stateJoiner.toString());
+
+    return queryString.toString();
+  }
 }
diff --git a/src/main/resources/Documentation/rest-api-pending-checks.md b/src/main/resources/Documentation/rest-api-pending-checks.md
index 37d7bc5..91ee6b2 100644
--- a/src/main/resources/Documentation/rest-api-pending-checks.md
+++ b/src/main/resources/Documentation/rest-api-pending-checks.md
@@ -8,7 +8,7 @@
 
 ## <a id="pending-checks-endpoints"> Pending Checks Endpoints
 
-### <a id="get-checker"> List Pending Checks
+### <a id="list-pending-checks"> List Pending Checks
 _'GET /plugins/@PLUGIN@/checks.pending/'_
 
 Lists pending checks for a checker.
@@ -16,18 +16,24 @@
 Checks are pending if they are in a non-final state and the external
 checker system intends to post further updates on them.
 
-By default this REST endpoint only returns checks that are in state
-`NOT_STARTED` but callers may specify the states that they are
-interested in (see [state](#state-param) request parameter).
-
 Request parameters:
 
-* <a id="checker-param"> `checker`: the UUID of the checker for which
-  pending checks should be listed (required)
-* <a id="state-param"> `state`: state that should be considered as
-  pending (optional, by default the state `NOT_STARTED` is assumed,
-  this option may be specified multiple times to request checks
-  matching any of several states)
+* <a id="query-param"> `query`: Query that should be used to match
+  pending checks (required). The query operators which can be used in
+  this query are described in the [Query Operators](#query-operators)
+  section below.
+
+Limitations for the input query:
+
+* Must contain exactly one [checker](#checker-operator) operator.
+* The `checker` operator must either be the only operator in the query
+  ('checker:<CHECKER_UUID>'), or appear at the root level as part of an
+  `AND` expression (e.g. 'checker:<CHECKER_UUID> state:<state>',
+  'checker:<CHECKER_UUID> (state:<state> OR state:<state>)').
+
+If no [state](#state-operator) is used in the input query this REST
+endpoint by default only returns checks that are in state
+`NOT_STARTED`.
 
 This REST endpoint only returns pending checks for current patch sets.
 
@@ -38,7 +44,7 @@
 #### Request by checker
 
 ```
-  GET /plugins/@PLUGIN@/checks.pending/?checker=test:my-checker&state=NOT_STARTED&state=SCHEDULED HTTP/1.0
+  GET /plugins/@PLUGIN@/checks.pending/?query=checker=test:my-checker+(state:NOT_STARTED+OR+state:SCHEDULED) HTTP/1.0
 ```
 
 As response a list of [PendingChecksInfo](#pending-checks-info)
@@ -106,3 +112,13 @@
 | `patch_set`      | The patch set for checks are pending as [CheckablePatchSetInfo](#checkable-patch-set-info) entity.
 | `pending_checks` | The checks that are pending for the patch set as [checker UUID](./rest-api-checkers.md#checker-id) to [PendingCheckInfo](#pending-check-info) entity.
 
+## <a id="query-operators"> Query Operators
+
+The following query operators are supported in the input
+[query](#query-param) for the
+[List Pending Checks](#list-pending-checks) REST endpoint.
+
+* <a id="checker-operator"></a> `checker:'CHECKER_UUID'`:
+  Matches checks of the checker with the UUID 'CHECKER_UUID'.
+* <a id="state-operator"></a> `state:'STATE'`:
+  Matches checks with the state 'STATE'.