-
Notifications
You must be signed in to change notification settings - Fork 130
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
9 changed files
with
632 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
api/client/src/main/java/org/projectnessie/client/api/MergeResponseInspector.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
* | ||
* <p>The returned stream contains one {@link MergeConflictDetails element} for each conflict. | ||
* Non-conflicting contents are not included. | ||
* | ||
* <p>Each {@link MergeConflictDetails conflict details object} allows callers to resolve | ||
* conflicts based on that information, also known as "content aware merge". | ||
* | ||
* <p>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. | ||
* | ||
* <p>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<MergeConflictDetails> 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(); | ||
} | ||
} |
142 changes: 142 additions & 0 deletions
142
api/client/src/main/java/org/projectnessie/client/api/impl/BaseMergeResponseInspector.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<MergeConflictDetails> 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> mergeConflictDetails() throws NessieNotFoundException { | ||
Map<ContentKey, Conflict> conflictMap = conflictMap(); | ||
Map<ContentKey, Content> mergeBaseContentByKey = mergeBaseContentByKey(); | ||
if (mergeBaseContentByKey.isEmpty()) { | ||
return emptyList(); | ||
} | ||
|
||
Map<String, List<DiffResponse.DiffEntry>> sourceDiff = sourceDiff(); | ||
Map<String, List<DiffResponse.DiffEntry>> targetDiff = targetDiff(); | ||
|
||
List<MergeConflictDetails> details = new ArrayList<>(mergeBaseContentByKey.size()); | ||
|
||
for (Map.Entry<ContentKey, Content> base : mergeBaseContentByKey.entrySet()) { | ||
ContentKey baseKey = base.getKey(); | ||
Content baseContent = base.getValue(); | ||
|
||
Map.Entry<ContentKey, Content> source = keyAndContent(baseKey, baseContent, sourceDiff); | ||
Map.Entry<ContentKey, Content> 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<ContentKey, Conflict> conflictMap() { | ||
return getResponse().getDetails().stream() | ||
.map(MergeResponse.ContentKeyDetails::getConflict) | ||
.filter(Objects::nonNull) | ||
.collect(Collectors.toMap(Conflict::key, Function.identity())); | ||
} | ||
|
||
protected Set<String> mergeBaseContentIds() throws NessieNotFoundException { | ||
return mergeBaseContents().stream() | ||
.map(GetMultipleContentsResponse.ContentWithKey::getContent) | ||
.map(Content::getId) | ||
.filter(Objects::nonNull) | ||
.collect(Collectors.toSet()); | ||
} | ||
|
||
protected Map<ContentKey, Content> mergeBaseContentByKey() throws NessieNotFoundException { | ||
return mergeBaseContents().stream() | ||
.collect( | ||
Collectors.toMap( | ||
GetMultipleContentsResponse.ContentWithKey::getKey, | ||
GetMultipleContentsResponse.ContentWithKey::getContent)); | ||
} | ||
|
||
protected Map<String, List<DiffResponse.DiffEntry>> 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<String, List<DiffResponse.DiffEntry>> targetDiff() throws NessieNotFoundException { | ||
return diffByContentId(Detached.REF_NAME, getResponse().getEffectiveTargetHash()); | ||
} | ||
|
||
protected abstract List<GetMultipleContentsResponse.ContentWithKey> mergeBaseContents() | ||
throws NessieNotFoundException; | ||
|
||
protected abstract Map<String, List<DiffResponse.DiffEntry>> 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<ContentKey, Content> keyAndContent( | ||
ContentKey baseKey, Content baseContent, Map<String, List<DiffResponse.DiffEntry>> diff) { | ||
List<DiffResponse.DiffEntry> 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); | ||
} | ||
} |
53 changes: 53 additions & 0 deletions
53
api/client/src/main/java/org/projectnessie/client/api/impl/MapEntry.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<K, V> implements Map.Entry<K, V> { | ||
private static final MapEntry<?, ?> EMPTY_ENTRY = (MapEntry<?, ?>) MapEntry.mapEntry(null, null); | ||
|
||
@SuppressWarnings("unchecked") | ||
static <K, V> Map.Entry<K, V> emptyEntry() { | ||
return (Map.Entry<K, V>) EMPTY_ENTRY; | ||
} | ||
|
||
static <K, V> Map.Entry<K, V> 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(); | ||
} | ||
} |
Oops, something went wrong.