Skip to content

Commit 45c109c

Browse files
committed
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.
1 parent 948b41c commit 45c109c

File tree

9 files changed

+632
-5
lines changed

9 files changed

+632
-5
lines changed

api/client/src/main/java/org/projectnessie/client/api/MergeReferenceBuilder.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,12 @@ default MergeReferenceBuilder fromRef(Reference fromRef) {
6262
return fromRefName(fromRef.getName()).fromHash(fromRef.getHash());
6363
}
6464

65+
/** Perform the merge operation. */
6566
MergeResponse merge() throws NessieNotFoundException, NessieConflictException;
67+
68+
/**
69+
* Perform the merge operation and allows to optionally inspect merge conflicts using the content
70+
* state of the conflicting contents on the merge-base, merge-source and merge-target.
71+
*/
72+
MergeResponseInspector mergeInspect() throws NessieNotFoundException, NessieConflictException;
6673
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright (C) 2023 Dremio
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.projectnessie.client.api;
17+
18+
import java.util.stream.Stream;
19+
import javax.annotation.Nullable;
20+
import org.immutables.value.Value;
21+
import org.projectnessie.api.v2.params.Merge;
22+
import org.projectnessie.error.NessieNotFoundException;
23+
import org.projectnessie.model.Conflict;
24+
import org.projectnessie.model.Conflict.ConflictType;
25+
import org.projectnessie.model.Content;
26+
import org.projectnessie.model.ContentKey;
27+
import org.projectnessie.model.MergeBehavior;
28+
import org.projectnessie.model.MergeKeyBehavior;
29+
import org.projectnessie.model.MergeResponse;
30+
31+
/**
32+
* Allows inspection of merge results, to resolve {@link ConflictType#VALUE_DIFFERS content related}
33+
* merge {@link Conflict conflicts} indicated in the {@link #getResponse() merge response}.
34+
*/
35+
public interface MergeResponseInspector {
36+
/** The merge request sent to Nessie. */
37+
Merge getRequest();
38+
39+
/** The merge response received from Nessie. */
40+
MergeResponse getResponse();
41+
42+
/**
43+
* Provides details about the conflicts that happened during a merge operation.
44+
*
45+
* <p>The returned stream contains one {@link MergeConflictDetails element} for each conflict.
46+
* Non-conflicting contents are not included.
47+
*
48+
* <p>Each {@link MergeConflictDetails conflict details object} allows callers to resolve
49+
* conflicts based on that information, also known as "content aware merge".
50+
*
51+
* <p>Once conflicts have been either resolved, or alternatively specific keys declared to "use
52+
* the {@link MergeBehavior#DROP left/from} or {@link MergeBehavior#FORCE right/target} side",
53+
* another {@link MergeReferenceBuilder merge operation} cna be performed, providing a {@link
54+
* MergeKeyBehavior#getResolvedContent() resolved content} for a content key.
55+
*
56+
* <p>Keep in mind that calling this function triggers API calls against nessie to retrieve the
57+
* relevant content objects on the merge-base and and the content keys and content objects on the
58+
* merge-from (source) and merge-target.
59+
*/
60+
Stream<MergeConflictDetails> collectMergeConflictDetails() throws NessieNotFoundException;
61+
62+
@Value.Immutable
63+
interface MergeConflictDetails {
64+
/** The content ID of the conflicting content. */
65+
default String getContentId() {
66+
return contentOnMergeBase().getId();
67+
}
68+
69+
/** Key of the content on the {@link MergeResponse#getCommonAncestor() merge-base commit}. */
70+
ContentKey keyOnMergeBase();
71+
72+
/** Key of the content on the {@link MergeReferenceBuilder#fromRef merge-from reference}. */
73+
@Nullable
74+
@jakarta.annotation.Nullable
75+
ContentKey keyOnSource();
76+
77+
/**
78+
* Key of the content on the {@link MergeResponse#getEffectiveTargetHash() merge-target
79+
* reference}.
80+
*/
81+
@Nullable
82+
@jakarta.annotation.Nullable
83+
ContentKey keyOnTarget();
84+
85+
/** Content object on the {@link MergeResponse#getCommonAncestor() merge-base commit}. */
86+
// TODO this can also be null, if the same key was added on source + target but is not present
87+
// on merge-base.
88+
Content contentOnMergeBase();
89+
90+
/**
91+
* Content on the {@link MergeReferenceBuilder#fromRef merge-from reference}, or {@code null} if
92+
* not present on the merge-from.
93+
*/
94+
@Nullable
95+
@jakarta.annotation.Nullable
96+
Content contentOnSource();
97+
98+
/**
99+
* Content on the {@link MergeResponse#getEffectiveTargetHash() merge-target reference}, or
100+
* {@code null} if not present on the merge-target.
101+
*/
102+
@Nullable
103+
@jakarta.annotation.Nullable
104+
Content contentOnTarget();
105+
106+
/**
107+
* Contains {@link Conflict#conflictType() machine interpretable} and {@link Conflict#message()
108+
* human.readable information} about the conflict.
109+
*/
110+
// TODO this can also be null, if the same key was added on source + target but is not present
111+
// on merge-base.
112+
Conflict conflict();
113+
}
114+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* Copyright (C) 2023 Dremio
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.projectnessie.client.api.impl;
17+
18+
import static java.util.Collections.emptyList;
19+
import static java.util.Objects.requireNonNull;
20+
import static org.projectnessie.client.api.impl.MapEntry.mapEntry;
21+
22+
import java.util.ArrayList;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.Objects;
26+
import java.util.Set;
27+
import java.util.function.Function;
28+
import java.util.stream.Collectors;
29+
import java.util.stream.Stream;
30+
import org.projectnessie.client.api.ImmutableMergeConflictDetails;
31+
import org.projectnessie.client.api.MergeResponseInspector;
32+
import org.projectnessie.error.NessieNotFoundException;
33+
import org.projectnessie.model.Conflict;
34+
import org.projectnessie.model.Content;
35+
import org.projectnessie.model.ContentKey;
36+
import org.projectnessie.model.Detached;
37+
import org.projectnessie.model.DiffResponse;
38+
import org.projectnessie.model.GetMultipleContentsResponse;
39+
import org.projectnessie.model.MergeResponse;
40+
41+
/** Base class used for testing and production code, because testing does not use the Nessie API. */
42+
public abstract class BaseMergeResponseInspector implements MergeResponseInspector {
43+
44+
@Override
45+
public Stream<MergeConflictDetails> collectMergeConflictDetails() throws NessieNotFoundException {
46+
// Note: The API exposes a `Stream` so we can optimize this implementation later to reduce
47+
// runtime or heap pressure.
48+
return mergeConflictDetails().stream();
49+
}
50+
51+
protected List<MergeConflictDetails> mergeConflictDetails() throws NessieNotFoundException {
52+
Map<ContentKey, Conflict> conflictMap = conflictMap();
53+
Map<ContentKey, Content> mergeBaseContentByKey = mergeBaseContentByKey();
54+
if (mergeBaseContentByKey.isEmpty()) {
55+
return emptyList();
56+
}
57+
58+
Map<String, List<DiffResponse.DiffEntry>> sourceDiff = sourceDiff();
59+
Map<String, List<DiffResponse.DiffEntry>> targetDiff = targetDiff();
60+
61+
List<MergeConflictDetails> details = new ArrayList<>(mergeBaseContentByKey.size());
62+
63+
for (Map.Entry<ContentKey, Content> base : mergeBaseContentByKey.entrySet()) {
64+
ContentKey baseKey = base.getKey();
65+
Content baseContent = base.getValue();
66+
67+
Map.Entry<ContentKey, Content> source = keyAndContent(baseKey, baseContent, sourceDiff);
68+
Map.Entry<ContentKey, Content> target = keyAndContent(baseKey, baseContent, targetDiff);
69+
70+
MergeConflictDetails detail =
71+
ImmutableMergeConflictDetails.builder()
72+
.conflict(conflictMap.get(baseKey))
73+
.keyOnMergeBase(baseKey)
74+
.keyOnSource(source.getKey())
75+
.keyOnTarget(target.getKey())
76+
.contentOnMergeBase(baseContent)
77+
.contentOnSource(source.getValue())
78+
.contentOnTarget(target.getValue())
79+
.build();
80+
81+
details.add(detail);
82+
}
83+
return details;
84+
}
85+
86+
protected Map<ContentKey, Conflict> conflictMap() {
87+
return getResponse().getDetails().stream()
88+
.map(MergeResponse.ContentKeyDetails::getConflict)
89+
.filter(Objects::nonNull)
90+
.collect(Collectors.toMap(Conflict::key, Function.identity()));
91+
}
92+
93+
protected Set<String> mergeBaseContentIds() throws NessieNotFoundException {
94+
return mergeBaseContents().stream()
95+
.map(GetMultipleContentsResponse.ContentWithKey::getContent)
96+
.map(Content::getId)
97+
.filter(Objects::nonNull)
98+
.collect(Collectors.toSet());
99+
}
100+
101+
protected Map<ContentKey, Content> mergeBaseContentByKey() throws NessieNotFoundException {
102+
return mergeBaseContents().stream()
103+
.collect(
104+
Collectors.toMap(
105+
GetMultipleContentsResponse.ContentWithKey::getKey,
106+
GetMultipleContentsResponse.ContentWithKey::getContent));
107+
}
108+
109+
protected Map<String, List<DiffResponse.DiffEntry>> sourceDiff() throws NessieNotFoundException {
110+
// TODO it would help to have the effective from-hash in the response, in case the merge-request
111+
// did not contain it. If we have that merge, we can use 'DETACHED' here.
112+
String hash = getRequest().getFromHash();
113+
String ref = hash != null ? Detached.REF_NAME : getRequest().getFromRefName();
114+
return diffByContentId(ref, hash);
115+
}
116+
117+
protected Map<String, List<DiffResponse.DiffEntry>> targetDiff() throws NessieNotFoundException {
118+
return diffByContentId(Detached.REF_NAME, getResponse().getEffectiveTargetHash());
119+
}
120+
121+
protected abstract List<GetMultipleContentsResponse.ContentWithKey> mergeBaseContents()
122+
throws NessieNotFoundException;
123+
124+
protected abstract Map<String, List<DiffResponse.DiffEntry>> diffByContentId(
125+
String ref, String hash) throws NessieNotFoundException;
126+
127+
static String contentIdFromDiffEntry(DiffResponse.DiffEntry e) {
128+
Content from = e.getFrom();
129+
return requireNonNull(from != null ? from.getId() : requireNonNull(e.getTo()).getId());
130+
}
131+
132+
static Map.Entry<ContentKey, Content> keyAndContent(
133+
ContentKey baseKey, Content baseContent, Map<String, List<DiffResponse.DiffEntry>> diff) {
134+
List<DiffResponse.DiffEntry> diffs = diff.get(baseContent.getId());
135+
if (diffs != null) {
136+
int size = diffs.size();
137+
DiffResponse.DiffEntry last = diffs.get(size - 1);
138+
return mapEntry(last.getKey(), last.getTo());
139+
}
140+
return mapEntry(baseKey, baseContent);
141+
}
142+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright (C) 2023 Dremio
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.projectnessie.client.api.impl;
17+
18+
import java.util.Map;
19+
import javax.annotation.Nullable;
20+
import org.immutables.value.Value;
21+
22+
/**
23+
* A helper that implements {@link Map.Entry}, because the code is built for Java 8 and Guava's not
24+
* a dependency.
25+
*/
26+
@Value.Immutable
27+
abstract class MapEntry<K, V> implements Map.Entry<K, V> {
28+
private static final MapEntry<?, ?> EMPTY_ENTRY = (MapEntry<?, ?>) MapEntry.mapEntry(null, null);
29+
30+
@SuppressWarnings("unchecked")
31+
static <K, V> Map.Entry<K, V> emptyEntry() {
32+
return (Map.Entry<K, V>) EMPTY_ENTRY;
33+
}
34+
35+
static <K, V> Map.Entry<K, V> mapEntry(K key, V value) {
36+
return ImmutableMapEntry.of(key, value);
37+
}
38+
39+
@Override
40+
@Value.Parameter(order = 1)
41+
@Nullable
42+
public abstract K getKey();
43+
44+
@Override
45+
@Value.Parameter(order = 2)
46+
@Nullable
47+
public abstract V getValue();
48+
49+
@Override
50+
public V setValue(V value) {
51+
throw new UnsupportedOperationException();
52+
}
53+
}

0 commit comments

Comments
 (0)