summaryrefslogtreecommitdiffstats
path: root/java/com/google/gerrit/server/schema/GroupBundle.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/google/gerrit/server/schema/GroupBundle.java')
-rw-r--r--java/com/google/gerrit/server/schema/GroupBundle.java768
1 files changed, 768 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/schema/GroupBundle.java b/java/com/google/gerrit/server/schema/GroupBundle.java
new file mode 100644
index 0000000000..4b8bdcc032
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/GroupBundle.java
@@ -0,0 +1,768 @@
+// Copyright (C) 2017 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.schema;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.checkColumns;
+import static java.util.Comparator.naturalOrder;
+import static java.util.Comparator.nullsLast;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multimaps;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.AuditLogReader;
+import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * A bundle of all entities rooted at a single {@link AccountGroup} entity.
+ *
+ * <p>Used primarily during the migration process. Most callers should prefer {@link InternalGroup}
+ * instead.
+ */
+@AutoValue
+abstract class GroupBundle {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ static {
+ // Initialization-time checks that the column set hasn't changed since the
+ // last time this file was updated.
+ checkColumns(AccountGroup.NameKey.class, 1);
+ checkColumns(AccountGroup.UUID.class, 1);
+ checkColumns(AccountGroup.Id.class, 1);
+ checkColumns(AccountGroup.class, 1, 2, 4, 7, 9, 10, 11);
+
+ checkColumns(AccountGroupById.Key.class, 1, 2);
+ checkColumns(AccountGroupById.class, 1);
+
+ checkColumns(AccountGroupByIdAud.Key.class, 1, 2, 3);
+ checkColumns(AccountGroupByIdAud.class, 1, 2, 3, 4);
+
+ checkColumns(AccountGroupMember.Key.class, 1, 2);
+ checkColumns(AccountGroupMember.class, 1);
+
+ checkColumns(AccountGroupMemberAudit.Key.class, 1, 2, 3);
+ checkColumns(AccountGroupMemberAudit.class, 1, 2, 3, 4);
+ }
+
+ public enum Source {
+ REVIEW_DB("ReviewDb"),
+ NOTE_DB("NoteDb");
+
+ private final String name;
+
+ private Source(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+ }
+
+ @Singleton
+ public static class Factory {
+ private final AuditLogReader auditLogReader;
+
+ @Inject
+ Factory(AuditLogReader auditLogReader) {
+ this.auditLogReader = auditLogReader;
+ }
+
+ public GroupBundle fromNoteDb(
+ Project.NameKey projectName, Repository repo, AccountGroup.UUID uuid)
+ throws ConfigInvalidException, IOException {
+ GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repo, uuid);
+ InternalGroup internalGroup = groupConfig.getLoadedGroup().get();
+ AccountGroup.Id groupId = internalGroup.getId();
+
+ AccountGroup accountGroup =
+ new AccountGroup(
+ internalGroup.getNameKey(),
+ internalGroup.getId(),
+ internalGroup.getGroupUUID(),
+ internalGroup.getCreatedOn());
+ accountGroup.setDescription(internalGroup.getDescription());
+ accountGroup.setOwnerGroupUUID(internalGroup.getOwnerGroupUUID());
+ accountGroup.setVisibleToAll(internalGroup.isVisibleToAll());
+
+ return create(
+ Source.NOTE_DB,
+ accountGroup,
+ internalGroup.getMembers().stream()
+ .map(
+ accountId ->
+ new AccountGroupMember(new AccountGroupMember.Key(accountId, groupId)))
+ .collect(toImmutableSet()),
+ auditLogReader.getMembersAudit(repo, uuid),
+ internalGroup.getSubgroups().stream()
+ .map(
+ subgroupUuid ->
+ new AccountGroupById(new AccountGroupById.Key(groupId, subgroupUuid)))
+ .collect(toImmutableSet()),
+ auditLogReader.getSubgroupsAudit(repo, uuid));
+ }
+
+ public static GroupBundle fromReviewDb(ReviewDb db, AccountGroup.UUID groupUuid)
+ throws OrmException {
+ JdbcSchema jdbcSchema = ReviewDbWrapper.unwrapJbdcSchema(db);
+ AccountGroup group = readAccountGroupFromReviewDb(jdbcSchema, groupUuid);
+ AccountGroup.Id groupId = group.getId();
+
+ return create(
+ Source.REVIEW_DB,
+ group,
+ readAccountGroupMembersFromReviewDb(jdbcSchema, groupId),
+ readAccountGroupMemberAuditsFromReviewDb(jdbcSchema, groupId),
+ readAccountGroupSubgroupsFromReviewDb(jdbcSchema, groupId),
+ readAccountGroupSubgroupAuditsFromReviewDb(jdbcSchema, groupId));
+ }
+
+ private static AccountGroup readAccountGroupFromReviewDb(
+ JdbcSchema jdbcSchema, AccountGroup.UUID groupUuid) throws OrmException {
+ try (Statement stmt = jdbcSchema.getConnection().createStatement();
+ ResultSet rs =
+ stmt.executeQuery(
+ "SELECT group_id,"
+ + " name,"
+ + " created_on,"
+ + " description,"
+ + " owner_group_uuid,"
+ + " visible_to_all"
+ + " FROM account_groups"
+ + " WHERE group_uuid = '"
+ + groupUuid.get()
+ + "'")) {
+ if (!rs.next()) {
+ throw new OrmException(String.format("Group %s not found", groupUuid));
+ }
+
+ AccountGroup.Id groupId = new AccountGroup.Id(rs.getInt(1));
+ AccountGroup.NameKey groupName = new AccountGroup.NameKey(rs.getString(2));
+ Timestamp createdOn = rs.getTimestamp(3);
+ String description = rs.getString(4);
+ AccountGroup.UUID ownerGroupUuid = new AccountGroup.UUID(rs.getString(5));
+ boolean visibleToAll = "Y".equals(rs.getString(6));
+
+ AccountGroup group = new AccountGroup(groupName, groupId, groupUuid, createdOn);
+ group.setDescription(description);
+ group.setOwnerGroupUUID(ownerGroupUuid);
+ group.setVisibleToAll(visibleToAll);
+
+ if (rs.next()) {
+ throw new OrmException(String.format("Group UUID %s is ambiguous", groupUuid));
+ }
+
+ return group;
+ } catch (SQLException e) {
+ throw new OrmException(
+ String.format("Failed to read account group %s from ReviewDb", groupUuid.get()), e);
+ }
+ }
+
+ private static List<AccountGroupMember> readAccountGroupMembersFromReviewDb(
+ JdbcSchema jdbcSchema, AccountGroup.Id groupId) throws OrmException {
+ try (Statement stmt = jdbcSchema.getConnection().createStatement();
+ ResultSet rs =
+ stmt.executeQuery(
+ "SELECT account_id"
+ + " FROM account_group_members"
+ + " WHERE group_id = '"
+ + groupId.get()
+ + "'")) {
+ List<AccountGroupMember> members = new ArrayList<>();
+ while (rs.next()) {
+ Account.Id accountId = new Account.Id(rs.getInt(1));
+ members.add(new AccountGroupMember(new AccountGroupMember.Key(accountId, groupId)));
+ }
+ return members;
+ } catch (SQLException e) {
+ throw new OrmException(
+ String.format(
+ "Failed to read members of account group %s from ReviewDb", groupId.get()),
+ e);
+ }
+ }
+
+ private static List<AccountGroupMemberAudit> readAccountGroupMemberAuditsFromReviewDb(
+ JdbcSchema jdbcSchema, AccountGroup.Id groupId) throws OrmException {
+ try (Statement stmt = jdbcSchema.getConnection().createStatement();
+ ResultSet rs =
+ stmt.executeQuery(
+ "SELECT account_id, added_by, added_on, removed_by, removed_on"
+ + " FROM account_group_members_audit"
+ + " WHERE group_id = '"
+ + groupId.get()
+ + "'")) {
+ List<AccountGroupMemberAudit> audits = new ArrayList<>();
+ while (rs.next()) {
+ Account.Id accountId = new Account.Id(rs.getInt(1));
+
+ Account.Id addedBy = new Account.Id(rs.getInt(2));
+ Timestamp addedOn = rs.getTimestamp(3);
+
+ Timestamp removedOn = rs.getTimestamp(5);
+ Account.Id removedBy = removedOn != null ? new Account.Id(rs.getInt(4)) : null;
+
+ AccountGroupMemberAudit.Key key =
+ new AccountGroupMemberAudit.Key(accountId, groupId, addedOn);
+ AccountGroupMemberAudit audit = new AccountGroupMemberAudit(key, addedBy);
+ audit.removed(removedBy, removedOn);
+ audits.add(audit);
+ }
+ return audits;
+ } catch (SQLException e) {
+ throw new OrmException(
+ String.format(
+ "Failed to read member audits of account group %s from ReviewDb", groupId.get()),
+ e);
+ }
+ }
+
+ private static List<AccountGroupById> readAccountGroupSubgroupsFromReviewDb(
+ JdbcSchema jdbcSchema, AccountGroup.Id groupId) throws OrmException {
+ try (Statement stmt = jdbcSchema.getConnection().createStatement();
+ ResultSet rs =
+ stmt.executeQuery(
+ "SELECT include_uuid"
+ + " FROM account_group_by_id"
+ + " WHERE group_id = '"
+ + groupId.get()
+ + "'")) {
+ List<AccountGroupById> subgroups = new ArrayList<>();
+ while (rs.next()) {
+ AccountGroup.UUID includedGroupUuid = new AccountGroup.UUID(rs.getString(1));
+ subgroups.add(new AccountGroupById(new AccountGroupById.Key(groupId, includedGroupUuid)));
+ }
+ return subgroups;
+ } catch (SQLException e) {
+ throw new OrmException(
+ String.format(
+ "Failed to read subgroups of account group %s from ReviewDb", groupId.get()),
+ e);
+ }
+ }
+
+ private static List<AccountGroupByIdAud> readAccountGroupSubgroupAuditsFromReviewDb(
+ JdbcSchema jdbcSchema, AccountGroup.Id groupId) throws OrmException {
+ try (Statement stmt = jdbcSchema.getConnection().createStatement();
+ ResultSet rs =
+ stmt.executeQuery(
+ "SELECT include_uuid, added_by, added_on, removed_by, removed_on"
+ + " FROM account_group_by_id_aud"
+ + " WHERE group_id = '"
+ + groupId.get()
+ + "'")) {
+ List<AccountGroupByIdAud> audits = new ArrayList<>();
+ while (rs.next()) {
+ AccountGroup.UUID includedGroupUuid = new AccountGroup.UUID(rs.getString(1));
+
+ Account.Id addedBy = new Account.Id(rs.getInt(2));
+ Timestamp addedOn = rs.getTimestamp(3);
+
+ Timestamp removedOn = rs.getTimestamp(5);
+ Account.Id removedBy = removedOn != null ? new Account.Id(rs.getInt(4)) : null;
+
+ AccountGroupByIdAud.Key key =
+ new AccountGroupByIdAud.Key(groupId, includedGroupUuid, addedOn);
+ AccountGroupByIdAud audit = new AccountGroupByIdAud(key, addedBy);
+ audit.removed(removedBy, removedOn);
+ audits.add(audit);
+ }
+ return audits;
+ } catch (SQLException e) {
+ throw new OrmException(
+ String.format(
+ "Failed to read subgroup audits of account group %s from ReviewDb", groupId.get()),
+ e);
+ }
+ }
+ }
+
+ private static final Comparator<AccountGroupMember> ACCOUNT_GROUP_MEMBER_COMPARATOR =
+ Comparator.comparingInt((AccountGroupMember m) -> m.getAccountGroupId().get())
+ .thenComparingInt(m -> m.getAccountId().get());
+
+ private static final Comparator<AccountGroupMemberAudit> ACCOUNT_GROUP_MEMBER_AUDIT_COMPARATOR =
+ Comparator.comparingInt((AccountGroupMemberAudit a) -> a.getGroupId().get())
+ .thenComparing(AccountGroupMemberAudit::getAddedOn)
+ .thenComparingInt(a -> a.getAddedBy().get())
+ .thenComparingInt(a -> a.getMemberId().get())
+ .thenComparing(
+ a -> a.getRemovedBy() != null ? a.getRemovedBy().get() : null,
+ nullsLast(naturalOrder()))
+ .thenComparing(AccountGroupMemberAudit::getRemovedOn, nullsLast(naturalOrder()));
+
+ private static final Comparator<AccountGroupById> ACCOUNT_GROUP_BY_ID_COMPARATOR =
+ Comparator.comparingInt((AccountGroupById m) -> m.getGroupId().get())
+ .thenComparing(AccountGroupById::getIncludeUUID);
+
+ private static final Comparator<AccountGroupByIdAud> ACCOUNT_GROUP_BY_ID_AUD_COMPARATOR =
+ Comparator.comparingInt((AccountGroupByIdAud a) -> a.getGroupId().get())
+ .thenComparing(AccountGroupByIdAud::getAddedOn)
+ .thenComparingInt(a -> a.getAddedBy().get())
+ .thenComparing(AccountGroupByIdAud::getIncludeUUID)
+ .thenComparing(
+ a -> a.getRemovedBy() != null ? a.getRemovedBy().get() : null,
+ nullsLast(naturalOrder()))
+ .thenComparing(AccountGroupByIdAud::getRemovedOn, nullsLast(naturalOrder()));
+
+ private static final Comparator<AuditEntry> AUDIT_ENTRY_COMPARATOR =
+ Comparator.comparing(AuditEntry::getTimestamp)
+ .thenComparing(AuditEntry::getAction, Comparator.comparingInt(Action::getOrder));
+
+ public static GroupBundle create(
+ Source source,
+ AccountGroup group,
+ Iterable<AccountGroupMember> members,
+ Iterable<AccountGroupMemberAudit> memberAudit,
+ Iterable<AccountGroupById> byId,
+ Iterable<AccountGroupByIdAud> byIdAudit) {
+ AccountGroup.UUID uuid = group.getGroupUUID();
+ return new AutoValue_GroupBundle.Builder()
+ .source(source)
+ .group(group)
+ .members(
+ logIfNotUnique(
+ source, uuid, members, ACCOUNT_GROUP_MEMBER_COMPARATOR, AccountGroupMember.class))
+ .memberAudit(
+ logIfNotUnique(
+ source,
+ uuid,
+ memberAudit,
+ ACCOUNT_GROUP_MEMBER_AUDIT_COMPARATOR,
+ AccountGroupMemberAudit.class))
+ .byId(
+ logIfNotUnique(
+ source, uuid, byId, ACCOUNT_GROUP_BY_ID_COMPARATOR, AccountGroupById.class))
+ .byIdAudit(
+ logIfNotUnique(
+ source,
+ uuid,
+ byIdAudit,
+ ACCOUNT_GROUP_BY_ID_AUD_COMPARATOR,
+ AccountGroupByIdAud.class))
+ .build();
+ }
+
+ private static <T> ImmutableSet<T> logIfNotUnique(
+ Source source,
+ AccountGroup.UUID uuid,
+ Iterable<T> iterable,
+ Comparator<T> comparator,
+ Class<T> clazz) {
+ List<T> list = Streams.stream(iterable).sorted(comparator).collect(toList());
+ ImmutableSet<T> set = ImmutableSet.copyOf(list);
+ if (set.size() != list.size()) {
+ // One way this can happen is that distinct audit entities can compare equal, because
+ // AccountGroup{MemberAudit,ByIdAud}.Key does not include the addedOn timestamp in its
+ // members() list. However, this particular issue only applies to pure adds, since removedOn
+ // *is* included in equality. As a result, if this happens, it means the audit log is already
+ // corrupt, and it's not clear if we can programmatically repair it. For migrating to NoteDb,
+ // we'll try our best to recreate it, but no guarantees it will match the real sequence of
+ // attempted operations, which is in any case lost in the mists of time.
+ logger.atWarning().log(
+ "group %s in %s has duplicate %s entities: %s",
+ uuid, source, clazz.getSimpleName(), iterable);
+ }
+ return set;
+ }
+
+ static Builder builder() {
+ return new AutoValue_GroupBundle.Builder().members().memberAudit().byId().byIdAudit();
+ }
+
+ public static ImmutableList<String> compareWithAudits(
+ GroupBundle reviewDbBundle, GroupBundle noteDbBundle) {
+ return compare(reviewDbBundle, noteDbBundle, true);
+ }
+
+ public static ImmutableList<String> compareWithoutAudits(
+ GroupBundle reviewDbBundle, GroupBundle noteDbBundle) {
+ return compare(reviewDbBundle, noteDbBundle, false);
+ }
+
+ private static ImmutableList<String> compare(
+ GroupBundle reviewDbBundle, GroupBundle noteDbBundle, boolean compareAudits) {
+ // Normalize the ReviewDb bundle to what we expect in NoteDb. This means that values in error
+ // messages will not reflect the actual data in ReviewDb, but it will make it easier for humans
+ // to see the difference.
+ reviewDbBundle = reviewDbBundle.truncateToSecond();
+ AccountGroup reviewDbGroup = new AccountGroup(reviewDbBundle.group());
+ reviewDbGroup.setDescription(Strings.emptyToNull(reviewDbGroup.getDescription()));
+ reviewDbBundle = reviewDbBundle.toBuilder().group(reviewDbGroup).build();
+
+ checkArgument(
+ reviewDbBundle.source() == Source.REVIEW_DB,
+ "first bundle's source must be %s: %s",
+ Source.REVIEW_DB,
+ reviewDbBundle);
+ checkArgument(
+ noteDbBundle.source() == Source.NOTE_DB,
+ "second bundle's source must be %s: %s",
+ Source.NOTE_DB,
+ noteDbBundle);
+
+ ImmutableList.Builder<String> result = ImmutableList.builder();
+ if (!reviewDbBundle.group().equals(noteDbBundle.group())) {
+ result.add(
+ "AccountGroups differ\n"
+ + ("ReviewDb: " + reviewDbBundle.group() + "\n")
+ + ("NoteDb : " + noteDbBundle.group()));
+ }
+ if (!reviewDbBundle.members().equals(noteDbBundle.members())) {
+ result.add(
+ "AccountGroupMembers differ\n"
+ + ("ReviewDb: " + reviewDbBundle.members() + "\n")
+ + ("NoteDb : " + noteDbBundle.members()));
+ }
+ if (compareAudits
+ && !areMemberAuditsConsideredEqual(
+ reviewDbBundle.memberAudit(), noteDbBundle.memberAudit())) {
+ result.add(
+ "AccountGroupMemberAudits differ\n"
+ + ("ReviewDb: " + reviewDbBundle.memberAudit() + "\n")
+ + ("NoteDb : " + noteDbBundle.memberAudit()));
+ }
+ if (!reviewDbBundle.byId().equals(noteDbBundle.byId())) {
+ result.add(
+ "AccountGroupByIds differ\n"
+ + ("ReviewDb: " + reviewDbBundle.byId() + "\n")
+ + ("NoteDb : " + noteDbBundle.byId()));
+ }
+ if (compareAudits
+ && !areByIdAuditsConsideredEqual(reviewDbBundle.byIdAudit(), noteDbBundle.byIdAudit())) {
+ result.add(
+ "AccountGroupByIdAudits differ\n"
+ + ("ReviewDb: " + reviewDbBundle.byIdAudit() + "\n")
+ + ("NoteDb : " + noteDbBundle.byIdAudit()));
+ }
+ return result.build();
+ }
+
+ private static boolean areMemberAuditsConsideredEqual(
+ ImmutableSet<AccountGroupMemberAudit> reviewDbMemberAudits,
+ ImmutableSet<AccountGroupMemberAudit> noteDbMemberAudits) {
+ ListMultimap<String, AuditEntry> reviewDbMemberAuditsByMemberId =
+ toMemberAuditEntriesByMemberId(reviewDbMemberAudits);
+ ListMultimap<String, AuditEntry> noteDbMemberAuditsByMemberId =
+ toMemberAuditEntriesByMemberId(noteDbMemberAudits);
+
+ return areConsideredEqual(reviewDbMemberAuditsByMemberId, noteDbMemberAuditsByMemberId);
+ }
+
+ private static boolean areByIdAuditsConsideredEqual(
+ ImmutableSet<AccountGroupByIdAud> reviewDbByIdAudits,
+ ImmutableSet<AccountGroupByIdAud> noteDbByIdAudits) {
+ ListMultimap<String, AuditEntry> reviewDbByIdAuditsById =
+ toByIdAuditEntriesById(reviewDbByIdAudits);
+ ListMultimap<String, AuditEntry> noteDbByIdAuditsById =
+ toByIdAuditEntriesById(noteDbByIdAudits);
+
+ return areConsideredEqual(reviewDbByIdAuditsById, noteDbByIdAuditsById);
+ }
+
+ private static ListMultimap<String, AuditEntry> toMemberAuditEntriesByMemberId(
+ ImmutableSet<AccountGroupMemberAudit> memberAudits) {
+ return memberAudits.stream()
+ .flatMap(GroupBundle::toAuditEntries)
+ .collect(
+ Multimaps.toMultimap(
+ AuditEntry::getTarget,
+ Function.identity(),
+ MultimapBuilder.hashKeys().arrayListValues()::build));
+ }
+
+ private static Stream<AuditEntry> toAuditEntries(AccountGroupMemberAudit memberAudit) {
+ AuditEntry additionAuditEntry =
+ AuditEntry.create(
+ Action.ADD,
+ memberAudit.getAddedBy(),
+ memberAudit.getMemberId(),
+ memberAudit.getAddedOn());
+ if (memberAudit.isActive()) {
+ return Stream.of(additionAuditEntry);
+ }
+
+ AuditEntry removalAuditEntry =
+ AuditEntry.create(
+ Action.REMOVE,
+ memberAudit.getRemovedBy(),
+ memberAudit.getMemberId(),
+ memberAudit.getRemovedOn());
+ return Stream.of(additionAuditEntry, removalAuditEntry);
+ }
+
+ private static ListMultimap<String, AuditEntry> toByIdAuditEntriesById(
+ ImmutableSet<AccountGroupByIdAud> byIdAudits) {
+ return byIdAudits.stream()
+ .flatMap(GroupBundle::toAuditEntries)
+ .collect(
+ Multimaps.toMultimap(
+ AuditEntry::getTarget,
+ Function.identity(),
+ MultimapBuilder.hashKeys().arrayListValues()::build));
+ }
+
+ private static Stream<AuditEntry> toAuditEntries(AccountGroupByIdAud byIdAudit) {
+ AuditEntry additionAuditEntry =
+ AuditEntry.create(
+ Action.ADD, byIdAudit.getAddedBy(), byIdAudit.getIncludeUUID(), byIdAudit.getAddedOn());
+ if (byIdAudit.isActive()) {
+ return Stream.of(additionAuditEntry);
+ }
+
+ AuditEntry removalAuditEntry =
+ AuditEntry.create(
+ Action.REMOVE,
+ byIdAudit.getRemovedBy(),
+ byIdAudit.getIncludeUUID(),
+ byIdAudit.getRemovedOn());
+ return Stream.of(additionAuditEntry, removalAuditEntry);
+ }
+
+ /**
+ * Determines whether the audit log entries are equal except for redundant entries. Entries of the
+ * same type (addition/removal) which follow directly on each other according to their timestamp
+ * are considered redundant.
+ */
+ private static boolean areConsideredEqual(
+ ListMultimap<String, AuditEntry> reviewDbMemberAuditsByTarget,
+ ListMultimap<String, AuditEntry> noteDbMemberAuditsByTarget) {
+ for (String target : reviewDbMemberAuditsByTarget.keySet()) {
+ ImmutableList<AuditEntry> reviewDbAuditEntries =
+ reviewDbMemberAuditsByTarget.get(target).stream()
+ .sorted(AUDIT_ENTRY_COMPARATOR)
+ .collect(toImmutableList());
+ ImmutableSet<AuditEntry> noteDbAuditEntries =
+ noteDbMemberAuditsByTarget.get(target).stream()
+ .sorted(AUDIT_ENTRY_COMPARATOR)
+ .collect(toImmutableSet());
+
+ int reviewDbIndex = 0;
+ for (AuditEntry noteDbAuditEntry : noteDbAuditEntries) {
+ Set<AuditEntry> redundantReviewDbAuditEntries = new HashSet<>();
+ while (reviewDbIndex < reviewDbAuditEntries.size()) {
+ AuditEntry reviewDbAuditEntry = reviewDbAuditEntries.get(reviewDbIndex);
+ if (!reviewDbAuditEntry.getAction().equals(noteDbAuditEntry.getAction())) {
+ break;
+ }
+ redundantReviewDbAuditEntries.add(reviewDbAuditEntry);
+ reviewDbIndex++;
+ }
+
+ // The order of the entries is not perfect as ReviewDb included milliseconds for timestamps
+ // and we cut off everything below seconds due to NoteDb/git. Consequently, we don't have a
+ // way to know in this method in which exact order additions/removals within the same second
+ // happened. The best we can do is to group all additions within the same second as
+ // redundant entries and the removals afterward. To compensate that we possibly group
+ // non-redundant additions/removals, we also accept NoteDb audit entries which just occur
+ // anywhere as ReviewDb audit entries.
+ if (!redundantReviewDbAuditEntries.contains(noteDbAuditEntry)
+ && !reviewDbAuditEntries.contains(noteDbAuditEntry)) {
+ return false;
+ }
+ }
+
+ if (reviewDbIndex < reviewDbAuditEntries.size()) {
+ // Some of the ReviewDb audit log entries aren't matched by NoteDb audit log entries.
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public AccountGroup.Id id() {
+ return group().getId();
+ }
+
+ public AccountGroup.UUID uuid() {
+ return group().getGroupUUID();
+ }
+
+ public abstract Source source();
+
+ public abstract AccountGroup group();
+
+ public abstract ImmutableSet<AccountGroupMember> members();
+
+ public abstract ImmutableSet<AccountGroupMemberAudit> memberAudit();
+
+ public abstract ImmutableSet<AccountGroupById> byId();
+
+ public abstract ImmutableSet<AccountGroupByIdAud> byIdAudit();
+
+ public abstract Builder toBuilder();
+
+ public GroupBundle truncateToSecond() {
+ AccountGroup newGroup = new AccountGroup(group());
+ if (newGroup.getCreatedOn() != null) {
+ newGroup.setCreatedOn(TimeUtil.truncateToSecond(newGroup.getCreatedOn()));
+ }
+ return toBuilder()
+ .group(newGroup)
+ .memberAudit(
+ memberAudit().stream().map(GroupBundle::truncateToSecond).collect(toImmutableSet()))
+ .byIdAudit(
+ byIdAudit().stream().map(GroupBundle::truncateToSecond).collect(toImmutableSet()))
+ .build();
+ }
+
+ private static AccountGroupMemberAudit truncateToSecond(AccountGroupMemberAudit a) {
+ AccountGroupMemberAudit result =
+ new AccountGroupMemberAudit(
+ new AccountGroupMemberAudit.Key(
+ a.getKey().getParentKey(),
+ a.getKey().getGroupId(),
+ TimeUtil.truncateToSecond(a.getKey().getAddedOn())),
+ a.getAddedBy());
+ if (a.getRemovedOn() != null) {
+ result.removed(a.getRemovedBy(), TimeUtil.truncateToSecond(a.getRemovedOn()));
+ }
+ return result;
+ }
+
+ private static AccountGroupByIdAud truncateToSecond(AccountGroupByIdAud a) {
+ AccountGroupByIdAud result =
+ new AccountGroupByIdAud(
+ new AccountGroupByIdAud.Key(
+ a.getKey().getParentKey(),
+ a.getKey().getIncludeUUID(),
+ TimeUtil.truncateToSecond(a.getKey().getAddedOn())),
+ a.getAddedBy());
+ if (a.getRemovedOn() != null) {
+ result.removed(a.getRemovedBy(), TimeUtil.truncateToSecond(a.getRemovedOn()));
+ }
+ return result;
+ }
+
+ public InternalGroup toInternalGroup() {
+ return InternalGroup.create(
+ group(),
+ members().stream().map(AccountGroupMember::getAccountId).collect(toImmutableSet()),
+ byId().stream().map(AccountGroupById::getIncludeUUID).collect(toImmutableSet()));
+ }
+
+ @Override
+ public int hashCode() {
+ throw new UnsupportedOperationException(
+ "hashCode is not supported because equals is not supported");
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ throw new UnsupportedOperationException("Use GroupBundle.compare(a, b) instead of equals");
+ }
+
+ @AutoValue
+ abstract static class AuditEntry {
+ private static AuditEntry create(
+ Action action, Account.Id userId, Account.Id memberId, Timestamp timestamp) {
+ return new AutoValue_GroupBundle_AuditEntry(
+ action, userId, String.valueOf(memberId.get()), timestamp);
+ }
+
+ private static AuditEntry create(
+ Action action, Account.Id userId, AccountGroup.UUID subgroupId, Timestamp timestamp) {
+ return new AutoValue_GroupBundle_AuditEntry(action, userId, subgroupId.get(), timestamp);
+ }
+
+ abstract Action getAction();
+
+ abstract Account.Id getUserId();
+
+ abstract String getTarget();
+
+ abstract Timestamp getTimestamp();
+ }
+
+ enum Action {
+ ADD(1),
+ REMOVE(2);
+
+ private final int order;
+
+ Action(int order) {
+ this.order = order;
+ }
+
+ public int getOrder() {
+ return order;
+ }
+ }
+
+ @AutoValue.Builder
+ abstract static class Builder {
+ abstract Builder source(Source source);
+
+ abstract Builder group(AccountGroup group);
+
+ abstract Builder members(AccountGroupMember... member);
+
+ abstract Builder members(Iterable<AccountGroupMember> member);
+
+ abstract Builder memberAudit(AccountGroupMemberAudit... audit);
+
+ abstract Builder memberAudit(Iterable<AccountGroupMemberAudit> audit);
+
+ abstract Builder byId(AccountGroupById... byId);
+
+ abstract Builder byId(Iterable<AccountGroupById> byId);
+
+ abstract Builder byIdAudit(AccountGroupByIdAud... audit);
+
+ abstract Builder byIdAudit(Iterable<AccountGroupByIdAud> audit);
+
+ abstract GroupBundle build();
+ }
+}