Skip to content

Commit

Permalink
Add MergeResponseInspector functionality to Java API
Browse files Browse the repository at this point in the history
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
snazy committed Jul 6, 2023
1 parent ba2c246 commit dab6be1
Show file tree
Hide file tree
Showing 9 changed files with 632 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
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();
}
}
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);
}
}
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();
}
}
Loading

0 comments on commit dab6be1

Please sign in to comment.