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 7e6e998a55 merge hop engine types for supported transforms, fixes 
#7643 (#7648)
7e6e998a55 is described below

commit 7e6e998a55ef30325086d8212eca242e7070cc71
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Mon Jul 27 18:39:04 2026 +0200

    merge hop engine types for supported transforms, fixes #7643 (#7648)
---
 .../modules/ROOT/pages/engine-compatibility.adoc   |   2 +-
 .../apache/hop/ui/hopgui/PaletteEngineFilter.java  | 111 ++++++++++++---------
 .../hop/ui/hopgui/PaletteEngineFilterTest.java     | 103 +++++++++++++++++++
 3 files changed, 166 insertions(+), 50 deletions(-)

diff --git a/docs/hop-dev-manual/modules/ROOT/pages/engine-compatibility.adoc 
b/docs/hop-dev-manual/modules/ROOT/pages/engine-compatibility.adoc
index 97837548da..afc1648797 100644
--- a/docs/hop-dev-manual/modules/ROOT/pages/engine-compatibility.adoc
+++ b/docs/hop-dev-manual/modules/ROOT/pages/engine-compatibility.adoc
@@ -122,7 +122,7 @@ The deep gate in `Pipeline.prepareExecution()` reads this 
variable on each invoc
 
 The pipeline and workflow canvas toolbars carry a **Design for:** combo (next 
to the zoom controls). The selection is persisted in `hop-config.json` 
(`HopGuiPaletteDesignPipelineEngine`, `HopGuiPaletteDesignWorkflowEngine`) and 
filters the right-click palette to only show transforms / actions that resolve 
to `SUPPORTED` or `UNKNOWN` on the selected engine. `UNSUPPORTED` entries are 
hidden so they cannot be accidentally added; `UNKNOWN` stays visible because it 
is the safe default for plu [...]
 
-The combo offers, in order: **All engines** (disables the filter), then 
**Hop** — a synthetic group that unifies the local and remote engines (they 
share the same plugin compatibility surface from a designer's perspective, so 
the combo doesn't split them) — then every other registered engine, 
alphabetically. The Beam engines therefore appear together after Hop, and 
third-party engines slot in wherever their display name puts them. Internally 
the Hop group expands to `{Local, Remote}` and [...]
+The combo offers, in order: **All engines** (disables the filter), then 
**Hop** — a synthetic group that unifies the local, remote and local 
single-threaded engines (they share the same plugin compatibility surface from 
a designer's perspective, so the combo doesn't split them) — then every other 
registered engine, alphabetically. The Beam engines therefore appear together 
after Hop, and third-party engines slot in wherever their display name puts 
them. Internally the Hop group expands t [...]
 
 == Reference
 
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/PaletteEngineFilter.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/PaletteEngineFilter.java
index 8dfb36948a..6362e78f6f 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/PaletteEngineFilter.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/PaletteEngineFilter.java
@@ -18,9 +18,8 @@
 package org.apache.hop.ui.hopgui;
 
 import java.util.ArrayList;
-import java.util.LinkedHashSet;
+import java.util.Collection;
 import java.util.List;
-import java.util.Set;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.config.HopConfig;
 import org.apache.hop.core.logging.LogChannel;
@@ -47,11 +46,11 @@ import 
org.apache.hop.workflow.engine.WorkflowEnginePluginType;
  * <p>An empty stored value means "no filter" — the palette behaves exactly as 
it did before this
  * feature was introduced, preserving the new-user experience.
  *
- * <p>The local "Local" and remote "Remote" engines are grouped under a single 
{@link
- * #HOP_ENGINE_GROUP_ID Hop} entry: from a user's perspective they are the 
same engine surface (same
- * plugin compatibility, same transform palette) and splitting them in the 
combo adds noise. A
- * plugin is shown when <em>any</em> engine in the group accepts it, hidden 
only when <em>every</em>
- * member of the group says UNSUPPORTED.
+ * <p>The Hop engines — local "Local", remote "Remote" and the local 
single-threaded "LocalSingle" —
+ * are grouped under a single {@link #HOP_ENGINE_GROUP_ID Hop} entry: from a 
user's perspective they
+ * are the same engine surface (same plugin compatibility, same transform 
palette) and splitting
+ * them in the combo adds noise. A plugin is shown when <em>any</em> engine in 
the group accepts it,
+ * hidden only when <em>every</em> member of the group says UNSUPPORTED.
  */
 public final class PaletteEngineFilter {
 
@@ -67,35 +66,61 @@ public final class PaletteEngineFilter {
   public static final String NO_FILTER_LABEL = "All engines";
 
   /**
-   * Synthetic engine id used by the combo to represent the unified Hop-engine 
group (Local +
-   * Remote, both pipeline and workflow). Stored verbatim in {@code 
hop-config.json}; when the
+   * Synthetic engine id used by the combo to represent the unified Hop-engine 
group (Local, Remote
+   * and — on the pipeline side — LocalSingle). Stored verbatim in {@code 
hop-config.json}; when the
    * filter loads this value it expands into probes for every member of the 
group.
    */
   public static final String HOP_ENGINE_GROUP_ID = "Hop";
 
-  /** Engine plugin ids collapsed into the {@link #HOP_ENGINE_GROUP_ID Hop} 
group. */
-  private static final Set<String> HOP_PIPELINE_ENGINE_IDS = Set.of("Local", 
"Remote");
+  /**
+   * Pipeline engine plugin ids collapsed into the {@link #HOP_ENGINE_GROUP_ID 
Hop} group. The local
+   * single-threaded engine ("LocalSingle") supports the same transforms as 
the local and remote
+   * engines, so it shares their combo entry instead of getting one of its own.
+   */
+  private static final List<String> HOP_PIPELINE_ENGINE_IDS =
+      List.of("Local", "Remote", "LocalSingle");
 
-  private static final Set<String> HOP_WORKFLOW_ENGINE_IDS = Set.of("Local", 
"Remote");
+  /** Workflow counterpart — there is no single-threaded workflow engine. */
+  private static final List<String> HOP_WORKFLOW_ENGINE_IDS = List.of("Local", 
"Remote");
 
   private final String engineId;
+
+  /**
+   * The concrete engine ids the filter consults, parallel to {@link #probes} 
— a single id, or
+   * every member of the Hop group when {@link #HOP_ENGINE_GROUP_ID} is 
selected.
+   */
+  private final List<String> engineIds;
+
   private final List<EngineProbe> probes;
 
-  private PaletteEngineFilter(String engineId, List<EngineProbe> probes) {
+  private PaletteEngineFilter(String engineId, List<String> engineIds, 
List<EngineProbe> probes) {
     this.engineId = engineId;
+    this.engineIds = engineIds;
     this.probes = probes;
   }
 
   /** Reads the saved pipeline-design engine id and instantiates a probe for 
it. */
   public static PaletteEngineFilter forPipelineDesign() {
-    String engineId = StringUtils.trimToEmpty(getPipelineDesignEngineId());
-    return new PaletteEngineFilter(engineId, pipelineProbesFor(engineId));
+    return forPipelineEngineId(getPipelineDesignEngineId());
   }
 
   /** Reads the saved workflow-design engine id and instantiates a probe for 
it. */
   public static PaletteEngineFilter forWorkflowDesign() {
-    String engineId = StringUtils.trimToEmpty(getWorkflowDesignEngineId());
-    return new PaletteEngineFilter(engineId, workflowProbesFor(engineId));
+    return forWorkflowEngineId(getWorkflowDesignEngineId());
+  }
+
+  /** Filter for an explicit pipeline engine id, bypassing the persisted 
selection. */
+  static PaletteEngineFilter forPipelineEngineId(String engineId) {
+    String id = StringUtils.trimToEmpty(engineId);
+    List<String> ids = expandGroup(id, HOP_PIPELINE_ENGINE_IDS);
+    return new PaletteEngineFilter(id, ids, probesFor(ids, true));
+  }
+
+  /** Filter for an explicit workflow engine id, bypassing the persisted 
selection. */
+  static PaletteEngineFilter forWorkflowEngineId(String engineId) {
+    String id = StringUtils.trimToEmpty(engineId);
+    List<String> ids = expandGroup(id, HOP_WORKFLOW_ENGINE_IDS);
+    return new PaletteEngineFilter(id, ids, probesFor(ids, false));
   }
 
   /** True iff a non-empty engine id is stored — i.e. the palette should 
actually filter. */
@@ -117,12 +142,11 @@ public final class PaletteEngineFilter {
     if (!isActive() || plugin == null) {
       return true;
     }
-    List<String> ids = expandGroup(engineId, HOP_PIPELINE_ENGINE_IDS, 
HOP_WORKFLOW_ENGINE_IDS);
-    for (int i = 0; i < ids.size(); i++) {
+    for (int i = 0; i < engineIds.size(); i++) {
       EngineProbe probe = i < probes.size() ? probes.get(i) : null;
       EngineCompatibility verdict =
           EngineCompatibilityResolver.resolve(
-              plugin, ids.get(i), probe == null ? null : probe::supports);
+              plugin, engineIds.get(i), probe == null ? null : 
probe::supports);
       if (!verdict.isUnsupported()) {
         return true;
       }
@@ -209,7 +233,7 @@ public final class PaletteEngineFilter {
   }
 
   private static List<String> labelsFor(
-      Class<? extends IPluginType> pluginType, Set<String> hopGroupIds) {
+      Class<? extends IPluginType> pluginType, Collection<String> hopGroupIds) 
{
     List<String> labels = new ArrayList<>();
     labels.add(NO_FILTER_LABEL);
     try {
@@ -237,7 +261,7 @@ public final class PaletteEngineFilter {
   }
 
   private static String labelForId(
-      Class<? extends IPluginType> pluginType, String engineId, Set<String> 
hopGroupIds) {
+      Class<? extends IPluginType> pluginType, String engineId, 
Collection<String> hopGroupIds) {
     if (StringUtils.isEmpty(engineId)) {
       return NO_FILTER_LABEL;
     }
@@ -275,7 +299,7 @@ public final class PaletteEngineFilter {
     return "";
   }
 
-  private static boolean matchesAnyId(IPlugin plugin, Set<String> ids) {
+  private static boolean matchesAnyId(IPlugin plugin, Collection<String> ids) {
     if (plugin == null || plugin.getIds() == null) {
       return false;
     }
@@ -290,37 +314,26 @@ public final class PaletteEngineFilter {
   /**
    * Expands a (possibly group) engine id into the concrete plugin ids the 
filter must consult.
    * Single-engine values pass through unchanged; the synthetic {@link 
#HOP_ENGINE_GROUP_ID} fans
-   * out to the union of pipeline + workflow Hop ids so that one logical "Hop" 
filter covers both
-   * canvases that share this class.
+   * out to the Hop engines of the canvas being filtered. The pipeline and 
workflow sides are kept
+   * separate on purpose: probing an id that has no engine on this side (e.g. 
"LocalSingle" as a
+   * workflow engine) always yields UNKNOWN, and since the group is permissive 
that one member would
+   * make every plugin visible and silently disable the filter.
+   *
+   * <p>A bare group member stored in {@code hop-config.json} (from an older 
release where "Local",
+   * "Remote" or "LocalSingle" were separate combo entries) expands to the 
whole group too, so the
+   * filter matches the "Hop" label the combo shows for it.
    */
-  private static List<String> expandGroup(
-      String engineId, Set<String> hopPipelineIds, Set<String> hopWorkflowIds) 
{
-    if (HOP_ENGINE_GROUP_ID.equals(engineId)) {
-      Set<String> all = new LinkedHashSet<>();
-      all.addAll(hopPipelineIds);
-      all.addAll(hopWorkflowIds);
-      return new ArrayList<>(all);
+  private static List<String> expandGroup(String engineId, List<String> 
hopGroupIds) {
+    if (StringUtils.isEmpty(engineId)) {
+      return List.of();
+    }
+    if (HOP_ENGINE_GROUP_ID.equals(engineId) || 
hopGroupIds.contains(engineId)) {
+      return hopGroupIds;
     }
     return List.of(engineId);
   }
 
-  private static List<EngineProbe> pipelineProbesFor(String engineId) {
-    return probesFor(engineId, HOP_PIPELINE_ENGINE_IDS, 
HOP_WORKFLOW_ENGINE_IDS, true);
-  }
-
-  private static List<EngineProbe> workflowProbesFor(String engineId) {
-    return probesFor(engineId, HOP_PIPELINE_ENGINE_IDS, 
HOP_WORKFLOW_ENGINE_IDS, false);
-  }
-
-  private static List<EngineProbe> probesFor(
-      String engineId,
-      Set<String> hopPipelineIds,
-      Set<String> hopWorkflowIds,
-      boolean forPipeline) {
-    if (StringUtils.isEmpty(engineId)) {
-      return List.of();
-    }
-    List<String> ids = expandGroup(engineId, hopPipelineIds, hopWorkflowIds);
+  private static List<EngineProbe> probesFor(List<String> ids, boolean 
forPipeline) {
     List<EngineProbe> probes = new ArrayList<>(ids.size());
     for (String id : ids) {
       EngineProbe probe =
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/PaletteEngineFilterTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/PaletteEngineFilterTest.java
new file mode 100644
index 0000000000..7d0752f888
--- /dev/null
+++ b/ui/src/test/java/org/apache/hop/ui/hopgui/PaletteEngineFilterTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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.ui.hopgui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.hop.core.plugins.IPlugin;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Covers the unified "Hop" engine group behind the canvas "Design for:" combo 
— in particular that
+ * the local single-threaded pipeline engine ("LocalSingle") is part of it, 
and that it does not
+ * leak into the workflow side where no such engine exists.
+ */
+class PaletteEngineFilterTest {
+
+  private static IPlugin plugin(String[] supported, String[] excluded) {
+    IPlugin plugin = mock(IPlugin.class);
+    when(plugin.getSupportedEngines()).thenReturn(supported);
+    when(plugin.getExcludedEngines()).thenReturn(excluded);
+    return plugin;
+  }
+
+  @Test
+  void singleThreadedEngineIsLabelledAsTheHopGroup() {
+    assertEquals(
+        PaletteEngineFilter.HOP_ENGINE_GROUP_ID,
+        PaletteEngineFilter.getPipelineEngineLabelForId("LocalSingle"));
+    assertEquals(
+        PaletteEngineFilter.HOP_ENGINE_GROUP_ID,
+        PaletteEngineFilter.getPipelineEngineLabelForId("Local"));
+    assertEquals(
+        PaletteEngineFilter.HOP_ENGINE_GROUP_ID,
+        PaletteEngineFilter.getPipelineEngineLabelForId("Remote"));
+    assertEquals(
+        PaletteEngineFilter.NO_FILTER_LABEL, 
PaletteEngineFilter.getPipelineEngineLabelForId(""));
+  }
+
+  @Test
+  void hopGroupAllowsTransformsDeclaredForAnyOfItsEngines() {
+    PaletteEngineFilter filter =
+        
PaletteEngineFilter.forPipelineEngineId(PaletteEngineFilter.HOP_ENGINE_GROUP_ID);
+
+    assertTrue(filter.isActive());
+    assertTrue(filter.isPluginAllowed(plugin(new String[] {"Local"}, new 
String[0])));
+    assertTrue(filter.isPluginAllowed(plugin(new String[] {"LocalSingle"}, new 
String[0])));
+    assertTrue(filter.isPluginAllowed(plugin(new String[0], new String[0])));
+  }
+
+  @Test
+  void hopGroupHidesTransformsRefusedByEveryMemberEngine() {
+    PaletteEngineFilter filter =
+        
PaletteEngineFilter.forPipelineEngineId(PaletteEngineFilter.HOP_ENGINE_GROUP_ID);
+
+    assertFalse(filter.isPluginAllowed(plugin(new String[] {"Beam*"}, new 
String[0])));
+    assertFalse(
+        filter.isPluginAllowed(
+            plugin(new String[0], new String[] {"Local", "Remote", 
"LocalSingle"})));
+  }
+
+  /** A stored single-member id from an older release still filters as the 
whole group. */
+  @Test
+  void storedGroupMemberIdExpandsToTheGroup() {
+    PaletteEngineFilter filter = 
PaletteEngineFilter.forPipelineEngineId("LocalSingle");
+
+    assertTrue(filter.isPluginAllowed(plugin(new String[] {"Local"}, new 
String[0])));
+    assertFalse(filter.isPluginAllowed(plugin(new String[] {"Beam*"}, new 
String[0])));
+  }
+
+  /**
+   * There is no single-threaded workflow engine, so "LocalSingle" must not be 
probed on the
+   * workflow side — an id without an engine always resolves UNKNOWN, and 
since the group is
+   * permissive that would make every action visible and silently disable the 
filter.
+   */
+  @Test
+  void workflowGroupDoesNotIncludeTheSingleThreadedEngine() {
+    PaletteEngineFilter filter =
+        
PaletteEngineFilter.forWorkflowEngineId(PaletteEngineFilter.HOP_ENGINE_GROUP_ID);
+
+    assertFalse(
+        filter.isPluginAllowed(plugin(new String[0], new String[] {"Local", 
"Remote"})),
+        "action excluded on both Hop workflow engines must stay hidden");
+  }
+}

Reply via email to