This is an automated email from the ASF dual-hosted git repository.

bamaer 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 c8539891af Issue #4010 : Refuse target-stream hops and splits onto 
multiple copies (#8685)
c8539891af is described below

commit c8539891af4b96c4489bdf7c78ab69dcdfc42fa7
Author: Matt Casters <[email protected]>
AuthorDate: Thu Oct 1 12:02:22 2026 +0200

    Issue #4010 : Refuse target-stream hops and splits onto multiple copies 
(#8685)
---
 .../java/org/apache/hop/pipeline/PipelineMeta.java |  88 ++++++++
 .../pipeline/PipelineMetaMultipleCopiesTest.java   | 222 +++++++++++++++++++++
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  52 ++---
 .../delegates/HopGuiPipelineHopDelegate.java       |   5 +
 .../delegates/HopGuiPipelineTransformDelegate.java |   6 +
 5 files changed, 347 insertions(+), 26 deletions(-)

diff --git a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java 
b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
index 151b4fb515..647312f6f2 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
@@ -85,6 +85,7 @@ import org.apache.hop.partition.PartitionSchema;
 import org.apache.hop.pipeline.analysis.BufferDeadlockRisk;
 import org.apache.hop.pipeline.analysis.PipelineBufferDeadlockAnalyzer;
 import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.ITransformIOMeta;
 import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transform.ITransformMetaChangeListener;
 import org.apache.hop.pipeline.transform.TransformErrorMeta;
@@ -781,6 +782,93 @@ public class PipelineMeta extends AbstractMeta
     return !isTransformInformative(to, hop.getFromTransform());
   }
 
+  /**
+   * Named target streams (Filter Rows true/false, Switch/Case, and similar) 
deliver rows to copy 0
+   * of the target only. A transform that is such a target cannot run in 
multiple copies.
+   *
+   * @param transformMeta transform that would be started in multiple copies
+   * @return {@code false} when an enabled previous hop comes from a transform 
that names it as a
+   *     target stream
+   */
+  public boolean allowsMultipleCopies(TransformMeta transformMeta) {
+    if (transformMeta == null) {
+      return true;
+    }
+    for (TransformMeta previous : findPreviousTransforms(transformMeta)) {
+      if (namesTarget(previous, transformMeta)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * @return {@code true} when the copies string resolves to an integer 
greater than one. Unresolved
+   *     variables and partitioning are ignored, matching the copies dialog.
+   */
+  public boolean hasMultipleCopies(TransformMeta transformMeta, IVariables 
variables) {
+    if (transformMeta == null || 
Utils.isEmpty(transformMeta.getCopiesString())) {
+      return false;
+    }
+    IVariables space = variables != null ? variables : 
Variables.getADefaultVariableSpace();
+    return Const.toInt(space.resolve(transformMeta.getCopiesString()), -1) > 1;
+  }
+
+  /**
+   * @return {@code true} when {@code hop} connects a named target stream to a 
transform that
+   *     already runs in multiple copies
+   */
+  public boolean isMultipleCopiesTargetHop(PipelineHopMeta hop, IVariables 
variables) {
+    if (hop == null
+        || !hop.isEnabled()
+        || hop.getFromTransform() == null
+        || hop.getToTransform() == null) {
+      return false;
+    }
+    return hasMultipleCopies(hop.getToTransform(), variables)
+        && namesTarget(hop.getFromTransform(), hop.getToTransform());
+  }
+
+  /**
+   * Splitting {@code hop} redirects the source transform's target streams 
from the current
+   * destination onto {@code inserted}.
+   *
+   * @return {@code true} when that redirect would land on a transform that 
already runs in multiple
+   *     copies
+   */
+  public boolean isMultipleCopiesTargetSplit(
+      PipelineHopMeta hop, TransformMeta inserted, IVariables variables) {
+    if (hop == null || hop.getFromTransform() == null || hop.getToTransform() 
== null) {
+      return false;
+    }
+    return hasMultipleCopies(inserted, variables)
+        && namesTarget(hop.getFromTransform(), hop.getToTransform());
+  }
+
+  private boolean namesTarget(TransformMeta source, TransformMeta target) {
+    if (source == null || target == null || Utils.isEmpty(target.getName())) {
+      return false;
+    }
+    ITransformMeta meta = source.getTransform();
+    if (meta == null) {
+      return false;
+    }
+    ITransformIOMeta ioMeta = meta.getTransformIOMeta();
+    if (ioMeta == null) {
+      return false;
+    }
+    String[] targetNames = ioMeta.getTargetTransformNames();
+    if (targetNames == null) {
+      return false;
+    }
+    for (String targetName : targetNames) {
+      if (!Utils.isEmpty(targetName) && 
targetName.equalsIgnoreCase(target.getName())) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   /**
    * Previous transforms on enabled main hops into {@code transformMeta}: not 
info, not error.
    * {@link #findPreviousTransforms(TransformMeta, boolean)} with {@code 
info=false} still includes
diff --git 
a/engine/src/test/java/org/apache/hop/pipeline/PipelineMetaMultipleCopiesTest.java
 
b/engine/src/test/java/org/apache/hop/pipeline/PipelineMetaMultipleCopiesTest.java
new file mode 100644
index 0000000000..51ce6ef1b6
--- /dev/null
+++ 
b/engine/src/test/java/org/apache/hop/pipeline/PipelineMetaMultipleCopiesTest.java
@@ -0,0 +1,222 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.transform.TransformIOMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transform.stream.IStream.StreamType;
+import org.apache.hop.pipeline.transform.stream.Stream;
+import org.apache.hop.pipeline.transform.stream.StreamIcon;
+import org.apache.hop.pipeline.transform.transforms.FakeMeta;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/** Copies are refused when a transform is the named target of a previous 
transform. */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class PipelineMetaMultipleCopiesTest {
+
+  private PipelineMeta pipelineMeta;
+  private IVariables variables;
+
+  @BeforeEach
+  void setUp() throws Exception {
+    pipelineMeta = new PipelineMeta();
+    variables = new Variables();
+    PluginRegistry.getInstance()
+        .registerPluginClass(FakeMeta.class.getName(), 
TransformPluginType.class, Transform.class);
+  }
+
+  @Test
+  void multipleCopiesAreDisallowedWhenAPreviousTransformTargetsIt() {
+    TransformMeta filter = transform("Filter");
+    TransformMeta selectValues = transform("Select values");
+    selectValues.setCopies(2);
+    nameTargets(filter, selectValues);
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(filter, selectValues));
+
+    assertFalse(pipelineMeta.allowsMultipleCopies(selectValues));
+    assertTrue(pipelineMeta.isMultipleCopiesTargetHop(hop(filter, 
selectValues), variables));
+  }
+
+  @Test
+  void multipleCopiesStayAllowedWhenThePreviousTransformDoesNotTargetIt() {
+    TransformMeta filter = transform("Filter");
+    TransformMeta selectValues = transform("Select values");
+    selectValues.setCopies(2);
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(filter, selectValues));
+
+    assertTrue(pipelineMeta.allowsMultipleCopies(selectValues));
+    assertFalse(pipelineMeta.isMultipleCopiesTargetHop(hop(filter, 
selectValues), variables));
+  }
+
+  @Test
+  void multipleCopiesStayAllowedWithoutAHopEvenIfTheTargetNameIsSet() {
+    TransformMeta filter = transform("Filter");
+    TransformMeta selectValues = transform("Select values");
+    selectValues.setCopies(2);
+    nameTargets(filter, selectValues);
+
+    assertTrue(pipelineMeta.allowsMultipleCopies(selectValues));
+    assertTrue(pipelineMeta.isMultipleCopiesTargetHop(hop(filter, 
selectValues), variables));
+  }
+
+  @Test
+  void secondTargetStreamAlsoDisallowsMultipleCopies() {
+    TransformMeta filter = transform("Filter");
+    TransformMeta trueTarget = transform("True path");
+    TransformMeta falseTarget = transform("False path");
+    falseTarget.setCopies(3);
+    nameTargets(filter, trueTarget, falseTarget);
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(filter, falseTarget));
+
+    assertTrue(pipelineMeta.allowsMultipleCopies(trueTarget));
+    assertFalse(pipelineMeta.allowsMultipleCopies(falseTarget));
+    assertFalse(pipelineMeta.isMultipleCopiesTargetHop(hop(filter, 
trueTarget), variables));
+    assertTrue(pipelineMeta.isMultipleCopiesTargetHop(hop(filter, 
falseTarget), variables));
+  }
+
+  @Test
+  void targetNameMatchIsCaseInsensitive() {
+    TransformMeta filter = transform("Filter");
+    TransformMeta selectValues = transform("Select values");
+    selectValues.setCopies(2);
+    nameTargets(filter, selectValues);
+
+    TransformMeta sameName = transform("SELECT VALUES");
+    sameName.setCopies(2);
+
+    assertTrue(pipelineMeta.isMultipleCopiesTargetHop(hop(filter, sameName), 
variables));
+  }
+
+  @Test
+  void singleCopyTargetHopIsAllowed() {
+    TransformMeta filter = transform("Filter");
+    TransformMeta selectValues = transform("Select values");
+    nameTargets(filter, selectValues);
+
+    assertFalse(pipelineMeta.hasMultipleCopies(selectValues, variables));
+    assertFalse(pipelineMeta.isMultipleCopiesTargetHop(hop(filter, 
selectValues), variables));
+  }
+
+  @Test
+  void disabledTargetHopIsNotRefused() {
+    TransformMeta filter = transform("Filter");
+    TransformMeta selectValues = transform("Select values");
+    selectValues.setCopies(2);
+    nameTargets(filter, selectValues);
+    PipelineHopMeta hop = hop(filter, selectValues);
+    hop.setEnabled(false);
+    pipelineMeta.addPipelineHop(hop);
+
+    assertFalse(pipelineMeta.isMultipleCopiesTargetHop(hop, variables));
+    assertTrue(pipelineMeta.allowsMultipleCopies(selectValues));
+  }
+
+  @Test
+  void splittingATargetHopOntoMultipleCopiesIsDisallowed() {
+    TransformMeta filter = transform("Filter");
+    TransformMeta dummy = transform("Dummy");
+    TransformMeta selectValues = transform("Select values");
+    selectValues.setCopies(2);
+    nameTargets(filter, dummy);
+    PipelineHopMeta hop = hop(filter, dummy);
+
+    assertTrue(pipelineMeta.isMultipleCopiesTargetSplit(hop, selectValues, 
variables));
+    selectValues.setCopies(1);
+    assertFalse(pipelineMeta.isMultipleCopiesTargetSplit(hop, selectValues, 
variables));
+  }
+
+  @Test
+  void splittingAPlainHopOntoMultipleCopiesIsAllowed() {
+    TransformMeta upstream = transform("Data grid");
+    TransformMeta downstream = transform("Dummy");
+    TransformMeta selectValues = transform("Select values");
+    selectValues.setCopies(2);
+
+    assertFalse(
+        pipelineMeta.isMultipleCopiesTargetSplit(
+            hop(upstream, downstream), selectValues, variables));
+  }
+
+  @Test
+  void splittingStillRefusesWhenTheHopIsDisabled() {
+    TransformMeta filter = transform("Filter");
+    TransformMeta dummy = transform("Dummy");
+    TransformMeta selectValues = transform("Select values");
+    selectValues.setCopies(2);
+    nameTargets(filter, dummy);
+    PipelineHopMeta hop = hop(filter, dummy);
+    hop.setEnabled(false);
+
+    assertTrue(pipelineMeta.isMultipleCopiesTargetSplit(hop, selectValues, 
variables));
+  }
+
+  @Test
+  void copiesStringResolvesVariablesAndIgnoresUnresolvedOnes() {
+    TransformMeta selectValues = transform("Select values");
+    selectValues.setCopiesString("${COPIES}");
+
+    assertFalse(pipelineMeta.hasMultipleCopies(selectValues, variables));
+
+    variables.setVariable("COPIES", "4");
+    assertTrue(pipelineMeta.hasMultipleCopies(selectValues, variables));
+
+    variables.setVariable("COPIES", "1");
+    assertFalse(pipelineMeta.hasMultipleCopies(selectValues, variables));
+  }
+
+  @Test
+  void nullArgumentsAreSafe() {
+    assertTrue(pipelineMeta.allowsMultipleCopies(null));
+    assertFalse(pipelineMeta.hasMultipleCopies(null, null));
+    assertFalse(pipelineMeta.isMultipleCopiesTargetHop(null, variables));
+    assertFalse(pipelineMeta.isMultipleCopiesTargetSplit(null, null, null));
+    PipelineHopMeta emptyHop = new PipelineHopMeta((TransformMeta) null, 
(TransformMeta) null);
+    emptyHop.setEnabled(true);
+    assertFalse(pipelineMeta.isMultipleCopiesTargetHop(emptyHop, variables));
+    assertFalse(pipelineMeta.isMultipleCopiesTargetSplit(emptyHop, 
transform("Copies"), null));
+  }
+
+  private TransformMeta transform(String name) {
+    return new TransformMeta(name, new FakeMeta());
+  }
+
+  private static PipelineHopMeta hop(TransformMeta from, TransformMeta to) {
+    return new PipelineHopMeta(from, to);
+  }
+
+  private static void nameTargets(TransformMeta source, TransformMeta... 
targets) {
+    TransformIOMeta ioMeta = new TransformIOMeta(true, true, false, false, 
false, false);
+    for (TransformMeta target : targets) {
+      ioMeta.addStream(
+          new Stream(
+              StreamType.TARGET, target, "Result is true", StreamIcon.TRUE, 
target.getName()));
+    }
+    ((FakeMeta) source.getTransform()).setTransformIOMeta(ioMeta);
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index 8df0a09c55..43be6f229d 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -2591,6 +2591,18 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   }
 
   private void splitHop(PipelineHopMeta hop) {
+    if (pipelineMeta.isMultipleCopiesTargetSplit(hop, currentTransform, 
getVariables())) {
+      if (hop != null) {
+        hop.setSplit(false);
+      }
+      if (lastHopSplit == hop) {
+        lastHopSplit = null;
+      }
+      showMultipleCopiesNotAllowedDialog();
+      splitHop = false;
+      return;
+    }
+
     int id = 0;
     if (!hopGui.getProps().getAutoSplit()) {
       MessageDialogWithToggle md =
@@ -3170,6 +3182,11 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
         pipelineHopDelegate.newHop(pipelineMeta, candidate);
         break;
       case TARGET:
+        // Named targets receive rows on copy 0 only. Refuse before the target 
is recorded.
+        if (pipelineMeta.hasMultipleCopies(candidate.getToTransform(), 
getVariables())) {
+          showMultipleCopiesNotAllowedDialog();
+          break;
+        }
         // We connect a target of the source transform to an output 
transform...
         //
         stream.setTransformMeta(candidate.getToTransform());
@@ -4052,13 +4069,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       int copies = Const.toInt(hopGui.getVariables().resolve(cop), -1);
       if (copies > 1 && !multipleOK) {
         cop = "1";
-
-        modalMessageDialog(
-            BaseMessages.getString(
-                PKG, 
"PipelineGraph.Dialog.MultipleCopiesAreNotAllowedHere.Title"),
-            BaseMessages.getString(
-                PKG, 
"PipelineGraph.Dialog.MultipleCopiesAreNotAllowedHere.Message"),
-            SWT.YES | SWT.ICON_WARNING);
+        showMultipleCopiesNotAllowedDialog();
       }
       String cps = transformMeta.getCopiesString();
       if (cps == null || !cps.equals(cop)) {
@@ -4790,25 +4801,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   }
 
   private boolean checkNumberOfCopies(PipelineMeta pipelineMeta, TransformMeta 
transformMeta) {
-    boolean enabled = true;
-    List<TransformMeta> prevTransforms = 
pipelineMeta.findPreviousTransforms(transformMeta);
-    for (TransformMeta prevTransform : prevTransforms) {
-      // See what the target transforms are.
-      // If one of the target transforms is our original transform, we can't 
start multiple copies
-      //
-      String[] targetTransforms =
-          
prevTransform.getTransform().getTransformIOMeta().getTargetTransformNames();
-      if (targetTransforms != null) {
-        for (int t = 0; t < targetTransforms.length && enabled; t++) {
-          if (!Utils.isEmpty(targetTransforms[t])
-              && 
targetTransforms[t].equalsIgnoreCase(transformMeta.getName())) {
-            enabled = false;
-            break;
-          }
-        }
-      }
-    }
-    return enabled;
+    return pipelineMeta.allowsMultipleCopies(transformMeta);
   }
 
   /**
@@ -7252,6 +7245,13 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     messageBox.open();
   }
 
+  public void showMultipleCopiesNotAllowedDialog() {
+    modalMessageDialog(
+        BaseMessages.getString(PKG, 
"PipelineGraph.Dialog.MultipleCopiesAreNotAllowedHere.Title"),
+        BaseMessages.getString(PKG, 
"PipelineGraph.Dialog.MultipleCopiesAreNotAllowedHere.Message"),
+        SWT.YES | SWT.ICON_WARNING);
+  }
+
   /**
    * Gets fileType
    *
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineHopDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineHopDelegate.java
index 38c606525a..c234f6bdef 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineHopDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineHopDelegate.java
@@ -143,6 +143,11 @@ public class HopGuiPipelineHopDelegate {
       ok = false;
     }
 
+    if (ok && pipelineMeta.isMultipleCopiesTargetHop(newHop, 
pipelineGraph.getVariables())) {
+      pipelineGraph.showMultipleCopiesNotAllowedDialog();
+      ok = false;
+    }
+
     if (ok) { // only do the following checks, e.g. checkRowMixingStatically
       // when not looping, otherwise we get a loop with
       // StackOverflow there ;-)
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
index 8f23b0cc45..e29796e1a9 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
@@ -529,6 +529,12 @@ public class HopGuiPipelineTransformDelegate {
    */
   public TransformMeta insertTransform(
       PipelineMeta pipelineMeta, PipelineHopMeta hop, TransformMeta 
transformMeta) {
+    if (pipelineMeta.isMultipleCopiesTargetSplit(
+        hop, transformMeta, pipelineGraph.getVariables())) {
+      pipelineGraph.showMultipleCopiesNotAllowedDialog();
+      return null;
+    }
+
     TransformMeta fromTransform = hop.getFromTransform();
     TransformMeta toTransform = hop.getToTransform();
 

Reply via email to