From 0a5be3535711378caceb3fd529bf3ce86986da99 Mon Sep 17 00:00:00 2001 From: Bence Simon Date: Fri, 19 Apr 2024 11:16:26 +0200 Subject: [PATCH] NIFI-13030 Adding endpoint for comparing versions of registered flows This closes #8670 Signed-off-by: Peter Gyori --- .../registry/flow/FlowVersionLocation.java | 17 ++ .../nifi/web/api/dto/DifferenceDTO.java | 14 ++ .../apache/nifi/web/NiFiServiceFacade.java | 11 ++ .../nifi/web/StandardNiFiServiceFacade.java | 36 ++++ .../org/apache/nifi/web/api/FlowResource.java | 117 +++++++++++++ .../nifi/web/util/ClosedOpenInterval.java | 82 +++++++++ .../org/apache/nifi/web/util/Interval.java | 63 +++++++ .../apache/nifi/web/util/IntervalFactory.java | 30 ++++ .../nifi/web/util/PaginationHelper.java | 99 +++++++++++ .../apache/nifi/web/api/TestFlowResource.java | 156 ++++++++++++++++++ .../nifi/web/util/ClosedOpenIntervalTest.java | 98 +++++++++++ .../nifi/web/util/PaginationHelperTest.java | 79 +++++++++ 12 files changed, 802 insertions(+) create mode 100644 nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/ClosedOpenInterval.java create mode 100644 nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/Interval.java create mode 100644 nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/IntervalFactory.java create mode 100644 nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/PaginationHelper.java create mode 100644 nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/ClosedOpenIntervalTest.java create mode 100644 nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/PaginationHelperTest.java diff --git a/nifi-api/src/main/java/org/apache/nifi/registry/flow/FlowVersionLocation.java b/nifi-api/src/main/java/org/apache/nifi/registry/flow/FlowVersionLocation.java index e615b6a45d75..f93ef63649a5 100644 --- a/nifi-api/src/main/java/org/apache/nifi/registry/flow/FlowVersionLocation.java +++ b/nifi-api/src/main/java/org/apache/nifi/registry/flow/FlowVersionLocation.java @@ -19,6 +19,8 @@ package org.apache.nifi.registry.flow; +import java.util.Objects; + /** * Information for locating a flow version in a flow registry. */ @@ -43,4 +45,19 @@ public void setVersion(final String version) { this.version = version; } + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final FlowVersionLocation that = (FlowVersionLocation) o; + return Objects.equals(getBranch(), that.getBranch()) + && Objects.equals(getBucketId(), that.getBucketId()) + && Objects.equals(getFlowId(), that.getFlowId()) + && Objects.equals(version, that.version); + } + + @Override + public int hashCode() { + return Objects.hash(getBranch(), getBucketId(), getFlowId(), version); + } } diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java index 0b1dd2a087d8..913084db5214 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/DifferenceDTO.java @@ -21,6 +21,8 @@ import jakarta.xml.bind.annotation.XmlType; +import java.util.Objects; + @XmlType(name = "difference") public class DifferenceDTO { private String differenceType; @@ -44,4 +46,16 @@ public void setDifference(String difference) { this.difference = difference; } + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final DifferenceDTO that = (DifferenceDTO) o; + return Objects.equals(differenceType, that.differenceType) && Objects.equals(difference, that.difference); + } + + @Override + public int hashCode() { + return Objects.hash(differenceType, difference); + } } diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java index b372d5cc83cd..ee6ea5982f3d 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java @@ -37,6 +37,7 @@ import org.apache.nifi.parameter.ParameterGroupConfiguration; import org.apache.nifi.registry.flow.FlowLocation; import org.apache.nifi.registry.flow.FlowSnapshotContainer; +import org.apache.nifi.registry.flow.FlowVersionLocation; import org.apache.nifi.registry.flow.RegisterAction; import org.apache.nifi.registry.flow.RegisteredFlow; import org.apache.nifi.registry.flow.RegisteredFlowSnapshot; @@ -1501,6 +1502,16 @@ Set getControllerServiceTypes(final String serviceType, final */ RegisteredFlow deleteVersionedFlow(String registryId, String branch, String bucketId, String flowId); + /** + * Returns the differences of version B from version A. + * + * @param registryId the ID of the registry + * @param versionLocationA Location of the baseline snapshot of the comparison + * @param versionLocationB location of the compared snapshot + * @return the differences between the snapshots + */ + FlowComparisonEntity getVersionDifference(String registryId, FlowVersionLocation versionLocationA, FlowVersionLocation versionLocationB); + /** * Adds the given snapshot to the already existing Versioned Flow, which resides in the given Flow Registry with the given id * diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java index dfa6f0df90a9..ce7c15ba70ce 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java @@ -5258,6 +5258,42 @@ public RegisteredFlow deleteVersionedFlow(final String registryId, final String } } + @Override + public FlowComparisonEntity getVersionDifference(final String registryId, FlowVersionLocation versionLocationA, FlowVersionLocation versionLocationB) { + final FlowComparisonEntity result = new FlowComparisonEntity(); + + if (versionLocationA.equals(versionLocationB)) { + // If both versions are the same, there is no need for comparison. Comparing them should have the same result but with the cost of some calls to the registry. + // Note: because of this optimization we return an empty non-error response in case of non-existing registry, bucket, flow or version if the versions are the same. + result.setComponentDifferences(Collections.emptySet()); + return result; + } + + final FlowSnapshotContainer snapshotA = this.getVersionedFlowSnapshot( + registryId, versionLocationA.getBranch(), versionLocationA.getBucketId(), versionLocationA.getFlowId(), versionLocationA.getVersion(), true); + final FlowSnapshotContainer snapshotB = this.getVersionedFlowSnapshot( + registryId, versionLocationB.getBranch(), versionLocationB.getBucketId(), versionLocationB.getFlowId(), versionLocationB.getVersion(), true); + + final VersionedProcessGroup flowContentsA = snapshotA.getFlowSnapshot().getFlowContents(); + final VersionedProcessGroup flowContentsB = snapshotB.getFlowSnapshot().getFlowContents(); + + final FlowComparator flowComparator = new StandardFlowComparator( + new StandardComparableDataFlow("Flow A", flowContentsA), + new StandardComparableDataFlow("Flow B", flowContentsB), + Collections.emptySet(), // Replacement of an external ControllerService is recognized as property change + new ConciseEvolvingDifferenceDescriptor(), + Function.identity(), + VersionedComponent::getIdentifier, + FlowComparatorVersionedStrategy.DEEP + ); + + final FlowComparison flowComparison = flowComparator.compare(); + final Set differenceDtos = dtoFactory.createComponentDifferenceDtosForLocalModifications(flowComparison, flowContentsA, controllerFacade.getFlowManager()); + result.setComponentDifferences(differenceDtos); + + return result; + } + @Override public boolean isAnyProcessGroupUnderVersionControl(final String groupId) { return isProcessGroupUnderVersionControl(processGroupDAO.getProcessGroup(groupId)); diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java index c62147a368c2..ed71ead166ae 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java @@ -49,6 +49,7 @@ import org.apache.nifi.groups.ProcessGroup; import org.apache.nifi.nar.NarClassLoadersHolder; import org.apache.nifi.registry.client.NiFiRegistryException; +import org.apache.nifi.registry.flow.FlowVersionLocation; import org.apache.nifi.util.NiFiProperties; import org.apache.nifi.web.IllegalClusterResourceRequestException; import org.apache.nifi.web.NiFiServiceFacade; @@ -60,6 +61,8 @@ import org.apache.nifi.web.api.dto.BulletinQueryDTO; import org.apache.nifi.web.api.dto.ClusterDTO; import org.apache.nifi.web.api.dto.ClusterSummaryDTO; +import org.apache.nifi.web.api.dto.ComponentDifferenceDTO; +import org.apache.nifi.web.api.dto.DifferenceDTO; import org.apache.nifi.web.api.dto.NodeDTO; import org.apache.nifi.web.api.dto.ProcessGroupDTO; import org.apache.nifi.web.api.dto.RevisionDTO; @@ -89,6 +92,7 @@ import org.apache.nifi.web.api.entity.FlowAnalysisResultEntity; import org.apache.nifi.web.api.entity.FlowAnalysisRuleTypesEntity; import org.apache.nifi.web.api.entity.FlowBreadcrumbEntity; +import org.apache.nifi.web.api.entity.FlowComparisonEntity; import org.apache.nifi.web.api.entity.FlowConfigurationEntity; import org.apache.nifi.web.api.entity.FlowRegistryBranchEntity; import org.apache.nifi.web.api.entity.FlowRegistryBranchesEntity; @@ -145,6 +149,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.StreamingOutput; +import org.apache.nifi.web.util.PaginationHelper; import java.text.Collator; import java.time.OffsetDateTime; @@ -2057,6 +2062,100 @@ public Response getDetails( return generateOkResponse(flowDetails).build(); } + @GET + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.APPLICATION_JSON) + @Path("registries/{registry-id}/branches/{branch-id-a}/buckets/{bucket-id-a}/flows/{flow-id-a}/{version-a}/diff/branches/{branch-id-b}/buckets/{bucket-id-b}/flows/{flow-id-b}/{version-b}") + @Operation( + summary = "Gets the differences between two versions of the same versioned flow, the basis of the comparison will be the first version", + responses = @ApiResponse(content = @Content(schema = @Schema(implementation = FlowComparisonEntity.class))), + security = { + @SecurityRequirement(name = "Read - /flow") + } + ) + @ApiResponses( + value = { + @ApiResponse(responseCode = "400", description = "NiFi was unable to complete the request because it was invalid. The request should not be retried without modification."), + @ApiResponse(responseCode = "401", description = "Client could not be authenticated."), + @ApiResponse(responseCode = "403", description = "Client is not authorized to make this request."), + @ApiResponse(responseCode = "404", description = "The specified resource could not be found."), + @ApiResponse(responseCode = "409", description = "The request was valid but NiFi was not in the appropriate state to process it.") + } + ) + public Response getVersionDifferences( + @Parameter( + description = "The registry client id.", + required = true + ) + @PathParam("registry-id") String registryId, + + @Parameter( + description = "The branch id for the base version.", + required = true + ) + @PathParam("branch-id-a") String branchIdA, + + @Parameter( + description = "The bucket id for the base version.", + required = true + ) + @PathParam("bucket-id-a") String bucketIdA, + + @Parameter( + description = "The flow id for the base version.", + required = true + ) + @PathParam("flow-id-a") String flowIdA, + + @Parameter( + description = "The base version.", + required = true + ) + @PathParam("version-a") String versionA, + + @Parameter( + description = "The branch id for the compared version.", + required = true + ) + @PathParam("branch-id-b") String branchIdB, + + @Parameter( + description = "The bucket id for the compared version.", + required = true + ) + @PathParam("bucket-id-b") String bucketIdB, + + @Parameter( + description = "The flow id for the compared version.", + required = true + ) + @PathParam("flow-id-b") String flowIdB, + + @Parameter( + description = "The compared version.", + required = true + ) + @PathParam("version-b") String versionB, + @QueryParam("offset") + @Parameter(description = "Must be a non-negative number. Specifies the starting point of the listing. 0 means start from the beginning.") + @DefaultValue("0") + int offset, + @QueryParam("limit") + @Parameter(description = "Limits the number of differences listed. This might lead to partial result. 0 means no limitation is applied.") + @DefaultValue("1000") + int limit + ) { + authorizeFlow(); + FlowVersionLocation baseVersionLocation = new FlowVersionLocation(branchIdA, bucketIdA, flowIdA, versionA); + FlowVersionLocation comparedVersionLocation = new FlowVersionLocation(branchIdB, bucketIdB, flowIdB, versionB); + final FlowComparisonEntity versionDifference = serviceFacade.getVersionDifference(registryId, baseVersionLocation, comparedVersionLocation); + // Note: with the current implementation, this is deterministic. However, the internal data structure used in comparison is set, thus + // later changes might cause discrepancies. Practical use of the endpoint usually remains within one "page" though. + return generateOkResponse(limitDifferences(versionDifference, offset, limit)) + .type(MediaType.APPLICATION_JSON_TYPE) + .build(); + } + @GET @Consumes(MediaType.WILDCARD) @Produces(MediaType.APPLICATION_JSON) @@ -2104,6 +2203,24 @@ public Response getVersions( return generateOkResponse(versionedFlowSnapshotMetadataSetEntity).build(); } + private static FlowComparisonEntity limitDifferences(final FlowComparisonEntity original, final int offset, final int limit) { + final List limited = PaginationHelper.paginateByContainedItems( + original.getComponentDifferences(), offset, limit, ComponentDifferenceDTO::getDifferences, FlowResource::limitDifferences); + final FlowComparisonEntity result = new FlowComparisonEntity(); + result.setComponentDifferences(new HashSet<>(limited)); + return result; + } + + private static ComponentDifferenceDTO limitDifferences(final ComponentDifferenceDTO original, final List partial) { + final ComponentDifferenceDTO result = new ComponentDifferenceDTO(); + result.setComponentType(original.getComponentType()); + result.setComponentId(original.getComponentId()); + result.setComponentName(original.getComponentName()); + result.setProcessGroupId(original.getProcessGroupId()); + result.setDifferences(partial); + return result; + } + // -------------- // bulletin board // -------------- diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/ClosedOpenInterval.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/ClosedOpenInterval.java new file mode 100644 index 000000000000..224aea94a65c --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/ClosedOpenInterval.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.web.util; + +/** + * This implementation includes the lower boundary but does not include the higher boundary. + */ +final class ClosedOpenInterval implements Interval { + private final int lowerBoundary; + private final int higherBoundary; + + /** + * @param lowerBoundary Inclusive index of lower boundary + * @param higherBoundary Exclusive index of higher boundary. In case of 0, the higher boundary is unspecified and the interval is open. + */ + ClosedOpenInterval(final int lowerBoundary, final int higherBoundary) { + if (lowerBoundary < 0) { + throw new IllegalArgumentException("Lower boundary cannot be negative"); + } + + if (higherBoundary < 0) { + throw new IllegalArgumentException("Higher boundary cannot be negative"); + } + + if (higherBoundary <= lowerBoundary && higherBoundary != 0) { + throw new IllegalArgumentException( + "Higher boundary cannot be lower than or equal to lower boundary except when unspecified. Higher boundary is considered unspecified when the value is set to 0" + ); + } + + this.lowerBoundary = lowerBoundary; + this.higherBoundary = higherBoundary; + } + + @Override + public RelativePosition getRelativePositionOf(final int otherIntervalLowerBoundary, final int otherIntervalHigherBoundary) { + if (otherIntervalLowerBoundary < 0) { + throw new IllegalArgumentException("Lower boundary cannot be negative"); + } + + if (otherIntervalHigherBoundary <= 0) { + // Note: as a design decision the implementation currently does not support comparison with unspecified higher boundary + throw new IllegalArgumentException("Higher boundary must be positive"); + } + + if (otherIntervalLowerBoundary >= otherIntervalHigherBoundary) { + throw new IllegalArgumentException("Higher boundary must be greater than lower boundary"); + } + + if (otherIntervalHigherBoundary <= lowerBoundary) { + return RelativePosition.BEFORE; + } else if (otherIntervalLowerBoundary < lowerBoundary && otherIntervalHigherBoundary > higherBoundary && !this.isEndUnspecified()) { + return RelativePosition.EXCEEDS; + } else if (otherIntervalLowerBoundary < lowerBoundary) { + return RelativePosition.TAIL_INTERSECTS; + } else if (otherIntervalHigherBoundary <= higherBoundary || this.isEndUnspecified()) { + return RelativePosition.WITHIN; + } else if (otherIntervalLowerBoundary < higherBoundary) { + return RelativePosition.HEAD_INTERSECTS; + } else { + return RelativePosition.AFTER; + } + } + + private boolean isEndUnspecified() { + return higherBoundary == 0; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/Interval.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/Interval.java new file mode 100644 index 000000000000..59a0c159096b --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/Interval.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.web.util; + +public interface Interval { + + enum RelativePosition { + /** + * The compared interval ends before the actual, there is no intersection. + */ + BEFORE, + + /** + * The compared interval exceeds the actual both at the low and high ends. + */ + EXCEEDS, + + /** + * The compared interval's tail (but not the whole interval) intersects the actual interval (part of it or the whole actual interval). + */ + TAIL_INTERSECTS, + + /** + * The compared interval is within the actual interval. It can match with the actual or contained by that. + */ + WITHIN, + + /** + *The compared interval's head (but not the whole interval) intersects the actual interval (part of it or the whole actual interval). + */ + HEAD_INTERSECTS, + + /** + * The compared interval starts after the actual, there is no intersection. + */ + AFTER, + } + + /** + * Relative position of the "other" interval compared to this. + * + * @param otherIntervalLowerBoundary Lower boundary of the compared interval. + * @param otherIntervalHigherBoundary Higher boundary of the compared interval. + * + * @return Returns the relative position of the "other" interval compared to this interval. For example: if the result + * is BEFORE, read it as: the other interval ends BEFORE the actual (and there is no intersection between them). + */ + RelativePosition getRelativePositionOf(final int otherIntervalLowerBoundary, final int otherIntervalHigherBoundary); +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/IntervalFactory.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/IntervalFactory.java new file mode 100644 index 000000000000..9d26eae1ec1e --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/IntervalFactory.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.web.util; + +public final class IntervalFactory { + private IntervalFactory() { + // Not to be instantiated + } + + /** + * @return Returns an interval instance with closed low and open high boundary. + */ + static Interval getClosedOpenInterval(final int lowerBoundary, final int higherBoundary) { + return new ClosedOpenInterval(lowerBoundary, higherBoundary); + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/PaginationHelper.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/PaginationHelper.java new file mode 100644 index 000000000000..04c90cfbd14a --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/util/PaginationHelper.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.web.util; + +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.function.BiFunction; +import java.util.function.Function; + +public class PaginationHelper { + public static List paginateByContainedItems( + final Iterable original, + final int offset, + final int limit, + final Function> getContainedItems, + final BiFunction, T> createPartialItem + ) { + Objects.requireNonNull(original); + Objects.requireNonNull(getContainedItems); + Objects.requireNonNull(createPartialItem); + + if (offset < 0) { + throw new IllegalArgumentException("Offset cannot be negative"); + } + + if (limit < 0) { + throw new IllegalArgumentException("Limit cannot be negative"); + } + + final List result = new LinkedList<>(); + final int higherBoundary = limit == 0 ? 0 : offset + limit; + final Interval interval = IntervalFactory.getClosedOpenInterval(offset, higherBoundary); + int pointer = 0; + + if (offset == 0 && limit == 0) { + original.forEach(result::add); + return result; + } + + for (final T candidate : original) { + final List containedItems = getContainedItems.apply(candidate); + final ClosedOpenInterval.RelativePosition position = interval.getRelativePositionOf(pointer, pointer + containedItems.size()); + + switch (position) { + case BEFORE: { + pointer += containedItems.size(); + break; + } + case EXCEEDS: { + final int startingPoint = offset - pointer; + final List partialItems = containedItems.subList(startingPoint, limit + 1); + final T partial = createPartialItem.apply(candidate, partialItems); + result.add(partial); + pointer += startingPoint + partialItems.size(); + break; + } + case TAIL_INTERSECTS: { + final List partialItems = containedItems.subList(offset - pointer, containedItems.size()); + final T partial = createPartialItem.apply(candidate, partialItems); + result.add(partial); + pointer += containedItems.size(); + break; + } + case WITHIN: { + result.add(candidate); + pointer += containedItems.size(); + break; + } + case HEAD_INTERSECTS: { + final List partialItems = containedItems.subList(0, limit + offset - pointer); + final T partial = createPartialItem.apply(candidate, partialItems); + result.add(partial); + pointer += partialItems.size(); + break; + } + case AFTER: + default: + // Do nothing + } + } + + return result; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java index 58fa0395e425..8635b709bf29 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java @@ -33,8 +33,12 @@ import org.apache.nifi.prometheus.util.JvmMetricsRegistry; import org.apache.nifi.prometheus.util.NiFiMetricsRegistry; import org.apache.nifi.prometheus.util.PrometheusMetricsUtil; +import org.apache.nifi.registry.flow.FlowVersionLocation; import org.apache.nifi.web.NiFiServiceFacade; import org.apache.nifi.web.ResourceNotFoundException; +import org.apache.nifi.web.api.dto.ComponentDifferenceDTO; +import org.apache.nifi.web.api.dto.DifferenceDTO; +import org.apache.nifi.web.api.entity.FlowComparisonEntity; import org.apache.nifi.web.api.request.FlowMetricsProducer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -50,10 +54,13 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -62,6 +69,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -82,6 +92,13 @@ public class TestFlowResource { private static final int COMPONENT_TYPE_VALUE_INDEX = 1; private static final String CLUSTER_TYPE_LABEL = "cluster"; private static final String CLUSTER_LABEL_KEY = "instance"; + private static final String SAMPLE_REGISTRY_ID = "0e87642a-7720-4799-a3bd-04db74b86e85"; + private static final String SAMPLE_BRANCH_ID_A = "c302f541-976e-4c51-952d-345516444e3d"; + private static final String SAMPLE_BUCKET_ID_A = "23da421d-a8da-4fa3-939e-658d8f35b972"; + private static final String SAMPLE_FLOW_ID_A = "34e4c8c5-f61d-45a4-8035-2aa3641ae904"; + private static final String SAMPLE_BRANCH_ID_B = "fae2ef59-eb0d-4de6-ae31-342089fd229f"; + private static final String SAMPLE_BUCKET_ID_B = "42998285-d06c-41dd-a757-7a14ab9673f4"; + private static final String SAMPLE_FLOW_ID_B = "e6483662-9226-41c1-adec-10357af97ce2"; @InjectMocks private FlowResource resource = new FlowResource(); @@ -282,6 +299,145 @@ public void testGetFlowMetricsPrometheusAsJsonSampleNameAndSampleLabelValue() th assertEquals(2L, result.get(SAMPLE_LABEL_VALUES_ROOT_PROCESS_GROUP)); } + @Test + public void testGetVersionDifferencesWithoutLimitations() { + setUpGetVersionDifference(); + + final Response response = resource.getVersionDifferences( + SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, SAMPLE_FLOW_ID_B, "2", 0, 0); + assertNotNull(response); + assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), response.getMediaType()); + assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity())); + + final FlowComparisonEntity entity = (FlowComparisonEntity) response.getEntity(); + final List differences = entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList()); + assertEquals(5, differences.size()); + } + + @Test + public void testGetVersionDifferencesFromBeginningWithPartialResults() { + setUpGetVersionDifference(); + + final Response response = resource.getVersionDifferences( + SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, SAMPLE_FLOW_ID_B, "2", 0, 2 + ); + + assertNotNull(response); + assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), response.getMediaType()); + assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity())); + + final FlowComparisonEntity entity = (FlowComparisonEntity) response.getEntity(); + final List differences = entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList()); + assertEquals(2, differences.size()); + assertEquals(createDifference("Component Added", "Connection was added"), differences.get(0)); + assertEquals(createDifference("Property Value Changed", "From '0B' to '1KB'"), differences.get(1)); + } + + @Test + public void testGetVersionDifferencesFromBeginningExtendedWithPartialResults() { + setUpGetVersionDifference(); + + final Response response = resource.getVersionDifferences( + SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, SAMPLE_FLOW_ID_B, "2", 0, 3 + ); + + assertNotNull(response); + assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), response.getMediaType()); + assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity())); + + final FlowComparisonEntity entity = (FlowComparisonEntity) response.getEntity(); + final List differences = entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList()); + assertEquals(3, differences.size()); + assertEquals(createDifference("Component Added", "Connection was added"), differences.get(0)); + assertEquals(createDifference("Property Value Changed", "From '0B' to '1KB'"), differences.get(1)); + assertEquals(createDifference("Position Changed", "Position was changed"), differences.get(2)); + } + + @Test + public void testGetVersionDifferencesWithOffsetAndPartialResults() { + setUpGetVersionDifference(); + + final Response response = resource.getVersionDifferences( + SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, SAMPLE_FLOW_ID_B, "2", 2, 3 + ); + + assertNotNull(response); + assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), response.getMediaType()); + assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity())); + + final FlowComparisonEntity entity = (FlowComparisonEntity) response.getEntity(); + final List differences = entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList()); + assertEquals(3, differences.size()); + assertEquals(createDifference("Position Changed", "Position was changed"), differences.get(0)); + assertEquals(createDifference("Property Value Changed", "From 'false' to 'true'"), differences.get(1)); + assertEquals(createDifference("Component Added", "Processor was added"), differences.get(2)); + } + + @Test + public void testGetVersionDifferencesWithOffsetAndOnlyPartialResult() { + setUpGetVersionDifference(); + + final Response response = resource.getVersionDifferences( + SAMPLE_REGISTRY_ID, SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, SAMPLE_FLOW_ID_A, "1", SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, SAMPLE_FLOW_ID_B, "2", 2, 1 + ); + + assertNotNull(response); + assertEquals(MediaType.valueOf(MediaType.APPLICATION_JSON), response.getMediaType()); + assertTrue(FlowComparisonEntity.class.isInstance(response.getEntity())); + + final FlowComparisonEntity entity = (FlowComparisonEntity) response.getEntity(); + final List differences = entity.getComponentDifferences().stream().map(ComponentDifferenceDTO::getDifferences).flatMap(Collection::stream).collect(Collectors.toList()); + assertEquals(1, differences.size()); + assertEquals(createDifference("Position Changed", "Position was changed"), differences.get(0)); + } + + private void setUpGetVersionDifference() { + final FlowVersionLocation baseLocation = new FlowVersionLocation(SAMPLE_BRANCH_ID_A, SAMPLE_BUCKET_ID_A, SAMPLE_FLOW_ID_A, "1"); + final FlowVersionLocation comparedLocation = new FlowVersionLocation(SAMPLE_BRANCH_ID_B, SAMPLE_BUCKET_ID_B, SAMPLE_FLOW_ID_B, "2"); + doReturn(getDifferences()).when(serviceFacade).getVersionDifference(anyString(), any(FlowVersionLocation.class), any(FlowVersionLocation.class)); + } + + private static DifferenceDTO createDifference(final String type, final String difference) { + final DifferenceDTO result = new DifferenceDTO(); + result.setDifferenceType(type); + result.setDifference(difference); + return result; + } + + private static FlowComparisonEntity getDifferences() { + final FlowComparisonEntity differences = new FlowComparisonEntity(); + final Set componentDifferences = new HashSet<>(); + + final ComponentDifferenceDTO changedComponent1 = new ComponentDifferenceDTO(); + changedComponent1.setComponentId("d72f9efe-506d-30e8-8a9f-257a69e73cd2"); + changedComponent1.setComponentName("LogAttribute"); + changedComponent1.setComponentType("Processor"); + changedComponent1.setDifferences(List.of(createDifference("Component Added", "Processor was added"))); + + final ComponentDifferenceDTO changedComponent2 = new ComponentDifferenceDTO(); + changedComponent2.setComponentId("46aa1d19-65ee-32f5-83dc-e14a8d3f7e7f"); + changedComponent2.setComponentName("GenerateFlowFile"); + changedComponent2.setComponentType("Processor"); + changedComponent2.setDifferences(List.of( + createDifference("Property Value Changed", "From '0B' to '1KB'"), + createDifference("Position Changed", "Position was changed"), + createDifference("Property Value Changed", "From 'false' to 'true'") + )); + + final ComponentDifferenceDTO changedComponent3 = new ComponentDifferenceDTO(); + changedComponent3.setComponentId("cfd8f7ec-3f40-3763-af15-2dc0e227ed61"); + changedComponent3.setComponentName(""); + changedComponent3.setComponentType("Connection"); + changedComponent3.setDifferences(List.of(createDifference("Component Added", "Connection was added"))); + + componentDifferences.add(changedComponent1); + componentDifferences.add(changedComponent2); + componentDifferences.add(changedComponent3); + differences.setComponentDifferences(componentDifferences); + + return differences; + } + private String getResponseOutput(final Response response) throws IOException { final StreamingOutput streamingOutput = (StreamingOutput) response.getEntity(); final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/ClosedOpenIntervalTest.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/ClosedOpenIntervalTest.java new file mode 100644 index 000000000000..a38475a0112c --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/ClosedOpenIntervalTest.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.web.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +class ClosedOpenIntervalTest { + + @Test + public void testNegativeLowerBoundary() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new ClosedOpenInterval(-1, 3)); + } + + @Test + public void testNegativeHigherBoundary() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new ClosedOpenInterval(0, -1)); + } + + @Test + public void testSwitchedBoundaries() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new ClosedOpenInterval(7, 3)); + } + + @Test + public void testCompareWhenOtherLowerBoundaryIsNegative() { + final ClosedOpenInterval testSubject = new ClosedOpenInterval(1, 3); + Assertions.assertThrows(IllegalArgumentException.class, () -> testSubject.getRelativePositionOf(-1, 4)); + } + + @Test + public void testCompareWhenOtherBoundariesAreSwitched() { + final ClosedOpenInterval testSubject = new ClosedOpenInterval(1, 3); + Assertions.assertThrows(IllegalArgumentException.class, () -> testSubject.getRelativePositionOf(9, 4)); + } + + @Test + public void testCompareWhenOtherHigherBoundaryIsUnspecified() { + final ClosedOpenInterval testSubject = new ClosedOpenInterval(1, 3); + Assertions.assertThrows(IllegalArgumentException.class, () -> testSubject.getRelativePositionOf(2, 0)); + } + + @Test + public void testZeroElementInterval() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new ClosedOpenInterval(3, 3)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("givenTestData") + public void testCheckForOverlapping( + final String name, final ClosedOpenInterval interval, final int otherIntervalLowerBoundary, final int otherIntervalHigherBoundary, final ClosedOpenInterval.RelativePosition expectedResult + ) { + Assertions.assertEquals(expectedResult, interval.getRelativePositionOf(otherIntervalLowerBoundary, otherIntervalHigherBoundary)); + } + + private static Stream givenTestData() { + return Stream.of( + // Both boundaries are defined + Arguments.of("Other starts after actual ends", new ClosedOpenInterval(7, 10), 11, 13, ClosedOpenInterval.RelativePosition.AFTER), + Arguments.of("Other starts where actual ends", new ClosedOpenInterval(7, 10), 10, 13, ClosedOpenInterval.RelativePosition.AFTER), + Arguments.of("Other starts within actual and ends after actual", new ClosedOpenInterval(7, 10), 9, 13, ClosedOpenInterval.RelativePosition.HEAD_INTERSECTS), + Arguments.of("Other starts within actual and ends where actual ends", new ClosedOpenInterval(7, 10), 8, 10, ClosedOpenInterval.RelativePosition.WITHIN), + Arguments.of("Other is contained by actual", new ClosedOpenInterval(7, 10), 8, 9, ClosedOpenInterval.RelativePosition.WITHIN), + Arguments.of("Other starts where actual and ends within actual", new ClosedOpenInterval(7, 10), 7, 12, ClosedOpenInterval.RelativePosition.HEAD_INTERSECTS), + Arguments.of("Other matches actual", new ClosedOpenInterval(7, 10), 7, 10, ClosedOpenInterval.RelativePosition.WITHIN), + Arguments.of("Other starts where actual and finishes within", new ClosedOpenInterval(7, 10), 7, 9, ClosedOpenInterval.RelativePosition.WITHIN), + Arguments.of("Other exceeds actual in both directions", new ClosedOpenInterval(7, 10), 6, 12, ClosedOpenInterval.RelativePosition.EXCEEDS), + Arguments.of("Other starts before actual and ends where actual ends", new ClosedOpenInterval(7, 10), 5, 10, ClosedOpenInterval.RelativePosition.TAIL_INTERSECTS), + Arguments.of("Other starts before actual and ends within", new ClosedOpenInterval(7, 10), 5, 9, ClosedOpenInterval.RelativePosition.TAIL_INTERSECTS), + Arguments.of("Other starts where actual ends", new ClosedOpenInterval(7, 10), 2, 7, ClosedOpenInterval.RelativePosition.BEFORE), + Arguments.of("Other precedes actual interval", new ClosedOpenInterval(7, 10), 2, 6, ClosedOpenInterval.RelativePosition.BEFORE), + + // Actual has no higher boundary defined + Arguments.of("Fully within with no higher boundary, when start at lower boundary", new ClosedOpenInterval(3, 0), 3, 6, ClosedOpenInterval.RelativePosition.WITHIN), + Arguments.of("Fully within with no higher boundary", new ClosedOpenInterval(3, 0), 11, 12, ClosedOpenInterval.RelativePosition.WITHIN), + Arguments.of("Tail is within with no higher boundary", new ClosedOpenInterval(3, 0), 2, 4, ClosedOpenInterval.RelativePosition.TAIL_INTERSECTS) + ); + } +} \ No newline at end of file diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/PaginationHelperTest.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/PaginationHelperTest.java new file mode 100644 index 000000000000..669f9105b972 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/util/PaginationHelperTest.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.web.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +class PaginationHelperTest { + + @Test + public void testCreatingWithNegativeOffset() { + Assertions.assertThrows(IllegalArgumentException.class, () -> PaginationHelper.paginateByContainedItems(getTestInput(), -1, 3, Function.identity(), (original, partialList) -> partialList)); + } + + @Test + public void testCreatingWithNegativeLimit() { + Assertions.assertThrows(IllegalArgumentException.class, () -> PaginationHelper.paginateByContainedItems(getTestInput(), 0, -1, Function.identity(), (original, partialList) -> partialList)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("givenTestData") + public void testPaginateByContainedItems(final String name, final int offset, final int limit, final List expectedResult) { + final List> result = PaginationHelper.paginateByContainedItems(getTestInput(), offset, limit, Function.identity(), (original, partialList) -> partialList); + final List flatten = result.stream().flatMap(Collection::stream).collect(Collectors.toList()); + Assertions.assertIterableEquals(expectedResult, flatten); + } + + private static Stream givenTestData() { + return Stream.of( + Arguments.of("Full result set", 0, 0, Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)), + Arguments.of("Offset only when starts with full", 3, 0, Arrays.asList(4, 5, 6, 7, 8, 9, 10, 11, 12)), + Arguments.of("Offset only when starts with partial", 4, 0, Arrays.asList(5, 6, 7, 8, 9, 10, 11, 12)), + Arguments.of("From beginning with partial", 0, 5, Arrays.asList(1, 2, 3, 4, 5)), + Arguments.of("Beginning with partial and offset", 1, 5, Arrays.asList(2, 3, 4, 5, 6)), + Arguments.of("Middle partial only", 4, 2, Arrays.asList(5, 6)), + Arguments.of("From the end with partial", 9, 3, Arrays.asList(10, 11, 12)), + Arguments.of("From the end with partial when spills over", 9, 5, Arrays.asList(10, 11, 12)), + Arguments.of("Clear cut", 3, 5, Arrays.asList(4, 5, 6, 7, 8)), + Arguments.of("Long result", 2, 8, Arrays.asList(3, 4, 5, 6, 7, 8, 9, 10)), + Arguments.of("All the original items", 0, 12, Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)), + Arguments.of("Second partial", 5, 7, Arrays.asList(6, 7, 8, 9, 10, 11, 12)), + Arguments.of("From the end of the range", 12, 3, Collections.emptyList()), + Arguments.of("Outside of the range", 14, 4, Collections.emptyList()) + ); + } + + private static List> getTestInput() { + final List l1 = Arrays.asList(1, 2, 3); + final List l2 = Arrays.asList(4, 5, 6, 7, 8); + final List l3 = Arrays.asList(9, 10, 11, 12); + return new ArrayList<>(Arrays.asList(l1, l2, l3)); + } +} \ No newline at end of file