diff options
Diffstat (limited to 'java/com/google/gerrit/server/index/change/ChangeField.java')
-rw-r--r-- | java/com/google/gerrit/server/index/change/ChangeField.java | 879 |
1 files changed, 879 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java new file mode 100644 index 0000000000..da807e79ba --- /dev/null +++ b/java/com/google/gerrit/server/index/change/ChangeField.java @@ -0,0 +1,879 @@ +// Copyright (C) 2013 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.index.change; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.google.gerrit.index.FieldDef.exact; +import static com.google.gerrit.index.FieldDef.fullText; +import static com.google.gerrit.index.FieldDef.intRange; +import static com.google.gerrit.index.FieldDef.integer; +import static com.google.gerrit.index.FieldDef.prefix; +import static com.google.gerrit.index.FieldDef.storedOnly; +import static com.google.gerrit.index.FieldDef.timestamp; +import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC; +import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.CHANGE_CODEC; +import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.PATCH_SET_CODEC; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toSet; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Enums; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableTable; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Table; +import com.google.common.flogger.FluentLogger; +import com.google.common.primitives.Longs; +import com.google.gerrit.common.data.SubmitRecord; +import com.google.gerrit.common.data.SubmitRequirement; +import com.google.gerrit.index.FieldDef; +import com.google.gerrit.index.RefState; +import com.google.gerrit.index.SchemaUtil; +import com.google.gerrit.mail.Address; +import com.google.gerrit.reviewdb.client.Account; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.reviewdb.client.ChangeMessage; +import com.google.gerrit.reviewdb.client.PatchSet; +import com.google.gerrit.reviewdb.client.PatchSetApproval; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.reviewdb.client.RefNames; +import com.google.gerrit.server.OutputFormat; +import com.google.gerrit.server.ReviewerByEmailSet; +import com.google.gerrit.server.ReviewerSet; +import com.google.gerrit.server.StarredChangesUtil; +import com.google.gerrit.server.config.AllUsersName; +import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern; +import com.google.gerrit.server.notedb.ChangeNotes; +import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage; +import com.google.gerrit.server.notedb.ReviewerStateInternal; +import com.google.gerrit.server.notedb.RobotCommentNotes; +import com.google.gerrit.server.project.SubmitRuleOptions; +import com.google.gerrit.server.query.change.ChangeData; +import com.google.gerrit.server.query.change.ChangeQueryBuilder; +import com.google.gerrit.server.query.change.ChangeStatusPredicate; +import com.google.gson.Gson; +import com.google.gwtorm.protobuf.ProtobufCodec; +import com.google.gwtorm.server.OrmException; +import com.google.protobuf.CodedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Stream; +import org.eclipse.jgit.lib.PersonIdent; + +/** + * Fields indexed on change documents. + * + * <p>Each field corresponds to both a field name supported by {@link ChangeQueryBuilder} for + * querying that field, and a method on {@link ChangeData} used for populating the corresponding + * document fields in the secondary index. + * + * <p>Field names are all lowercase alphanumeric plus underscore; index implementations may create + * unambiguous derived field names containing other characters. + */ +public class ChangeField { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + public static final int NO_ASSIGNEE = -1; + + private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson(); + + /** Legacy change ID. */ + public static final FieldDef<ChangeData, Integer> LEGACY_ID = + integer("legacy_id").stored().build(cd -> cd.getId().get()); + + /** Newer style Change-Id key. */ + public static final FieldDef<ChangeData, String> ID = + prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get())); + + /** Change status string, in the same format as {@code status:}. */ + public static final FieldDef<ChangeData, String> STATUS = + exact(ChangeQueryBuilder.FIELD_STATUS) + .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus()))); + + /** Project containing the change. */ + public static final FieldDef<ChangeData, String> PROJECT = + exact(ChangeQueryBuilder.FIELD_PROJECT) + .stored() + .build(changeGetter(c -> c.getProject().get())); + + /** Project containing the change, as a prefix field. */ + public static final FieldDef<ChangeData, String> PROJECTS = + prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get())); + + /** Reference (aka branch) the change will submit onto. */ + public static final FieldDef<ChangeData, String> REF = + exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().get())); + + /** Topic, a short annotation on the branch. */ + public static final FieldDef<ChangeData, String> EXACT_TOPIC = + exact("topic4").build(ChangeField::getTopic); + + /** Topic, a short annotation on the branch. */ + public static final FieldDef<ChangeData, String> FUZZY_TOPIC = + fullText("topic5").build(ChangeField::getTopic); + + /** Submission id assigned by MergeOp. */ + public static final FieldDef<ChangeData, String> SUBMISSIONID = + exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId)); + + /** Last update time since January 1, 1970. */ + public static final FieldDef<ChangeData, Timestamp> UPDATED = + timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn)); + + /** List of full file paths modified in the current patch set. */ + public static final FieldDef<ChangeData, Iterable<String>> PATH = + // Named for backwards compatibility. + exact(ChangeQueryBuilder.FIELD_FILE) + .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of())); + + public static Set<String> getFileParts(ChangeData cd) throws OrmException { + List<String> paths; + try { + paths = cd.currentFilePaths(); + } catch (IOException e) { + throw new OrmException(e); + } + + Splitter s = Splitter.on('/').omitEmptyStrings(); + Set<String> r = new HashSet<>(); + for (String path : paths) { + for (String part : s.split(path)) { + r.add(part); + } + } + return r; + } + + /** Hashtags tied to a change */ + public static final FieldDef<ChangeData, Iterable<String>> HASHTAG = + exact(ChangeQueryBuilder.FIELD_HASHTAG) + .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet())); + + /** Hashtags with original case. */ + public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE = + storedOnly("_hashtag") + .buildRepeatable( + cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet())); + + /** Components of each file path modified in the current patch set. */ + public static final FieldDef<ChangeData, Iterable<String>> FILE_PART = + exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts); + + /** Owner/creator of the change. */ + public static final FieldDef<ChangeData, Integer> OWNER = + integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get())); + + /** The user assigned to the change. */ + public static final FieldDef<ChangeData, Integer> ASSIGNEE = + integer(ChangeQueryBuilder.FIELD_ASSIGNEE) + .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE)); + + /** Reviewer(s) associated with the change. */ + public static final FieldDef<ChangeData, Iterable<String>> REVIEWER = + exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers())); + + /** Reviewer(s) associated with the change that do not have a gerrit account. */ + public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL = + exact("reviewer_by_email") + .stored() + .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail())); + + /** Reviewer(s) modified during change's current WIP phase. */ + public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER = + exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER) + .stored() + .buildRepeatable(cd -> getReviewerFieldValues(cd.pendingReviewers())); + + /** Reviewer(s) by email modified during change's current WIP phase. */ + public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL = + exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL) + .stored() + .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail())); + + /** References a change that this change reverts. */ + public static final FieldDef<ChangeData, Integer> REVERT_OF = + integer(ChangeQueryBuilder.FIELD_REVERTOF) + .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null); + + @VisibleForTesting + static List<String> getReviewerFieldValues(ReviewerSet reviewers) { + List<String> r = new ArrayList<>(reviewers.asTable().size() * 2); + for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> c : + reviewers.asTable().cellSet()) { + String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey()); + r.add(v); + r.add(v + ',' + c.getValue().getTime()); + } + return r; + } + + public static String getReviewerFieldValue(ReviewerStateInternal state, Account.Id id) { + return state.toString() + ',' + id; + } + + @VisibleForTesting + static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) { + List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2); + for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c : + reviewersByEmail.asTable().cellSet()) { + String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey()); + r.add(v); + if (c.getColumnKey().getName() != null) { + // Add another entry without the name to provide search functionality on the email + Address emailOnly = new Address(c.getColumnKey().getEmail()); + r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly)); + } + r.add(v + ',' + c.getValue().getTime()); + } + return r; + } + + public static String getReviewerByEmailFieldValue(ReviewerStateInternal state, Address adr) { + return state.toString() + ',' + adr; + } + + public static ReviewerSet parseReviewerFieldValues(Change.Id changeId, Iterable<String> values) { + ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b = + ImmutableTable.builder(); + for (String v : values) { + + int i = v.indexOf(','); + if (i < 0) { + logger.atWarning().log( + "Invalid value for reviewer field from change %s: %s", changeId.get(), v); + continue; + } + + int i2 = v.lastIndexOf(','); + if (i2 == i) { + // Don't log a warning here. + // For each reviewer we store 2 values in the reviewer field, one value with the format + // "<reviewer-type>,<account-id>" and one value with the format + // "<reviewer-type>,<account-id>,<timestamp>" (see #getReviewerFieldValues(ReviewerSet)). + // For parsing we are only interested in the "<reviewer-type>,<account-id>,<timestamp>" + // value and the "<reviewer-type>,<account-id>" value is ignored here. + continue; + } + + com.google.common.base.Optional<ReviewerStateInternal> reviewerState = + Enums.getIfPresent(ReviewerStateInternal.class, v.substring(0, i)); + if (!reviewerState.isPresent()) { + logger.atWarning().log( + "Failed to parse reviewer state of reviewer field from change %s: %s", + changeId.get(), v); + continue; + } + + Optional<Account.Id> accountId = Account.Id.tryParse(v.substring(i + 1, i2)); + if (!accountId.isPresent()) { + logger.atWarning().log( + "Failed to parse account ID of reviewer field from change %s: %s", changeId.get(), v); + continue; + } + + Long l = Longs.tryParse(v.substring(i2 + 1, v.length())); + if (l == null) { + logger.atWarning().log( + "Failed to parse timestamp of reviewer field from change %s: %s", changeId.get(), v); + continue; + } + Timestamp timestamp = new Timestamp(l); + + b.put(reviewerState.get(), accountId.get(), timestamp); + } + return ReviewerSet.fromTable(b.build()); + } + + public static ReviewerByEmailSet parseReviewerByEmailFieldValues( + Change.Id changeId, Iterable<String> values) { + ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder(); + for (String v : values) { + int i = v.indexOf(','); + if (i < 0) { + logger.atWarning().log( + "Invalid value for reviewer by email field from change %s: %s", changeId.get(), v); + continue; + } + + int i2 = v.lastIndexOf(','); + if (i2 == i) { + // Don't log a warning here. + // For each reviewer we store 2 values in the reviewer field, one value with the format + // "<reviewer-type>,<email>" and one value with the format + // "<reviewer-type>,<email>,<timestamp>" (see + // #getReviewerByEmailFieldValues(ReviewerByEmailSet)). + // For parsing we are only interested in the "<reviewer-type>,<email>,<timestamp>" value + // and the "<reviewer-type>,<email>" value is ignored here. + continue; + } + + com.google.common.base.Optional<ReviewerStateInternal> reviewerState = + Enums.getIfPresent(ReviewerStateInternal.class, v.substring(0, i)); + if (!reviewerState.isPresent()) { + logger.atWarning().log( + "Failed to parse reviewer state of reviewer by email field from change %s: %s", + changeId.get(), v); + continue; + } + + Address address = Address.tryParse(v.substring(i + 1, i2)); + if (address == null) { + logger.atWarning().log( + "Failed to parse address of reviewer by email field from change %s: %s", + changeId.get(), v); + continue; + } + + Long l = Longs.tryParse(v.substring(i2 + 1, v.length())); + if (l == null) { + logger.atWarning().log( + "Failed to parse timestamp of reviewer by email field from change %s: %s", + changeId.get(), v); + continue; + } + Timestamp timestamp = new Timestamp(l); + + b.put(reviewerState.get(), address, timestamp); + } + return ReviewerByEmailSet.fromTable(b.build()); + } + + /** Commit ID of any patch set on the change, using prefix match. */ + public static final FieldDef<ChangeData, Iterable<String>> COMMIT = + prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions); + + /** Commit ID of any patch set on the change, using exact match. */ + public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT = + exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions); + + private static Set<String> getRevisions(ChangeData cd) throws OrmException { + Set<String> revisions = new HashSet<>(); + for (PatchSet ps : cd.patchSets()) { + if (ps.getRevision() != null) { + revisions.add(ps.getRevision().get()); + } + } + return revisions; + } + + /** Tracking id extracted from a footer. */ + public static final FieldDef<ChangeData, Iterable<String>> TR = + exact(ChangeQueryBuilder.FIELD_TR) + .buildRepeatable(cd -> ImmutableSet.copyOf(cd.trackingFooters().values())); + + /** List of labels on the current patch set including change owner votes. */ + public static final FieldDef<ChangeData, Iterable<String>> LABEL = + exact("label2").buildRepeatable(cd -> getLabels(cd, true)); + + private static Iterable<String> getLabels(ChangeData cd, boolean owners) throws OrmException { + Set<String> allApprovals = new HashSet<>(); + Set<String> distinctApprovals = new HashSet<>(); + for (PatchSetApproval a : cd.currentApprovals()) { + if (a.getValue() != 0 && !a.isLegacySubmit()) { + allApprovals.add(formatLabel(a.getLabel(), a.getValue(), a.getAccountId())); + if (owners && cd.change().getOwner().equals(a.getAccountId())) { + allApprovals.add( + formatLabel(a.getLabel(), a.getValue(), ChangeQueryBuilder.OWNER_ACCOUNT_ID)); + } + distinctApprovals.add(formatLabel(a.getLabel(), a.getValue())); + } + } + allApprovals.addAll(distinctApprovals); + return allApprovals; + } + + public static Set<String> getAuthorParts(ChangeData cd) throws OrmException, IOException { + return SchemaUtil.getPersonParts(cd.getAuthor()); + } + + public static Set<String> getAuthorNameAndEmail(ChangeData cd) throws OrmException, IOException { + return getNameAndEmail(cd.getAuthor()); + } + + public static Set<String> getCommitterParts(ChangeData cd) throws OrmException, IOException { + return SchemaUtil.getPersonParts(cd.getCommitter()); + } + + public static Set<String> getCommitterNameAndEmail(ChangeData cd) + throws OrmException, IOException { + return getNameAndEmail(cd.getCommitter()); + } + + private static Set<String> getNameAndEmail(PersonIdent person) { + if (person == null) { + return ImmutableSet.of(); + } + + String name = person.getName().toLowerCase(Locale.US); + String email = person.getEmailAddress().toLowerCase(Locale.US); + + StringBuilder nameEmailBuilder = new StringBuilder(); + PersonIdent.appendSanitized(nameEmailBuilder, name); + nameEmailBuilder.append(" <"); + PersonIdent.appendSanitized(nameEmailBuilder, email); + nameEmailBuilder.append('>'); + + return ImmutableSet.of(name, email, nameEmailBuilder.toString()); + } + + /** + * The exact email address, or any part of the author name or email address, in the current patch + * set. + */ + public static final FieldDef<ChangeData, Iterable<String>> AUTHOR = + fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts); + + /** The exact name, email address and NameEmail of the author. */ + public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR = + exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR) + .buildRepeatable(ChangeField::getAuthorNameAndEmail); + + /** + * The exact email address, or any part of the committer name or email address, in the current + * patch set. + */ + public static final FieldDef<ChangeData, Iterable<String>> COMMITTER = + fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts); + + /** The exact name, email address, and NameEmail of the committer. */ + public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER = + exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER) + .buildRepeatable(ChangeField::getCommitterNameAndEmail); + + /** Serialized change object, used for pre-populating results. */ + public static final FieldDef<ChangeData, byte[]> CHANGE = + storedOnly("_change").build(changeGetter(CHANGE_CODEC::encodeToByteArray)); + + /** Serialized approvals for the current patch set, used for pre-populating results. */ + public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL = + storedOnly("_approval") + .buildRepeatable(cd -> toProtos(APPROVAL_CODEC, cd.currentApprovals())); + + public static String formatLabel(String label, int value) { + return formatLabel(label, value, null); + } + + public static String formatLabel(String label, int value, Account.Id accountId) { + return label.toLowerCase() + + (value >= 0 ? "+" : "") + + value + + (accountId != null ? "," + formatAccount(accountId) : ""); + } + + private static String formatAccount(Account.Id accountId) { + if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) { + return ChangeQueryBuilder.ARG_ID_OWNER; + } + return Integer.toString(accountId.get()); + } + + /** Commit message of the current patch set. */ + public static final FieldDef<ChangeData, String> COMMIT_MESSAGE = + fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage); + + /** Summary or inline comment. */ + public static final FieldDef<ChangeData, Iterable<String>> COMMENT = + fullText(ChangeQueryBuilder.FIELD_COMMENT) + .buildRepeatable( + cd -> + Stream.concat( + cd.publishedComments().stream().map(c -> c.message), + cd.messages().stream().map(ChangeMessage::getMessage)) + .collect(toSet())); + + /** Number of unresolved comments of the change. */ + public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT = + intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT) + .build(ChangeData::unresolvedCommentCount); + + /** Whether the change is mergeable. */ + public static final FieldDef<ChangeData, String> MERGEABLE = + exact(ChangeQueryBuilder.FIELD_MERGEABLE) + .stored() + .build( + cd -> { + Boolean m = cd.isMergeable(); + if (m == null) { + return null; + } + return m ? "1" : "0"; + }); + + /** The number of inserted lines in this change. */ + public static final FieldDef<ChangeData, Integer> ADDED = + intRange(ChangeQueryBuilder.FIELD_ADDED) + .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null); + + /** The number of deleted lines in this change. */ + public static final FieldDef<ChangeData, Integer> DELETED = + intRange(ChangeQueryBuilder.FIELD_DELETED) + .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null); + + /** The total number of modified lines in this change. */ + public static final FieldDef<ChangeData, Integer> DELTA = + intRange(ChangeQueryBuilder.FIELD_DELTA) + .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null)); + + /** Determines if this change is private. */ + public static final FieldDef<ChangeData, String> PRIVATE = + exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0"); + + /** Determines if this change is work in progress. */ + public static final FieldDef<ChangeData, String> WIP = + exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0"); + + /** Determines if this change has started review. */ + public static final FieldDef<ChangeData, String> STARTED = + exact(ChangeQueryBuilder.FIELD_STARTED) + .build(cd -> cd.change().hasReviewStarted() ? "1" : "0"); + + /** Users who have commented on this change. */ + public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY = + integer(ChangeQueryBuilder.FIELD_COMMENTBY) + .buildRepeatable( + cd -> + Stream.concat( + cd.messages().stream().map(ChangeMessage::getAuthor), + cd.publishedComments().stream().map(c -> c.author.getId())) + .filter(Objects::nonNull) + .map(Account.Id::get) + .collect(toSet())); + + /** Star labels on this change in the format: <account-id>:<label> */ + public static final FieldDef<ChangeData, Iterable<String>> STAR = + exact(ChangeQueryBuilder.FIELD_STAR) + .stored() + .buildRepeatable( + cd -> + Iterables.transform( + cd.stars().entries(), + e -> + StarredChangesUtil.StarField.create(e.getKey(), e.getValue()) + .toString())); + + /** Users that have starred the change with any label. */ + public static final FieldDef<ChangeData, Iterable<Integer>> STARBY = + integer(ChangeQueryBuilder.FIELD_STARBY) + .buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get)); + + /** Opaque group identifiers for this change's patch sets. */ + public static final FieldDef<ChangeData, Iterable<String>> GROUP = + exact(ChangeQueryBuilder.FIELD_GROUP) + .buildRepeatable( + cd -> + cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet())); + + /** Serialized patch set object, used for pre-populating results. */ + public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET = + storedOnly("_patch_set").buildRepeatable(cd -> toProtos(PATCH_SET_CODEC, cd.patchSets())); + + /** Users who have edits on this change. */ + public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY = + integer(ChangeQueryBuilder.FIELD_EDITBY) + .buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet())); + + /** Users who have draft comments on this change. */ + public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY = + integer(ChangeQueryBuilder.FIELD_DRAFTBY) + .buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet())); + + public static final Integer NOT_REVIEWED = -1; + + /** + * Users the change was reviewed by since the last author update. + * + * <p>A change is considered reviewed by a user if the latest update by that user is newer than + * the latest update by the change author. Both top-level change messages and new patch sets are + * considered to be updates. + * + * <p>If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is + * emitted. + */ + public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY = + integer(ChangeQueryBuilder.FIELD_REVIEWEDBY) + .stored() + .buildRepeatable( + cd -> { + Set<Account.Id> reviewedBy = cd.reviewedBy(); + if (reviewedBy.isEmpty()) { + return ImmutableSet.of(NOT_REVIEWED); + } + return reviewedBy.stream().map(Account.Id::get).collect(toList()); + }); + + public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT = + SubmitRuleOptions.builder().allowClosed(true).build(); + + public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT = + SubmitRuleOptions.builder().build(); + + /** + * JSON type for storing SubmitRecords. + * + * <p>Stored fields need to use a stable format over a long period; this type insulates the index + * from implementation changes in SubmitRecord itself. + */ + public static class StoredSubmitRecord { + static class StoredLabel { + String label; + SubmitRecord.Label.Status status; + Integer appliedBy; + } + + static class StoredRequirement { + String fallbackText; + String type; + Map<String, String> data; + } + + SubmitRecord.Status status; + List<StoredLabel> labels; + List<StoredRequirement> requirements; + String errorMessage; + + public StoredSubmitRecord(SubmitRecord rec) { + this.status = rec.status; + this.errorMessage = rec.errorMessage; + if (rec.labels != null) { + this.labels = new ArrayList<>(rec.labels.size()); + for (SubmitRecord.Label label : rec.labels) { + StoredLabel sl = new StoredLabel(); + sl.label = label.label; + sl.status = label.status; + sl.appliedBy = label.appliedBy != null ? label.appliedBy.get() : null; + this.labels.add(sl); + } + } + if (rec.requirements != null) { + this.requirements = new ArrayList<>(rec.requirements.size()); + for (SubmitRequirement requirement : rec.requirements) { + StoredRequirement sr = new StoredRequirement(); + sr.type = requirement.type(); + sr.fallbackText = requirement.fallbackText(); + sr.data = requirement.data(); + this.requirements.add(sr); + } + } + } + + public SubmitRecord toSubmitRecord() { + SubmitRecord rec = new SubmitRecord(); + rec.status = status; + rec.errorMessage = errorMessage; + if (labels != null) { + rec.labels = new ArrayList<>(labels.size()); + for (StoredLabel label : labels) { + SubmitRecord.Label srl = new SubmitRecord.Label(); + srl.label = label.label; + srl.status = label.status; + srl.appliedBy = label.appliedBy != null ? new Account.Id(label.appliedBy) : null; + rec.labels.add(srl); + } + } + if (requirements != null) { + rec.requirements = new ArrayList<>(requirements.size()); + for (StoredRequirement req : requirements) { + SubmitRequirement sr = + SubmitRequirement.builder() + .setType(req.type) + .setFallbackText(req.fallbackText) + .setData(req.data) + .build(); + rec.requirements.add(sr); + } + } + return rec; + } + } + + public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD = + exact("submit_record").buildRepeatable(ChangeField::formatSubmitRecordValues); + + public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT = + storedOnly("full_submit_record_strict") + .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT)); + + public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT = + storedOnly("full_submit_record_lenient") + .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT)); + + public static void parseSubmitRecords( + Collection<String> values, SubmitRuleOptions opts, ChangeData out) { + List<SubmitRecord> records = parseSubmitRecords(values); + if (records.isEmpty()) { + // Assume no values means the field is not in the index; + // SubmitRuleEvaluator ensures the list is non-empty. + return; + } + out.setSubmitRecords(opts, records); + } + + @VisibleForTesting + static List<SubmitRecord> parseSubmitRecords(Collection<String> values) { + return values.stream() + .map(v -> GSON.fromJson(v, StoredSubmitRecord.class).toSubmitRecord()) + .collect(toList()); + } + + @VisibleForTesting + static List<byte[]> storedSubmitRecords(List<SubmitRecord> records) { + return Lists.transform(records, r -> GSON.toJson(new StoredSubmitRecord(r)).getBytes(UTF_8)); + } + + private static Iterable<byte[]> storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts) { + return storedSubmitRecords(cd.submitRecords(opts)); + } + + public static List<String> formatSubmitRecordValues(ChangeData cd) throws OrmException { + return formatSubmitRecordValues( + cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner()); + } + + @VisibleForTesting + static List<String> formatSubmitRecordValues(List<SubmitRecord> records, Account.Id changeOwner) { + List<String> result = new ArrayList<>(); + for (SubmitRecord rec : records) { + result.add(rec.status.name()); + if (rec.labels == null) { + continue; + } + for (SubmitRecord.Label label : rec.labels) { + String sl = label.status.toString() + ',' + label.label.toLowerCase(); + result.add(sl); + String slc = sl + ','; + if (label.appliedBy != null) { + result.add(slc + label.appliedBy.get()); + if (label.appliedBy.equals(changeOwner)) { + result.add(slc + ChangeQueryBuilder.OWNER_ACCOUNT_ID.get()); + } + } + } + } + return result; + } + + /** + * All values of all refs that were used in the course of indexing this document. + * + * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}. + */ + public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE = + storedOnly("ref_state") + .buildRepeatable( + cd -> { + List<byte[]> result = new ArrayList<>(); + Project.NameKey project = cd.change().getProject(); + + cd.editRefs() + .values() + .forEach(r -> result.add(RefState.of(r).toByteArray(project))); + cd.starRefs() + .values() + .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(allUsers(cd)))); + + if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) { + ChangeNotes notes = cd.notes(); + result.add( + RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project)); + notes.getRobotComments(); // Force loading robot comments. + RobotCommentNotes robotNotes = notes.getRobotCommentNotes(); + result.add( + RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()) + .toByteArray(project)); + cd.draftRefs() + .values() + .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd)))); + } + + return result; + }); + + /** + * All ref wildcard patterns that were used in the course of indexing this document. + * + * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link + * RefStatePattern} for the pattern format. + */ + public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN = + storedOnly("ref_state_pattern") + .buildRepeatable( + cd -> { + Change.Id id = cd.getId(); + Project.NameKey project = cd.change().getProject(); + List<byte[]> result = new ArrayList<>(3); + result.add( + RefStatePattern.create( + RefNames.REFS_USERS + "*/" + RefNames.EDIT_PREFIX + id + "/*") + .toByteArray(project)); + result.add( + RefStatePattern.create(RefNames.refsStarredChangesPrefix(id) + "*") + .toByteArray(allUsers(cd))); + if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) { + result.add( + RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*") + .toByteArray(allUsers(cd))); + } + return result; + }); + + private static String getTopic(ChangeData cd) throws OrmException { + Change c = cd.change(); + if (c == null) { + return null; + } + return firstNonNull(c.getTopic(), ""); + } + + private static <T> List<byte[]> toProtos(ProtobufCodec<T> codec, Collection<T> objs) + throws OrmException { + List<byte[]> result = Lists.newArrayListWithCapacity(objs.size()); + ByteArrayOutputStream out = new ByteArrayOutputStream(256); + try { + for (T obj : objs) { + out.reset(); + CodedOutputStream cos = CodedOutputStream.newInstance(out); + codec.encode(obj, cos); + cos.flush(); + result.add(out.toByteArray()); + } + } catch (IOException e) { + throw new OrmException(e); + } + return result; + } + + private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) { + return in -> in.change() != null ? func.apply(in.change()) : null; + } + + private static AllUsersName allUsers(ChangeData cd) { + return cd.getAllUsersNameForIndexing(); + } +} |