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 1e674f68df Issue #9098 : Keep hop-ui and SWT out of the Oracle, MS SQL 
Server Native and Redshift database metas (#9138)
1e674f68df is described below

commit 1e674f68dffd1cd8539390f7c7c12de4795444ee
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Fri Oct 30 05:08:25 2026 -0400

    Issue #9098 : Keep hop-ui and SWT out of the Oracle, MS SQL Server Native 
and Redshift database metas (#9138)
    
    The three metas implemented IGuiPluginCompositeWidgetsListener themselves,
    so loading them needed hop-ui and SWT, which breaks using Hop as a library
    without the GUI.
    
    The listener code moves unchanged into a separate class in each plugin.
    IDatabase gets getGuiWidgetsListenerClassName(), which names that class,
    and DatabaseMetaEditor creates it from the plugin's class loader. A meta
    that implements the listener itself still works.
---
 .../org/apache/hop/core/database/IDatabase.java    |  12 ++
 .../mssqlnative/MsSqlServerNativeDatabaseMeta.java |  60 +--------
 .../MsSqlServerNativeWidgetsListener.java          |  93 +++++++++++++
 .../MsSqlServerNativeDatabaseMetaHeadlessTest.java |  82 +++++++++++
 .../hop/databases/oracle/OracleDatabaseMeta.java   | 110 +--------------
 .../databases/oracle/OracleWidgetsListener.java    | 150 +++++++++++++++++++++
 .../oracle/OracleDatabaseMetaHeadlessTest.java     |  82 +++++++++++
 .../oracle/OracleWidgetVisibilityTest.java         |  24 ++--
 .../databases/redshift/RedshiftDatabaseMeta.java   |  92 +------------
 .../redshift/RedshiftWidgetsListener.java          | 130 ++++++++++++++++++
 .../redshift/RedshiftDatabaseMetaHeadlessTest.java |  82 +++++++++++
 .../hop/ui/core/database/DatabaseMetaEditor.java   |  37 ++++-
 .../hop/ui/testing/DisabledGuiWidgetsTestBase.java |  25 +++-
 13 files changed, 711 insertions(+), 268 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/core/database/IDatabase.java 
b/core/src/main/java/org/apache/hop/core/database/IDatabase.java
index 814e908510..3b561a40fe 100644
--- a/core/src/main/java/org/apache/hop/core/database/IDatabase.java
+++ b/core/src/main/java/org/apache/hop/core/database/IDatabase.java
@@ -1325,6 +1325,18 @@ public interface IDatabase extends Cloneable {
     return true;
   }
 
+  /**
+   * Names a class in the same plugin that adjusts this database's widgets in 
the connection dialog,
+   * for example to hide options that do not apply. The class implements {@code
+   * IGuiPluginCompositeWidgetsListener} and has a public constructor taking 
this {@link IDatabase}.
+   * It is given as a name so that the database can be used without the GUI on 
the classpath.
+   *
+   * @return the class name, or null when the widgets need no adjusting
+   */
+  default String getGuiWidgetsListenerClassName() {
+    return null;
+  }
+
   /**
    * @return the unsupported message if database does not support standard 
table output transform
    */
diff --git 
a/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMeta.java
 
b/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMeta.java
index 0ac37c60f7..fee6b95533 100644
--- 
a/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMeta.java
+++ 
b/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMeta.java
@@ -17,10 +17,7 @@
 
 package org.apache.hop.databases.mssqlnative;
 
-import java.util.ArrayList;
-import java.util.List;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.database.BaseDatabaseMeta;
 import org.apache.hop.core.database.DatabaseMeta;
 import org.apache.hop.core.database.DatabaseMetaPlugin;
 import org.apache.hop.core.database.DriverDownload;
@@ -30,10 +27,6 @@ import org.apache.hop.core.gui.plugin.GuiWidgetElement;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.databases.mssql.MsSqlServerDatabaseMeta;
 import org.apache.hop.metadata.api.HopMetadataProperty;
-import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
-import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
-import org.eclipse.swt.widgets.Button;
-import org.eclipse.swt.widgets.Control;
 
 @DatabaseMetaPlugin(
     type = "MSSQLNATIVE",
@@ -42,8 +35,7 @@ import org.eclipse.swt.widgets.Control;
     documentationUrl = "/database/databases/mssqlnative.html",
     classLoaderGroup = "mssqlnative-db")
 @GuiPlugin(id = "GUI-MSSQLServerNativeDatabaseMeta")
-public class MsSqlServerNativeDatabaseMeta extends MsSqlServerDatabaseMeta
-    implements IGuiPluginCompositeWidgetsListener {
+public class MsSqlServerNativeDatabaseMeta extends MsSqlServerDatabaseMeta {
 
   public static final String ID_INTEGRATED_SECURITY_WIDGET = 
"usingIntegratedSecurity";
 
@@ -57,54 +49,8 @@ public class MsSqlServerNativeDatabaseMeta extends 
MsSqlServerDatabaseMeta
   private boolean usingIntegratedSecurity;
 
   @Override
-  public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
-    // not needed
-  }
-
-  @Override
-  public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
-    enableField(compositeWidgets);
-  }
-
-  @Override
-  public void widgetModified(
-      GuiCompositeWidgets compositeWidgets, Control changedWidget, String 
widgetId) {
-    enableField(compositeWidgets);
-  }
-
-  @Override
-  public void persistContents(GuiCompositeWidgets compositeWidgets) {
-    // not needed
-  }
-
-  private void enableField(GuiCompositeWidgets compositeWidgets) {
-    List<Control> controls = new ArrayList<>();
-    String[] ids =
-        new String[] {
-          BaseDatabaseMeta.ID_USERNAME_LABEL,
-          BaseDatabaseMeta.ID_USERNAME_WIDGET,
-          BaseDatabaseMeta.ID_PASSWORD_LABEL,
-          BaseDatabaseMeta.ID_PASSWORD_WIDGET,
-        };
-    for (String id : ids) {
-      Control control = compositeWidgets.getWidgetsMap().get(id);
-      if (control != null) {
-        controls.add(control);
-      }
-    }
-    Button wIntegratedSecurity =
-        (Button) 
compositeWidgets.getWidgetsMap().get(ID_INTEGRATED_SECURITY_WIDGET);
-
-    // The checkbox is absent when it is switched off in 
disabledGuiElements.xml. There is nothing
-    // to read the setting from then, so we go by what is on the metadata 
itself.
-    //
-    boolean enable =
-        wIntegratedSecurity == null
-            ? !isUsingIntegratedSecurity()
-            : !wIntegratedSecurity.getSelection();
-    for (Control control : controls) {
-      control.setEnabled(enable);
-    }
+  public String getGuiWidgetsListenerClassName() {
+    return 
"org.apache.hop.databases.mssqlnative.MsSqlServerNativeWidgetsListener";
   }
 
   /**
diff --git 
a/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeWidgetsListener.java
 
b/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeWidgetsListener.java
new file mode 100644
index 0000000000..802581b823
--- /dev/null
+++ 
b/plugins/databases/mssqlnative/src/main/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeWidgetsListener.java
@@ -0,0 +1,93 @@
+/*
+ * 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.databases.mssqlnative;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.database.BaseDatabaseMeta;
+import org.apache.hop.core.database.IDatabase;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+
+/**
+ * Disables the user name and password when integrated security is used, in 
the connection dialog.
+ */
+public class MsSqlServerNativeWidgetsListener implements 
IGuiPluginCompositeWidgetsListener {
+
+  private final MsSqlServerNativeDatabaseMeta database;
+
+  public MsSqlServerNativeWidgetsListener(IDatabase database) {
+    this.database = (MsSqlServerNativeDatabaseMeta) database;
+  }
+
+  @Override
+  public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
+    // not needed
+  }
+
+  @Override
+  public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
+    enableField(compositeWidgets);
+  }
+
+  @Override
+  public void widgetModified(
+      GuiCompositeWidgets compositeWidgets, Control changedWidget, String 
widgetId) {
+    enableField(compositeWidgets);
+  }
+
+  @Override
+  public void persistContents(GuiCompositeWidgets compositeWidgets) {
+    // not needed
+  }
+
+  private void enableField(GuiCompositeWidgets compositeWidgets) {
+    List<Control> controls = new ArrayList<>();
+    String[] ids =
+        new String[] {
+          BaseDatabaseMeta.ID_USERNAME_LABEL,
+          BaseDatabaseMeta.ID_USERNAME_WIDGET,
+          BaseDatabaseMeta.ID_PASSWORD_LABEL,
+          BaseDatabaseMeta.ID_PASSWORD_WIDGET,
+        };
+    for (String id : ids) {
+      Control control = compositeWidgets.getWidgetsMap().get(id);
+      if (control != null) {
+        controls.add(control);
+      }
+    }
+    Button wIntegratedSecurity =
+        (Button)
+            compositeWidgets
+                .getWidgetsMap()
+                
.get(MsSqlServerNativeDatabaseMeta.ID_INTEGRATED_SECURITY_WIDGET);
+
+    // The checkbox is absent when it is switched off in 
disabledGuiElements.xml. There is nothing
+    // to read the setting from then, so we go by what is on the metadata 
itself.
+    //
+    boolean enable =
+        wIntegratedSecurity == null
+            ? !database.isUsingIntegratedSecurity()
+            : !wIntegratedSecurity.getSelection();
+    for (Control control : controls) {
+      control.setEnabled(enable);
+    }
+  }
+}
diff --git 
a/plugins/databases/mssqlnative/src/test/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMetaHeadlessTest.java
 
b/plugins/databases/mssqlnative/src/test/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMetaHeadlessTest.java
new file mode 100644
index 0000000000..8bc1e04dae
--- /dev/null
+++ 
b/plugins/databases/mssqlnative/src/test/java/org/apache/hop/databases/mssqlnative/MsSqlServerNativeDatabaseMetaHeadlessTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.mssqlnative;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.File;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.database.IDatabase;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.junit.jupiter.api.Test;
+
+/** The database must be usable as a library, without hop-ui or SWT on the 
classpath. */
+class MsSqlServerNativeDatabaseMetaHeadlessTest {
+
+  @Test
+  void databaseLoadsWithoutTheGui() throws Exception {
+    try (URLClassLoader withoutGui = new ClassLoaderWithoutGui()) {
+      Object database =
+          Class.forName(MsSqlServerNativeDatabaseMeta.class.getName(), true, 
withoutGui)
+              .getConstructor()
+              .newInstance();
+
+      assertEquals(
+          MsSqlServerNativeWidgetsListener.class.getName(),
+          
database.getClass().getMethod("getGuiWidgetsListenerClassName").invoke(database));
+    }
+  }
+
+  @Test
+  void namedListenerIsCreatedForTheDatabase() throws Exception {
+    IDatabase database = new MsSqlServerNativeDatabaseMeta();
+
+    Object listener =
+        Class.forName(database.getGuiWidgetsListenerClassName())
+            .getConstructor(IDatabase.class)
+            .newInstance(database);
+
+    assertEquals(true, listener instanceof IGuiPluginCompositeWidgetsListener);
+  }
+
+  /** Loads everything from the test classpath itself, except the GUI classes. 
*/
+  private static class ClassLoaderWithoutGui extends URLClassLoader {
+    ClassLoaderWithoutGui() throws Exception {
+      super(classpath(), ClassLoader.getPlatformClassLoader());
+    }
+
+    @Override
+    protected Class<?> loadClass(String name, boolean resolve) throws 
ClassNotFoundException {
+      if (name.startsWith("org.apache.hop.ui.") || 
name.startsWith("org.eclipse.swt.")) {
+        throw new ClassNotFoundException(name);
+      }
+      return super.loadClass(name, resolve);
+    }
+
+    private static URL[] classpath() throws Exception {
+      List<URL> urls = new ArrayList<>();
+      for (String entry : 
System.getProperty("java.class.path").split(File.pathSeparator)) {
+        urls.add(new File(entry).toURI().toURL());
+      }
+      return urls.toArray(new URL[0]);
+    }
+  }
+}
diff --git 
a/plugins/databases/oracle/src/main/java/org/apache/hop/databases/oracle/OracleDatabaseMeta.java
 
b/plugins/databases/oracle/src/main/java/org/apache/hop/databases/oracle/OracleDatabaseMeta.java
index 0d884801f9..c94403d395 100644
--- 
a/plugins/databases/oracle/src/main/java/org/apache/hop/databases/oracle/OracleDatabaseMeta.java
+++ 
b/plugins/databases/oracle/src/main/java/org/apache/hop/databases/oracle/OracleDatabaseMeta.java
@@ -20,10 +20,8 @@ package org.apache.hop.databases.oracle;
 import java.sql.ResultSet;
 import java.sql.Types;
 import java.util.ArrayList;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Properties;
-import java.util.Set;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
@@ -49,11 +47,6 @@ import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.metadata.api.HopMetadataProperty;
-import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
-import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
-import org.eclipse.swt.widgets.Button;
-import org.eclipse.swt.widgets.Combo;
-import org.eclipse.swt.widgets.Control;
 
 /** Contains Oracle specific information through static final members */
 @DatabaseMetaPlugin(
@@ -63,8 +56,7 @@ import org.eclipse.swt.widgets.Control;
     documentationUrl = "/database/databases/oracle.html",
     classLoaderGroup = "oracle-db")
 @GuiPlugin(id = "GUI-OracleDatabaseMeta")
-public class OracleDatabaseMeta extends BaseDatabaseMeta
-    implements IDatabase, IGuiPluginCompositeWidgetsListener {
+public class OracleDatabaseMeta extends BaseDatabaseMeta implements IDatabase {
 
   /** RAW and LONG RAW arrive as variable length binary but are really 
strings. */
   private static final List<IDatabaseTypeRule> RAW_RULES =
@@ -696,104 +688,8 @@ public class OracleDatabaseMeta extends BaseDatabaseMeta
   }
 
   @Override
-  public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
-    // Nothing to do, the values aren't set yet at this point.
-  }
-
-  @Override
-  public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
-    hideFieldsThatDoNotApply(compositeWidgets);
-  }
-
-  @Override
-  public void widgetModified(
-      GuiCompositeWidgets compositeWidgets, Control changedWidget, String 
widgetId) {
-    hideFieldsThatDoNotApply(compositeWidgets);
-  }
-
-  @Override
-  public void persistContents(GuiCompositeWidgets compositeWidgets) {
-    // Not needed, the dialog reads the widgets back itself.
-  }
-
-  /**
-   * Keeps the dialog down to the fields that can actually do something for 
the connection being
-   * edited. Most Oracle connections are a plain host and port, and a wall of 
TLS and wallet fields
-   * that will only ever stay empty makes the ones that matter harder to find.
-   */
-  private void hideFieldsThatDoNotApply(GuiCompositeWidgets compositeWidgets) {
-    OracleConnectionType type = readConnectionType(compositeWidgets);
-
-    // A TNS alias and a hand written descriptor say where and how to connect 
all by themselves, so
-    // neither the address nor the TCPS checkbox has anything left to decide. 
Their certificates
-    // are still ours to pass on, which is why the credentials below stay put.
-    //
-    boolean addressApplies =
-        type != OracleConnectionType.TNS_ALIAS && type != 
OracleConnectionType.DESCRIPTOR;
-    boolean tlsApplies =
-        !addressApplies || readCheckbox(compositeWidgets, ID_USE_TCPS, 
isUseTcps());
-    OracleTlsCredentialType credentials = readCredentialType(compositeWidgets);
-
-    Set<String> hidden = new HashSet<>();
-    hideUnless(hidden, addressApplies, ID_HOSTNAME, ID_PORT, ID_USE_TCPS);
-    hideUnless(hidden, tlsApplies, ID_TLS_CREDENTIAL_TYPE);
-    hideUnless(hidden, tlsApplies, ID_SSL_SERVER_DN_MATCH, 
ID_SSL_SERVER_CERT_DN);
-    hideUnless(
-        hidden,
-        tlsApplies && credentials == OracleTlsCredentialType.WALLET,
-        ID_WALLET_DIRECTORY,
-        ID_WALLET_PASSWORD);
-    hideUnless(
-        hidden,
-        tlsApplies && credentials == OracleTlsCredentialType.JKS,
-        ID_TRUST_STORE_FILE,
-        ID_TRUST_STORE_PASSWORD,
-        ID_TRUST_STORE_TYPE,
-        ID_KEY_STORE_FILE,
-        ID_KEY_STORE_PASSWORD,
-        ID_KEY_STORE_TYPE);
-
-    compositeWidgets.setWidgetsHidden(this, hidden);
-  }
-
-  private void hideUnless(Set<String> hidden, boolean applies, String... 
widgetIds) {
-    if (!applies) {
-      hidden.addAll(List.of(widgetIds));
-    }
-  }
-
-  private OracleConnectionType readConnectionType(GuiCompositeWidgets 
compositeWidgets) {
-    Control control = compositeWidgets.getWidgetsMap().get(ID_CONNECTION_TYPE);
-    if (control instanceof Combo combo) {
-      try {
-        return OracleConnectionType.valueOf(combo.getText());
-      } catch (IllegalArgumentException e) {
-        // Nothing selected yet, the metadata is the better answer.
-      }
-    }
-    return getConnectionType();
-  }
-
-  /**
-   * Widgets can be switched off through disabledGuiElements.xml, in which 
case there is nothing to
-   * read the setting from and we fall back to what is on the metadata itself.
-   */
-  private boolean readCheckbox(
-      GuiCompositeWidgets compositeWidgets, String widgetId, boolean fallback) 
{
-    Control control = compositeWidgets.getWidgetsMap().get(widgetId);
-    return control instanceof Button button ? button.getSelection() : fallback;
-  }
-
-  private OracleTlsCredentialType readCredentialType(GuiCompositeWidgets 
compositeWidgets) {
-    Control control = 
compositeWidgets.getWidgetsMap().get(ID_TLS_CREDENTIAL_TYPE);
-    if (control instanceof Combo combo) {
-      try {
-        return OracleTlsCredentialType.valueOf(combo.getText());
-      } catch (IllegalArgumentException e) {
-        // Nothing selected yet, the metadata is the better answer.
-      }
-    }
-    return getTlsCredentialType();
+  public String getGuiWidgetsListenerClassName() {
+    return "org.apache.hop.databases.oracle.OracleWidgetsListener";
   }
 
   /**
diff --git 
a/plugins/databases/oracle/src/main/java/org/apache/hop/databases/oracle/OracleWidgetsListener.java
 
b/plugins/databases/oracle/src/main/java/org/apache/hop/databases/oracle/OracleWidgetsListener.java
new file mode 100644
index 0000000000..e3498e2c49
--- /dev/null
+++ 
b/plugins/databases/oracle/src/main/java/org/apache/hop/databases/oracle/OracleWidgetsListener.java
@@ -0,0 +1,150 @@
+/*
+ * 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.databases.oracle;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.hop.core.database.IDatabase;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Control;
+
+/** Hides the Oracle connection options that do not apply, in the connection 
dialog. */
+public class OracleWidgetsListener implements 
IGuiPluginCompositeWidgetsListener {
+
+  private final OracleDatabaseMeta database;
+
+  public OracleWidgetsListener(IDatabase database) {
+    this.database = (OracleDatabaseMeta) database;
+  }
+
+  @Override
+  public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
+    // Nothing to do, the values aren't set yet at this point.
+  }
+
+  @Override
+  public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
+    hideFieldsThatDoNotApply(compositeWidgets);
+  }
+
+  @Override
+  public void widgetModified(
+      GuiCompositeWidgets compositeWidgets, Control changedWidget, String 
widgetId) {
+    hideFieldsThatDoNotApply(compositeWidgets);
+  }
+
+  @Override
+  public void persistContents(GuiCompositeWidgets compositeWidgets) {
+    // Not needed, the dialog reads the widgets back itself.
+  }
+
+  /**
+   * Keeps the dialog down to the fields that can actually do something for 
the connection being
+   * edited. Most Oracle connections are a plain host and port, and a wall of 
TLS and wallet fields
+   * that will only ever stay empty makes the ones that matter harder to find.
+   */
+  private void hideFieldsThatDoNotApply(GuiCompositeWidgets compositeWidgets) {
+    OracleConnectionType type = readConnectionType(compositeWidgets);
+
+    // A TNS alias and a hand written descriptor say where and how to connect 
all by themselves, so
+    // neither the address nor the TCPS checkbox has anything left to decide. 
Their certificates
+    // are still ours to pass on, which is why the credentials below stay put.
+    //
+    boolean addressApplies =
+        type != OracleConnectionType.TNS_ALIAS && type != 
OracleConnectionType.DESCRIPTOR;
+    boolean tlsApplies =
+        !addressApplies
+            || readCheckbox(compositeWidgets, OracleDatabaseMeta.ID_USE_TCPS, 
database.isUseTcps());
+    OracleTlsCredentialType credentials = readCredentialType(compositeWidgets);
+
+    Set<String> hidden = new HashSet<>();
+    hideUnless(
+        hidden,
+        addressApplies,
+        OracleDatabaseMeta.ID_HOSTNAME,
+        OracleDatabaseMeta.ID_PORT,
+        OracleDatabaseMeta.ID_USE_TCPS);
+    hideUnless(hidden, tlsApplies, OracleDatabaseMeta.ID_TLS_CREDENTIAL_TYPE);
+    hideUnless(
+        hidden,
+        tlsApplies,
+        OracleDatabaseMeta.ID_SSL_SERVER_DN_MATCH,
+        OracleDatabaseMeta.ID_SSL_SERVER_CERT_DN);
+    hideUnless(
+        hidden,
+        tlsApplies && credentials == OracleTlsCredentialType.WALLET,
+        OracleDatabaseMeta.ID_WALLET_DIRECTORY,
+        OracleDatabaseMeta.ID_WALLET_PASSWORD);
+    hideUnless(
+        hidden,
+        tlsApplies && credentials == OracleTlsCredentialType.JKS,
+        OracleDatabaseMeta.ID_TRUST_STORE_FILE,
+        OracleDatabaseMeta.ID_TRUST_STORE_PASSWORD,
+        OracleDatabaseMeta.ID_TRUST_STORE_TYPE,
+        OracleDatabaseMeta.ID_KEY_STORE_FILE,
+        OracleDatabaseMeta.ID_KEY_STORE_PASSWORD,
+        OracleDatabaseMeta.ID_KEY_STORE_TYPE);
+
+    compositeWidgets.setWidgetsHidden(database, hidden);
+  }
+
+  private void hideUnless(Set<String> hidden, boolean applies, String... 
widgetIds) {
+    if (!applies) {
+      hidden.addAll(List.of(widgetIds));
+    }
+  }
+
+  private OracleConnectionType readConnectionType(GuiCompositeWidgets 
compositeWidgets) {
+    Control control = 
compositeWidgets.getWidgetsMap().get(OracleDatabaseMeta.ID_CONNECTION_TYPE);
+    if (control instanceof Combo combo) {
+      try {
+        return OracleConnectionType.valueOf(combo.getText());
+      } catch (IllegalArgumentException e) {
+        // Nothing selected yet, the metadata is the better answer.
+      }
+    }
+    return database.getConnectionType();
+  }
+
+  /**
+   * Widgets can be switched off through disabledGuiElements.xml, in which 
case there is nothing to
+   * read the setting from and we fall back to what is on the metadata itself.
+   */
+  private boolean readCheckbox(
+      GuiCompositeWidgets compositeWidgets, String widgetId, boolean fallback) 
{
+    Control control = compositeWidgets.getWidgetsMap().get(widgetId);
+    return control instanceof Button button ? button.getSelection() : fallback;
+  }
+
+  private OracleTlsCredentialType readCredentialType(GuiCompositeWidgets 
compositeWidgets) {
+    Control control =
+        
compositeWidgets.getWidgetsMap().get(OracleDatabaseMeta.ID_TLS_CREDENTIAL_TYPE);
+    if (control instanceof Combo combo) {
+      try {
+        return OracleTlsCredentialType.valueOf(combo.getText());
+      } catch (IllegalArgumentException e) {
+        // Nothing selected yet, the metadata is the better answer.
+      }
+    }
+    return database.getTlsCredentialType();
+  }
+}
diff --git 
a/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleDatabaseMetaHeadlessTest.java
 
b/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleDatabaseMetaHeadlessTest.java
new file mode 100644
index 0000000000..9d7758922a
--- /dev/null
+++ 
b/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleDatabaseMetaHeadlessTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.oracle;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.File;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.database.IDatabase;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.junit.jupiter.api.Test;
+
+/** The database must be usable as a library, without hop-ui or SWT on the 
classpath. */
+class OracleDatabaseMetaHeadlessTest {
+
+  @Test
+  void databaseLoadsWithoutTheGui() throws Exception {
+    try (URLClassLoader withoutGui = new ClassLoaderWithoutGui()) {
+      Object database =
+          Class.forName(OracleDatabaseMeta.class.getName(), true, withoutGui)
+              .getConstructor()
+              .newInstance();
+
+      assertEquals(
+          OracleWidgetsListener.class.getName(),
+          
database.getClass().getMethod("getGuiWidgetsListenerClassName").invoke(database));
+    }
+  }
+
+  @Test
+  void namedListenerIsCreatedForTheDatabase() throws Exception {
+    IDatabase database = new OracleDatabaseMeta();
+
+    Object listener =
+        Class.forName(database.getGuiWidgetsListenerClassName())
+            .getConstructor(IDatabase.class)
+            .newInstance(database);
+
+    assertEquals(true, listener instanceof IGuiPluginCompositeWidgetsListener);
+  }
+
+  /** Loads everything from the test classpath itself, except the GUI classes. 
*/
+  private static class ClassLoaderWithoutGui extends URLClassLoader {
+    ClassLoaderWithoutGui() throws Exception {
+      super(classpath(), ClassLoader.getPlatformClassLoader());
+    }
+
+    @Override
+    protected Class<?> loadClass(String name, boolean resolve) throws 
ClassNotFoundException {
+      if (name.startsWith("org.apache.hop.ui.") || 
name.startsWith("org.eclipse.swt.")) {
+        throw new ClassNotFoundException(name);
+      }
+      return super.loadClass(name, resolve);
+    }
+
+    private static URL[] classpath() throws Exception {
+      List<URL> urls = new ArrayList<>();
+      for (String entry : 
System.getProperty("java.class.path").split(File.pathSeparator)) {
+        urls.add(new File(entry).toURI().toURL());
+      }
+      return urls.toArray(new URL[0]);
+    }
+  }
+}
diff --git 
a/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleWidgetVisibilityTest.java
 
b/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleWidgetVisibilityTest.java
index 57e0d846ad..b02e5ee044 100644
--- 
a/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleWidgetVisibilityTest.java
+++ 
b/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleWidgetVisibilityTest.java
@@ -202,7 +202,8 @@ class OracleWidgetVisibilityTest extends SwtBotTestBase {
           Combo credentials =
               (Combo) 
widgets.getWidgetsMap().get(OracleDatabaseMeta.ID_TLS_CREDENTIAL_TYPE);
           credentials.setText(OracleTlsCredentialType.JKS.name());
-          meta.widgetModified(widgets, credentials, 
OracleDatabaseMeta.ID_TLS_CREDENTIAL_TYPE);
+          new OracleWidgetsListener(meta)
+              .widgetModified(widgets, credentials, 
OracleDatabaseMeta.ID_TLS_CREDENTIAL_TYPE);
           assertHangsBelow(
               widgets,
               OracleDatabaseMeta.ID_SSL_SERVER_DN_MATCH,
@@ -262,8 +263,9 @@ class OracleWidgetVisibilityTest extends SwtBotTestBase {
       widgets.createCompositeWidgets(
           meta, null, inner, DatabaseMeta.GUI_PLUGIN_ELEMENT_PARENT_ID, null);
       widgets.setWidgetsContents(meta, inner, 
DatabaseMeta.GUI_PLUGIN_ELEMENT_PARENT_ID);
-      widgets.setWidgetsListener(meta);
-      meta.widgetsPopulated(widgets);
+      OracleWidgetsListener listener = new OracleWidgetsListener(meta);
+      widgets.setWidgetsListener(listener);
+      listener.widgetsPopulated(widgets);
 
       shell.layout(true, true);
       shell.pack();
@@ -331,12 +333,14 @@ class OracleWidgetVisibilityTest extends SwtBotTestBase {
           Button useTcps = (Button) 
widgets.getWidgetsMap().get(OracleDatabaseMeta.ID_USE_TCPS);
 
           useTcps.setSelection(false);
-          meta.widgetModified(widgets, useTcps, 
OracleDatabaseMeta.ID_USE_TCPS);
+          new OracleWidgetsListener(meta)
+              .widgetModified(widgets, useTcps, 
OracleDatabaseMeta.ID_USE_TCPS);
           assertHidden(widgets, OracleDatabaseMeta.ID_WALLET_DIRECTORY);
           assertHidden(widgets, OracleDatabaseMeta.ID_TLS_CREDENTIAL_TYPE);
 
           useTcps.setSelection(true);
-          meta.widgetModified(widgets, useTcps, 
OracleDatabaseMeta.ID_USE_TCPS);
+          new OracleWidgetsListener(meta)
+              .widgetModified(widgets, useTcps, 
OracleDatabaseMeta.ID_USE_TCPS);
           assertVisible(widgets, OracleDatabaseMeta.ID_WALLET_DIRECTORY);
           assertVisible(widgets, OracleDatabaseMeta.ID_TLS_CREDENTIAL_TYPE);
         });
@@ -362,8 +366,9 @@ class OracleWidgetVisibilityTest extends SwtBotTestBase {
       widgets.createCompositeWidgets(
           meta, null, shell, DatabaseMeta.GUI_PLUGIN_ELEMENT_PARENT_ID, null);
       widgets.setWidgetsContents(meta, shell, 
DatabaseMeta.GUI_PLUGIN_ELEMENT_PARENT_ID);
-      widgets.setWidgetsListener(meta);
-      meta.widgetsPopulated(widgets);
+      OracleWidgetsListener listener = new OracleWidgetsListener(meta);
+      widgets.setWidgetsListener(listener);
+      listener.widgetsPopulated(widgets);
 
       shell.layout(true, true);
       shell.pack();
@@ -439,7 +444,7 @@ class OracleWidgetVisibilityTest extends SwtBotTestBase {
       GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
       widgets.createCompositeWidgets(
           meta, null, shell, DatabaseMeta.GUI_PLUGIN_ELEMENT_PARENT_ID, null);
-      widgets.setWidgetsListener(meta);
+      widgets.setWidgetsListener(new OracleWidgetsListener(meta));
 
       // setWidgetsContents() calls Combo.setText(), which fires a Modify 
event, which sends the
       // editor straight back into getWidgetsContents() while the rest of the 
widgets are still
@@ -500,7 +505,8 @@ class OracleWidgetVisibilityTest extends SwtBotTestBase {
         (meta, widgets) -> {
           Combo combo = (Combo) 
widgets.getWidgetsMap().get(OracleDatabaseMeta.ID_CONNECTION_TYPE);
           combo.setText(OracleConnectionType.SERVICE_NAME.name());
-          meta.widgetModified(widgets, combo, 
OracleDatabaseMeta.ID_CONNECTION_TYPE);
+          new OracleWidgetsListener(meta)
+              .widgetModified(widgets, combo, 
OracleDatabaseMeta.ID_CONNECTION_TYPE);
 
           // getWidgetsContents() is what the Test button runs before it 
builds the URL, so this is
           // the path between the combo and what actually gets connected to.
diff --git 
a/plugins/databases/redshift/src/main/java/org/apache/hop/databases/redshift/RedshiftDatabaseMeta.java
 
b/plugins/databases/redshift/src/main/java/org/apache/hop/databases/redshift/RedshiftDatabaseMeta.java
index 9dcc006a25..cf3af3b578 100644
--- 
a/plugins/databases/redshift/src/main/java/org/apache/hop/databases/redshift/RedshiftDatabaseMeta.java
+++ 
b/plugins/databases/redshift/src/main/java/org/apache/hop/databases/redshift/RedshiftDatabaseMeta.java
@@ -16,10 +16,8 @@
  */
 package org.apache.hop.databases.redshift;
 
-import java.util.HashSet;
 import java.util.List;
 import java.util.Properties;
-import java.util.Set;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
@@ -39,10 +37,6 @@ import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.databases.postgresql.PostgreSqlDatabaseMeta;
 import org.apache.hop.metadata.api.HopMetadataProperty;
-import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
-import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
-import org.eclipse.swt.widgets.Combo;
-import org.eclipse.swt.widgets.Control;
 
 @DatabaseMetaPlugin(
     type = "REDSHIFT",
@@ -51,8 +45,7 @@ import org.eclipse.swt.widgets.Control;
     documentationUrl = "/database/databases/redshift.html",
     classLoaderGroup = "redshift-db")
 @GuiPlugin(id = "GUI-RedshiftDatabaseMeta")
-public class RedshiftDatabaseMeta extends PostgreSqlDatabaseMeta
-    implements IGuiPluginCompositeWidgetsListener {
+public class RedshiftDatabaseMeta extends PostgreSqlDatabaseMeta {
 
   /** The host name of every serverless workgroup ends in this. */
   public static final String SERVERLESS_HOST_SUFFIX = 
".redshift-serverless.amazonaws.com";
@@ -458,86 +451,7 @@ public class RedshiftDatabaseMeta extends 
PostgreSqlDatabaseMeta
   }
 
   @Override
-  public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
-    // Nothing to do, the values aren't set yet at this point.
-  }
-
-  @Override
-  public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
-    hideFieldsThatDoNotApply(compositeWidgets);
-  }
-
-  @Override
-  public void widgetModified(
-      GuiCompositeWidgets compositeWidgets, Control changedWidget, String 
widgetId) {
-    hideFieldsThatDoNotApply(compositeWidgets);
-  }
-
-  @Override
-  public void persistContents(GuiCompositeWidgets compositeWidgets) {
-    // Not needed, the dialog reads the widgets back itself.
-  }
-
-  /**
-   * Keeps the dialog down to the fields that can actually do something for 
the connection being
-   * edited. Between two deployments and four ways to authenticate, showing 
everything at once would
-   * leave most of the tab permanently empty and the fields that matter hard 
to pick out.
-   */
-  private void hideFieldsThatDoNotApply(GuiCompositeWidgets compositeWidgets) {
-    RedshiftDeploymentType deployment = readDeploymentType(compositeWidgets);
-    RedshiftAuthenticationType authentication = 
readAuthenticationType(compositeWidgets);
-    boolean serverless = deployment == RedshiftDeploymentType.SERVERLESS;
-
-    Set<String> hidden = new HashSet<>();
-    // The host name stays on offer for a serverless workgroup: left empty Hop 
builds the endpoint,
-    // filled in it wins, which is how a custom domain name or a load balancer 
is reached.
-    hideUnless(hidden, serverless, ID_WORKGROUP, ID_ACCOUNT_ID);
-    // A cluster only has to name itself when IAM has to find it.
-    hideUnless(hidden, !serverless && authentication.isIam(), ID_CLUSTER_ID);
-    // The region builds the host name, and IAM needs it to know which AWS to 
ask.
-    hideUnless(hidden, serverless || authentication.isIam(), ID_AWS_REGION);
-
-    hideUnless(
-        hidden,
-        authentication == RedshiftAuthenticationType.IAM_CREDENTIALS,
-        ID_AWS_ACCESS_KEY_ID,
-        ID_AWS_SECRET_ACCESS_KEY,
-        ID_AWS_SESSION_TOKEN);
-    hideUnless(hidden, authentication == 
RedshiftAuthenticationType.IAM_PROFILE, ID_AWS_PROFILE);
-    // Only a provisioned cluster maps IAM onto a named database user.
-    hideUnless(
-        hidden, authentication.isIam() && !serverless, ID_DB_USER, 
ID_DB_GROUPS, ID_AUTO_CREATE);
-
-    compositeWidgets.setWidgetsHidden(this, hidden);
-  }
-
-  private void hideUnless(Set<String> hidden, boolean applies, String... 
widgetIds) {
-    if (!applies) {
-      hidden.addAll(List.of(widgetIds));
-    }
-  }
-
-  private RedshiftDeploymentType readDeploymentType(GuiCompositeWidgets 
compositeWidgets) {
-    Control control = compositeWidgets.getWidgetsMap().get(ID_DEPLOYMENT_TYPE);
-    if (control instanceof Combo combo) {
-      try {
-        return RedshiftDeploymentType.valueOf(combo.getText());
-      } catch (IllegalArgumentException e) {
-        // Nothing selected yet, the metadata is the better answer.
-      }
-    }
-    return getDeploymentType();
-  }
-
-  private RedshiftAuthenticationType 
readAuthenticationType(GuiCompositeWidgets compositeWidgets) {
-    Control control = 
compositeWidgets.getWidgetsMap().get(ID_AUTHENTICATION_TYPE);
-    if (control instanceof Combo combo) {
-      try {
-        return RedshiftAuthenticationType.valueOf(combo.getText());
-      } catch (IllegalArgumentException e) {
-        // Nothing selected yet, the metadata is the better answer.
-      }
-    }
-    return getAuthenticationType();
+  public String getGuiWidgetsListenerClassName() {
+    return "org.apache.hop.databases.redshift.RedshiftWidgetsListener";
   }
 }
diff --git 
a/plugins/databases/redshift/src/main/java/org/apache/hop/databases/redshift/RedshiftWidgetsListener.java
 
b/plugins/databases/redshift/src/main/java/org/apache/hop/databases/redshift/RedshiftWidgetsListener.java
new file mode 100644
index 0000000000..53e82dbaea
--- /dev/null
+++ 
b/plugins/databases/redshift/src/main/java/org/apache/hop/databases/redshift/RedshiftWidgetsListener.java
@@ -0,0 +1,130 @@
+/*
+ * 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.databases.redshift;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.hop.core.database.IDatabase;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Control;
+
+/** Hides the Redshift connection options that do not apply, in the connection 
dialog. */
+public class RedshiftWidgetsListener implements 
IGuiPluginCompositeWidgetsListener {
+
+  private final RedshiftDatabaseMeta database;
+
+  public RedshiftWidgetsListener(IDatabase database) {
+    this.database = (RedshiftDatabaseMeta) database;
+  }
+
+  @Override
+  public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
+    // Nothing to do, the values aren't set yet at this point.
+  }
+
+  @Override
+  public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
+    hideFieldsThatDoNotApply(compositeWidgets);
+  }
+
+  @Override
+  public void widgetModified(
+      GuiCompositeWidgets compositeWidgets, Control changedWidget, String 
widgetId) {
+    hideFieldsThatDoNotApply(compositeWidgets);
+  }
+
+  @Override
+  public void persistContents(GuiCompositeWidgets compositeWidgets) {
+    // Not needed, the dialog reads the widgets back itself.
+  }
+
+  /**
+   * Keeps the dialog down to the fields that can actually do something for 
the connection being
+   * edited. Between two deployments and four ways to authenticate, showing 
everything at once would
+   * leave most of the tab permanently empty and the fields that matter hard 
to pick out.
+   */
+  private void hideFieldsThatDoNotApply(GuiCompositeWidgets compositeWidgets) {
+    RedshiftDeploymentType deployment = readDeploymentType(compositeWidgets);
+    RedshiftAuthenticationType authentication = 
readAuthenticationType(compositeWidgets);
+    boolean serverless = deployment == RedshiftDeploymentType.SERVERLESS;
+
+    Set<String> hidden = new HashSet<>();
+    // The host name stays on offer for a serverless workgroup: left empty Hop 
builds the endpoint,
+    // filled in it wins, which is how a custom domain name or a load balancer 
is reached.
+    hideUnless(
+        hidden, serverless, RedshiftDatabaseMeta.ID_WORKGROUP, 
RedshiftDatabaseMeta.ID_ACCOUNT_ID);
+    // A cluster only has to name itself when IAM has to find it.
+    hideUnless(hidden, !serverless && authentication.isIam(), 
RedshiftDatabaseMeta.ID_CLUSTER_ID);
+    // The region builds the host name, and IAM needs it to know which AWS to 
ask.
+    hideUnless(hidden, serverless || authentication.isIam(), 
RedshiftDatabaseMeta.ID_AWS_REGION);
+
+    hideUnless(
+        hidden,
+        authentication == RedshiftAuthenticationType.IAM_CREDENTIALS,
+        RedshiftDatabaseMeta.ID_AWS_ACCESS_KEY_ID,
+        RedshiftDatabaseMeta.ID_AWS_SECRET_ACCESS_KEY,
+        RedshiftDatabaseMeta.ID_AWS_SESSION_TOKEN);
+    hideUnless(
+        hidden,
+        authentication == RedshiftAuthenticationType.IAM_PROFILE,
+        RedshiftDatabaseMeta.ID_AWS_PROFILE);
+    // Only a provisioned cluster maps IAM onto a named database user.
+    hideUnless(
+        hidden,
+        authentication.isIam() && !serverless,
+        RedshiftDatabaseMeta.ID_DB_USER,
+        RedshiftDatabaseMeta.ID_DB_GROUPS,
+        RedshiftDatabaseMeta.ID_AUTO_CREATE);
+
+    compositeWidgets.setWidgetsHidden(database, hidden);
+  }
+
+  private void hideUnless(Set<String> hidden, boolean applies, String... 
widgetIds) {
+    if (!applies) {
+      hidden.addAll(List.of(widgetIds));
+    }
+  }
+
+  private RedshiftDeploymentType readDeploymentType(GuiCompositeWidgets 
compositeWidgets) {
+    Control control = 
compositeWidgets.getWidgetsMap().get(RedshiftDatabaseMeta.ID_DEPLOYMENT_TYPE);
+    if (control instanceof Combo combo) {
+      try {
+        return RedshiftDeploymentType.valueOf(combo.getText());
+      } catch (IllegalArgumentException e) {
+        // Nothing selected yet, the metadata is the better answer.
+      }
+    }
+    return database.getDeploymentType();
+  }
+
+  private RedshiftAuthenticationType 
readAuthenticationType(GuiCompositeWidgets compositeWidgets) {
+    Control control =
+        
compositeWidgets.getWidgetsMap().get(RedshiftDatabaseMeta.ID_AUTHENTICATION_TYPE);
+    if (control instanceof Combo combo) {
+      try {
+        return RedshiftAuthenticationType.valueOf(combo.getText());
+      } catch (IllegalArgumentException e) {
+        // Nothing selected yet, the metadata is the better answer.
+      }
+    }
+    return database.getAuthenticationType();
+  }
+}
diff --git 
a/plugins/databases/redshift/src/test/java/org/apache/hop/databases/redshift/RedshiftDatabaseMetaHeadlessTest.java
 
b/plugins/databases/redshift/src/test/java/org/apache/hop/databases/redshift/RedshiftDatabaseMetaHeadlessTest.java
new file mode 100644
index 0000000000..f50e117bcf
--- /dev/null
+++ 
b/plugins/databases/redshift/src/test/java/org/apache/hop/databases/redshift/RedshiftDatabaseMetaHeadlessTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.databases.redshift;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.File;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.database.IDatabase;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.junit.jupiter.api.Test;
+
+/** The database must be usable as a library, without hop-ui or SWT on the 
classpath. */
+class RedshiftDatabaseMetaHeadlessTest {
+
+  @Test
+  void databaseLoadsWithoutTheGui() throws Exception {
+    try (URLClassLoader withoutGui = new ClassLoaderWithoutGui()) {
+      Object database =
+          Class.forName(RedshiftDatabaseMeta.class.getName(), true, withoutGui)
+              .getConstructor()
+              .newInstance();
+
+      assertEquals(
+          RedshiftWidgetsListener.class.getName(),
+          
database.getClass().getMethod("getGuiWidgetsListenerClassName").invoke(database));
+    }
+  }
+
+  @Test
+  void namedListenerIsCreatedForTheDatabase() throws Exception {
+    IDatabase database = new RedshiftDatabaseMeta();
+
+    Object listener =
+        Class.forName(database.getGuiWidgetsListenerClassName())
+            .getConstructor(IDatabase.class)
+            .newInstance(database);
+
+    assertEquals(true, listener instanceof IGuiPluginCompositeWidgetsListener);
+  }
+
+  /** Loads everything from the test classpath itself, except the GUI classes. 
*/
+  private static class ClassLoaderWithoutGui extends URLClassLoader {
+    ClassLoaderWithoutGui() throws Exception {
+      super(classpath(), ClassLoader.getPlatformClassLoader());
+    }
+
+    @Override
+    protected Class<?> loadClass(String name, boolean resolve) throws 
ClassNotFoundException {
+      if (name.startsWith("org.apache.hop.ui.") || 
name.startsWith("org.eclipse.swt.")) {
+        throw new ClassNotFoundException(name);
+      }
+      return super.loadClass(name, resolve);
+    }
+
+    private static URL[] classpath() throws Exception {
+      List<URL> urls = new ArrayList<>();
+      for (String entry : 
System.getProperty("java.class.path").split(File.pathSeparator)) {
+        urls.add(new File(entry).toURI().toURL());
+      }
+      return urls.toArray(new URL[0]);
+    }
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java 
b/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java
index c3da2ee969..4e590a0193 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java
@@ -108,6 +108,8 @@ public class DatabaseMetaEditor extends 
MetadataEditor<DatabaseMeta> {
 
   private Composite wDatabaseSpecificComp;
   private GuiCompositeWidgets guiCompositeWidgets;
+  private IDatabase pluginListenerDatabase;
+  private IGuiPluginCompositeWidgetsListener pluginListener;
 
   private Button wSupportsBoolean;
   private Button wSupportsTimestamp;
@@ -486,8 +488,9 @@ public class DatabaseMetaEditor extends 
MetadataEditor<DatabaseMeta> {
 
   /**
    * The composite has a single listener slot, which this editor needs for its 
own change tracking.
-   * Database plugins that implement {@link 
IGuiPluginCompositeWidgetsListener} -- to enable, hide
-   * or otherwise adjust their own widgets -- are forwarded to from here; 
without this their
+   * Database plugins that adjust their own widgets -- to enable, hide or 
otherwise change them --
+   * through {@link IDatabase#getGuiWidgetsListenerClassName()}, or by 
implementing {@link
+   * IGuiPluginCompositeWidgetsListener} themselves, are forwarded to from 
here; without this their
    * callbacks would never fire.
    */
   private IGuiPluginCompositeWidgetsListener createWidgetsListener() {
@@ -524,9 +527,33 @@ public class DatabaseMetaEditor extends 
MetadataEditor<DatabaseMeta> {
     if (databaseMeta == null) {
       return Optional.empty();
     }
-    return databaseMeta.getIDatabase() instanceof 
IGuiPluginCompositeWidgetsListener listener
-        ? Optional.of(listener)
-        : Optional.empty();
+    IDatabase iDatabase = databaseMeta.getIDatabase();
+    if (iDatabase instanceof IGuiPluginCompositeWidgetsListener listener) {
+      return Optional.of(listener);
+    }
+    // The listener belongs to one database object: make a new one when the 
type is changed.
+    if (iDatabase != pluginListenerDatabase) {
+      pluginListenerDatabase = iDatabase;
+      pluginListener = createPluginWidgetsListener(iDatabase);
+    }
+    return Optional.ofNullable(pluginListener);
+  }
+
+  private IGuiPluginCompositeWidgetsListener 
createPluginWidgetsListener(IDatabase iDatabase) {
+    String className = iDatabase == null ? null : 
iDatabase.getGuiWidgetsListenerClassName();
+    if (Utils.isEmpty(className)) {
+      return null;
+    }
+    try {
+      Class<?> listenerClass = 
iDatabase.getClass().getClassLoader().loadClass(className);
+      return (IGuiPluginCompositeWidgetsListener)
+          listenerClass.getConstructor(IDatabase.class).newInstance(iDatabase);
+    } catch (Exception e) {
+      HopGui.getInstance()
+          .getLog()
+          .logError("Error creating the widgets listener " + className + " for 
the database", e);
+      return null;
+    }
   }
 
   private void addDefaultPortButton() {
diff --git 
a/ui/src/test/java/org/apache/hop/ui/testing/DisabledGuiWidgetsTestBase.java 
b/ui/src/test/java/org/apache/hop/ui/testing/DisabledGuiWidgetsTestBase.java
index 1a4cb7e82e..34f25a1f56 100644
--- a/ui/src/test/java/org/apache/hop/ui/testing/DisabledGuiWidgetsTestBase.java
+++ b/ui/src/test/java/org/apache/hop/ui/testing/DisabledGuiWidgetsTestBase.java
@@ -29,10 +29,12 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import org.apache.hop.core.database.IDatabase;
 import org.apache.hop.core.gui.plugin.GuiElements;
 import org.apache.hop.core.gui.plugin.GuiRegistry;
 import org.apache.hop.core.gui.plugin.menu.GuiMenuItem;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarItem;
+import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.Variables;
 import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
 import org.apache.hop.ui.core.gui.GuiMenuWidgets;
@@ -324,7 +326,7 @@ public abstract class DisabledGuiWidgetsTestBase extends 
SwtBotTestBase {
       widgets.createCompositeWidgets(source, null, shell, parentId, null);
       widgets.setWidgetsContents(source, shell, parentId);
 
-      if (source instanceof IGuiPluginCompositeWidgetsListener listener) {
+      if (listenerFor(source) instanceof IGuiPluginCompositeWidgetsListener 
listener) {
         widgets.setWidgetsListener(listener);
         listener.widgetsCreated(widgets);
         listener.widgetsPopulated(widgets);
@@ -347,6 +349,27 @@ public abstract class DisabledGuiWidgetsTestBase extends 
SwtBotTestBase {
     }
   }
 
+  /**
+   * The object that reacts to the widgets: the source object itself, or for a 
database the listener
+   * it names, created the way {@code DatabaseMetaEditor} does it.
+   */
+  private static Object listenerFor(Object source) {
+    if (source instanceof IDatabase database
+        && !Utils.isEmpty(database.getGuiWidgetsListenerClassName())) {
+      try {
+        return database
+            .getClass()
+            .getClassLoader()
+            .loadClass(database.getGuiWidgetsListenerClassName())
+            .getConstructor(IDatabase.class)
+            .newInstance(database);
+      } catch (ReflectiveOperationException e) {
+        throw new IllegalStateException(e);
+      }
+    }
+    return source;
+  }
+
   /**
    * Builds the toolbar the way a perspective or dialog does, minus the 
disabled item.
    *

Reply via email to