You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by ha...@apache.org on 2026/04/17 21:18:22 UTC
(hop) branch main updated: switch case detach and tests, fixes #3969 (#7021)
This is an automated email from the ASF dual-hosted git repository.
hansva 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 6d8a6e2994 switch case detach and tests, fixes #3969 (#7021)
6d8a6e2994 is described below
commit 6d8a6e29940f30bc3f1faf50ce2187e777e24a51
Author: Hans Van Akelyen <ha...@gmail.com>
AuthorDate: Fri Apr 17 23:18:16 2026 +0200
switch case detach and tests, fixes #3969 (#7021)
---
.../transforms/switchcase/SwitchCaseMeta.java | 44 +-
.../switchcase/ContainsKeyToRowSetMapTest.java | 204 +++++++++
.../transforms/switchcase/KeyToRowSetMapTest.java | 186 ++++++++
.../switchcase/SwitchCaseMetaTopologyTest.java | 500 +++++++++++++++++++++
.../switchcase/SwitchCaseRoutingTest.java | 452 +++++++++++++++++++
.../hopgui/file/pipeline/HopGuiPipelineGraph.java | 53 ++-
.../delegates/HopGuiPipelineHopDelegate.java | 3 +
.../delegates/HopGuiPipelineTransformDelegate.java | 3 +
8 files changed, 1437 insertions(+), 8 deletions(-)
diff --git a/plugins/transforms/switchcase/src/main/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMeta.java b/plugins/transforms/switchcase/src/main/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMeta.java
index 785fa6683e..a784dabc88 100644
--- a/plugins/transforms/switchcase/src/main/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMeta.java
+++ b/plugins/transforms/switchcase/src/main/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMeta.java
@@ -363,7 +363,49 @@ public class SwitchCaseMeta extends BaseTransformMeta<SwitchCase, SwitchCaseData
@Override
public void convertIOMetaToTransformNames() {
- // TODO
+ List<IStream> targetStreams = getTransformIOMeta().getTargetStreams();
+ int index = 0;
+ for (SwitchCaseTarget target : caseTargets) {
+ if (index < targetStreams.size()) {
+ IStream stream = targetStreams.get(index);
+ target.setCaseTargetTransformName(stream.getTransformName());
+ }
+ index++;
+ }
+ // Last stream is the default target (if any)
+ if (StringUtils.isNotEmpty(defaultTargetTransformName) && index < targetStreams.size()) {
+ defaultTargetTransformName = targetStreams.get(index).getTransformName();
+ }
+ }
+
+ @Override
+ public boolean cleanAfterHopFromRemove(TransformMeta toTransform) {
+ if (toTransform == null) {
+ return false;
+ }
+ String toName = toTransform.getName();
+ boolean changed = false;
+
+ // Clear matching case targets
+ for (SwitchCaseTarget target : caseTargets) {
+ if (toName.equals(target.getCaseTargetTransformName())) {
+ target.setCaseTargetTransformName(null);
+ changed = true;
+ }
+ }
+
+ // Clear default target if it matches
+ if (toName.equals(defaultTargetTransformName)) {
+ defaultTargetTransformName = null;
+ changed = true;
+ }
+
+ if (changed) {
+ // Reset the cached IO meta so it is rebuilt without the stale entries on next access
+ resetTransformIoMeta();
+ }
+
+ return changed;
}
private static final IStream newDefaultStream =
diff --git a/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/ContainsKeyToRowSetMapTest.java b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/ContainsKeyToRowSetMapTest.java
new file mode 100644
index 0000000000..ef44cf07b4
--- /dev/null
+++ b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/ContainsKeyToRowSetMapTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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.switchcase;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import java.util.Set;
+import org.apache.hop.core.IRowSet;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link ContainsKeyToRowSetMap}, the substring-matching variant of {@link
+ * KeyToRowSetMap} used when Switch Case is configured with {@code usingContains=true}.
+ *
+ * <p>In this mode, a lookup value matches a stored key if the value <em>contains</em> the key as a
+ * substring, rather than requiring an exact match.
+ */
+class ContainsKeyToRowSetMapTest {
+
+ private IRowSet rowSetA;
+ private IRowSet rowSetB;
+ private ContainsKeyToRowSetMap map;
+
+ @BeforeEach
+ void setUp() {
+ rowSetA = mock(IRowSet.class);
+ rowSetB = mock(IRowSet.class);
+ map = new ContainsKeyToRowSetMap();
+ }
+
+ // ---------------------------------------------------------------------------
+ // get — substring matching
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void get_emptyMap_returnsNull() {
+ assertNull(map.get("anything"));
+ }
+
+ @Test
+ void get_exactMatch_returnsSet() {
+ map.put("hello", rowSetA);
+ assertNotNull(map.get("hello"));
+ assertTrue(map.get("hello").contains(rowSetA));
+ }
+
+ @Test
+ void get_valueContainsKey_returnsSet() {
+ map.put("error", rowSetA);
+ Set<IRowSet> result = map.get("fatal error occurred");
+ assertNotNull(result);
+ assertTrue(result.contains(rowSetA));
+ }
+
+ @Test
+ void get_valueDoesNotContainAnyKey_returnsNull() {
+ map.put("error", rowSetA);
+ assertNull(map.get("all good"));
+ }
+
+ @Test
+ void get_keyAtStartOfValue_matches() {
+ map.put("start", rowSetA);
+ assertNotNull(map.get("start of the string"));
+ }
+
+ @Test
+ void get_keyAtEndOfValue_matches() {
+ map.put("end", rowSetA);
+ assertNotNull(map.get("reached the end"));
+ }
+
+ @Test
+ void get_multipleKeys_returnsFirstInsertedMatch() {
+ // Keys are matched in insertion order; the first key that the value contains is returned
+ map.put("first", rowSetA);
+ map.put("second", rowSetB);
+ // "first and second" contains both keys; insertion order means "first" wins
+ Set<IRowSet> result = map.get("first and second");
+ assertNotNull(result);
+ assertTrue(result.contains(rowSetA));
+ }
+
+ @Test
+ void get_onlySecondKeyMatches_returnsSecondSet() {
+ map.put("alpha", rowSetA);
+ map.put("beta", rowSetB);
+ Set<IRowSet> result = map.get("contains beta only");
+ assertNotNull(result);
+ assertTrue(result.contains(rowSetB));
+ }
+
+ @Test
+ void get_neitherKeyMatches_returnsNull() {
+ map.put("alpha", rowSetA);
+ map.put("beta", rowSetB);
+ assertNull(map.get("gamma delta"));
+ }
+
+ // ---------------------------------------------------------------------------
+ // containsKey — substring matching
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void containsKey_emptyMap_returnsFalse() {
+ assertFalse(map.containsKey("anything"));
+ }
+
+ @Test
+ void containsKey_valueContainsKey_returnsTrue() {
+ map.put("needle", rowSetA);
+ assertTrue(map.containsKey("find the needle here"));
+ }
+
+ @Test
+ void containsKey_exactMatch_returnsTrue() {
+ map.put("needle", rowSetA);
+ assertTrue(map.containsKey("needle"));
+ }
+
+ @Test
+ void containsKey_valueDoesNotContainAnyKey_returnsFalse() {
+ map.put("needle", rowSetA);
+ assertFalse(map.containsKey("no match here"));
+ }
+
+ @Test
+ void containsKey_multipleKeys_trueIfValueContainsAny() {
+ map.put("foo", rowSetA);
+ map.put("bar", rowSetB);
+ assertTrue(map.containsKey("contains bar somewhere"));
+ assertTrue(map.containsKey("contains foo somewhere"));
+ assertFalse(map.containsKey("contains neither"));
+ }
+
+ // ---------------------------------------------------------------------------
+ // put — accumulation and list tracking
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void put_sameKey_accumulatesRowSets() {
+ map.put("key", rowSetA);
+ map.put("key", rowSetB);
+ Set<IRowSet> result = map.get("contains key here");
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ assertTrue(result.contains(rowSetA));
+ assertTrue(result.contains(rowSetB));
+ }
+
+ @Test
+ void put_storesKeyInUnderlyingMap() {
+ map.put("stored", rowSetA);
+ assertFalse(map.isEmpty());
+ }
+
+ @Test
+ void put_keyOnlyStoredOnce_inList_evenIfPutTwice() {
+ // Putting the same key twice adds it to the list twice, which would cause duplicate matches.
+ // This test documents the current behaviour: each put appends to the list independently.
+ map.put("k", rowSetA);
+ map.put("k", rowSetB); // same key, second rowSet
+ // Value that contains "k" should return the accumulated set
+ Set<IRowSet> result = map.get("k-value");
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ }
+
+ // ---------------------------------------------------------------------------
+ // isEmpty (inherited behaviour)
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void isEmpty_newMap_returnsTrue() {
+ assertTrue(map.isEmpty());
+ }
+
+ @Test
+ void isEmpty_afterPut_returnsFalse() {
+ map.put("k", rowSetA);
+ assertFalse(map.isEmpty());
+ }
+}
diff --git a/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/KeyToRowSetMapTest.java b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/KeyToRowSetMapTest.java
new file mode 100644
index 0000000000..26e4b968ef
--- /dev/null
+++ b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/KeyToRowSetMapTest.java
@@ -0,0 +1,186 @@
+/*
+ * 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.switchcase;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import java.util.Set;
+import org.apache.hop.core.IRowSet;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class KeyToRowSetMapTest {
+
+ private IRowSet rowSetA;
+ private IRowSet rowSetB;
+ private KeyToRowSetMap map;
+
+ @BeforeEach
+ void setUp() {
+ rowSetA = mock(IRowSet.class);
+ rowSetB = mock(IRowSet.class);
+ map = new KeyToRowSetMap();
+ }
+
+ // ---------------------------------------------------------------------------
+ // isEmpty
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void isEmpty_newMap_returnsTrue() {
+ assertTrue(map.isEmpty());
+ }
+
+ @Test
+ void isEmpty_afterPut_returnsFalse() {
+ map.put("key", rowSetA);
+ assertFalse(map.isEmpty());
+ }
+
+ // ---------------------------------------------------------------------------
+ // get
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void get_missingKey_returnsNull() {
+ assertNull(map.get("missing"));
+ }
+
+ @Test
+ void get_existingKey_returnsSetContainingRowSet() {
+ map.put("k", rowSetA);
+ Set<IRowSet> result = map.get("k");
+ assertNotNull(result);
+ assertTrue(result.contains(rowSetA));
+ }
+
+ @Test
+ void get_differentKeys_returnIndependentSets() {
+ map.put("k1", rowSetA);
+ map.put("k2", rowSetB);
+ assertFalse(map.get("k1").contains(rowSetB));
+ assertFalse(map.get("k2").contains(rowSetA));
+ }
+
+ // ---------------------------------------------------------------------------
+ // put
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void put_sameKey_accumulatesMultipleRowSets() {
+ map.put("k", rowSetA);
+ map.put("k", rowSetB);
+ Set<IRowSet> result = map.get("k");
+ assertEquals(2, result.size());
+ assertTrue(result.contains(rowSetA));
+ assertTrue(result.contains(rowSetB));
+ }
+
+ @Test
+ void put_sameKeyAndSameRowSet_deduplicates() {
+ // The backing collection is a Set — duplicate rowsets for the same key must not be stored twice
+ map.put("k", rowSetA);
+ map.put("k", rowSetA);
+ assertEquals(1, map.get("k").size());
+ }
+
+ @Test
+ void put_multipleKeys_eachKeyHasOwnSet() {
+ map.put("k1", rowSetA);
+ map.put("k2", rowSetB);
+ assertEquals(1, map.get("k1").size());
+ assertEquals(1, map.get("k2").size());
+ }
+
+ // ---------------------------------------------------------------------------
+ // containsKey
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void containsKey_existingKey_returnsTrue() {
+ map.put("k", rowSetA);
+ assertTrue(map.containsKey("k"));
+ }
+
+ @Test
+ void containsKey_missingKey_returnsFalse() {
+ assertFalse(map.containsKey("missing"));
+ }
+
+ @Test
+ void containsKey_emptyMap_returnsFalse() {
+ assertFalse(map.containsKey("anything"));
+ }
+
+ // ---------------------------------------------------------------------------
+ // keySet / entrySet
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void keySet_containsAllInsertedKeys() {
+ map.put("k1", rowSetA);
+ map.put("k2", rowSetB);
+ assertEquals(Set.of("k1", "k2"), map.keySet());
+ }
+
+ @Test
+ void entrySet_sizeMatchesNumberOfDistinctKeys() {
+ map.put("k1", rowSetA);
+ map.put("k1", rowSetB); // same key, different rowset
+ map.put("k2", rowSetA);
+ // 2 distinct keys
+ assertEquals(2, map.entrySet().size());
+ }
+
+ @Test
+ void entrySet_eachEntryHoldsCorrectRowSets() {
+ map.put("k", rowSetA);
+ map.put("k", rowSetB);
+ map.entrySet()
+ .forEach(
+ e -> {
+ assertEquals("k", e.getKey());
+ assertEquals(2, e.getValue().size());
+ });
+ }
+
+ // ---------------------------------------------------------------------------
+ // non-String keys (the map is typed Object, used with pre-processed values)
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void put_integerKey_worksLikeStringKey() {
+ map.put(42, rowSetA);
+ assertTrue(map.containsKey(42));
+ assertNotNull(map.get(42));
+ }
+
+ @Test
+ void put_byteArrayHashCode_worksAsKey() {
+ // SwitchCase converts byte[] via Arrays.hashCode before storing
+ int hashCode = java.util.Arrays.hashCode(new byte[] {1, 2, 3});
+ map.put(hashCode, rowSetA);
+ assertTrue(map.containsKey(hashCode));
+ assertEquals(rowSetA, map.get(hashCode).iterator().next());
+ }
+}
diff --git a/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMetaTopologyTest.java b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMetaTopologyTest.java
new file mode 100644
index 0000000000..7d0943fa19
--- /dev/null
+++ b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMetaTopologyTest.java
@@ -0,0 +1,500 @@
+/*
+ * 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.switchcase;
+
+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.plugins.PluginRegistry;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.transform.ITransformIOMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transform.stream.IStream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * Tests for topology-management methods in {@link SwitchCaseMeta}:
+ *
+ * <ul>
+ * <li>{@code cleanAfterHopFromRemove} – clears stale target references when a hop is removed
+ * <li>{@code searchInfoAndTargetTransforms} – resolves stored names → TransformMeta objects
+ * <li>{@code convertIOMetaToTransformNames} – flushes resolved stream names back to stored fields
+ * <li>{@code handleStreamSelection} – keeps stored names in sync when a stream is repointed
+ * </ul>
+ */
+class SwitchCaseMetaTopologyTest {
+
+ @RegisterExtension
+ static RestoreHopEngineEnvironmentExtension env = new RestoreHopEngineEnvironmentExtension();
+
+ @BeforeEach
+ void setUp() throws Exception {
+ HopEnvironment.init();
+ PluginRegistry.init();
+ }
+
+ // ---------------------------------------------------------------------------
+ // helpers
+ // ---------------------------------------------------------------------------
+
+ private static SwitchCaseMeta metaWithTargets(
+ String case1Name, String case2Name, String defaultName) {
+ SwitchCaseMeta meta = new SwitchCaseMeta();
+ meta.setFieldName("status");
+ addCaseTarget(meta, "A", case1Name);
+ addCaseTarget(meta, "B", case2Name);
+ meta.setDefaultTargetTransformName(defaultName);
+ return meta;
+ }
+
+ private static void addCaseTarget(SwitchCaseMeta meta, String caseValue, String targetName) {
+ SwitchCaseTarget t = new SwitchCaseTarget();
+ t.setCaseValue(caseValue);
+ t.setCaseTargetTransformName(targetName);
+ meta.getCaseTargets().add(t);
+ }
+
+ /** Minimal TransformMeta – only the name matters for topology look-ups. */
+ private static TransformMeta tm(String name) {
+ return new TransformMeta(name, new SwitchCaseMeta());
+ }
+
+ // ---------------------------------------------------------------------------
+ // cleanAfterHopFromRemove
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void cleanAfterHopFromRemove_returnsFalseForNull() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+ assertFalse(meta.cleanAfterHopFromRemove(null));
+ assertEquals("Out1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ assertEquals("Default", meta.getDefaultTargetTransformName());
+ }
+
+ @Test
+ void cleanAfterHopFromRemove_returnsFalseWhenNotReferenced() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+ assertFalse(meta.cleanAfterHopFromRemove(tm("NotReferenced")));
+ assertEquals("Out1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+ assertEquals("Default", meta.getDefaultTargetTransformName());
+ }
+
+ @Test
+ void cleanAfterHopFromRemove_returnsFalseWithEmptyCaseTargets() {
+ SwitchCaseMeta meta = new SwitchCaseMeta();
+ assertFalse(meta.cleanAfterHopFromRemove(tm("Anything")));
+ }
+
+ @Test
+ void cleanAfterHopFromRemove_clearsCaseTargetName() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+ assertTrue(meta.cleanAfterHopFromRemove(tm("Out1")));
+ assertNull(meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+ assertEquals("Default", meta.getDefaultTargetTransformName());
+ }
+
+ @Test
+ void cleanAfterHopFromRemove_clearsDefaultTargetName() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+ assertTrue(meta.cleanAfterHopFromRemove(tm("Default")));
+ assertNull(meta.getDefaultTargetTransformName());
+ assertEquals("Out1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+ }
+
+ @Test
+ void cleanAfterHopFromRemove_clearsAllCaseTargetsWithSameName() {
+ // Two case targets pointing at the same transform — both should be cleared
+ SwitchCaseMeta meta = metaWithTargets("Shared", "Shared", "Default");
+ assertTrue(meta.cleanAfterHopFromRemove(tm("Shared")));
+ assertNull(meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ assertNull(meta.getCaseTargets().get(1).getCaseTargetTransformName());
+ assertEquals("Default", meta.getDefaultTargetTransformName());
+ }
+
+ @Test
+ void cleanAfterHopFromRemove_clearsCaseAndDefaultWhenSameName() {
+ SwitchCaseMeta meta = metaWithTargets("Both", "Out2", "Both");
+ assertTrue(meta.cleanAfterHopFromRemove(tm("Both")));
+ assertNull(meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ assertNull(meta.getDefaultTargetTransformName());
+ assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+ }
+
+ @Test
+ void cleanAfterHopFromRemove_resetsIoMetaCacheOnChange() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+ ITransformIOMeta before = meta.getTransformIOMeta();
+ meta.cleanAfterHopFromRemove(tm("Out1"));
+ // The cached IO meta object must be replaced (resetTransformIoMeta was called)
+ assertNotSame(before, meta.getTransformIOMeta());
+ }
+
+ @Test
+ void cleanAfterHopFromRemove_doesNotResetIoMetaWhenNoChange() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+ ITransformIOMeta before = meta.getTransformIOMeta();
+ meta.cleanAfterHopFromRemove(tm("NotReferenced"));
+ // No change → same cached object
+ assertNotSame(null, before); // guard
+ // The identity of the IO meta should be unchanged
+ assertEquals(before, meta.getTransformIOMeta());
+ }
+
+ @Test
+ void cleanAfterHopFromRemove_rebuiltIoMetaReflectsClearedName() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+ assertEquals(3, meta.getTransformIOMeta().getTargetStreams().size());
+
+ meta.cleanAfterHopFromRemove(tm("Out1"));
+
+ // The case target entry survives (the SwitchCaseTarget itself is kept),
+ // so the rebuilt IO meta still has 3 streams; first stream now has a null subject.
+ List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+ assertEquals(3, streams.size());
+ assertNull(streams.get(0).getSubject());
+ assertEquals("Out2", streams.get(1).getSubject());
+ assertEquals("Default", streams.get(2).getSubject());
+ }
+
+ // ---------------------------------------------------------------------------
+ // searchInfoAndTargetTransforms
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void searchInfoAndTargetTransforms_resolvesCaseTargets() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", null);
+ meta.setDefaultTargetTransformName(null);
+ TransformMeta out1 = tm("Out1");
+ TransformMeta out2 = tm("Out2");
+
+ meta.searchInfoAndTargetTransforms(List.of(out1, out2));
+
+ List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+ assertEquals(out1, streams.get(0).getTransformMeta());
+ assertEquals(out2, streams.get(1).getTransformMeta());
+ }
+
+ @Test
+ void searchInfoAndTargetTransforms_resolvesDefaultTarget() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Def");
+ TransformMeta def = tm("Def");
+
+ meta.searchInfoAndTargetTransforms(List.of(tm("Out1"), tm("Out2"), def));
+
+ List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+ assertEquals(def, streams.get(2).getTransformMeta());
+ }
+
+ @Test
+ void searchInfoAndTargetTransforms_yieldsNullStreamForUnknownName() {
+ SwitchCaseMeta meta = metaWithTargets("Known", "Unknown", null);
+ meta.setDefaultTargetTransformName(null);
+
+ meta.searchInfoAndTargetTransforms(List.of(tm("Known")));
+
+ List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+ assertNotNull(streams.get(0).getTransformMeta());
+ assertNull(streams.get(1).getTransformMeta());
+ }
+
+ @Test
+ void searchInfoAndTargetTransforms_handlesEmptyCaseTargets() {
+ SwitchCaseMeta meta = new SwitchCaseMeta();
+ meta.setDefaultTargetTransformName("Def");
+ TransformMeta def = tm("Def");
+
+ meta.searchInfoAndTargetTransforms(List.of(def));
+
+ List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+ assertEquals(1, streams.size());
+ assertEquals(def, streams.get(0).getTransformMeta());
+ }
+
+ @Test
+ void searchInfoAndTargetTransforms_afterClean_remainingTargetsStillResolve() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+ meta.cleanAfterHopFromRemove(tm("Out1"));
+
+ // "Out1" was cleared; Out2 and Default are still valid
+ meta.searchInfoAndTargetTransforms(List.of(tm("Out2"), tm("Default")));
+
+ List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+ assertNull(streams.get(0).getTransformMeta(), "Cleared target should resolve to null");
+ assertNotNull(streams.get(1).getTransformMeta());
+ assertEquals("Out2", streams.get(1).getTransformMeta().getName());
+ assertNotNull(streams.get(2).getTransformMeta());
+ assertEquals("Default", streams.get(2).getTransformMeta().getName());
+ }
+
+ // ---------------------------------------------------------------------------
+ // convertIOMetaToTransformNames
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void convertIOMetaToTransformNames_syncsCaseTargetNamesFromStreams() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", null);
+ meta.setDefaultTargetTransformName(null);
+ TransformMeta out1 = tm("Out1");
+ TransformMeta out2 = tm("Out2");
+ meta.searchInfoAndTargetTransforms(List.of(out1, out2));
+
+ // Repoint stream 0 to a new transform (as handleStreamSelection would do during detach)
+ TransformMeta newOut = tm("NewOut1");
+ meta.getTransformIOMeta().getTargetStreams().get(0).setTransformMeta(newOut);
+
+ meta.convertIOMetaToTransformNames();
+
+ assertEquals("NewOut1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+ }
+
+ @Test
+ void convertIOMetaToTransformNames_syncsDefaultTargetNameFromStream() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Def");
+ meta.searchInfoAndTargetTransforms(List.of(tm("Out1"), tm("Out2"), tm("Def")));
+
+ TransformMeta newDef = tm("NewDef");
+ meta.getTransformIOMeta().getTargetStreams().get(2).setTransformMeta(newDef);
+
+ meta.convertIOMetaToTransformNames();
+
+ assertEquals("NewDef", meta.getDefaultTargetTransformName());
+ }
+
+ @Test
+ void convertIOMetaToTransformNames_doesNotUpdateAbsentDefault() {
+ // When defaultTargetTransformName is empty the guard in convertIOMetaToTransformNames
+ // must not overwrite it even if there happened to be a stream at that index.
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", null);
+ meta.setDefaultTargetTransformName(null);
+ meta.searchInfoAndTargetTransforms(List.of(tm("Out1"), tm("Out2")));
+
+ meta.convertIOMetaToTransformNames();
+
+ assertNull(meta.getDefaultTargetTransformName());
+ }
+
+ // ---------------------------------------------------------------------------
+ // handleStreamSelection — repointing existing target streams
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void handleStreamSelection_updatesCaseTargetName() {
+ SwitchCaseMeta meta = metaWithTargets("Old", "Out2", null);
+ meta.setDefaultTargetTransformName(null);
+ meta.searchInfoAndTargetTransforms(List.of(tm("Old"), tm("Out2")));
+
+ TransformMeta newTarget = tm("New");
+ IStream stream = meta.getTransformIOMeta().getTargetStreams().get(0);
+ stream.setTransformMeta(newTarget);
+ stream.setSubject(newTarget.getName());
+ meta.handleStreamSelection(stream);
+
+ assertEquals("New", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+ }
+
+ @Test
+ void handleStreamSelection_updatesDefaultTargetName() {
+ SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "OldDefault");
+ meta.searchInfoAndTargetTransforms(List.of(tm("Out1"), tm("Out2"), tm("OldDefault")));
+
+ TransformMeta newDef = tm("NewDefault");
+ IStream defStream = meta.getTransformIOMeta().getTargetStreams().get(2);
+ defStream.setTransformMeta(newDef);
+ defStream.setSubject(newDef.getName());
+ meta.handleStreamSelection(defStream);
+
+ assertEquals("NewDefault", meta.getDefaultTargetTransformName());
+ assertEquals("Out1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+ }
+
+ // ---------------------------------------------------------------------------
+ // handleStreamSelection — adding new streams via getOptionalStreams()
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void handleStreamSelection_newCaseTargetStreamAddsEntry() {
+ SwitchCaseMeta meta = new SwitchCaseMeta(); // no targets yet
+
+ // Prime the IO meta cache first — the canvas always renders existing streams before the user
+ // can interact with the "add" placeholder, so the cache is already warm when
+ // handleStreamSelection is called in practice.
+ assertEquals(0, meta.getTransformIOMeta().getTargetStreams().size());
+
+ // newCaseTargetStream is always the last element returned by getOptionalStreams()
+ List<IStream> optional = meta.getOptionalStreams();
+ IStream newCaseTargetStream = optional.get(optional.size() - 1);
+
+ TransformMeta added = tm("CaseX");
+ newCaseTargetStream.setTransformMeta(added);
+ meta.handleStreamSelection(newCaseTargetStream);
+
+ assertEquals(1, meta.getCaseTargets().size());
+ assertEquals("CaseX", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+ // The IO meta should carry exactly the one new stream
+ assertEquals(1, meta.getTransformIOMeta().getTargetStreams().size());
+ }
+
+ @Test
+ void handleStreamSelection_newDefaultStreamSetsDefaultName() {
+ SwitchCaseMeta meta = new SwitchCaseMeta(); // no default yet
+
+ // Prime the IO meta cache (see note in newCaseTargetStream test above)
+ meta.getTransformIOMeta();
+
+ // newDefaultStream is only offered when defaultTargetTransformName is empty
+ List<IStream> optional = meta.getOptionalStreams();
+ IStream newDefaultStream = optional.get(0); // first when no default exists
+
+ TransformMeta defTarget = tm("Default");
+ newDefaultStream.setTransformMeta(defTarget);
+ meta.handleStreamSelection(newDefaultStream);
+
+ assertEquals("Default", meta.getDefaultTargetTransformName());
+ }
+
+ @Test
+ void handleStreamSelection_newDefaultStreamIsNoLongerOfferedAfterSet() {
+ SwitchCaseMeta meta = new SwitchCaseMeta();
+ meta.getTransformIOMeta(); // prime cache
+
+ List<IStream> optional = meta.getOptionalStreams();
+ IStream newDefaultStream = optional.get(0);
+ newDefaultStream.setTransformMeta(tm("Default"));
+ meta.handleStreamSelection(newDefaultStream);
+
+ // Once default is set, getOptionalStreams should only return newCaseTargetStream
+ List<IStream> afterSet = meta.getOptionalStreams();
+ assertEquals(1, afterSet.size());
+ }
+
+ // ---------------------------------------------------------------------------
+ // End-to-end topology simulations
+ // ---------------------------------------------------------------------------
+
+ /**
+ * Simulates inserting a new transform C between Switch Case and B:
+ *
+ * <pre>SwitchCase → B becomes SwitchCase → C → B</pre>
+ *
+ * The UI delegate calls handleStreamSelection on the upstream (Switch Case) with its target
+ * stream repointed to C. The stored case-target name must be updated to "C".
+ */
+ @Test
+ void simulation_insertTransformBetweenSwitchCaseAndTarget() {
+ SwitchCaseMeta meta = metaWithTargets("B", "Out2", "Default");
+ meta.searchInfoAndTargetTransforms(List.of(tm("B"), tm("Out2"), tm("Default")));
+
+ // Insert C: detach target stream from B, attach to C
+ TransformMeta c = tm("C");
+ IStream streamToB = meta.getTransformIOMeta().getTargetStreams().get(0);
+ streamToB.setTransformMeta(c);
+ streamToB.setSubject(c.getName());
+ meta.handleStreamSelection(streamToB);
+
+ assertEquals(
+ "C",
+ meta.getCaseTargets().get(0).getCaseTargetTransformName(),
+ "After insert, Switch Case case target must point to the inserted transform C");
+ assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+ assertEquals("Default", meta.getDefaultTargetTransformName());
+
+ // Re-resolve with the new topology (C replaced B in this slot)
+ meta.searchInfoAndTargetTransforms(List.of(c, tm("Out2"), tm("Default")));
+ assertEquals(c, meta.getTransformIOMeta().getTargetStreams().get(0).getTransformMeta());
+ }
+
+ /**
+ * Simulates detaching B from between Switch Case and C:
+ *
+ * <pre>SwitchCase → B → C becomes SwitchCase → C</pre>
+ *
+ * The UI delegate calls handleStreamSelection on Switch Case with the target stream repointed to
+ * C. The stored case-target name must be updated from "B" to "C".
+ */
+ @Test
+ void simulation_detachTransformBetweenSwitchCaseAndDownstream() {
+ SwitchCaseMeta meta = metaWithTargets("B", "Out2", "Default");
+ meta.searchInfoAndTargetTransforms(List.of(tm("B"), tm("Out2"), tm("Default")));
+
+ // Detach B, reconnect Switch Case → C
+ TransformMeta c = tm("C");
+ IStream streamToB = meta.getTransformIOMeta().getTargetStreams().get(0);
+ streamToB.setTransformMeta(c);
+ streamToB.setSubject(c.getName());
+ meta.handleStreamSelection(streamToB);
+
+ assertEquals(
+ "C",
+ meta.getCaseTargets().get(0).getCaseTargetTransformName(),
+ "After detach, Switch Case case target must be updated to the new downstream C");
+
+ // searchInfoAndTargetTransforms with the post-detach topology must resolve correctly
+ meta.searchInfoAndTargetTransforms(List.of(tm("B"), c, tm("Out2"), tm("Default")));
+ assertEquals(
+ c,
+ meta.getTransformIOMeta().getTargetStreams().get(0).getTransformMeta(),
+ "Re-resolve must find C, not the detached B");
+ }
+
+ /**
+ * Full round-trip: build → resolve → repoint → re-resolve. Verifies that stored names and
+ * resolved stream objects stay consistent across multiple cycles.
+ */
+ @Test
+ void roundTrip_buildResolveRepointResolve() {
+ SwitchCaseMeta meta = metaWithTargets("A", "B", "Default");
+ TransformMeta tmA = tm("A");
+ TransformMeta tmB = tm("B");
+ TransformMeta tmDefault = tm("Default");
+ meta.searchInfoAndTargetTransforms(List.of(tmA, tmB, tmDefault));
+
+ // Verify initial resolution
+ List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+ assertEquals(tmA, streams.get(0).getTransformMeta());
+ assertEquals(tmB, streams.get(1).getTransformMeta());
+ assertEquals(tmDefault, streams.get(2).getTransformMeta());
+
+ // Repoint "A" → "C"
+ TransformMeta tmC = tm("C");
+ streams.get(0).setTransformMeta(tmC);
+ streams.get(0).setSubject(tmC.getName());
+ meta.handleStreamSelection(streams.get(0));
+ assertEquals("C", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+
+ // Re-resolve — "C" now in the pipeline, "A" dropped
+ meta.searchInfoAndTargetTransforms(List.of(tmC, tmB, tmDefault));
+ List<IStream> resolved = meta.getTransformIOMeta().getTargetStreams();
+ assertEquals(tmC, resolved.get(0).getTransformMeta());
+ assertEquals(tmB, resolved.get(1).getTransformMeta());
+ assertEquals(tmDefault, resolved.get(2).getTransformMeta());
+ }
+}
diff --git a/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseRoutingTest.java b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseRoutingTest.java
new file mode 100644
index 0000000000..044092a18f
--- /dev/null
+++ b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseRoutingTest.java
@@ -0,0 +1,452 @@
+/*
+ * 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.switchcase;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import org.apache.hop.core.IRowSet;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link SwitchCase} routing behaviour — covering {@code createOutputValueMapping} and
+ * the full {@code processRow} flow.
+ *
+ * <p>Unlike the existing {@link SwitchCaseTest} (which only tests {@code prepareObjectType}), these
+ * tests exercise the actual row-routing logic: exact matching, null handling, default fallback,
+ * contains-mode, and error paths.
+ */
+class SwitchCaseRoutingTest {
+
+ private TransformMockHelper<SwitchCaseMeta, SwitchCaseData> helper;
+
+ @BeforeEach
+ void setUp() {
+ helper = new TransformMockHelper<>("Switch Case", SwitchCaseMeta.class, SwitchCaseData.class);
+ when(helper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+ .thenReturn(helper.iLogChannel);
+ when(helper.pipeline.isRunning()).thenReturn(true);
+ }
+
+ @AfterEach
+ void tearDown() {
+ helper.cleanUp();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Test helper
+ // ---------------------------------------------------------------------------
+
+ /**
+ * A minimal {@link SwitchCase} subclass that wires a <em>real</em> {@link SwitchCaseMeta} into
+ * {@link org.apache.hop.pipeline.transform.BaseTransform} (bypassing the Mockito mock in the
+ * helper). This ensures that {@code createOutputValueMapping()} and {@code processRow()} use the
+ * fully-configured metadata rather than a stub.
+ *
+ * <p>Data field setup is done manually (without calling {@code init()}) to avoid the {@link
+ * org.apache.hop.core.plugins.PluginRegistry} dependency: we supply a real {@link
+ * ValueMetaString} for both {@code valueMeta} and {@code stringValueMeta}, and wire the correct
+ * {@link KeyToRowSetMap} variant for the chosen matching mode.
+ */
+ private class SwitchCaseForTest extends SwitchCase {
+
+ final Map<String, IRowSet> rowSets = new HashMap<>();
+ final Queue<Object[]> inputRows = new LinkedList<>();
+ final List<IRowSet> routedTo = new ArrayList<>();
+
+ SwitchCaseForTest(SwitchCaseMeta realMeta) throws Exception {
+ super(
+ helper.transformMeta,
+ realMeta,
+ new SwitchCaseData(),
+ 0,
+ helper.pipelineMeta,
+ helper.pipeline);
+
+ // Build a mock input row meta that returns a real ValueMetaString for the switch field.
+ // createOutputValueMapping() uses this to locate the field index and its IValueMeta.
+ IRowMeta mockInputMeta = mock(IRowMeta.class);
+ ValueMetaString fieldMeta = new ValueMetaString(realMeta.getFieldName());
+ when(mockInputMeta.clone()).thenReturn(mockInputMeta);
+ when(mockInputMeta.indexOfValue(anyString())).thenReturn(0);
+ when(mockInputMeta.getValueMeta(anyInt())).thenReturn(fieldMeta);
+
+ // Attach the mock as the single input stream meta
+ setInputRowMeta(mockInputMeta);
+
+ // Manually prime data so we don't need ValueMetaFactory / PluginRegistry.
+ // ValueMetaString handles all String-type conversions natively.
+ SwitchCaseData d = switchCaseData();
+ d.outputMap =
+ realMeta.isUsingContains() ? new ContainsKeyToRowSetMap() : new KeyToRowSetMap();
+ d.valueMeta = new ValueMetaString(realMeta.getFieldName());
+ d.stringValueMeta = new ValueMetaString(realMeta.getFieldName());
+ }
+
+ @Override
+ public IRowSet findOutputRowSet(String targetTransform) {
+ return rowSets.get(targetTransform);
+ }
+
+ @Override
+ public Object[] getRow() {
+ return inputRows.poll();
+ }
+
+ @Override
+ public void putRowTo(IRowMeta rowMeta, Object[] row, IRowSet rowSet)
+ throws HopTransformException {
+ routedTo.add(rowSet);
+ }
+
+ // Expose the protected BaseTransform.data field for assertions in the outer test class.
+ SwitchCaseData switchCaseData() {
+ return data;
+ }
+
+ // Convenience: run processRow() until it returns false (no more rows).
+ void drainRows() throws HopException {
+ //noinspection StatementWithEmptyBody
+ while (processRow()) {}
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // helpers
+ // ---------------------------------------------------------------------------
+
+ private static SwitchCaseMeta metaWithCases(String fieldName, SwitchCaseTarget... targets) {
+ SwitchCaseMeta meta = new SwitchCaseMeta();
+ meta.setFieldName(fieldName);
+ for (SwitchCaseTarget t : targets) {
+ meta.getCaseTargets().add(t);
+ }
+ return meta;
+ }
+
+ private static SwitchCaseTarget target(String value, String transformName) {
+ SwitchCaseTarget t = new SwitchCaseTarget();
+ t.setCaseValue(value);
+ t.setCaseTargetTransformName(transformName);
+ return t;
+ }
+
+ // ---------------------------------------------------------------------------
+ // createOutputValueMapping — mapping construction
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void createOutputValueMapping_exactCaseValue_addsToOutputMap() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("status", target("A", "TargetA"), target("B", "TargetB"));
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsA = mock(IRowSet.class);
+ IRowSet rsB = mock(IRowSet.class);
+ sc.rowSets.put("TargetA", rsA);
+ sc.rowSets.put("TargetB", rsB);
+
+ sc.createOutputValueMapping();
+
+ assertTrue(
+ sc.switchCaseData().outputMap.containsKey("A"), "Key 'A' should be in the output map");
+ assertTrue(sc.switchCaseData().outputMap.get("A").contains(rsA));
+ assertTrue(sc.switchCaseData().outputMap.containsKey("B"));
+ assertTrue(sc.switchCaseData().outputMap.get("B").contains(rsB));
+ }
+
+ @Test
+ void createOutputValueMapping_defaultTarget_addedToDefaultAndNullSets() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("status", target("A", "TargetA"));
+ meta.setDefaultTargetTransformName("Default");
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsA = mock(IRowSet.class);
+ IRowSet rsDefault = mock(IRowSet.class);
+ sc.rowSets.put("TargetA", rsA);
+ sc.rowSets.put("Default", rsDefault);
+
+ sc.createOutputValueMapping();
+
+ assertTrue(sc.switchCaseData().defaultRowSetSet.contains(rsDefault));
+ // When no explicit null target is configured, the default also handles nulls
+ assertTrue(sc.switchCaseData().nullRowSetSet.contains(rsDefault));
+ }
+
+ @Test
+ void createOutputValueMapping_usesKeyToRowSetMapForExactMode() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("x", target("v", "T"));
+ meta.setUsingContains(false);
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ sc.rowSets.put("T", mock(IRowSet.class));
+
+ sc.createOutputValueMapping();
+
+ assertInstanceOf(KeyToRowSetMap.class, sc.switchCaseData().outputMap);
+ }
+
+ @Test
+ void createOutputValueMapping_usesContainsKeyToRowSetMapForContainsMode() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("x", target("substring", "T"));
+ meta.setUsingContains(true);
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ sc.rowSets.put("T", mock(IRowSet.class));
+
+ sc.createOutputValueMapping();
+
+ assertInstanceOf(ContainsKeyToRowSetMap.class, sc.switchCaseData().outputMap);
+ }
+
+ @Test
+ void createOutputValueMapping_throwsWhenTargetRowSetNotFound() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("status", target("A", "Missing"));
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ // "Missing" not registered in sc.rowSets
+
+ assertThrows(HopException.class, sc::createOutputValueMapping);
+ }
+
+ @Test
+ void createOutputValueMapping_throwsWhenFieldNotFoundInRow() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("nonExistentField", target("A", "T"));
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ sc.rowSets.put("T", mock(IRowSet.class));
+
+ // Override getInputRowMeta to return -1 for the field index (field not found)
+ IRowMeta badMeta = mock(IRowMeta.class);
+ when(badMeta.clone()).thenReturn(badMeta);
+ when(badMeta.indexOfValue(anyString())).thenReturn(-1);
+ sc.setInputRowMeta(badMeta);
+
+ assertThrows(HopException.class, sc::createOutputValueMapping);
+ }
+
+ @Test
+ void createOutputValueMapping_sameTransformForMultipleCaseValues_bothKeysPresent()
+ throws Exception {
+ SwitchCaseMeta meta = metaWithCases("x", target("A", "Shared"), target("B", "Shared"));
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsShared = mock(IRowSet.class);
+ sc.rowSets.put("Shared", rsShared);
+
+ sc.createOutputValueMapping();
+
+ assertTrue(sc.switchCaseData().outputMap.get("A").contains(rsShared));
+ assertTrue(sc.switchCaseData().outputMap.get("B").contains(rsShared));
+ }
+
+ // ---------------------------------------------------------------------------
+ // processRow — end-to-end routing
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void processRow_exactMatch_routesToCorrectRowSet() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("col", target("X", "TargetX"), target("Y", "TargetY"));
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsX = mock(IRowSet.class);
+ IRowSet rsY = mock(IRowSet.class);
+ sc.rowSets.put("TargetX", rsX);
+ sc.rowSets.put("TargetY", rsY);
+
+ sc.inputRows.add(new Object[] {"X"});
+ sc.inputRows.add(new Object[] {"Y"});
+ sc.inputRows.add(new Object[] {"X"});
+ sc.drainRows();
+
+ assertEquals(3, sc.routedTo.size());
+ assertEquals(rsX, sc.routedTo.get(0));
+ assertEquals(rsY, sc.routedTo.get(1));
+ assertEquals(rsX, sc.routedTo.get(2));
+ }
+
+ @Test
+ void processRow_nullValue_routesToNullRowSetSet() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("col", target("A", "TargetA"));
+ meta.setDefaultTargetTransformName("Default");
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsA = mock(IRowSet.class);
+ IRowSet rsDefault = mock(IRowSet.class);
+ sc.rowSets.put("TargetA", rsA);
+ sc.rowSets.put("Default", rsDefault);
+
+ sc.inputRows.add(new Object[] {null}); // null → nullRowSetSet → defaults to rsDefault
+ sc.drainRows();
+
+ assertEquals(1, sc.routedTo.size());
+ assertEquals(rsDefault, sc.routedTo.get(0));
+ }
+
+ @Test
+ void processRow_unmatchedValue_routesToDefault() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("col", target("A", "TargetA"));
+ meta.setDefaultTargetTransformName("Default");
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsA = mock(IRowSet.class);
+ IRowSet rsDefault = mock(IRowSet.class);
+ sc.rowSets.put("TargetA", rsA);
+ sc.rowSets.put("Default", rsDefault);
+
+ sc.inputRows.add(new Object[] {"NoMatch"});
+ sc.drainRows();
+
+ assertEquals(1, sc.routedTo.size());
+ assertEquals(rsDefault, sc.routedTo.get(0));
+ }
+
+ @Test
+ void processRow_mixedRows_routedCorrectly() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("col", target("A", "TargetA"), target("B", "TargetB"));
+ meta.setDefaultTargetTransformName("Default");
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsA = mock(IRowSet.class);
+ IRowSet rsB = mock(IRowSet.class);
+ IRowSet rsDefault = mock(IRowSet.class);
+ sc.rowSets.put("TargetA", rsA);
+ sc.rowSets.put("TargetB", rsB);
+ sc.rowSets.put("Default", rsDefault);
+
+ sc.inputRows.add(new Object[] {"A"});
+ sc.inputRows.add(new Object[] {"B"});
+ sc.inputRows.add(new Object[] {"unknown"});
+ sc.inputRows.add(new Object[] {null});
+ sc.drainRows();
+
+ assertEquals(4, sc.routedTo.size());
+ assertEquals(rsA, sc.routedTo.get(0));
+ assertEquals(rsB, sc.routedTo.get(1));
+ assertEquals(rsDefault, sc.routedTo.get(2)); // unmatched → default
+ assertEquals(rsDefault, sc.routedTo.get(3)); // null → default (no explicit null target)
+ }
+
+ @Test
+ void processRow_containsMode_routesBySubstringMatch() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("message", target("ERROR", "ErrorTarget"));
+ meta.setUsingContains(true);
+ meta.setDefaultTargetTransformName("Default");
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsError = mock(IRowSet.class);
+ IRowSet rsDefault = mock(IRowSet.class);
+ sc.rowSets.put("ErrorTarget", rsError);
+ sc.rowSets.put("Default", rsDefault);
+
+ sc.inputRows.add(new Object[] {"FATAL ERROR: disk full"}); // contains "ERROR"
+ sc.inputRows.add(new Object[] {"INFO: all good"}); // no match
+ sc.drainRows();
+
+ assertEquals(2, sc.routedTo.size());
+ assertEquals(rsError, sc.routedTo.get(0));
+ assertEquals(rsDefault, sc.routedTo.get(1));
+ }
+
+ @Test
+ void processRow_emptyInput_noRowsRouted() throws Exception {
+ SwitchCaseMeta meta = metaWithCases("col", target("A", "TargetA"));
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ sc.rowSets.put("TargetA", mock(IRowSet.class));
+
+ // No rows added to inputRows
+ sc.drainRows();
+
+ assertTrue(sc.routedTo.isEmpty());
+ }
+
+ @Test
+ void processRow_sameValueForMultipleCases_allTargetRowSetsReceiveRow() throws Exception {
+ // Two case targets with the same value map to different rowsets —
+ // both rowsets should receive the row.
+ SwitchCaseMeta meta = metaWithCases("x", target("same", "T1"), target("same", "T2"));
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rs1 = mock(IRowSet.class);
+ IRowSet rs2 = mock(IRowSet.class);
+ sc.rowSets.put("T1", rs1);
+ sc.rowSets.put("T2", rs2);
+
+ sc.inputRows.add(new Object[] {"same"});
+ sc.drainRows();
+
+ assertEquals(2, sc.routedTo.size());
+ assertTrue(sc.routedTo.contains(rs1));
+ assertTrue(sc.routedTo.contains(rs2));
+ }
+
+ @Test
+ void processRow_byteArray_routedViaHashCode() throws Exception {
+ // SwitchCase converts byte[] to Arrays.hashCode before map lookup.
+ // Here we register the hash code directly as the case value to confirm the conversion applies.
+ byte[] bytes = {1, 2, 3};
+ int hashCode = java.util.Arrays.hashCode(bytes);
+
+ SwitchCaseMeta meta =
+ metaWithCases("payload", target(String.valueOf(hashCode), "BinaryTarget"));
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsBinary = mock(IRowSet.class);
+ sc.rowSets.put("BinaryTarget", rsBinary);
+
+ // The value meta is a String meta, so convertData will convert int → string; we verify
+ // the prepareObjectType path separately via SwitchCase.prepareObjectType tests.
+ // What we test here is that the mechanism doesn't blow up on non-string values in the map.
+ sc.inputRows.add(new Object[] {String.valueOf(hashCode)});
+ sc.drainRows();
+
+ assertEquals(1, sc.routedTo.size());
+ assertEquals(rsBinary, sc.routedTo.get(0));
+ }
+
+ // ---------------------------------------------------------------------------
+ // IValueMeta.TYPE_NONE default (field value type left unspecified)
+ // ---------------------------------------------------------------------------
+
+ @Test
+ void processRow_noExplicitCaseValueType_stringValuesStillRoute() throws Exception {
+ // When no caseValueType is set the meta defaults to TYPE_NONE (empty string).
+ // The routing still works because ValueMetaString.convertDataFromString passes the
+ // string through unchanged, and map.get("A") finds the key.
+ SwitchCaseMeta meta =
+ metaWithCases("field", target("hello", "Hello"), target("world", "World"));
+ SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+ IRowSet rsHello = mock(IRowSet.class);
+ IRowSet rsWorld = mock(IRowSet.class);
+ sc.rowSets.put("Hello", rsHello);
+ sc.rowSets.put("World", rsWorld);
+
+ sc.inputRows.add(new Object[] {"hello"});
+ sc.inputRows.add(new Object[] {"world"});
+ sc.drainRows();
+
+ assertEquals(rsHello, sc.routedTo.get(0));
+ assertEquals(rsWorld, sc.routedTo.get(1));
+ }
+}
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 1bb59dc27d..7eb6d01d05 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
@@ -2446,10 +2446,9 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
pipelineHopDelegate.newHop(
pipelineMeta, new PipelineHopMeta(newUpstream, toHop.getToTransform()));
- // Same as split hop (insertTransform) but in reverse: point the target's info streams that
- // were reading from the detached transform to the new upstream so
- // searchInfoAndTargetTransforms
- // can resolve correctly.
+ // Input side: point info streams of the new downstream that were reading from the detached
+ // transform to the new upstream, keeping stored source names in sync via
+ // handleStreamSelection.
TransformMeta targetTransform = toHop.getToTransform();
if (targetTransform != null
&& targetTransform.getTransform() != null
@@ -2466,13 +2465,53 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
}
}
}
+
+ // Output side: point target streams of the new upstream that were pointing to the detached
+ // transform to the new downstream, keeping stored target names in sync via
+ // handleStreamSelection. This is the mirror of the input-side handling above and ensures
+ // transforms like Switch Case update their stored case-target names correctly on detach.
+ if (newUpstream != null && newUpstream.getTransform() != null) {
+ ITransformIOMeta fromIo = newUpstream.getTransform().getTransformIOMeta();
+ if (fromIo != null) {
+ TransformMeta newDownstream = toHop.getToTransform();
+ for (IStream stream : fromIo.getTargetStreams()) {
+ if (stream.getTransformMeta() != null
+ && stream.getTransformMeta().equals(transformMeta)
+ && newDownstream != null) {
+ stream.setTransformMeta(newDownstream);
+ stream.setSubject(newDownstream.getName());
+ newUpstream.getTransform().handleStreamSelection(stream);
+ }
+ }
+ }
+ }
+ }
+
+ // For upstream transforms that had a hop pointing TO the detached transform, clear the stale
+ // target reference when it was not already redirected via handleStreamSelection above.
+ // This covers the "leaf detach" case (no toHop) where no new downstream exists to reconnect to.
+ for (PipelineHopMeta hop : removedHops) {
+ TransformMeta from = hop.getFromTransform();
+ TransformMeta to = hop.getToTransform();
+ if (to != null
+ && to.equals(transformMeta)
+ && from != null
+ && !from.equals(transformMeta)
+ && from.getTransform() != null) {
+ boolean handledByReconnect = fromHop != null && toHop != null && hop == fromHop;
+ if (!handledByReconnect) {
+ from.getTransform().cleanAfterHopFromRemove(transformMeta);
+ }
+ }
}
- // Same as split hop (insertTransform): after topology change, trigger IO meta update on
- // targets of removed hops so linked transform names are updated.
+ // After the topology change, let the downstream transforms re-resolve their info/target stream
+ // names against the updated pipeline.
for (PipelineHopMeta hop : removedHops) {
TransformMeta toTransform = hop.getToTransform();
- if (toTransform != null && toTransform.getTransform() != null) {
+ if (toTransform != null
+ && toTransform.getTransform() != null
+ && !toTransform.equals(transformMeta)) {
toTransform.getTransform().searchInfoAndTargetTransforms(pipelineMeta.getTransforms());
}
}
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 591d25e382..ee6accf014 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
@@ -230,6 +230,9 @@ public class HopGuiPipelineHopDelegate {
boolean transformFromNeedAddUndoChange =
fromTransformMeta.getTransform().cleanAfterHopFromRemove(pipelineHopMeta.getToTransform());
+ if (fromTransformMeta.getTransform() != null) {
+ fromTransformMeta.getTransform().searchInfoAndTargetTransforms(pipelineMeta.getTransforms());
+ }
boolean transformToNeedAddUndoChange =
toTransformMeta.getTransform().cleanAfterHopToRemove(fromTransformMeta);
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 3cb0647a62..c15dcf3d6d 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
@@ -728,6 +728,9 @@ public class HopGuiPipelineTransformDelegate {
}
if (!transforms.contains(fromTransform) && fromTransform.getTransform() != null) {
fromTransform.getTransform().cleanAfterHopFromRemove(toTransform);
+ fromTransform
+ .getTransform()
+ .searchInfoAndTargetTransforms(pipelineMeta.getTransforms());
}
pipelineMeta.removePipelineHop(idx);