From dab6be16b8267466f64cb7eb6436111585f522af Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Thu, 6 Jul 2023 12:56:12 +0200 Subject: [PATCH] Add `MergeResponseInspector` functionality to Java API Provides a simple API to collect information that is necessary for content-aware merges. The base for that information is already available via the `Conflict`s returned in a `MergeResponse`. The approach is based on information from the merge-request and merge-response. It collects the conflicting contents from the _merge-base_ using a Nessie-API "get-contents" call and then uses Nessie's diff-operations to identify and filter the conflicting contents from the diffs of _merge-base_-to-_merge-source_ and _merge-base_-to-_merge-target_. The content information is aggregated ("grouped" by content-ID) and returned as a Java `Stream` via the introduced API. This change does not implement any content-aware merge operation. This is a pure Nessie-Java-API/client change, no REST API change. --- .../client/api/MergeReferenceBuilder.java | 7 + .../client/api/MergeResponseInspector.java | 114 +++++++++++++ .../api/impl/BaseMergeResponseInspector.java | 142 ++++++++++++++++ .../client/api/impl/MapEntry.java | 53 ++++++ .../api/impl/MergeResponseInspectorImpl.java | 117 +++++++++++++ .../client/http/v1api/HttpMergeReference.java | 6 + .../client/http/v2api/HttpApiV2.java | 2 +- .../client/http/v2api/HttpMergeReference.java | 38 ++++- .../api/impl/TestMergeResponseInspector.java | 158 ++++++++++++++++++ 9 files changed, 632 insertions(+), 5 deletions(-) create mode 100644 api/client/src/main/java/org/projectnessie/client/api/MergeResponseInspector.java create mode 100644 api/client/src/main/java/org/projectnessie/client/api/impl/BaseMergeResponseInspector.java create mode 100644 api/client/src/main/java/org/projectnessie/client/api/impl/MapEntry.java create mode 100644 api/client/src/main/java/org/projectnessie/client/api/impl/MergeResponseInspectorImpl.java create mode 100644 api/client/src/test/java/org/projectnessie/client/api/impl/TestMergeResponseInspector.java diff --git a/api/client/src/main/java/org/projectnessie/client/api/MergeReferenceBuilder.java b/api/client/src/main/java/org/projectnessie/client/api/MergeReferenceBuilder.java index f72d538b986..7ea03e8ea5f 100644 --- a/api/client/src/main/java/org/projectnessie/client/api/MergeReferenceBuilder.java +++ b/api/client/src/main/java/org/projectnessie/client/api/MergeReferenceBuilder.java @@ -62,5 +62,12 @@ default MergeReferenceBuilder fromRef(Reference fromRef) { return fromRefName(fromRef.getName()).fromHash(fromRef.getHash()); } + /** Perform the merge operation. */ MergeResponse merge() throws NessieNotFoundException, NessieConflictException; + + /** + * Perform the merge operation and allows to optionally inspect merge conflicts using the content + * state of the conflicting contents on the merge-base, merge-source and merge-target. + */ + MergeResponseInspector mergeInspect() throws NessieNotFoundException, NessieConflictException; } diff --git a/api/client/src/main/java/org/projectnessie/client/api/MergeResponseInspector.java b/api/client/src/main/java/org/projectnessie/client/api/MergeResponseInspector.java new file mode 100644 index 00000000000..b9a31c7dd61 --- /dev/null +++ b/api/client/src/main/java/org/projectnessie/client/api/MergeResponseInspector.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2023 Dremio + * + * 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 org.projectnessie.client.api; + +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.immutables.value.Value; +import org.projectnessie.api.v2.params.Merge; +import org.projectnessie.error.NessieNotFoundException; +import org.projectnessie.model.Conflict; +import org.projectnessie.model.Conflict.ConflictType; +import org.projectnessie.model.Content; +import org.projectnessie.model.ContentKey; +import org.projectnessie.model.MergeBehavior; +import org.projectnessie.model.MergeKeyBehavior; +import org.projectnessie.model.MergeResponse; + +/** + * Allows inspection of merge results, to resolve {@link ConflictType#VALUE_DIFFERS content related} + * merge {@link Conflict conflicts} indicated in the {@link #getResponse() merge response}. + */ +public interface MergeResponseInspector { + /** The merge request sent to Nessie. */ + Merge getRequest(); + + /** The merge response received from Nessie. */ + MergeResponse getResponse(); + + /** + * Provides details about the conflicts that happened during a merge operation. + * + *

The returned stream contains one {@link MergeConflictDetails element} for each conflict. + * Non-conflicting contents are not included. + * + *

Each {@link MergeConflictDetails conflict details object} allows callers to resolve + * conflicts based on that information, also known as "content aware merge". + * + *

Once conflicts have been either resolved, or alternatively specific keys declared to "use + * the {@link MergeBehavior#DROP left/from} or {@link MergeBehavior#FORCE right/target} side", + * another {@link MergeReferenceBuilder merge operation} cna be performed, providing a {@link + * MergeKeyBehavior#getResolvedContent() resolved content} for a content key. + * + *

Keep in mind that calling this function triggers API calls against nessie to retrieve the + * relevant content objects on the merge-base and and the content keys and content objects on the + * merge-from (source) and merge-target. + */ + Stream collectMergeConflictDetails() throws NessieNotFoundException; + + @Value.Immutable + interface MergeConflictDetails { + /** The content ID of the conflicting content. */ + default String getContentId() { + return contentOnMergeBase().getId(); + } + + /** Key of the content on the {@link MergeResponse#getCommonAncestor() merge-base commit}. */ + ContentKey keyOnMergeBase(); + + /** Key of the content on the {@link MergeReferenceBuilder#fromRef merge-from reference}. */ + @Nullable + @jakarta.annotation.Nullable + ContentKey keyOnSource(); + + /** + * Key of the content on the {@link MergeResponse#getEffectiveTargetHash() merge-target + * reference}. + */ + @Nullable + @jakarta.annotation.Nullable + ContentKey keyOnTarget(); + + /** Content object on the {@link MergeResponse#getCommonAncestor() merge-base commit}. */ + // TODO this can also be null, if the same key was added on source + target but is not present + // on merge-base. + Content contentOnMergeBase(); + + /** + * Content on the {@link MergeReferenceBuilder#fromRef merge-from reference}, or {@code null} if + * not present on the merge-from. + */ + @Nullable + @jakarta.annotation.Nullable + Content contentOnSource(); + + /** + * Content on the {@link MergeResponse#getEffectiveTargetHash() merge-target reference}, or + * {@code null} if not present on the merge-target. + */ + @Nullable + @jakarta.annotation.Nullable + Content contentOnTarget(); + + /** + * Contains {@link Conflict#conflictType() machine interpretable} and {@link Conflict#message() + * human.readable information} about the conflict. + */ + // TODO this can also be null, if the same key was added on source + target but is not present + // on merge-base. + Conflict conflict(); + } +} diff --git a/api/client/src/main/java/org/projectnessie/client/api/impl/BaseMergeResponseInspector.java b/api/client/src/main/java/org/projectnessie/client/api/impl/BaseMergeResponseInspector.java new file mode 100644 index 00000000000..1cbf178f7ad --- /dev/null +++ b/api/client/src/main/java/org/projectnessie/client/api/impl/BaseMergeResponseInspector.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2023 Dremio + * + * 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 org.projectnessie.client.api.impl; + +import static java.util.Collections.emptyList; +import static java.util.Objects.requireNonNull; +import static org.projectnessie.client.api.impl.MapEntry.mapEntry; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.projectnessie.client.api.ImmutableMergeConflictDetails; +import org.projectnessie.client.api.MergeResponseInspector; +import org.projectnessie.error.NessieNotFoundException; +import org.projectnessie.model.Conflict; +import org.projectnessie.model.Content; +import org.projectnessie.model.ContentKey; +import org.projectnessie.model.Detached; +import org.projectnessie.model.DiffResponse; +import org.projectnessie.model.GetMultipleContentsResponse; +import org.projectnessie.model.MergeResponse; + +/** Base class used for testing and production code, because testing does not use the Nessie API. */ +public abstract class BaseMergeResponseInspector implements MergeResponseInspector { + + @Override + public Stream collectMergeConflictDetails() throws NessieNotFoundException { + // Note: The API exposes a `Stream` so we can optimize this implementation later to reduce + // runtime or heap pressure. + return mergeConflictDetails().stream(); + } + + protected List mergeConflictDetails() throws NessieNotFoundException { + Map conflictMap = conflictMap(); + Map mergeBaseContentByKey = mergeBaseContentByKey(); + if (mergeBaseContentByKey.isEmpty()) { + return emptyList(); + } + + Map> sourceDiff = sourceDiff(); + Map> targetDiff = targetDiff(); + + List details = new ArrayList<>(mergeBaseContentByKey.size()); + + for (Map.Entry base : mergeBaseContentByKey.entrySet()) { + ContentKey baseKey = base.getKey(); + Content baseContent = base.getValue(); + + Map.Entry source = keyAndContent(baseKey, baseContent, sourceDiff); + Map.Entry target = keyAndContent(baseKey, baseContent, targetDiff); + + MergeConflictDetails detail = + ImmutableMergeConflictDetails.builder() + .conflict(conflictMap.get(baseKey)) + .keyOnMergeBase(baseKey) + .keyOnSource(source.getKey()) + .keyOnTarget(target.getKey()) + .contentOnMergeBase(baseContent) + .contentOnSource(source.getValue()) + .contentOnTarget(target.getValue()) + .build(); + + details.add(detail); + } + return details; + } + + protected Map conflictMap() { + return getResponse().getDetails().stream() + .map(MergeResponse.ContentKeyDetails::getConflict) + .filter(Objects::nonNull) + .collect(Collectors.toMap(Conflict::key, Function.identity())); + } + + protected Set mergeBaseContentIds() throws NessieNotFoundException { + return mergeBaseContents().stream() + .map(GetMultipleContentsResponse.ContentWithKey::getContent) + .map(Content::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + protected Map mergeBaseContentByKey() throws NessieNotFoundException { + return mergeBaseContents().stream() + .collect( + Collectors.toMap( + GetMultipleContentsResponse.ContentWithKey::getKey, + GetMultipleContentsResponse.ContentWithKey::getContent)); + } + + protected Map> sourceDiff() throws NessieNotFoundException { + // TODO it would help to have the effective from-hash in the response, in case the merge-request + // did not contain it. If we have that merge, we can use 'DETACHED' here. + String hash = getRequest().getFromHash(); + String ref = hash != null ? Detached.REF_NAME : getRequest().getFromRefName(); + return diffByContentId(ref, hash); + } + + protected Map> targetDiff() throws NessieNotFoundException { + return diffByContentId(Detached.REF_NAME, getResponse().getEffectiveTargetHash()); + } + + protected abstract List mergeBaseContents() + throws NessieNotFoundException; + + protected abstract Map> diffByContentId( + String ref, String hash) throws NessieNotFoundException; + + static String contentIdFromDiffEntry(DiffResponse.DiffEntry e) { + Content from = e.getFrom(); + return requireNonNull(from != null ? from.getId() : requireNonNull(e.getTo()).getId()); + } + + static Map.Entry keyAndContent( + ContentKey baseKey, Content baseContent, Map> diff) { + List diffs = diff.get(baseContent.getId()); + if (diffs != null) { + int size = diffs.size(); + DiffResponse.DiffEntry last = diffs.get(size - 1); + return mapEntry(last.getKey(), last.getTo()); + } + return mapEntry(baseKey, baseContent); + } +} diff --git a/api/client/src/main/java/org/projectnessie/client/api/impl/MapEntry.java b/api/client/src/main/java/org/projectnessie/client/api/impl/MapEntry.java new file mode 100644 index 00000000000..444732a7d6b --- /dev/null +++ b/api/client/src/main/java/org/projectnessie/client/api/impl/MapEntry.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2023 Dremio + * + * 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 org.projectnessie.client.api.impl; + +import java.util.Map; +import javax.annotation.Nullable; +import org.immutables.value.Value; + +/** + * A helper that implements {@link Map.Entry}, because the code is built for Java 8 and Guava's not + * a dependency. + */ +@Value.Immutable +abstract class MapEntry implements Map.Entry { + private static final MapEntry EMPTY_ENTRY = (MapEntry) MapEntry.mapEntry(null, null); + + @SuppressWarnings("unchecked") + static Map.Entry emptyEntry() { + return (Map.Entry) EMPTY_ENTRY; + } + + static Map.Entry mapEntry(K key, V value) { + return ImmutableMapEntry.of(key, value); + } + + @Override + @Value.Parameter(order = 1) + @Nullable + public abstract K getKey(); + + @Override + @Value.Parameter(order = 2) + @Nullable + public abstract V getValue(); + + @Override + public V setValue(V value) { + throw new UnsupportedOperationException(); + } +} diff --git a/api/client/src/main/java/org/projectnessie/client/api/impl/MergeResponseInspectorImpl.java b/api/client/src/main/java/org/projectnessie/client/api/impl/MergeResponseInspectorImpl.java new file mode 100644 index 00000000000..adbcf13bb8e --- /dev/null +++ b/api/client/src/main/java/org/projectnessie/client/api/impl/MergeResponseInspectorImpl.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2023 Dremio + * + * 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 org.projectnessie.client.api.impl; + +import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.groupingBy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.immutables.value.Value; +import org.projectnessie.api.v2.params.Merge; +import org.projectnessie.client.api.NessieApiV2; +import org.projectnessie.error.NessieNotFoundException; +import org.projectnessie.model.Conflict; +import org.projectnessie.model.Content; +import org.projectnessie.model.ContentKey; +import org.projectnessie.model.Detached; +import org.projectnessie.model.DiffResponse; +import org.projectnessie.model.GetMultipleContentsResponse; +import org.projectnessie.model.MergeResponse; + +@Value.Immutable +public abstract class MergeResponseInspectorImpl extends BaseMergeResponseInspector { + public static ImmutableMergeResponseInspectorImpl.Builder builder() { + return ImmutableMergeResponseInspectorImpl.builder(); + } + + protected abstract NessieApiV2 api(); + + @Override + public abstract Merge getRequest(); + + @Override + public abstract MergeResponse getResponse(); + + @Value.Lazy + protected List mergeBaseContents() + throws NessieNotFoundException { + Map conflictMap = conflictMap(); + if (conflictMap.isEmpty()) { + return emptyList(); + } + + @SuppressWarnings("resource") + NessieApiV2 api = api(); + + // Collect the contents on the merge base for the conflicting keys. + return api.getContent() + .keys(new ArrayList<>(conflictMap.keySet())) + .refName(Detached.REF_NAME) + .hashOnRef(getResponse().getCommonAncestor()) + .getWithResponse() + .getContents(); + } + + @Value.Lazy + protected Map conflictMap() { + return super.conflictMap(); + } + + @Value.Lazy + protected Set mergeBaseContentIds() throws NessieNotFoundException { + return super.mergeBaseContentIds(); + } + + @Value.Lazy + protected Map mergeBaseContentByKey() throws NessieNotFoundException { + return super.mergeBaseContentByKey(); + } + + @Value.Lazy + protected Map> sourceDiff() throws NessieNotFoundException { + return super.sourceDiff(); + } + + @Value.Lazy + protected Map> targetDiff() throws NessieNotFoundException { + return super.targetDiff(); + } + + @Override + protected Map> diffByContentId(String ref, String hash) + throws NessieNotFoundException { + @SuppressWarnings("resource") + NessieApiV2 api = api(); + + Set mergeBaseContentIds = mergeBaseContentIds(); + + // Use the diff from merge-base to merge-target and collect the diff-entries by content ID. + // This is needed to look up the contents irrespective of a rename between merge-base and + // merge-target. + return api + .getDiff() + .fromRefName(Detached.REF_NAME) + .fromHashOnRef(getResponse().getCommonAncestor()) + .toRefName(ref) + .toHashOnRef(hash) + .stream() + .filter(diff -> mergeBaseContentIds.contains(contentIdFromDiffEntry(diff))) + .collect(groupingBy(MergeResponseInspectorImpl::contentIdFromDiffEntry)); + } +} diff --git a/api/client/src/main/java/org/projectnessie/client/http/v1api/HttpMergeReference.java b/api/client/src/main/java/org/projectnessie/client/http/v1api/HttpMergeReference.java index c10ad5987b9..258894e62bb 100644 --- a/api/client/src/main/java/org/projectnessie/client/http/v1api/HttpMergeReference.java +++ b/api/client/src/main/java/org/projectnessie/client/http/v1api/HttpMergeReference.java @@ -17,6 +17,7 @@ import org.projectnessie.api.v1.params.ImmutableMerge; import org.projectnessie.client.api.MergeReferenceBuilder; +import org.projectnessie.client.api.MergeResponseInspector; import org.projectnessie.client.builder.BaseMergeReferenceBuilder; import org.projectnessie.client.http.NessieApiClient; import org.projectnessie.error.NessieConflictException; @@ -64,4 +65,9 @@ public MergeResponse merge() throws NessieNotFoundException, NessieConflictExcep return client.getTreeApi().mergeRefIntoBranch(branchName, hash, merge.build()); } + + @Override + public MergeResponseInspector mergeInspect() { + throw new UnsupportedOperationException("Merge response inspection is not available in API v1"); + } } diff --git a/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpApiV2.java b/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpApiV2.java index 1c26ef150b8..f5dd857cc17 100644 --- a/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpApiV2.java +++ b/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpApiV2.java @@ -133,7 +133,7 @@ public TransplantCommitsBuilder transplantCommitsIntoBranch() { @Override public MergeReferenceBuilder mergeRefIntoBranch() { - return new HttpMergeReference(client); + return new HttpMergeReference(this, client); } @Override diff --git a/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpMergeReference.java b/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpMergeReference.java index e83623a9e16..02df4b66629 100644 --- a/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpMergeReference.java +++ b/api/client/src/main/java/org/projectnessie/client/http/v2api/HttpMergeReference.java @@ -16,7 +16,10 @@ package org.projectnessie.client.http.v2api; import org.projectnessie.api.v2.params.ImmutableMerge; +import org.projectnessie.api.v2.params.Merge; import org.projectnessie.client.api.MergeReferenceBuilder; +import org.projectnessie.client.api.MergeResponseInspector; +import org.projectnessie.client.api.impl.MergeResponseInspectorImpl; import org.projectnessie.client.builder.BaseMergeReferenceBuilder; import org.projectnessie.client.http.HttpClient; import org.projectnessie.error.NessieConflictException; @@ -25,9 +28,11 @@ import org.projectnessie.model.Reference; final class HttpMergeReference extends BaseMergeReferenceBuilder { + private final HttpApiV2 api; private final HttpClient client; - public HttpMergeReference(HttpClient client) { + public HttpMergeReference(HttpApiV2 api, HttpClient client) { + this.api = api; this.client = client; } @@ -39,8 +44,7 @@ public MergeReferenceBuilder keepIndividualCommits(boolean keepIndividualCommits return this; } - @Override - public MergeResponse merge() throws NessieNotFoundException, NessieConflictException { + private Merge buildMerge() { ImmutableMerge.Builder merge = ImmutableMerge.builder() .fromHash(fromHash) @@ -59,12 +63,38 @@ public MergeResponse merge() throws NessieNotFoundException, NessieConflictExcep merge.keyMergeModes(mergeModes.values()); } + return merge.build(); + } + + private MergeResponse submitMergeRequest(Merge request) + throws NessieNotFoundException, NessieConflictException { return client .newRequest() .path("trees/{ref}/history/merge") .resolveTemplate("ref", Reference.toPathString(branchName, hash)) .unwrap(NessieNotFoundException.class, NessieConflictException.class) - .post(merge.build()) + .post(request) .readEntity(MergeResponse.class); } + + @Override + public MergeResponse merge() throws NessieNotFoundException, NessieConflictException { + Merge request = buildMerge(); + + return submitMergeRequest(request); + } + + @Override + public MergeResponseInspector mergeInspect() + throws NessieNotFoundException, NessieConflictException { + Merge request = buildMerge(); + + MergeResponse response = submitMergeRequest(request); + + return MergeResponseInspectorImpl.builder() + .api(api) + .request(request) + .response(response) + .build(); + } } diff --git a/api/client/src/test/java/org/projectnessie/client/api/impl/TestMergeResponseInspector.java b/api/client/src/test/java/org/projectnessie/client/api/impl/TestMergeResponseInspector.java new file mode 100644 index 00000000000..27689133b79 --- /dev/null +++ b/api/client/src/test/java/org/projectnessie/client/api/impl/TestMergeResponseInspector.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2023 Dremio + * + * 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 org.projectnessie.client.api.impl; + +import static org.projectnessie.model.Conflict.conflict; +import static org.projectnessie.model.DiffResponse.DiffEntry.diffEntry; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.assertj.core.api.SoftAssertions; +import org.assertj.core.api.junit.jupiter.InjectSoftAssertions; +import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.projectnessie.api.v2.params.ImmutableMerge; +import org.projectnessie.api.v2.params.Merge; +import org.projectnessie.client.api.MergeResponseInspector; +import org.projectnessie.model.Conflict; +import org.projectnessie.model.Content; +import org.projectnessie.model.ContentKey; +import org.projectnessie.model.Detached; +import org.projectnessie.model.DiffResponse.DiffEntry; +import org.projectnessie.model.GetMultipleContentsResponse.ContentWithKey; +import org.projectnessie.model.IcebergTable; +import org.projectnessie.model.ImmutableContentKeyDetails; +import org.projectnessie.model.ImmutableMergeResponse; +import org.projectnessie.model.MergeBehavior; +import org.projectnessie.model.MergeResponse; + +@ExtendWith(SoftAssertionsExtension.class) +public class TestMergeResponseInspector { + @InjectSoftAssertions protected SoftAssertions soft; + + @Test + public void foo() throws Exception { + String mainHash = "00000000"; + String sourceHash = "deadbeef"; + String mergeBase = "12345678"; + + String cidOne = "1"; + String cidTwo = "2"; + + ContentKey keyOne = ContentKey.of("one"); + ContentKey keyTwo = ContentKey.of("two"); + ContentKey keyOneSource = ContentKey.of("one-s"); + ContentKey keyOneTarget = ContentKey.of("one-t"); + + IcebergTable oneBase = IcebergTable.of("one", 1, 1, 1, 1, cidOne); + IcebergTable twoBase = IcebergTable.of("two", 2, 2, 2, 2, cidTwo); + IcebergTable oneSource = IcebergTable.of("one-source", 1, 1, 1, 1, cidOne); + IcebergTable twoSource = IcebergTable.of("two-source", 2, 2, 2, 2, cidTwo); + IcebergTable oneTarget = IcebergTable.of("one-target", 1, 1, 1, 1, cidOne); + IcebergTable twoTarget = IcebergTable.of("two-target", 2, 2, 2, 2, cidTwo); + Map> diffsSource = new HashMap<>(); + diffsSource.put( + oneSource.getId(), + diffForRename(keyOne, oneBase, keyOneSource, oneSource).collect(Collectors.toList())); + diffsSource.put( + twoSource.getId(), diffForUpdate(keyTwo, twoBase, twoTarget).collect(Collectors.toList())); + Map> diffsTarget = new HashMap<>(); + + Function buildContentKeyDetails = + key -> + ImmutableContentKeyDetails.builder() + .key(key) + .conflict(conflict(Conflict.ConflictType.VALUE_DIFFERS, key, "value differs")) + .mergeBehavior(MergeBehavior.NORMAL) + .build(); + + Merge request = ImmutableMerge.builder().fromRefName("source").fromHash(sourceHash).build(); + MergeResponse response = + ImmutableMergeResponse.builder() + .targetBranch("main") + .effectiveTargetHash(mainHash) + .commonAncestor(mergeBase) + .addDetails(buildContentKeyDetails.apply(keyOne)) + .addDetails(buildContentKeyDetails.apply(keyTwo)) + .build(); + + MergeResponseInspector tested = + new AbstractTestMergeResponseTest(request, response) { + @Override + protected List mergeBaseContents() { + return Arrays.asList( + ContentWithKey.of(keyOne, oneBase), ContentWithKey.of(keyTwo, twoBase)); + } + + @Override + protected Map> diffByContentId(String ref, String hash) { + if ((ref.equals("main") || ref.equals(Detached.REF_NAME)) && hash.equals(mainHash)) { + return diffsTarget; + } else if ((ref.equals("source") || ref.equals(Detached.REF_NAME)) + && hash.equals(sourceHash)) { + return diffsSource; + } else { + throw new IllegalArgumentException(ref + " / " + hash); + } + } + }; + + List details = + tested.collectMergeConflictDetails().collect(Collectors.toList()); + soft.assertThat(details) + .extracting(MergeResponseInspector.MergeConflictDetails::keyOnMergeBase) + .containsExactlyInAnyOrder(keyOne, keyTwo); + } + + static Stream diffForRename( + ContentKey keyFrom, Content from, ContentKey keyTo, Content to) { + return Stream.of(diffEntry(keyFrom, from, null), diffEntry(keyTo, null, to)); + } + + static Stream diffForReadd(ContentKey key, Content from, Content to) { + return diffForUpdate(key, from, to); + } + + static Stream diffForUpdate(ContentKey key, Content from, Content to) { + return Stream.of(diffEntry(key, from, to)); + } + + abstract static class AbstractTestMergeResponseTest extends BaseMergeResponseInspector { + final Merge request; + final MergeResponse response; + + AbstractTestMergeResponseTest(Merge request, MergeResponse response) { + this.request = request; + this.response = response; + } + + @Override + public Merge getRequest() { + return request; + } + + @Override + public MergeResponse getResponse() { + return response; + } + } +}