// Copyright (C) 2009 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.server.query.change; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Lists; import com.google.gerrit.common.Nullable; import com.google.gerrit.common.data.GroupReference; import com.google.gerrit.reviewdb.client.Account; import com.google.gerrit.reviewdb.client.AccountGroup; import com.google.gerrit.reviewdb.client.Branch; import com.google.gerrit.reviewdb.client.Change; import com.google.gerrit.reviewdb.server.ReviewDb; import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.account.AccountResolver; import com.google.gerrit.server.account.CapabilityControl; import com.google.gerrit.server.account.GroupBackend; import com.google.gerrit.server.account.GroupBackends; import com.google.gerrit.server.config.AllProjectsName; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.config.TrackingFooters; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.git.strategy.SubmitStrategyFactory; import com.google.gerrit.server.index.ChangeField; import com.google.gerrit.server.index.ChangeIndex; import com.google.gerrit.server.index.IndexCollection; import com.google.gerrit.server.index.Schema; import com.google.gerrit.server.patch.PatchListCache; import com.google.gerrit.server.project.ChangeControl; import com.google.gerrit.server.project.ListChildProjects; import com.google.gerrit.server.project.ProjectCache; import com.google.gerrit.server.query.IntPredicate; import com.google.gerrit.server.query.Predicate; import com.google.gerrit.server.query.QueryBuilder; import com.google.gerrit.server.query.QueryParseException; import com.google.gwtorm.server.OrmException; import com.google.inject.Inject; import com.google.inject.Provider; import com.google.inject.assistedinject.Assisted; import org.eclipse.jgit.lib.AbbreviatedObjectId; import org.eclipse.jgit.lib.Config; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; /** * Parses a query string meant to be applied to change objects. */ public class ChangeQueryBuilder extends QueryBuilder<ChangeData> { private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$"); private static final Pattern PAT_CHANGE_ID = Pattern.compile("^[iI][0-9a-f]{4,}.*$"); private static final Pattern DEF_CHANGE = Pattern.compile("^([1-9][0-9]*|[iI][0-9a-f]{4,}.*)$"); // NOTE: As new search operations are added, please keep the // SearchSuggestOracle up to date. public static final String FIELD_ADDED = "added"; public static final String FIELD_AFTER = "after"; public static final String FIELD_AGE = "age"; public static final String FIELD_BEFORE = "before"; public static final String FIELD_BRANCH = "branch"; public static final String FIELD_CHANGE = "change"; public static final String FIELD_COMMENT = "comment"; public static final String FIELD_COMMIT = "commit"; public static final String FIELD_CONFLICTS = "conflicts"; public static final String FIELD_DELETED = "deleted"; public static final String FIELD_DELTA = "delta"; public static final String FIELD_DRAFTBY = "draftby"; public static final String FIELD_FILE = "file"; public static final String FIELD_IS = "is"; public static final String FIELD_HAS = "has"; public static final String FIELD_LABEL = "label"; public static final String FIELD_LIMIT = "limit"; public static final String FIELD_MERGEABLE = "mergeable"; public static final String FIELD_MESSAGE = "message"; public static final String FIELD_OWNER = "owner"; public static final String FIELD_OWNERIN = "ownerin"; public static final String FIELD_PARENTPROJECT = "parentproject"; public static final String FIELD_PATH = "path"; public static final String FIELD_PROJECT = "project"; public static final String FIELD_PROJECTS = "projects"; public static final String FIELD_REF = "ref"; public static final String FIELD_REVIEWER = "reviewer"; public static final String FIELD_REVIEWERIN = "reviewerin"; public static final String FIELD_STARREDBY = "starredby"; public static final String FIELD_STATUS = "status"; public static final String FIELD_TOPIC = "topic"; public static final String FIELD_TR = "tr"; public static final String FIELD_VISIBLETO = "visibleto"; public static final String FIELD_WATCHEDBY = "watchedby"; public static final String ARG_ID_USER = "user"; public static final String ARG_ID_GROUP = "group"; private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> mydef = new QueryBuilder.Definition<ChangeData, ChangeQueryBuilder>( ChangeQueryBuilder.class); @SuppressWarnings("unchecked") public static Integer getLimit(Predicate<ChangeData> p) { IntPredicate<?> ip = (IntPredicate<?>) find(p, IntPredicate.class, FIELD_LIMIT); return ip != null ? ip.intValue() : null; } public static boolean hasNonTrivialSortKeyAfter(Schema<ChangeData> schema, Predicate<ChangeData> p) { SortKeyPredicate after = find(p, SortKeyPredicate.class, "sortkey_after"); return after != null && after.getMaxValue(schema) > 0; } public static boolean hasSortKey(Predicate<ChangeData> p) { return find(p, SortKeyPredicate.class, "sortkey_after") != null || find(p, SortKeyPredicate.class, "sortkey_before") != null; } @VisibleForTesting public static class Arguments { final Provider<ReviewDb> db; final Provider<ChangeQueryRewriter> rewriter; final IdentifiedUser.GenericFactory userFactory; final Provider<CurrentUser> self; final CapabilityControl.Factory capabilityControlFactory; final ChangeControl.GenericFactory changeControlGenericFactory; final ChangeData.Factory changeDataFactory; final AccountResolver accountResolver; final GroupBackend groupBackend; final AllProjectsName allProjectsName; final PatchListCache patchListCache; final GitRepositoryManager repoManager; final ProjectCache projectCache; final Provider<ListChildProjects> listChildProjects; final IndexCollection indexes; final SubmitStrategyFactory submitStrategyFactory; final ConflictsCache conflictsCache; final TrackingFooters trackingFooters; final boolean allowsDrafts; @Inject @VisibleForTesting public Arguments(Provider<ReviewDb> dbProvider, Provider<ChangeQueryRewriter> rewriter, IdentifiedUser.GenericFactory userFactory, Provider<CurrentUser> self, CapabilityControl.Factory capabilityControlFactory, ChangeControl.GenericFactory changeControlGenericFactory, ChangeData.Factory changeDataFactory, AccountResolver accountResolver, GroupBackend groupBackend, AllProjectsName allProjectsName, PatchListCache patchListCache, GitRepositoryManager repoManager, ProjectCache projectCache, Provider<ListChildProjects> listChildProjects, IndexCollection indexes, SubmitStrategyFactory submitStrategyFactory, ConflictsCache conflictsCache, TrackingFooters trackingFooters, @GerritServerConfig Config cfg) { this.db = dbProvider; this.rewriter = rewriter; this.userFactory = userFactory; this.self = self; this.capabilityControlFactory = capabilityControlFactory; this.changeControlGenericFactory = changeControlGenericFactory; this.changeDataFactory = changeDataFactory; this.accountResolver = accountResolver; this.groupBackend = groupBackend; this.allProjectsName = allProjectsName; this.patchListCache = patchListCache; this.repoManager = repoManager; this.projectCache = projectCache; this.listChildProjects = listChildProjects; this.indexes = indexes; this.submitStrategyFactory = submitStrategyFactory; this.conflictsCache = conflictsCache; this.trackingFooters = trackingFooters; this.allowsDrafts = cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true); } } public interface Factory { ChangeQueryBuilder create(CurrentUser user); } private final Arguments args; private final CurrentUser currentUser; @Inject public ChangeQueryBuilder(Arguments args, @Assisted CurrentUser currentUser) { super(mydef); this.args = args; this.currentUser = currentUser; } @VisibleForTesting protected ChangeQueryBuilder( QueryBuilder.Definition<ChangeData, ? extends ChangeQueryBuilder> def, Arguments args, CurrentUser currentUser) { super(def); this.args = args; this.currentUser = currentUser; } @Operator public Predicate<ChangeData> age(String value) { return new AgePredicate(schema(args.indexes), value); } @Operator public Predicate<ChangeData> before(String value) throws QueryParseException { return new BeforePredicate(schema(args.indexes), value); } @Operator public Predicate<ChangeData> until(String value) throws QueryParseException { return before(value); } @Operator public Predicate<ChangeData> after(String value) throws QueryParseException { return new AfterPredicate(schema(args.indexes), value); } @Operator public Predicate<ChangeData> since(String value) throws QueryParseException { return after(value); } @Operator public Predicate<ChangeData> change(String query) { if (PAT_LEGACY_ID.matcher(query).matches()) { return new LegacyChangeIdPredicate(args, Change.Id.parse(query)); } else if (PAT_CHANGE_ID.matcher(query).matches()) { return new ChangeIdPredicate(args, parseChangeId(query)); } throw new IllegalArgumentException(); } @Operator public Predicate<ChangeData> comment(String value) throws QueryParseException { ChangeIndex index = args.indexes.getSearchIndex(); return new CommentPredicate(args, index, value); } @Operator public Predicate<ChangeData> status(String statusName) { if ("open".equals(statusName) || "pending".equals(statusName)) { return status_open(); } else if ("closed".equals(statusName)) { return ChangeStatusPredicate.closed(args.db); } else if ("reviewed".equalsIgnoreCase(statusName)) { return new IsReviewedPredicate(); } else { return new ChangeStatusPredicate(statusName); } } public Predicate<ChangeData> status_open() { return ChangeStatusPredicate.open(args.db); } @Operator public Predicate<ChangeData> has(String value) { if ("star".equalsIgnoreCase(value)) { return new IsStarredByPredicate(args, currentUser); } if ("draft".equalsIgnoreCase(value)) { return new HasDraftByPredicate(args, self()); } throw new IllegalArgumentException(); } @Operator public Predicate<ChangeData> is(String value) throws QueryParseException { if ("starred".equalsIgnoreCase(value)) { return new IsStarredByPredicate(args, currentUser); } if ("watched".equalsIgnoreCase(value)) { return new IsWatchedByPredicate(args, currentUser, false); } if ("visible".equalsIgnoreCase(value)) { return is_visible(); } if ("reviewed".equalsIgnoreCase(value)) { return new IsReviewedPredicate(); } if ("owner".equalsIgnoreCase(value)) { return new OwnerPredicate(self()); } if ("reviewer".equalsIgnoreCase(value)) { return new ReviewerPredicate(self(), args.allowsDrafts); } if ("mergeable".equalsIgnoreCase(value)) { return new IsMergeablePredicate(); } try { return status(value); } catch (IllegalArgumentException e) { // not status: alias? } throw new IllegalArgumentException(); } @Operator public Predicate<ChangeData> commit(String id) { return new CommitPredicate(args, AbbreviatedObjectId.fromString(id)); } @Operator public Predicate<ChangeData> conflicts(String value) throws OrmException, QueryParseException { return new ConflictsPredicate(args, value, parseChange(value)); } @Operator public Predicate<ChangeData> p(String name) { return project(name); } @Operator public Predicate<ChangeData> project(String name) { if (name.startsWith("^")) return new RegexProjectPredicate(name); return new ProjectPredicate(name); } @Operator public Predicate<ChangeData> projects(String name) throws QueryParseException { if (!schema(args.indexes).hasField(ChangeField.PROJECTS)) { throw new QueryParseException("Unsupported operator: " + FIELD_PROJECTS); } return new ProjectPrefixPredicate(name); } @Operator public Predicate<ChangeData> parentproject(String name) { return new ParentProjectPredicate(args.db, args.projectCache, args.listChildProjects, args.self, name); } @Operator public Predicate<ChangeData> branch(String name) { if (name.startsWith("^")) return ref("^" + branchToRef(name.substring(1))); return ref(branchToRef(name)); } private static String branchToRef(String name) { if (!name.startsWith(Branch.R_HEADS)) return Branch.R_HEADS + name; return name; } @Operator public Predicate<ChangeData> topic(String name) { if (name.startsWith("^")) return new RegexTopicPredicate(schema(args.indexes), name); return new TopicPredicate(schema(args.indexes), name); } @Operator public Predicate<ChangeData> ref(String ref) { if (ref.startsWith("^")) return new RegexRefPredicate(ref); return new RefPredicate(ref); } @Operator public Predicate<ChangeData> f(String file) throws QueryParseException { return file(file); } @Operator public Predicate<ChangeData> file(String file) throws QueryParseException { if (file.startsWith("^")) { return new RegexPathPredicate(FIELD_FILE, file); } else { return EqualsFilePredicate.create(args, file); } } @Operator public Predicate<ChangeData> path(String path) throws QueryParseException { if (path.startsWith("^")) { return new RegexPathPredicate(FIELD_PATH, path); } else { return new EqualsPathPredicate(FIELD_PATH, path); } } @Operator public Predicate<ChangeData> label(String name) throws QueryParseException, OrmException { Set<Account.Id> accounts = null; AccountGroup.UUID group = null; // Parse for: // label:CodeReview=1,user=jsmith or // label:CodeReview=1,jsmith or // label:CodeReview=1,group=android_approvers or // label:CodeReview=1,android_approvers // user/groups without a label will first attempt to match user String[] splitReviewer = name.split(",", 2); name = splitReviewer[0]; // remove all but the vote piece, e.g.'CodeReview=1' if (splitReviewer.length == 2) { // process the user/group piece PredicateArgs lblArgs = new PredicateArgs(splitReviewer[1]); for (Map.Entry<String, String> pair : lblArgs.keyValue.entrySet()) { if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) { accounts = parseAccount(pair.getValue()); } else if (pair.getKey().equalsIgnoreCase(ARG_ID_GROUP)) { group = parseGroup(pair.getValue()).getUUID(); } else { throw new QueryParseException( "Invalid argument identifier '" + pair.getKey() + "'"); } } for (String value : lblArgs.positional) { if (accounts != null || group != null) { throw new QueryParseException("more than one user/group specified (" + value + ")"); } try { accounts = parseAccount(value); } catch (QueryParseException qpex) { // If it doesn't match an account, see if it matches a group // (accounts get precedence) try { group = parseGroup(value).getUUID(); } catch (QueryParseException e) { throw error("Neither user nor group " + value + " found"); } } } } return new LabelPredicate(args.projectCache, args.changeControlGenericFactory, args.userFactory, args.db, name, accounts, group); } @Operator public Predicate<ChangeData> message(String text) throws QueryParseException { ChangeIndex index = args.indexes.getSearchIndex(); return new MessagePredicate(args, index, text); } @Operator public Predicate<ChangeData> starredby(String who) throws QueryParseException, OrmException { if ("self".equals(who)) { return new IsStarredByPredicate(args, currentUser); } Set<Account.Id> m = parseAccount(who); List<IsStarredByPredicate> p = Lists.newArrayListWithCapacity(m.size()); for (Account.Id id : m) { p.add(new IsStarredByPredicate(args, args.userFactory.create(args.db, id))); } return Predicate.or(p); } @Operator public Predicate<ChangeData> watchedby(String who) throws QueryParseException, OrmException { Set<Account.Id> m = parseAccount(who); List<IsWatchedByPredicate> p = Lists.newArrayListWithCapacity(m.size()); for (Account.Id id : m) { if (currentUser.isIdentifiedUser() && id.equals(((IdentifiedUser) currentUser).getAccountId())) { p.add(new IsWatchedByPredicate(args, currentUser, false)); } else { p.add(new IsWatchedByPredicate(args, args.userFactory.create(args.db, id), true)); } } return Predicate.or(p); } @Operator public Predicate<ChangeData> draftby(String who) throws QueryParseException, OrmException { Set<Account.Id> m = parseAccount(who); List<HasDraftByPredicate> p = Lists.newArrayListWithCapacity(m.size()); for (Account.Id id : m) { p.add(new HasDraftByPredicate(args, id)); } return Predicate.or(p); } @Operator public Predicate<ChangeData> visibleto(String who) throws QueryParseException, OrmException { if ("self".equals(who)) { return is_visible(); } Set<Account.Id> m = args.accountResolver.findAll(who); if (!m.isEmpty()) { List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size()); for (Account.Id id : m) { return visibleto(args.userFactory.create(args.db, id)); } return Predicate.or(p); } // If its not an account, maybe its a group? // Collection<GroupReference> suggestions = args.groupBackend.suggest(who, null); if (!suggestions.isEmpty()) { HashSet<AccountGroup.UUID> ids = new HashSet<>(); for (GroupReference ref : suggestions) { ids.add(ref.getUUID()); } return visibleto(new SingleGroupUser(args.capabilityControlFactory, ids)); } throw error("No user or group matches \"" + who + "\"."); } public Predicate<ChangeData> visibleto(CurrentUser user) { return new IsVisibleToPredicate(args.db, // args.changeControlGenericFactory, // user); } public Predicate<ChangeData> is_visible() { return visibleto(currentUser); } @Operator public Predicate<ChangeData> o(String who) throws QueryParseException, OrmException { return owner(who); } @Operator public Predicate<ChangeData> owner(String who) throws QueryParseException, OrmException { Set<Account.Id> m = parseAccount(who); List<OwnerPredicate> p = Lists.newArrayListWithCapacity(m.size()); for (Account.Id id : m) { p.add(new OwnerPredicate(id)); } return Predicate.or(p); } @Operator public Predicate<ChangeData> ownerin(String group) throws QueryParseException { GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group); if (g == null) { throw error("Group " + group + " not found"); } return new OwnerinPredicate(args.db, args.userFactory, g.getUUID()); } @Operator public Predicate<ChangeData> r(String who) throws QueryParseException, OrmException { return reviewer(who); } @Operator public Predicate<ChangeData> reviewer(String who) throws QueryParseException, OrmException { Set<Account.Id> m = parseAccount(who); List<ReviewerPredicate> p = Lists.newArrayListWithCapacity(m.size()); for (Account.Id id : m) { p.add(new ReviewerPredicate(id, args.allowsDrafts)); } return Predicate.or(p); } @Operator public Predicate<ChangeData> reviewerin(String group) throws QueryParseException { GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group); if (g == null) { throw error("Group " + group + " not found"); } return new ReviewerinPredicate(args.db, args.userFactory, g.getUUID()); } @Operator public Predicate<ChangeData> tr(String trackingId) { return new TrackingIdPredicate(args.trackingFooters, trackingId); } @Operator public Predicate<ChangeData> bug(String trackingId) { return tr(trackingId); } @Operator public Predicate<ChangeData> limit(String limit) { return limit(Integer.parseInt(limit)); } static class LimitPredicate extends IntPredicate<ChangeData> { LimitPredicate(int limit) { super(FIELD_LIMIT, limit); } @Override public boolean match(ChangeData object) { return true; } @Override public int getCost() { return 0; } } public Predicate<ChangeData> limit(int limit) { return new LimitPredicate(limit); } boolean supportsSortKey() { return SortKeyPredicate.hasSortKeyField(schema(args.indexes)); } @Operator public Predicate<ChangeData> sortkey_after(String sortKey) { return new SortKeyPredicate.After(schema(args.indexes), args.db, sortKey); } @Operator public Predicate<ChangeData> sortkey_before(String sortKey) { return new SortKeyPredicate.Before(schema(args.indexes), args.db, sortKey); } @Operator public Predicate<ChangeData> resume_sortkey(String sortKey) { return sortkey_before(sortKey); } @Operator public Predicate<ChangeData> added(String value) throws QueryParseException { return new AddedPredicate(value); } @Operator public Predicate<ChangeData> deleted(String value) throws QueryParseException { return new DeletedPredicate(value); } @Operator public Predicate<ChangeData> size(String value) throws QueryParseException { return delta(value); } @Operator public Predicate<ChangeData> delta(String value) throws QueryParseException { return new DeltaPredicate(value); } @Override protected Predicate<ChangeData> defaultField(String query) { if (query.startsWith("refs/")) { return ref(query); } else if (DEF_CHANGE.matcher(query).matches()) { return change(query); } List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(9); try { predicates.add(commit(query)); } catch (IllegalArgumentException e) { // Skip. } try { predicates.add(owner(query)); } catch (OrmException | QueryParseException e) { // Skip. } try { predicates.add(reviewer(query)); } catch (OrmException | QueryParseException e) { // Skip. } try { predicates.add(file(query)); } catch (QueryParseException e) { // Skip. } try { predicates.add(label(query)); } catch (OrmException | QueryParseException e) { // Skip. } try { predicates.add(message(query)); } catch (QueryParseException e) { // Skip. } try { predicates.add(comment(query)); } catch (QueryParseException e) { // Skip. } try { predicates.add(projects(query)); } catch (QueryParseException e) { // Skip. } predicates.add(ref(query)); predicates.add(branch(query)); predicates.add(topic(query)); return Predicate.or(predicates); } private Set<Account.Id> parseAccount(String who) throws QueryParseException, OrmException { if ("self".equals(who)) { return Collections.singleton(self()); } Set<Account.Id> matches = args.accountResolver.findAll(who); if (matches.isEmpty()) { throw error("User " + who + " not found"); } return matches; } private GroupReference parseGroup(String group) throws QueryParseException { GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group); if (g == null) { throw error("Group " + group + " not found"); } return g; } private List<Change> parseChange(String value) throws OrmException, QueryParseException { if (PAT_LEGACY_ID.matcher(value).matches()) { return Collections.singletonList(args.db.get().changes() .get(Change.Id.parse(value))); } else if (PAT_CHANGE_ID.matcher(value).matches()) { Change.Key a = new Change.Key(parseChangeId(value)); List<Change> changes = args.db.get().changes().byKeyRange(a, a.max()).toList(); if (changes.isEmpty()) { throw error("Change " + value + " not found"); } return changes; } throw error("Change " + value + " not found"); } private static String parseChangeId(String value) { if (value.charAt(0) == 'i') { value = "I" + value.substring(1); } return value; } private Account.Id self() { if (currentUser.isIdentifiedUser()) { return ((IdentifiedUser) currentUser).getAccountId(); } throw new IllegalArgumentException(); } private static Schema<ChangeData> schema(@Nullable IndexCollection indexes) { ChangeIndex index = indexes != null ? indexes.getSearchIndex() : null; return index != null ? index.getSchema() : null; } }