This is an automated email from the ASF dual-hosted git repository.
mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new ada9a5c51b Issue #2728 : Create mapping from selected sequential
transforms (#8272)
ada9a5c51b is described below
commit ada9a5c51bf8704274e3566a0f5b065c20d7a757
Author: Matt Casters <[email protected]>
AuthorDate: Tue Sep 8 14:22:02 2026 +0200
Issue #2728 : Create mapping from selected sequential transforms (#8272)
* Issue #2728 : Create mapping from selected sequential transforms
* Issue #2728 : Keep dialog filename and align mapping save with pipeline
save
---
.../ROOT/pages/pipeline/create-pipeline.adoc | 1 +
.../pages/pipeline/transforms/simple-mapping.adoc | 11 +
.../pages/snippets/best-practices/mappings.adoc | 2 +
.../mapping/CreateMappingFromSelection.java | 603 +++++++++++++++++++++
.../transforms/mapping/CreateMappingGuiPlugin.java | 208 +++++++
.../mapping/messages/messages_en_US.properties | 19 +-
.../mapping/CreateMappingFromSelectionTest.java | 409 ++++++++++++++
7 files changed, 1252 insertions(+), 1 deletion(-)
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
index cb435efbf1..81759a9e18 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
@@ -96,6 +96,7 @@ image::hop-gui/pipeline/transforms.png[Hop -
transforms,75%,align="left"]
|===
|Action|Description
|Detach transform|Detach the transform from the pipeline
+|Create mapping|Extract the selected sequential transforms into a mapping
pipeline and replace them with a Simple Mapping transform
|Edit the transform|Edit the transform's metadata
|Copy transform to clipboard|Copies selected items to clipboard.
|Create hop| Creates a new hop between two transforms.
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/simple-mapping.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/simple-mapping.adoc
index 881107c7e9..234a7e97b7 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/simple-mapping.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/simple-mapping.adoc
@@ -46,6 +46,17 @@ Use this transform if you find yourself repeating the same
logic multiple times
If you need more than one input or output, or info / target hops (for example
a reusable Stream Lookup or Switch / Case), use
xref:pipeline/transforms/multi-mapping.adoc[Multi Mapping] instead.
+== Create mapping from the canvas
+
+To extract an existing sequential chain into a mapping:
+
+. Select one transform, or a chain of transforms connected by hops (`A → B →
C`).
+. Right-click a selected transform and choose *Create mapping*.
+. Save the new mapping pipeline (Mapping input, the selected transforms,
Mapping output).
+. Hop replaces the selection with a Simple Mapping transform at the location
of the first selected transform and rewires the surrounding hops.
+
+The selection must be a single path with at most one incoming hop and one
outgoing hop. Splits, joins, extra info hops, and error hops that leave the
selection are not supported; use Multi Mapping for those cases. Cancelling the
save dialog leaves the original pipeline unchanged.
+
== Options
The options are fairly self-explanatory:
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/snippets/best-practices/mappings.adoc
b/docs/hop-user-manual/modules/ROOT/pages/snippets/best-practices/mappings.adoc
index 8667fae483..fc46ad928c 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/snippets/best-practices/mappings.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/snippets/best-practices/mappings.adoc
@@ -26,6 +26,8 @@ The Simple Mapping is a pipeline reading from a
xref:pipeline/transforms/mapping
You can re-use the work in other pipelines using the
xref:pipeline/transforms/simple-mapping.adoc[Simple Mapping] transform.
+To turn an existing sequential chain into a mapping, select the transforms on
the canvas and choose *Create mapping* from the transform context menu.
+
When the reusable logic needs more than one input or output, or info / target
hops, use xref:pipeline/transforms/multi-mapping.adoc[Multi Mapping] instead.
diff --git
a/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelection.java
b/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelection.java
new file mode 100644
index 0000000000..1e4fc71396
--- /dev/null
+++
b/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelection.java
@@ -0,0 +1,603 @@
+/*
+ * 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.hop.pipeline.transforms.mapping;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.Getter;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.parameters.DuplicateParamException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineHopMeta;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.ITransformIOMeta;
+import org.apache.hop.pipeline.transform.ITransformMeta;
+import org.apache.hop.pipeline.transform.TransformErrorMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transform.stream.IStream;
+import org.apache.hop.pipeline.transforms.input.MappingInputMeta;
+import org.apache.hop.pipeline.transforms.multimapping.MultiMappingMeta;
+import org.apache.hop.pipeline.transforms.output.MappingOutputMeta;
+
+/**
+ * Extracts a sequential chain of transforms into a mapping pipeline (Mapping
Input → selection →
+ * Mapping Output) and replaces that chain in the parent pipeline with a
Simple Mapping transform.
+ */
+public final class CreateMappingFromSelection {
+
+ private static final Class<?> PKG = CreateMappingFromSelection.class;
+
+ static final String PLUGIN_MAPPING_INPUT = "MappingInput";
+ static final String PLUGIN_MAPPING_OUTPUT = "MappingOutput";
+ static final String PLUGIN_SIMPLE_MAPPING = "SimpleMapping";
+ static final String PLUGIN_MULTI_MAPPING = "MultiMapping";
+
+ static final String DEFAULT_MAPPING_INPUT_NAME = "Mapping Input";
+ static final String DEFAULT_MAPPING_OUTPUT_NAME = "Mapping Output";
+ static final String DEFAULT_SIMPLE_MAPPING_NAME = "Simple mapping";
+
+ static final int MAPPING_IO_X_OFFSET = 150;
+
+ public static final String KEY_EMPTY = "CreateMapping.Error.EmptySelection";
+ public static final String KEY_MAPPING_TRANSFORM =
"CreateMapping.Error.MappingTransform";
+ public static final String KEY_NOT_CONNECTED =
"CreateMapping.Error.NotConnected";
+ public static final String KEY_NOT_A_PATH = "CreateMapping.Error.NotAPath";
+ public static final String KEY_MULTIPLE_INPUTS =
"CreateMapping.Error.MultipleInputs";
+ public static final String KEY_MULTIPLE_OUTPUTS =
"CreateMapping.Error.MultipleOutputs";
+ public static final String KEY_ERROR_HOP = "CreateMapping.Error.ErrorHopOut";
+ public static final String KEY_INFO_HOP = "CreateMapping.Error.InfoHopIn";
+ public static final String KEY_CYCLE = "CreateMapping.Error.Cycle";
+
+ private CreateMappingFromSelection() {
+ // utility
+ }
+
+ /**
+ * When the context transform is part of a multi-selection, operate on the
whole selection;
+ * otherwise just the clicked transform.
+ */
+ public static List<TransformMeta> resolveSelectedTransforms(
+ PipelineMeta pipelineMeta, TransformMeta contextTransform) {
+ if (contextTransform == null) {
+ return List.of();
+ }
+ List<TransformMeta> selected = pipelineMeta.getSelectedTransforms();
+ if (Utils.isEmpty(selected) || !selected.contains(contextTransform)) {
+ return List.of(contextTransform);
+ }
+ return new ArrayList<>(selected);
+ }
+
+ public static Result analyze(PipelineMeta parent, List<TransformMeta>
selectedTransforms) {
+ if (Utils.isEmpty(selectedTransforms)) {
+ return Result.error(KEY_EMPTY);
+ }
+
+ Set<TransformMeta> selected = new HashSet<>(selectedTransforms);
+ for (TransformMeta transformMeta : selectedTransforms) {
+ if (isMappingPlugin(transformMeta)) {
+ return Result.error(KEY_MAPPING_TRANSFORM, transformMeta.getName());
+ }
+ }
+
+ List<PipelineHopMeta> internalHops = new ArrayList<>();
+ List<PipelineHopMeta> externalIncoming = new ArrayList<>();
+ List<PipelineHopMeta> externalOutgoing = new ArrayList<>();
+ boolean infoIncoming = false;
+ boolean boundaryErrorHop = false;
+
+ for (PipelineHopMeta hop : parent.getPipelineHops()) {
+ TransformMeta from = hop.getFromTransform();
+ TransformMeta to = hop.getToTransform();
+ if (from == null || to == null) {
+ continue;
+ }
+ boolean fromSel = selected.contains(from);
+ boolean toSel = selected.contains(to);
+ if (!fromSel && !toSel) {
+ continue;
+ }
+ if (isErrorHop(hop, from, to) && fromSel != toSel) {
+ boundaryErrorHop = true;
+ }
+ if (fromSel && toSel) {
+ internalHops.add(hop);
+ } else if (!fromSel && toSel) {
+ if (to.getTransform() != null && parent.isTransformInformative(to,
from)) {
+ infoIncoming = true;
+ }
+ externalIncoming.add(hop);
+ } else {
+ externalOutgoing.add(hop);
+ }
+ }
+
+ for (TransformMeta transformMeta : selectedTransforms) {
+ TransformErrorMeta errorMeta = transformMeta.getTransformErrorMeta();
+ if (errorMeta != null
+ && errorMeta.getTargetTransform() != null
+ && !selected.contains(errorMeta.getTargetTransform())) {
+ boundaryErrorHop = true;
+ }
+ }
+
+ if (boundaryErrorHop) {
+ return Result.error(KEY_ERROR_HOP);
+ }
+ if (infoIncoming) {
+ return Result.error(KEY_INFO_HOP);
+ }
+ if (externalIncoming.size() > 1) {
+ return Result.error(KEY_MULTIPLE_INPUTS);
+ }
+ if (externalOutgoing.size() > 1) {
+ return Result.error(KEY_MULTIPLE_OUTPUTS);
+ }
+
+ Map<TransformMeta, Integer> selectedPred = new HashMap<>();
+ Map<TransformMeta, Integer> selectedSucc = new HashMap<>();
+ Map<TransformMeta, List<TransformMeta>> undirected = new HashMap<>();
+ for (TransformMeta transformMeta : selectedTransforms) {
+ selectedPred.put(transformMeta, 0);
+ selectedSucc.put(transformMeta, 0);
+ undirected.put(transformMeta, new ArrayList<>());
+ }
+ for (PipelineHopMeta hop : internalHops) {
+ TransformMeta from = hop.getFromTransform();
+ TransformMeta to = hop.getToTransform();
+ selectedSucc.merge(from, 1, Integer::sum);
+ selectedPred.merge(to, 1, Integer::sum);
+ undirected.get(from).add(to);
+ undirected.get(to).add(from);
+ }
+ for (TransformMeta transformMeta : selectedTransforms) {
+ if (selectedPred.get(transformMeta) > 1 ||
selectedSucc.get(transformMeta) > 1) {
+ return Result.error(KEY_NOT_A_PATH);
+ }
+ }
+
+ if (selectedTransforms.size() > 1) {
+ if (internalHops.isEmpty()) {
+ return Result.error(KEY_NOT_CONNECTED);
+ }
+ Set<TransformMeta> visited = new HashSet<>();
+ ArrayDeque<TransformMeta> queue = new ArrayDeque<>();
+ TransformMeta start = selectedTransforms.get(0);
+ queue.add(start);
+ visited.add(start);
+ while (!queue.isEmpty()) {
+ TransformMeta current = queue.removeFirst();
+ for (TransformMeta neighbor : undirected.get(current)) {
+ if (visited.add(neighbor)) {
+ queue.add(neighbor);
+ }
+ }
+ }
+ if (visited.size() != selected.size()) {
+ return Result.error(KEY_NOT_CONNECTED);
+ }
+ }
+
+ List<TransformMeta> entries = new ArrayList<>();
+ List<TransformMeta> exits = new ArrayList<>();
+ for (TransformMeta transformMeta : selectedTransforms) {
+ if (selectedPred.get(transformMeta) == 0) {
+ entries.add(transformMeta);
+ }
+ if (selectedSucc.get(transformMeta) == 0) {
+ exits.add(transformMeta);
+ }
+ }
+ if (entries.size() != 1 || exits.size() != 1) {
+ return Result.error(KEY_CYCLE);
+ }
+
+ TransformMeta entry = entries.get(0);
+ TransformMeta exit = exits.get(0);
+ PipelineHopMeta incomingHop = externalIncoming.isEmpty() ? null :
externalIncoming.get(0);
+ PipelineHopMeta outgoingHop = externalOutgoing.isEmpty() ? null :
externalOutgoing.get(0);
+
+ PipelineMeta mappingPipeline =
+ buildMappingPipeline(parent, selectedTransforms, internalHops, entry,
exit);
+
+ Point location =
+ entry.getLocation() != null ? new Point(entry.getLocation()) : new
Point(50, 50);
+
+ return new Result(
+ null,
+ null,
+ mappingPipeline,
+ entry,
+ exit,
+ incomingHop != null ? incomingHop.getFromTransform() : null,
+ outgoingHop != null ? outgoingHop.getToTransform() : null,
+ incomingHop == null || incomingHop.isEnabled(),
+ outgoingHop == null || outgoingHop.isEnabled(),
+ location,
+ new ArrayList<>(selectedTransforms));
+ }
+
+ public static TransformMeta replaceSelection(
+ PipelineMeta parent, Result result, String mappingFilename) {
+ if (result == null || !result.isValid()) {
+ return null;
+ }
+
+ List<TransformMeta> selected = result.getSelected();
+ Set<TransformMeta> selectedSet = new HashSet<>(selected);
+
+ List<PipelineHopMeta> hopsToRemove = new ArrayList<>();
+ for (PipelineHopMeta hop : parent.getPipelineHops()) {
+ TransformMeta from = hop.getFromTransform();
+ TransformMeta to = hop.getToTransform();
+ if ((from != null && selectedSet.contains(from))
+ || (to != null && selectedSet.contains(to))) {
+ hopsToRemove.add(hop);
+ }
+ }
+
+ for (PipelineHopMeta hop : hopsToRemove) {
+ TransformMeta from = hop.getFromTransform();
+ TransformMeta to = hop.getToTransform();
+ if (to != null && !selectedSet.contains(to) && to.getTransform() !=
null) {
+ to.getTransform().cleanAfterHopToRemove(from);
+ }
+ if (from != null && !selectedSet.contains(from) && from.getTransform()
!= null) {
+ from.getTransform().cleanAfterHopFromRemove(to);
+ }
+ }
+
+ for (TransformMeta transformMeta : selected) {
+ int index = parent.indexOfTransform(transformMeta);
+ if (index >= 0) {
+ parent.removeTransform(index);
+ }
+ }
+
+ SimpleMappingMeta mappingMeta = new SimpleMappingMeta();
+ mappingMeta.setDefault();
+ mappingMeta.setFilename(mappingFilename);
+
+ String name =
parent.getAlternativeTransformName(transformNameFromFilename(mappingFilename));
+ TransformMeta simpleMapping = new TransformMeta(PLUGIN_SIMPLE_MAPPING,
name, mappingMeta);
+ Point location = result.getSimpleMappingLocation();
+ if (location != null) {
+ simpleMapping.setLocation(location.x, location.y);
+ }
+ parent.addTransform(simpleMapping);
+
+ TransformMeta incomingFrom = result.getIncomingFrom();
+ if (incomingFrom != null && parent.findTransform(incomingFrom.getName())
!= null) {
+ PipelineHopMeta inHop =
+ new PipelineHopMeta(incomingFrom, simpleMapping,
result.isIncomingHopEnabled());
+ parent.addPipelineHop(inHop);
+ retargetTargetStreams(incomingFrom, result.getEntry(), simpleMapping);
+ }
+
+ TransformMeta outgoingTo = result.getOutgoingTo();
+ if (outgoingTo != null && parent.findTransform(outgoingTo.getName()) !=
null) {
+ PipelineHopMeta outHop =
+ new PipelineHopMeta(simpleMapping, outgoingTo,
result.isOutgoingHopEnabled());
+ parent.addPipelineHop(outHop);
+ retargetInfoStreams(outgoingTo, result.getExit(), simpleMapping);
+ }
+
+ if (incomingFrom != null && incomingFrom.getTransform() != null) {
+
incomingFrom.getTransform().searchInfoAndTargetTransforms(parent.getTransforms());
+ }
+ if (outgoingTo != null && outgoingTo.getTransform() != null) {
+
outgoingTo.getTransform().searchInfoAndTargetTransforms(parent.getTransforms());
+ }
+
+ parent.unselectAll();
+ simpleMapping.setSelected(true);
+ return simpleMapping;
+ }
+
+ /** Base filename without extension, used as the Simple Mapping transform
name. */
+ public static String transformNameFromFilename(String mappingFilename) {
+ if (StringUtils.isEmpty(mappingFilename)) {
+ return DEFAULT_SIMPLE_MAPPING_NAME;
+ }
+ String baseName = FilenameUtils.getBaseName(mappingFilename.replace('\\',
'/'));
+ return StringUtils.isEmpty(baseName) ? DEFAULT_SIMPLE_MAPPING_NAME :
baseName;
+ }
+
+ /**
+ * Resolves variables in a path. Returns {@code null} when the result still
contains {@code
+ * ${...}} so callers do not treat an expression as a real folder (e.g.
{@code
+ * ./target/hop/${PROJECT_HOME}}).
+ */
+ public static String resolveFilesystemPath(String path, IVariables
variables) {
+ if (StringUtils.isEmpty(path)) {
+ return path;
+ }
+ String resolved = variables != null ? variables.resolve(path) : path;
+ if (resolved.contains("${")) {
+ return null;
+ }
+ return resolved;
+ }
+
+ public static String suggestFilename(
+ PipelineMeta parent, TransformMeta entry, IVariables variables) {
+ String folder = null;
+ String parentBase = "pipeline";
+ if (parent != null && StringUtils.isNotEmpty(parent.getFilename())) {
+ String parentFilename = resolveFilesystemPath(parent.getFilename(),
variables);
+ if (StringUtils.isNotEmpty(parentFilename)) {
+ try {
+ FileObject file = HopVfs.getFileObject(parentFilename, variables);
+ FileObject parentFolder = file.getParent();
+ if (parentFolder != null) {
+ folder = parentFolder.getName().getURI();
+ }
+ String baseName = file.getName().getBaseName();
+ if
(baseName.toLowerCase().endsWith(PipelineMeta.PIPELINE_EXTENSION)) {
+ parentBase =
+ baseName.substring(0, baseName.length() -
PipelineMeta.PIPELINE_EXTENSION.length());
+ } else if (StringUtils.isNotEmpty(baseName)) {
+ parentBase = baseName;
+ }
+ } catch (Exception e) {
+ folder = null;
+ }
+ } else {
+ String baseName = parent.getFilename().replace('\\', '/');
+ int slash = baseName.lastIndexOf('/');
+ if (slash >= 0) {
+ baseName = baseName.substring(slash + 1);
+ }
+ if (baseName.toLowerCase().endsWith(PipelineMeta.PIPELINE_EXTENSION)) {
+ parentBase =
+ baseName.substring(0, baseName.length() -
PipelineMeta.PIPELINE_EXTENSION.length());
+ } else if (StringUtils.isNotEmpty(baseName)) {
+ parentBase = baseName;
+ }
+ }
+ }
+ if (folder == null && variables != null) {
+ folder = resolveFilesystemPath(Const.VAR_PROJECT_HOME, variables);
+ }
+ if (folder == null) {
+ folder = "";
+ }
+ String transformPart =
+ entry != null && StringUtils.isNotEmpty(entry.getName())
+ ? entry.getName().replaceAll("[^A-Za-z0-9._-]+", "-")
+ : "mapping";
+ String separator = folder.endsWith("/") || folder.endsWith("\\") ||
folder.isEmpty() ? "" : "/";
+ return folder + separator + parentBase + "-" + transformPart +
"-mapping.hpl";
+ }
+
+ private static PipelineMeta buildMappingPipeline(
+ PipelineMeta parent,
+ List<TransformMeta> selectedTransforms,
+ List<PipelineHopMeta> internalHops,
+ TransformMeta entry,
+ TransformMeta exit) {
+ PipelineMeta mapping = new PipelineMeta();
+ mapping.setMetadataProvider(parent.getMetadataProvider());
+ mapping.setNameSynchronizedWithFilename(true);
+ copyParameters(parent, mapping);
+
+ Map<TransformMeta, TransformMeta> clones = new HashMap<>();
+ for (TransformMeta transformMeta : selectedTransforms) {
+ TransformMeta clone = (TransformMeta) transformMeta.clone();
+ clone.setSelected(false);
+ mapping.addTransform(clone);
+ clones.put(transformMeta, clone);
+ }
+
+ for (PipelineHopMeta hop : internalHops) {
+ TransformMeta from = clones.get(hop.getFromTransform());
+ TransformMeta to = clones.get(hop.getToTransform());
+ if (from == null || to == null) {
+ continue;
+ }
+ PipelineHopMeta clonedHop = new PipelineHopMeta(from, to,
hop.isEnabled());
+ clonedHop.setErrorHop(hop.isErrorHop());
+ mapping.addPipelineHop(clonedHop);
+ }
+
+ for (TransformMeta original : selectedTransforms) {
+ TransformMeta clone = clones.get(original);
+ TransformErrorMeta errorMeta = clone.getTransformErrorMeta();
+ if (errorMeta == null) {
+ continue;
+ }
+ errorMeta.setSourceTransform(clone);
+ TransformMeta originalTarget = errorMeta.getTargetTransform();
+ if (originalTarget != null && clones.containsKey(originalTarget)) {
+ errorMeta.setTargetTransform(clones.get(originalTarget));
+ } else {
+ clone.setTransformErrorMeta(null);
+ }
+ }
+
+ for (TransformMeta clone : clones.values()) {
+ ITransformMeta meta = clone.getTransform();
+ if (meta != null) {
+ meta.searchInfoAndTargetTransforms(mapping.getTransforms());
+ }
+ }
+
+ TransformMeta mappingInput =
+ new TransformMeta(
+ PLUGIN_MAPPING_INPUT,
+ mapping.getAlternativeTransformName(DEFAULT_MAPPING_INPUT_NAME),
+ new MappingInputMeta());
+ Point entryLocation = entry.getLocation() != null ? entry.getLocation() :
new Point(200, 100);
+ mappingInput.setLocation(entryLocation.x - MAPPING_IO_X_OFFSET,
entryLocation.y);
+ mapping.addTransform(mappingInput);
+ mapping.addPipelineHop(new PipelineHopMeta(mappingInput,
clones.get(entry)));
+
+ TransformMeta mappingOutput =
+ new TransformMeta(
+ PLUGIN_MAPPING_OUTPUT,
+ mapping.getAlternativeTransformName(DEFAULT_MAPPING_OUTPUT_NAME),
+ new MappingOutputMeta());
+ Point exitLocation = exit.getLocation() != null ? exit.getLocation() :
entryLocation;
+ mappingOutput.setLocation(exitLocation.x + MAPPING_IO_X_OFFSET,
exitLocation.y);
+ mapping.addTransform(mappingOutput);
+ mapping.addPipelineHop(new PipelineHopMeta(clones.get(exit),
mappingOutput));
+
+ return mapping;
+ }
+
+ private static void copyParameters(PipelineMeta parent, PipelineMeta
mapping) {
+ for (String param : parent.listParameters()) {
+ try {
+ mapping.addParameterDefinition(
+ param, parent.getParameterDefault(param),
parent.getParameterDescription(param));
+ } catch (DuplicateParamException e) {
+ // already defined
+ } catch (Exception e) {
+ // skip a parameter we cannot copy
+ }
+ }
+ }
+
+ private static void retargetTargetStreams(
+ TransformMeta from, TransformMeta oldTarget, TransformMeta newTarget) {
+ if (from == null || from.getTransform() == null || oldTarget == null ||
newTarget == null) {
+ return;
+ }
+ ITransformIOMeta io = from.getTransform().getTransformIOMeta();
+ if (io == null) {
+ return;
+ }
+ for (IStream stream : io.getTargetStreams()) {
+ if (stream.getTransformMeta() != null &&
stream.getTransformMeta().equals(oldTarget)) {
+ stream.setTransformMeta(newTarget);
+ stream.setSubject(newTarget.getName());
+ from.getTransform().handleStreamSelection(stream);
+ }
+ }
+ }
+
+ private static void retargetInfoStreams(
+ TransformMeta to, TransformMeta oldSource, TransformMeta newSource) {
+ if (to == null || to.getTransform() == null || oldSource == null ||
newSource == null) {
+ return;
+ }
+ ITransformIOMeta io = to.getTransform().getTransformIOMeta();
+ if (io == null) {
+ return;
+ }
+ for (IStream stream : io.getInfoStreams()) {
+ if (stream.getTransformMeta() != null &&
stream.getTransformMeta().equals(oldSource)) {
+ stream.setTransformMeta(newSource);
+ stream.setSubject(newSource.getName());
+ to.getTransform().handleStreamSelection(stream);
+ }
+ }
+ }
+
+ private static boolean isErrorHop(PipelineHopMeta hop, TransformMeta from,
TransformMeta to) {
+ if (hop.isErrorHop()) {
+ return true;
+ }
+ return from != null && from.isSendingErrorRowsToTransform(to);
+ }
+
+ private static boolean isMappingPlugin(TransformMeta transformMeta) {
+ if (transformMeta == null) {
+ return false;
+ }
+ ITransformMeta meta = transformMeta.getTransform();
+ if (meta instanceof MappingInputMeta
+ || meta instanceof MappingOutputMeta
+ || meta instanceof SimpleMappingMeta
+ || meta instanceof MultiMappingMeta) {
+ return true;
+ }
+ String pluginId = transformMeta.getPluginId();
+ return PLUGIN_MAPPING_INPUT.equals(pluginId)
+ || PLUGIN_MAPPING_OUTPUT.equals(pluginId)
+ || PLUGIN_SIMPLE_MAPPING.equals(pluginId)
+ || PLUGIN_MULTI_MAPPING.equals(pluginId);
+ }
+
+ @Getter
+ public static final class Result {
+ private final String validationKey;
+ private final Object[] validationArgs;
+ private final PipelineMeta mappingPipeline;
+ private final TransformMeta entry;
+ private final TransformMeta exit;
+ private final TransformMeta incomingFrom;
+ private final TransformMeta outgoingTo;
+ private final boolean incomingHopEnabled;
+ private final boolean outgoingHopEnabled;
+ private final Point simpleMappingLocation;
+ private final List<TransformMeta> selected;
+
+ private Result(
+ String validationKey,
+ Object[] validationArgs,
+ PipelineMeta mappingPipeline,
+ TransformMeta entry,
+ TransformMeta exit,
+ TransformMeta incomingFrom,
+ TransformMeta outgoingTo,
+ boolean incomingHopEnabled,
+ boolean outgoingHopEnabled,
+ Point simpleMappingLocation,
+ List<TransformMeta> selected) {
+ this.validationKey = validationKey;
+ this.validationArgs = validationArgs == null ? new Object[0] :
validationArgs;
+ this.mappingPipeline = mappingPipeline;
+ this.entry = entry;
+ this.exit = exit;
+ this.incomingFrom = incomingFrom;
+ this.outgoingTo = outgoingTo;
+ this.incomingHopEnabled = incomingHopEnabled;
+ this.outgoingHopEnabled = outgoingHopEnabled;
+ this.simpleMappingLocation = simpleMappingLocation;
+ this.selected = selected == null ? List.of() : selected;
+ }
+
+ static Result error(String key, Object... args) {
+ return new Result(key, args, null, null, null, null, null, true, true,
null, List.of());
+ }
+
+ public boolean isValid() {
+ return validationKey == null;
+ }
+
+ public String getValidationError() {
+ if (validationKey == null) {
+ return null;
+ }
+ return BaseMessages.getString(PKG, validationKey, validationArgs);
+ }
+ }
+}
diff --git
a/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingGuiPlugin.java
b/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingGuiPlugin.java
new file mode 100644
index 0000000000..6c4146be4f
--- /dev/null
+++
b/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingGuiPlugin.java
@@ -0,0 +1,208 @@
+/*
+ * 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.hop.pipeline.transforms.mapping;
+
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.action.GuiContextAction;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPointHandler;
+import org.apache.hop.core.extension.HopExtensionPoint;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.security.Permission;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.core.security.HopSecurityUi;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
+import org.apache.hop.ui.hopgui.file.pipeline.HopPipelineFileType;
+import
org.apache.hop.ui.hopgui.file.pipeline.context.HopGuiPipelineTransformContext;
+import
org.apache.hop.ui.hopgui.file.pipeline.extension.PipelineRenamedExtension;
+import org.apache.hop.ui.hopgui.file.shared.ReferencedConnectionSaveValidator;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Shell;
+
+@GuiPlugin
+public class CreateMappingGuiPlugin {
+
+ public static final String ACTION_ID_PIPELINE_GRAPH_TRANSFORM_CREATE_MAPPING
=
+ "pipeline-graph-transform-10900-create-mapping";
+
+ private static final Class<?> PKG = CreateMappingGuiPlugin.class;
+
+ @GuiContextAction(
+ id = ACTION_ID_PIPELINE_GRAPH_TRANSFORM_CREATE_MAPPING,
+ parentId = HopGuiPipelineTransformContext.CONTEXT_ID,
+ type = GuiActionType.Create,
+ name = "i18n::CreateMapping.Action.Name",
+ tooltip = "i18n::CreateMapping.Action.Tooltip",
+ image = "MAP.svg",
+ category =
+
"i18n:org.apache.hop.ui.hopgui.file.pipeline:HopGuiPipelineGraph.ContextualAction.Category.Basic.Text",
+ categoryOrder = "1")
+ public void createMapping(HopGuiPipelineTransformContext context) {
+ HopGui hopGui = HopGui.getInstance();
+ if (!HopSecurityUi.check(Permission.FILE_SAVE) ||
!HopSecurityUi.check(Permission.FILE_EDIT)) {
+ return;
+ }
+
+ PipelineMeta pipelineMeta = context.getPipelineMeta();
+ HopGuiPipelineGraph pipelineGraph = context.getPipelineGraph();
+ IVariables variables = pipelineGraph.getVariables();
+ Shell shell = hopGui.getShell();
+
+ List<TransformMeta> selected =
+ CreateMappingFromSelection.resolveSelectedTransforms(
+ pipelineMeta, context.getTransformMeta());
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(pipelineMeta, selected);
+ if (!result.isValid()) {
+ MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+ box.setText(BaseMessages.getString(PKG, "CreateMapping.Error.Title"));
+ box.setMessage(result.getValidationError());
+ box.open();
+ return;
+ }
+
+ try {
+ String filename = askSaveFilename(shell, pipelineMeta,
result.getEntry(), variables);
+ if (StringUtils.isEmpty(filename)) {
+ return;
+ }
+ if (!filename.toLowerCase().endsWith(PipelineMeta.PIPELINE_EXTENSION)) {
+ filename = filename + PipelineMeta.PIPELINE_EXTENSION;
+ }
+ // Keep the filename the dialog handed us: the HopGuiFileOpenedDialog
extension point has
+ // already replaced every project path variable in it. Resolve only for
the VFS write.
+ String resolvedFilename =
+ CreateMappingFromSelection.resolveFilesystemPath(filename,
variables);
+ if (StringUtils.isEmpty(resolvedFilename)) {
+ MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+ box.setText(BaseMessages.getString(PKG, "CreateMapping.Error.Title"));
+ box.setMessage(BaseMessages.getString(PKG,
"CreateMapping.Error.UnresolvedPath"));
+ box.open();
+ return;
+ }
+ resolvedFilename = HopVfs.normalize(resolvedFilename);
+ if (!confirmOverwrite(shell, resolvedFilename, variables)) {
+ return;
+ }
+
+ PipelineMeta mappingPipeline = result.getMappingPipeline();
+ IHopMetadataProvider saveMetadataProvider =
mappingPipeline.getMetadataProvider();
+ if (saveMetadataProvider == null) {
+ saveMetadataProvider = hopGui.getMetadataProvider();
+ }
+ if (!ReferencedConnectionSaveValidator.confirmSave(
+ shell, mappingPipeline, variables, saveMetadataProvider)) {
+ return;
+ }
+
+ mappingPipeline.setFilename(resolvedFilename);
+ mappingPipeline.setModifiedHopVersion(Const.NVL(Const.getHopVersion(),
""));
+ writePipeline(mappingPipeline, variables, hopGui.getLog());
+
+ AuditManager.registerEvent(HopNamespace.getNamespace(), "file",
resolvedFilename, "save");
+ ExtensionPointHandler.callExtensionPoint(
+ hopGui.getLog(),
+ variables,
+ HopExtensionPoint.PipelineAfterSaveAs.id,
+ new PipelineRenamedExtension(mappingPipeline, null));
+
+ ExplorerPerspective explorer = HopGui.getExplorerPerspective();
+ if (explorer != null) {
+ explorer.refresh();
+ }
+
+ pipelineGraph.markUndoPoint();
+ CreateMappingFromSelection.replaceSelection(pipelineMeta, result,
filename);
+ pipelineMeta.setChanged();
+ pipelineGraph.updateGui();
+ } catch (Exception e) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG, "CreateMapping.Error.Save.Title"),
+ BaseMessages.getString(PKG, "CreateMapping.Error.Save.Message"),
+ e);
+ }
+ }
+
+ private String askSaveFilename(
+ Shell shell, PipelineMeta pipelineMeta, TransformMeta entry, IVariables
variables)
+ throws Exception {
+ HopPipelineFileType<PipelineMeta> fileType = new HopPipelineFileType<>();
+ String suggested =
CreateMappingFromSelection.suggestFilename(pipelineMeta, entry, variables);
+ FileObject startFile = null;
+ if (StringUtils.isNotEmpty(suggested)) {
+ startFile = HopVfs.getFileObject(suggested, variables);
+ }
+ return BaseDialog.presentFileDialog(
+ true,
+ shell,
+ null,
+ startFile,
+ fileType.getFilterExtensions(),
+ fileType.getFilterNames(),
+ true);
+ }
+
+ private boolean confirmOverwrite(Shell shell, String filename, IVariables
variables)
+ throws HopException {
+ if (!HopVfs.fileExists(filename, variables)) {
+ return true;
+ }
+ MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO |
SWT.ICON_QUESTION);
+ box.setText(BaseMessages.getString(PKG, "CreateMapping.Overwrite.Title"));
+ box.setMessage(BaseMessages.getString(PKG,
"CreateMapping.Overwrite.Message", filename));
+ return (box.open() & SWT.YES) != 0;
+ }
+
+ private void writePipeline(PipelineMeta mappingPipeline, IVariables
variables, ILogChannel log)
+ throws Exception {
+ ExtensionPointHandler.callExtensionPoint(
+ log, variables, HopExtensionPoint.PipelineBeforeSave.id,
mappingPipeline);
+
+ String xml = mappingPipeline.getXml(variables);
+ try (OutputStream out =
+ HopVfs.getOutputStream(mappingPipeline.getFilename(), false,
variables)) {
+
out.write(XmlHandler.getXmlHeader(Const.UTF_8).getBytes(StandardCharsets.UTF_8));
+ out.write(xml.getBytes(StandardCharsets.UTF_8));
+ mappingPipeline.clearChanged();
+ }
+
+ ExtensionPointHandler.callExtensionPoint(
+ log, variables, HopExtensionPoint.PipelineAfterSave.id,
mappingPipeline);
+ }
+}
diff --git
a/plugins/transforms/mapping/src/main/resources/org/apache/hop/pipeline/transforms/mapping/messages/messages_en_US.properties
b/plugins/transforms/mapping/src/main/resources/org/apache/hop/pipeline/transforms/mapping/messages/messages_en_US.properties
index 83bf0cccd4..521011085e 100644
---
a/plugins/transforms/mapping/src/main/resources/org/apache/hop/pipeline/transforms/mapping/messages/messages_en_US.properties
+++
b/plugins/transforms/mapping/src/main/resources/org/apache/hop/pipeline/transforms/mapping/messages/messages_en_US.properties
@@ -166,4 +166,21 @@ SimpleMapping.Injection.INHERIT_ALL_VARIABLES=Inherit all
variables from the par
SimpleMapping.Injection.VARIABLE=Mapping parameter name
SimpleMapping.Injection.VALUE=Mapping parameter value
SimpleMapping.Injection.Group.FIELDS=Field mappings
-SimpleMapping.Injection.Group.MAPPINGS=Parameter mappings
\ No newline at end of file
+SimpleMapping.Injection.Group.MAPPINGS=Parameter mappings
+CreateMapping.Action.Name=Create mapping
+CreateMapping.Action.Tooltip=Extract the selected sequential transforms into a
mapping pipeline and replace them with a Simple Mapping transform
+CreateMapping.Error.Title=Cannot create mapping
+CreateMapping.Error.EmptySelection=Select one or more sequential transforms to
extract into a mapping.
+CreateMapping.Error.MappingTransform=Transform ''{0}'' is already a mapping
transform and cannot be extracted.
+CreateMapping.Error.NotConnected=The selected transforms are not connected to
each other by pipeline hops. Select a single sequential chain.
+CreateMapping.Error.NotAPath=The selected transforms are not a sequential
chain (a split or join was found). Simple Mapping supports one input and one
output.
+CreateMapping.Error.MultipleInputs=The selection has more than one incoming
hop. Simple Mapping supports a single input stream.
+CreateMapping.Error.MultipleOutputs=The selection has more than one outgoing
hop. Simple Mapping supports a single output stream.
+CreateMapping.Error.ErrorHopOut=The selection has an error hop that crosses
its boundary. Simple Mapping has no error-output port.
+CreateMapping.Error.InfoHopIn=The selection receives an info hop from outside.
Use Multi Mapping for extra info or target streams.
+CreateMapping.Error.Cycle=The selected transforms form a loop. Select a
sequential chain with a single start and end.
+CreateMapping.Error.Save.Title=Error
+CreateMapping.Error.Save.Message=Unable to save the mapping pipeline or
replace the selected transforms.
+CreateMapping.Error.UnresolvedPath=The selected filename still contains an
unresolved variable (for example '${PROJECT_HOME}'). Open a project or pick a
real folder.
+CreateMapping.Overwrite.Title=Overwrite?
+CreateMapping.Overwrite.Message=Are you sure you want to overwrite file
''{0}''?
\ No newline at end of file
diff --git
a/plugins/transforms/mapping/src/test/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelectionTest.java
b/plugins/transforms/mapping/src/test/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelectionTest.java
new file mode 100644
index 0000000000..e8c0d3e793
--- /dev/null
+++
b/plugins/transforms/mapping/src/test/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelectionTest.java
@@ -0,0 +1,409 @@
+/*
+ * 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.hop.pipeline.transforms.mapping;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.pipeline.PipelineHopMeta;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.dummy.DummyMeta;
+import org.apache.hop.pipeline.transforms.input.MappingInputMeta;
+import org.apache.hop.pipeline.transforms.output.MappingOutputMeta;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class CreateMappingFromSelectionTest {
+
+ @BeforeAll
+ static void setUpBeforeClass() throws Exception {
+ HopEnvironment.init();
+ PluginRegistry.init();
+ }
+
+ @Test
+ void threeTransformPathIsExtractedAndReplaced() {
+ PipelineMeta parent = pipeline("In", "A", "B", "C", "Out");
+ List<TransformMeta> selected = List.of(find(parent, "A"), find(parent,
"B"), find(parent, "C"));
+
+ CreateMappingFromSelection.Result result =
CreateMappingFromSelection.analyze(parent, selected);
+ assertTrue(result.isValid(), result.getValidationError());
+ assertEquals("A", result.getEntry().getName());
+ assertEquals("C", result.getExit().getName());
+ assertEquals("In", result.getIncomingFrom().getName());
+ assertEquals("Out", result.getOutgoingTo().getName());
+ assertEquals(100, result.getSimpleMappingLocation().x);
+ assertEquals(50, result.getSimpleMappingLocation().y);
+
+ PipelineMeta mapping = result.getMappingPipeline();
+ assertEquals(5, mapping.nrTransforms());
+ assertNotNull(mapping.findTransform("A"));
+ assertNotNullInstance(mapping, MappingInputMeta.class);
+ assertNotNullInstance(mapping, MappingOutputMeta.class);
+ assertEquals("A", mapping.findTransform("A").getName());
+ assertEquals("B", mapping.findTransform("B").getName());
+ assertEquals("C", mapping.findTransform("C").getName());
+ assertEquals(4, mapping.nrPipelineHops());
+ assertHop(mapping, "Mapping Input", "A");
+ assertHop(mapping, "A", "B");
+ assertHop(mapping, "B", "C");
+ assertHop(mapping, "C", "Mapping Output");
+
+ TransformMeta clonedA = mapping.findTransform("A");
+ assertNotSame(find(parent, "A"), clonedA);
+ assertNotSame(find(parent, "A").getTransform(), clonedA.getTransform());
+
+ TransformMeta simpleMapping =
+ CreateMappingFromSelection.replaceSelection(parent, result,
"${PROJECT_HOME}/map.hpl");
+ assertEquals("map", simpleMapping.getName());
+ assertEquals(100, simpleMapping.getLocation().x);
+ assertEquals(50, simpleMapping.getLocation().y);
+ assertTrue(simpleMapping.getTransform() instanceof SimpleMappingMeta);
+ assertEquals(
+ "${PROJECT_HOME}/map.hpl",
+ ((SimpleMappingMeta) simpleMapping.getTransform()).getFilename());
+ assertNull(parent.findTransform("A"));
+ assertNull(parent.findTransform("B"));
+ assertNull(parent.findTransform("C"));
+ assertHop(parent, "In", "map");
+ assertHop(parent, "map", "Out");
+ assertTrue(simpleMapping.isSelected());
+ assertFalse(find(parent, "In").isSelected());
+ }
+
+ @Test
+ void singleTransformWithNeighbors() {
+ PipelineMeta parent = pipeline("In", "A", "Out");
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A")));
+ assertTrue(result.isValid(), result.getValidationError());
+ assertEquals("A", result.getEntry().getName());
+ assertEquals("A", result.getExit().getName());
+ PipelineMeta mapping = result.getMappingPipeline();
+ assertEquals(3, mapping.nrTransforms());
+ assertHop(mapping, "Mapping Input", "A");
+ assertHop(mapping, "A", "Mapping Output");
+
+ CreateMappingFromSelection.replaceSelection(parent, result,
"extracted.hpl");
+ assertHop(parent, "In", "extracted");
+ assertHop(parent, "extracted", "Out");
+ }
+
+ @Test
+ void isolatedSingleTransform() {
+ PipelineMeta parent = new PipelineMeta();
+ parent.addTransform(dummy("Solo", 40, 80));
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(find(parent,
"Solo")));
+ assertTrue(result.isValid(), result.getValidationError());
+ assertNull(result.getIncomingFrom());
+ assertNull(result.getOutgoingTo());
+ assertEquals(3, result.getMappingPipeline().nrTransforms());
+
+ TransformMeta sm = CreateMappingFromSelection.replaceSelection(parent,
result, "solo-map.hpl");
+ assertEquals(1, parent.nrTransforms());
+ assertEquals(0, parent.nrPipelineHops());
+ assertEquals("solo-map", sm.getName());
+ assertEquals(40, sm.getLocation().x);
+ assertEquals(80, sm.getLocation().y);
+ }
+
+ @Test
+ void preservesDisabledInternalHop() {
+ PipelineMeta parent = pipeline("In", "A", "B", "Out");
+ PipelineHopMeta ab = parent.findPipelineHop(find(parent, "A"),
find(parent, "B"));
+ ab.setEnabled(false);
+
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A"),
find(parent, "B")));
+ assertTrue(result.isValid(), result.getValidationError());
+ PipelineHopMeta cloned =
+ result
+ .getMappingPipeline()
+ .findPipelineHop(
+ find(result.getMappingPipeline(), "A"),
+ find(result.getMappingPipeline(), "B"),
+ true);
+ assertFalse(cloned.isEnabled());
+ }
+
+ @Test
+ void rejectsDisconnectedSelection() {
+ PipelineMeta parent = pipeline("A", "B");
+ parent.addTransform(dummy("D", 400, 50));
+ parent.addTransform(dummy("E", 500, 50));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "D"), find(parent,
"E")));
+
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(
+ parent, List.of(find(parent, "A"), find(parent, "B"), find(parent,
"D")));
+ assertFalse(result.isValid());
+ assertEquals(CreateMappingFromSelection.KEY_NOT_CONNECTED,
result.getValidationKey());
+ }
+
+ @Test
+ void rejectsMissingMiddleTransform() {
+ PipelineMeta parent = pipeline("A", "B", "C");
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A"),
find(parent, "C")));
+ assertFalse(result.isValid());
+ assertEquals(CreateMappingFromSelection.KEY_NOT_CONNECTED,
result.getValidationKey());
+ }
+
+ @Test
+ void rejectsSplit() {
+ PipelineMeta parent = new PipelineMeta();
+ parent.addTransform(dummy("A", 0, 50));
+ parent.addTransform(dummy("B", 100, 0));
+ parent.addTransform(dummy("C", 100, 100));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent,
"B")));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent,
"C")));
+
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(
+ parent, List.of(find(parent, "A"), find(parent, "B"), find(parent,
"C")));
+ assertFalse(result.isValid());
+ assertEquals(CreateMappingFromSelection.KEY_NOT_A_PATH,
result.getValidationKey());
+ }
+
+ @Test
+ void rejectsJoin() {
+ PipelineMeta parent = new PipelineMeta();
+ parent.addTransform(dummy("A", 0, 0));
+ parent.addTransform(dummy("B", 0, 100));
+ parent.addTransform(dummy("C", 100, 50));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent,
"C")));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "B"), find(parent,
"C")));
+
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(
+ parent, List.of(find(parent, "A"), find(parent, "B"), find(parent,
"C")));
+ assertFalse(result.isValid());
+ assertEquals(CreateMappingFromSelection.KEY_NOT_A_PATH,
result.getValidationKey());
+ }
+
+ @Test
+ void rejectsTwoExternalInputs() {
+ PipelineMeta parent = new PipelineMeta();
+ parent.addTransform(dummy("X", 0, 0));
+ parent.addTransform(dummy("Y", 0, 100));
+ parent.addTransform(dummy("A", 100, 50));
+ parent.addTransform(dummy("B", 200, 50));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "X"), find(parent,
"A")));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "Y"), find(parent,
"A")));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent,
"B")));
+
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A"),
find(parent, "B")));
+ assertFalse(result.isValid());
+ assertEquals(CreateMappingFromSelection.KEY_MULTIPLE_INPUTS,
result.getValidationKey());
+ }
+
+ @Test
+ void rejectsTwoExternalOutputs() {
+ PipelineMeta parent = new PipelineMeta();
+ parent.addTransform(dummy("A", 0, 50));
+ parent.addTransform(dummy("B", 100, 50));
+ parent.addTransform(dummy("X", 200, 0));
+ parent.addTransform(dummy("Y", 200, 100));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent,
"B")));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "B"), find(parent,
"X")));
+ parent.addPipelineHop(new PipelineHopMeta(find(parent, "B"), find(parent,
"Y")));
+
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A"),
find(parent, "B")));
+ assertFalse(result.isValid());
+ assertEquals(CreateMappingFromSelection.KEY_MULTIPLE_OUTPUTS,
result.getValidationKey());
+ }
+
+ @Test
+ void rejectsErrorHopLeavingSelection() {
+ PipelineMeta parent = pipeline("A", "B");
+ PipelineHopMeta hop = parent.findPipelineHop(find(parent, "A"),
find(parent, "B"));
+ hop.setErrorHop(true);
+
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A")));
+ assertFalse(result.isValid());
+ assertEquals(CreateMappingFromSelection.KEY_ERROR_HOP,
result.getValidationKey());
+ }
+
+ @Test
+ void rejectsMappingTransform() {
+ PipelineMeta parent = new PipelineMeta();
+ SimpleMappingMeta meta = new SimpleMappingMeta();
+ meta.setDefault();
+ TransformMeta sm = new TransformMeta("SimpleMapping", "SM", meta);
+ sm.setLocation(10, 10);
+ parent.addTransform(sm);
+
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(sm));
+ assertFalse(result.isValid());
+ assertEquals(CreateMappingFromSelection.KEY_MAPPING_TRANSFORM,
result.getValidationKey());
+ }
+
+ @Test
+ void emptySelectionIsInvalid() {
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(new PipelineMeta(), List.of());
+ assertFalse(result.isValid());
+ assertEquals(CreateMappingFromSelection.KEY_EMPTY,
result.getValidationKey());
+ }
+
+ @Test
+ void resolveSelectedUsesMultiSelectionWhenContextIsSelected() {
+ PipelineMeta parent = pipeline("A", "B", "C");
+ find(parent, "A").setSelected(true);
+ find(parent, "B").setSelected(true);
+ List<TransformMeta> resolved =
+ CreateMappingFromSelection.resolveSelectedTransforms(parent,
find(parent, "A"));
+ assertEquals(2, resolved.size());
+ }
+
+ @Test
+ void resolveSelectedFallsBackToContextWhenNotInSelection() {
+ PipelineMeta parent = pipeline("A", "B");
+ find(parent, "A").setSelected(true);
+ List<TransformMeta> resolved =
+ CreateMappingFromSelection.resolveSelectedTransforms(parent,
find(parent, "B"));
+ assertEquals(1, resolved.size());
+ assertEquals("B", resolved.get(0).getName());
+ }
+
+ @Test
+ void suggestFilenameUsesParentFolderAndEntryName() {
+ PipelineMeta parent = new PipelineMeta();
+ parent.setFilename("/data/project/etl/main.hpl");
+ TransformMeta entry = dummy("Calc Name", 0, 0);
+ String suggested = CreateMappingFromSelection.suggestFilename(parent,
entry, new Variables());
+ assertTrue(suggested.endsWith("main-Calc-Name-mapping.hpl"), suggested);
+ assertTrue(suggested.contains("etl"), suggested);
+ }
+
+ @Test
+ void suggestFilenameResolvesProjectHomeInParentPath() {
+ Variables variables = new Variables();
+ variables.setVariable("PROJECT_HOME", "/data/project");
+ PipelineMeta parent = new PipelineMeta();
+ parent.setFilename("${PROJECT_HOME}/etl/main.hpl");
+ TransformMeta entry = dummy("Calculator", 0, 0);
+ String suggested = CreateMappingFromSelection.suggestFilename(parent,
entry, variables);
+ assertFalse(suggested.contains("${"), suggested);
+ assertTrue(suggested.contains("/data/project/etl"), suggested);
+ assertTrue(suggested.endsWith("main-Calculator-mapping.hpl"), suggested);
+ }
+
+ @Test
+ void transformNameFromFilenameUsesBaseName() {
+ assertEquals(
+ "map",
CreateMappingFromSelection.transformNameFromFilename("${PROJECT_HOME}/map.hpl"));
+ assertEquals(
+ "child",
CreateMappingFromSelection.transformNameFromFilename("/data/project/child.hpl"));
+ assertEquals("Simple mapping",
CreateMappingFromSelection.transformNameFromFilename(""));
+ }
+
+ @Test
+ void resolveFilesystemPathExpandsVariables() {
+ Variables variables = new Variables();
+ variables.setVariable("PROJECT_HOME", "/data/project");
+ assertEquals(
+ "/data/project/map.hpl",
+
CreateMappingFromSelection.resolveFilesystemPath("${PROJECT_HOME}/map.hpl",
variables));
+ assertNull(
+ CreateMappingFromSelection.resolveFilesystemPath(
+ "${PROJECT_HOME}/map.hpl", new Variables()));
+ assertEquals(
+ "/tmp/a.hpl",
CreateMappingFromSelection.resolveFilesystemPath("/tmp/a.hpl", variables));
+ }
+
+ @Test
+ void copiesNamedParametersToMappingPipeline() throws Exception {
+ PipelineMeta parent = pipeline("A");
+ parent.addParameterDefinition("ENV", "dev", "environment");
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A")));
+ assertTrue(result.isValid(), result.getValidationError());
+ assertEquals("dev",
result.getMappingPipeline().getParameterDefault("ENV"));
+ }
+
+ @Test
+ void mappingInputIsClampedOffTheLeftEdge() {
+ PipelineMeta parent = new PipelineMeta();
+ parent.addTransform(dummy("A", 10, 20));
+ CreateMappingFromSelection.Result result =
+ CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A")));
+ TransformMeta mappingInput =
result.getMappingPipeline().findTransform("Mapping Input");
+ assertEquals(0, mappingInput.getLocation().x);
+ assertEquals(20, mappingInput.getLocation().y);
+ }
+
+ private static PipelineMeta pipeline(String... names) {
+ PipelineMeta pipelineMeta = new PipelineMeta();
+ TransformMeta previous = null;
+ int x = 0;
+ for (String name : names) {
+ TransformMeta transformMeta = dummy(name, x, 50);
+ pipelineMeta.addTransform(transformMeta);
+ if (previous != null) {
+ pipelineMeta.addPipelineHop(new PipelineHopMeta(previous,
transformMeta));
+ }
+ previous = transformMeta;
+ x += 100;
+ }
+ return pipelineMeta;
+ }
+
+ private static TransformMeta dummy(String name, int x, int y) {
+ TransformMeta transformMeta = new TransformMeta("Dummy", name, new
DummyMeta());
+ transformMeta.setLocation(new Point(x, y));
+ return transformMeta;
+ }
+
+ private static TransformMeta find(PipelineMeta pipelineMeta, String name) {
+ return pipelineMeta.findTransform(name);
+ }
+
+ private static void assertHop(PipelineMeta pipelineMeta, String from, String
to) {
+ assertTrue(
+ pipelineMeta.findPipelineHop(
+ pipelineMeta.findTransform(from),
pipelineMeta.findTransform(to), true)
+ != null,
+ "expected hop " + from + " -> " + to);
+ }
+
+ private static void assertNotNullInstance(PipelineMeta pipelineMeta,
Class<?> type) {
+ boolean found = false;
+ for (TransformMeta transformMeta : pipelineMeta.getTransforms()) {
+ if (type.isInstance(transformMeta.getTransform())) {
+ found = true;
+ break;
+ }
+ }
+ assertTrue(found, "expected a transform of type " + type.getSimpleName());
+ }
+}