You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by mc...@apache.org on 2026/09/05 20:05:12 UTC

(hop) branch main updated: Issue #8212 : Add a Database perspective as an alternative to the SQL editor dialog (#8214)

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

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new f6c2d411b0 Issue #8212 : Add a Database perspective as an alternative to the SQL editor dialog (#8214)
f6c2d411b0 is described below

commit f6c2d411b0c6e7503daef8ab1050c2b4d0b22d4c
Author: Matt Casters <ma...@gmail.com>
AuthorDate: Sat Sep 5 22:05:05 2026 +0200

    Issue #8212 : Add a Database perspective as an alternative to the SQL editor dialog (#8214)
    
    * Issue #8212 : Add a Database perspective as an alternative to the SQL editor dialog
    
    First cut of a non-modal Database perspective: project RDBMS tree, SQL editor
    tabs with Ctrl+Enter, results sash, table info, and a killable operations panel.
    The workbench is a Composite so it can later float as a dialog or dock.
    
    Fixes #8212.
    
    * Issue #8212 : Float/dock the Database workbench and fix SQL editor limits
    
    The workbench can open as a non-modal window or a bottom-dock tab, like
    Search. getRows now calls setQueryLimit so the 1000-row cap reaches the
    driver; execStatement applies the statement timeout; cancel watchers are
    skipped on Hop Web and started as named daemon threads.
    
    Fixes #8224, #8228, #8229.
    
    * Issue #8212 : Open generated SQL in the floating Database window
    
    SQL on table output, lookups, updates and bulk loaders now opens the
    floating Database workbench on that connection. The schema/table tree
    loads in the background while the suggested SQL can be edited and run.
    Database explorer Open SQL, Generate DDL and Truncate use the same path.
    
    * Issue #8212 : Remember Database SQL editor tabs across perspective, window and dock
    
    SQL tabs (connection, file or unsaved buffer) are stored per project in the
    audit trail, the same way terminal tabs are. Closing the floating window or
    dock, switching project, or restarting Hop Gui restores them. Query results
    are not persisted.
    
    * Issue #8212 : Calcite SQL formatter, SQL editor execution, and Database perspective docs
    
    Add an Apache Calcite Format SQL plugin, object-info DDL, CTE query
    classification, Run all, connect-before-execute, query row limit,
    select-executed-SQL, a compact operations status, schema TreeMemory,
    and opening results in ShowRowsDialog. Include user-manual screenshots.
---
 NOTICE                                             |    3 +
 assemblies/plugins/pom.xml                         |    6 +
 .../apache/hop/core/database/BaseDatabaseMeta.java |   57 +-
 .../org/apache/hop/core/database/Database.java     |   93 +-
 .../org/apache/hop/core/database/DatabaseMeta.java |   18 +
 .../hop/core/database/DatabaseObjectDdl.java       |  124 ++
 .../org/apache/hop/core/database/IDatabase.java    |   25 +
 .../hop/core/database/SqlQueryClassifier.java      |  361 ++++++
 .../hop/core/database/BaseDatabaseMetaTest.java    |    4 +
 .../hop/core/database/DatabaseObjectDdlTest.java   |   79 ++
 .../hop/core/database/SqlQueryClassifierTest.java  |  124 ++
 .../modules/ROOT/pages/gui-plugins-toolbars.adoc   |    5 +-
 ...-formatter-keywords-and-identifiers-options.png |  Bin 0 -> 111983 bytes
 ...apache-calcite-sql-formatter-layout-options.png |  Bin 0 -> 153698 bytes
 .../database-perspective-configuration-options.png |  Bin 0 -> 75065 bytes
 .../database-perspective-icon-and-tree.png         |  Bin 0 -> 322110 bytes
 .../database/database-perspective-panel.png        |  Bin 0 -> 263418 bytes
 .../database/database-perspective-popup-dialog.png |  Bin 0 -> 452373 bytes
 docs/hop-user-manual/modules/ROOT/nav.adoc         |    2 +
 .../ROOT/pages/getting-started/hop-gui.adoc        |    2 +
 .../ROOT/pages/hop-gui/perspective-database.adoc   |   96 ++
 .../modules/ROOT/pages/hop-gui/perspectives.adoc   |    2 +
 .../ROOT/pages/technology/calcite/index.adoc       |   58 +
 .../modules/ROOT/pages/technology/technology.adoc  |    1 +
 .../cratedbbulkloader/CrateDBBulkLoaderDialog.java |    8 +-
 .../hop/databases/generic/GenericDatabaseMeta.java |   16 +
 .../hop/databases/hive/HiveDatabaseMeta.java       |    5 +
 .../hop/databases/hive/HiveDatabaseMetaTest.java   |    1 +
 .../monetdbbulkloader/MonetDbBulkLoaderDialog.java |    7 +-
 .../MsSqlServerBulkLoaderDialog.java               |    8 +-
 .../hop/databases/mysql/MySqlDatabaseMeta.java     |    5 +
 .../mysqlbulkloader/MySqlBulkLoaderDialog.java     |    8 +-
 .../hop/databases/mysql/MySqlDatabaseMetaTest.java |    2 +
 .../hop/databases/oracle/OracleDatabaseMeta.java   |   13 +
 .../orabulkloader/OraBulkLoaderDialog.java         |    8 +-
 .../databases/oracle/OracleDatabaseMetaTest.java   |    3 +
 .../postgresql/PostgreSqlDatabaseMeta.java         |   16 +
 .../pgbulkloader/PGBulkLoaderDialog.java           |    8 +-
 .../postgresql/PostgreSqlDatabaseMetaTest.java     |    3 +
 .../bulkloader/SnowflakeBulkLoaderDialog.java      |    8 +-
 .../hop/databases/sqlite/SqliteDatabaseMeta.java   |   15 +
 .../databases/sqlite/SqliteDatabaseMetaTest.java   |    3 +
 .../bulkloader/VerticaBulkLoaderDialog.java        |    8 +-
 .../bulkloader/RedshiftBulkLoaderDialog.java       |    8 +-
 plugins/tech/calcite/pom.xml                       |  182 +++
 plugins/tech/calcite/src/assembly/assembly.xml     |   37 +
 .../org/apache/hop/calcite/CalciteSqlDialects.java |   90 ++
 .../apache/hop/calcite/CalciteSqlFormatter.java    |  303 +++++
 .../org/apache/hop/calcite/CalciteSqlStyle.java    |   27 +
 .../apache/hop/calcite/SqlFormatToolbarButton.java |  173 +++
 .../hop/calcite/config/CalciteSqlFormatConfig.java |  142 ++
 .../config/CalciteSqlFormatConfigPlugin.java       |  312 +++++
 .../config/CalciteSqlFormatConfigSingleton.java    |   66 +
 .../tech/calcite/src/main/resources/format-sql.svg |    7 +
 .../config/messages/messages_en_US.properties      |   43 +
 .../hop/calcite/messages/messages_en_US.properties |   19 +
 .../tech/calcite/src/main/resources/version.xml    |   18 +
 .../apache/hop/calcite/CalciteSqlDialectsTest.java |   52 +
 .../hop/calcite/CalciteSqlFormatterTest.java       |  120 ++
 .../hop/calcite/SqlFormatToolbarButtonTest.java    |  182 +++
 .../config/CalciteSqlFormatConfigPluginTest.java   |   64 +
 .../calcite/config/CalciteSqlFormatConfigTest.java |   94 ++
 plugins/tech/pom.xml                               |    1 +
 .../combinationlookup/CombinationLookupDialog.java |   14 +-
 .../dimensionlookup/DimensionLookupDialog.java     |    8 +-
 .../insertupdate/InsertUpdateDialog.java           |    8 +-
 .../sqlfileoutput/SQLFileOutputDialog.java         |    8 +-
 .../SynchronizeAfterMergeDialog.java               |    8 +-
 .../transforms/tableoutput/TableOutputDialog.java  |    8 +-
 .../pipeline/transforms/update/UpdateDialog.java   |    8 +-
 .../hop/ui/hopgui/ContentEditorFacadeImpl.java     |   18 +
 .../org/apache/hop/ui/hopgui/monaco-editor.js      |   18 +-
 .../apache/hop/ui/hopgui/ContentEditorWidget.java  |   53 +-
 .../database/dialog/DatabaseExplorerDialog.java    |   25 +-
 .../dialog/DatabaseProgressCancelWatcher.java      |   78 ++
 .../dialog/GetPreviewTableProgressDialog.java      |   30 +-
 .../dialog/GetQueryFieldsProgressDialog.java       |   29 +-
 .../dialog/GetTableSizeProgressDialog.java         |   27 +-
 .../hop/ui/core/database/dialog/SqlEditor.java     |   31 +-
 .../ui/core/metadata/MetadataFileTypeHandler.java  |    4 +-
 .../apache/hop/ui/core/widget/StyledTextVar.java   |    5 +-
 .../core/widget/editor/IContentEditorWidget.java   |  104 ++
 .../ui/hopgui/delegates/HopGuiAuditDelegate.java   |   19 +-
 .../hop/ui/hopgui/file/IHopFileTypeHandler.java    |    5 +-
 .../hopgui/file/empty/EmptyHopFileTypeHandler.java |    4 +-
 .../database/DatabaseConnectionState.java          |   39 +
 .../perspective/database/DatabaseIndexInfo.java    |   36 +
 .../perspective/database/DatabaseOperation.java    |  113 ++
 .../database/DatabaseOperationsPanel.java          |  343 +++++
 .../perspective/database/DatabasePerspective.java  |  272 ++++
 .../perspective/database/DatabaseResultsPanel.java |  241 ++++
 .../perspective/database/DatabaseSqlEditorTab.java |  762 +++++++++++
 .../perspective/database/DatabaseSqlFileType.java  |  112 ++
 .../perspective/database/DatabaseSqlTabMemory.java |  232 ++++
 .../database/DatabaseTableInfoFileType.java        |  100 ++
 .../perspective/database/DatabaseTableInfoTab.java |  569 ++++++++
 .../perspective/database/DatabaseTreeNode.java     |  105 ++
 .../perspective/database/DatabaseWorkbench.java    | 1356 ++++++++++++++++++++
 .../database/DatabaseWorkbenchDialog.java          |  162 +++
 .../database/DatabaseWorkbenchViews.java           |  108 ++
 .../database/HopGuiDatabaseWorkbenchHost.java      |  102 ++
 .../database/IDatabaseWorkbenchHost.java           |   55 +
 .../perspective/database/SqlExecuteRange.java      |  307 +++++
 .../database/SqlFileDatabaseActions.java           |  164 +++
 .../database/config/DatabasePerspectiveConfig.java |   64 +
 .../config/DatabasePerspectiveConfigPlugin.java    |  202 +++
 .../config/DatabasePerspectiveConfigSingleton.java |   66 +
 .../explorer/file/ExplorerFileTypeHandler.java     |    4 +-
 .../text/BaseTextExplorerFileTypeHandler.java      |   14 +
 .../config/messages/messages_en_US.properties      |   26 +
 .../database/messages/messages_en_US.properties    |  104 ++
 .../resources/ui/images/database-perspective.svg   |    9 +
 ui/src/main/resources/ui/images/run-all.svg        |   12 +
 .../dialog/DatabaseProgressCancelWatcherTest.java  |   31 +
 .../hop/ui/hopgui/ContentEditorActionsTest.java    |   28 +
 .../HopGuiAuditDelegateStatePropertiesTest.java    |   54 +
 .../perspective/DisabledPerspectivesTest.java      |   17 +
 .../database/DatabaseOperationsPanelTest.java      |   58 +
 .../database/DatabaseResultsPanelTest.java         |   41 +
 .../database/DatabaseSqlEditorTabTest.java         |   48 +
 .../database/DatabaseSqlTabMemoryTest.java         |   83 ++
 .../database/DatabaseTableInfoTabTest.java         |   81 ++
 .../perspective/database/DatabaseTreeNodeTest.java |   71 +
 .../database/DatabaseWorkbenchPreviewSqlTest.java  |   68 +
 .../database/DatabaseWorkbenchViewsTest.java       |   47 +
 .../perspective/database/SqlExecuteRangeTest.java  |  217 ++++
 .../DatabasePerspectiveConfigPluginTest.java       |   94 ++
 127 files changed, 10012 insertions(+), 262 deletions(-)

diff --git a/NOTICE b/NOTICE
index 3c525898df..ee0041e676 100644
--- a/NOTICE
+++ b/NOTICE
@@ -16,6 +16,9 @@ See: https://github.com/delta-io/delta
 This product bundles Apache Iceberg under the Apache License, Version 2.0.
 See: https://iceberg.apache.org/
 
+This product bundles Apache Calcite under the Apache License, Version 2.0.
+See: https://calcite.apache.org/
+
 This product bundles Unity Catalog client libraries (transitive dependency of
 Delta Lake) under the Apache License, Version 2.0.
 See: https://github.com/unitycatalog/unitycatalog
diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index 3b815f5aa6..398e0f2ed0 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -720,6 +720,12 @@
             <version>${project.version}</version>
             <type>zip</type>
         </dependency>
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-tech-calcite</artifactId>
+            <version>${project.version}</version>
+            <type>zip</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-tech-ftp</artifactId>
diff --git a/core/src/main/java/org/apache/hop/core/database/BaseDatabaseMeta.java b/core/src/main/java/org/apache/hop/core/database/BaseDatabaseMeta.java
index 2f4a5f338e..06dc32a709 100644
--- a/core/src/main/java/org/apache/hop/core/database/BaseDatabaseMeta.java
+++ b/core/src/main/java/org/apache/hop/core/database/BaseDatabaseMeta.java
@@ -924,6 +924,56 @@ public abstract class BaseDatabaseMeta implements Cloneable, IDatabase {
     return "SELECT * FROM " + tableName;
   }
 
+  /**
+   * SQL-standard catalog lookup. Dialects without {@code INFORMATION_SCHEMA.VIEWS} override this
+   * (Oracle, SQLite) or {@link #getSqlObjectDdl(String, String)} (MySQL {@code SHOW CREATE TABLE}).
+   */
+  @Override
+  public String getSqlViewDefinition(String schemaName, String viewName) {
+    if (Utils.isEmpty(viewName)) {
+      return null;
+    }
+    StringBuilder sql = new StringBuilder();
+    sql.append("SELECT VIEW_DEFINITION FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_NAME = ");
+    sql.append(quoteSqlString(viewName));
+    if (!Utils.isEmpty(schemaName)) {
+      sql.append(" AND TABLE_SCHEMA = ").append(quoteSqlString(schemaName));
+    }
+    return sql.toString();
+  }
+
+  /**
+   * Quote an identifier for catalog SQL such as {@code SHOW CREATE TABLE}. Always quotes, unlike
+   * {@link DatabaseMeta#quoteField(String)}.
+   */
+  protected String quoteIdentifierAlways(String name) {
+    if (name == null) {
+      return null;
+    }
+    String start = Const.NVL(getStartQuote(), "");
+    String end = Const.NVL(getEndQuote(), start);
+    if (start.isEmpty()) {
+      return name;
+    }
+    return start + name.replace(end, end + end) + end;
+  }
+
+  /**
+   * {@code SHOW CREATE TABLE} for MySQL, Hive and similar. Quotes schema and object names.
+   *
+   * @return the statement, or {@code null} when {@code objectName} is empty
+   */
+  protected String showCreateTableSql(String schemaName, String objectName) {
+    if (Utils.isEmpty(objectName)) {
+      return null;
+    }
+    String qualified =
+        Utils.isEmpty(schemaName)
+            ? quoteIdentifierAlways(objectName)
+            : quoteIdentifierAlways(schemaName) + "." + quoteIdentifierAlways(objectName);
+    return "SHOW CREATE TABLE " + qualified;
+  }
+
   /**
    * Most databases round number(7,2) 17.29999999 to 17.30, but some don't.
    *
@@ -1648,12 +1698,7 @@ public abstract class BaseDatabaseMeta implements Cloneable, IDatabase {
         String stat = all.substring(from, to);
         if (!onlySpaces(stat)) {
           String s = Const.trim(stat);
-          statements.add(
-              new SqlScriptStatement(
-                  s,
-                  from,
-                  to,
-                  s.toUpperCase().startsWith("SELECT") || s.toLowerCase().startsWith("show")));
+          statements.add(new SqlScriptStatement(s, from, to, SqlQueryClassifier.isQuery(s)));
         }
         to++;
         from = to;
diff --git a/core/src/main/java/org/apache/hop/core/database/Database.java b/core/src/main/java/org/apache/hop/core/database/Database.java
index cd3e758a25..1b25d89990 100644
--- a/core/src/main/java/org/apache/hop/core/database/Database.java
+++ b/core/src/main/java/org/apache/hop/core/database/Database.java
@@ -130,8 +130,9 @@ public class Database implements IVariables, ILoggingObject, AutoCloseable {
 
   /**
    * When positive, applied to statements created in {@link #openQuery(String, IRowMeta, Object[],
-   * int, boolean)} via {@link Statement#setQueryTimeout(int)} (whole seconds). Zero leaves the JDBC
-   * driver default (typically unlimited). Intended for short-lived GUI preview connections.
+   * int, boolean)} and {@link #execStatement(String, IRowMeta, Object[])} via {@link
+   * Statement#setQueryTimeout(int)} (whole seconds). Zero leaves the JDBC driver default (typically
+   * unlimited). Intended for short-lived GUI preview connections.
    */
   private int statementQueryTimeoutSeconds;
 
@@ -270,8 +271,8 @@ public class Database implements IVariables, ILoggingObject, AutoCloseable {
 
   /**
    * Sets the JDBC {@link Statement#setQueryTimeout(int)} (seconds) for statements opened by {@link
-   * #openQuery(String, IRowMeta, Object[], int, boolean)} until {@link #disconnect()}. Use {@code
-   * 0} to use the driver default.
+   * #openQuery(String, IRowMeta, Object[], int, boolean)} and {@link #execStatement(String,
+   * IRowMeta, Object[])} until {@link #disconnect()}. Use {@code 0} to use the driver default.
    *
    * @param seconds query timeout in whole seconds; values {@code < 0} are treated as {@code 0}
    */
@@ -1525,12 +1526,14 @@ public class Database implements IVariables, ILoggingObject, AutoCloseable {
       if (params != null) {
         PreparedStatement prepStmt = connection.prepareStatement(databaseMeta.stripCR(sql));
         setValues(params, data, prepStmt); // set the parameters!
+        applyStatementQueryTimeout(prepStmt);
         resultSet = prepStmt.execute();
         count = prepStmt.getUpdateCount();
         prepStmt.close();
       } else {
         String sqlStripped = databaseMeta.stripCR(sql);
         try (Statement stmt = connection.createStatement()) {
+          applyStatementQueryTimeout(stmt);
           resultSet = stmt.execute(sqlStripped);
           count = stmt.getUpdateCount();
         }
@@ -1614,8 +1617,7 @@ public class Database implements IVariables, ILoggingObject, AutoCloseable {
 
         if (!Const.onlySpaces(stat)) {
           String sql = Const.trim(stat);
-          if (sql.toUpperCase().startsWith("SELECT")
-              && !sql.toUpperCase().matches("(?is)^(select\\s.*\\sinto\\s).*")) {
+          if (SqlQueryClassifier.isQuery(sql)) {
             // A Query
             if (log.isDetailed()) {
               log.logDetailed("launch SELECT statement: " + Const.CR + sql);
@@ -3275,6 +3277,70 @@ public class Database implements IVariables, ILoggingObject, AutoCloseable {
     return retval.toString();
   }
 
+  /**
+   * DDL to recreate a table or view: catalog text when the dialect can read it, otherwise a {@code
+   * CREATE TABLE} (or a column-only {@code CREATE VIEW}) from field metadata.
+   *
+   * @param schemaName schema or catalog, may be {@code null}
+   * @param objectName table or view name
+   * @param view {@code true} to emit {@code CREATE VIEW}
+   * @param fields columns already loaded, or {@code null} to look them up
+   */
+  public String getObjectDdl(String schemaName, String objectName, boolean view, IRowMeta fields)
+      throws HopDatabaseException {
+    String qualified = databaseMeta.getQuotedSchemaTableCombination(this, schemaName, objectName);
+    String catalog = readCatalogDdl(schemaName, objectName, view);
+    if (!Utils.isEmpty(catalog)) {
+      if (view && !DatabaseObjectDdl.startsWithCreate(catalog)) {
+        return DatabaseObjectDdl.asCreateViewStatement(qualified, catalog);
+      }
+      return DatabaseObjectDdl.ensureSemicolon(catalog);
+    }
+    IRowMeta layout = fields;
+    if (layout == null || layout.isEmpty()) {
+      layout = loadObjectFields(schemaName, objectName, qualified);
+    }
+    if (view) {
+      return DatabaseObjectDdl.synthesizeCreateView(
+          qualified, layout, "View definition is not available from the catalog");
+    }
+    if (layout == null || layout.isEmpty()) {
+      return "";
+    }
+    databaseMeta.quoteReservedWords(layout);
+    return getCreateTableStatement(qualified, layout, null, false, null, true);
+  }
+
+  private IRowMeta loadObjectFields(String schemaName, String objectName, String qualified)
+      throws HopDatabaseException {
+    try {
+      IRowMeta meta = getTableFieldsMeta(schemaName, objectName);
+      if (meta != null && meta.size() > 0) {
+        return meta;
+      }
+    } catch (Exception ignored) {
+      // Fall back to the query-based layout.
+    }
+    return getTableFields(qualified);
+  }
+
+  private String readCatalogDdl(String schemaName, String objectName, boolean view) {
+    String sql = databaseMeta.getSqlObjectDdl(schemaName, objectName);
+    if (Utils.isEmpty(sql) && view) {
+      sql = databaseMeta.getSqlViewDefinition(schemaName, objectName);
+    }
+    if (Utils.isEmpty(sql)) {
+      return null;
+    }
+    try {
+      RowMetaAndData row = getOneRow(sql);
+      return DatabaseObjectDdl.extractDefinition(row);
+    } catch (Exception e) {
+      log.logDebug("Unable to read object DDL from the catalog", e);
+      return null;
+    }
+  }
+
   public String getAlterTableStatement(
       String tableName,
       IRowMeta fields,
@@ -3694,9 +3760,18 @@ public class Database implements IVariables, ILoggingObject, AutoCloseable {
     if (monitor != null) {
       monitor.setTaskName("Opening query...");
     }
-    ResultSet rset = openQuery(sql, params, data, fetchMode, lazyConversion);
-
-    return getRows(rset, limit, monitor);
+    // openQuery honours setQueryLimit via Statement.setMaxRows. The limit argument used to only
+    // stop the client read loop, so drivers that buffer the result still fetched the whole table.
+    int previousLimit = rowlimit;
+    if (limit > 0) {
+      setQueryLimit(limit);
+    }
+    try {
+      ResultSet rset = openQuery(sql, params, data, fetchMode, lazyConversion);
+      return getRows(rset, limit, monitor);
+    } finally {
+      rowlimit = previousLimit;
+    }
   }
 
   /**
diff --git a/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java b/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
index 6c1d0a4347..4fc82f3918 100644
--- a/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
+++ b/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
@@ -1180,6 +1180,24 @@ public class DatabaseMeta extends HopMetadataBase implements Cloneable, IHopMeta
     return iDatabase.getSqlQueryFields(tableName);
   }
 
+  /**
+   * @param schemaName schema or catalog, or {@code null}
+   * @param viewName view name
+   * @return catalog SQL for the view definition, or {@code null}
+   */
+  public String getSqlViewDefinition(String schemaName, String viewName) {
+    return iDatabase.getSqlViewDefinition(schemaName, viewName);
+  }
+
+  /**
+   * @param schemaName schema or catalog, or {@code null}
+   * @param objectName table or view name
+   * @return catalog SQL for {@code CREATE TABLE}/{@code CREATE VIEW}, or {@code null}
+   */
+  public String getSqlObjectDdl(String schemaName, String objectName) {
+    return iDatabase.getSqlObjectDdl(schemaName, objectName);
+  }
+
   public String getAddColumnStatement(
       String tableName,
       IValueMeta v,
diff --git a/core/src/main/java/org/apache/hop/core/database/DatabaseObjectDdl.java b/core/src/main/java/org/apache/hop/core/database/DatabaseObjectDdl.java
new file mode 100644
index 0000000000..cf8495ba8e
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/database/DatabaseObjectDdl.java
@@ -0,0 +1,124 @@
+/*
+ * 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.core.database;
+
+import java.util.Locale;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.RowMetaAndData;
+import org.apache.hop.core.exception.HopValueException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.util.Utils;
+
+/**
+ * Helpers for CREATE TABLE / CREATE VIEW text shown in the Database perspective object-information
+ * tab.
+ */
+public final class DatabaseObjectDdl {
+
+  private DatabaseObjectDdl() {}
+
+  /**
+   * Pick the catalog definition from a query row. Prefers columns named like {@code CREATE}, {@code
+   * VIEW_DEFINITION}, {@code sql} or {@code TEXT}; otherwise the last non-empty string.
+   */
+  public static String extractDefinition(RowMetaAndData row) throws HopValueException {
+    if (row == null || row.getRowMeta() == null || row.getData() == null) {
+      return null;
+    }
+    IRowMeta meta = row.getRowMeta();
+    int fallback = -1;
+    for (int i = 0; i < meta.size(); i++) {
+      String value = row.getString(i, null);
+      if (Utils.isEmpty(value)) {
+        continue;
+      }
+      fallback = i;
+      String column = Const.NVL(meta.getValueMeta(i).getName(), "").toUpperCase(Locale.ROOT);
+      if (column.contains("CREATE")
+          || column.contains("DEFINITION")
+          || column.contains("DDL")
+          || "SQL".equals(column)
+          || "TEXT".equals(column)) {
+        return value;
+      }
+    }
+    return fallback >= 0 ? row.getString(fallback, null) : null;
+  }
+
+  /**
+   * If {@code definition} is already a CREATE statement, return it (with a trailing semicolon).
+   * Otherwise wrap it as {@code CREATE VIEW qualified AS ...}.
+   */
+  public static String asCreateViewStatement(String qualifiedName, String definition) {
+    if (Utils.isEmpty(definition)) {
+      return "";
+    }
+    String trimmed = definition.trim();
+    if (startsWithCreate(trimmed)) {
+      return ensureSemicolon(trimmed);
+    }
+    String name = Utils.isEmpty(qualifiedName) ? "view" : qualifiedName;
+    return "CREATE VIEW " + name + " AS" + Const.CR + trimmed + ";";
+  }
+
+  public static boolean startsWithCreate(String sql) {
+    if (Utils.isEmpty(sql)) {
+      return false;
+    }
+    return sql.trim().toUpperCase(Locale.ROOT).startsWith("CREATE");
+  }
+
+  public static String ensureSemicolon(String sql) {
+    if (Utils.isEmpty(sql)) {
+      return sql;
+    }
+    String trimmed = sql.trim();
+    if (trimmed.endsWith(";")) {
+      return trimmed;
+    }
+    return trimmed + ";";
+  }
+
+  /**
+   * Last-resort view DDL when the catalog has no SELECT text: column list only.
+   *
+   * @param comment optional leading SQL comment (without {@code --})
+   */
+  public static String synthesizeCreateView(String qualifiedName, IRowMeta fields, String comment) {
+    StringBuilder buffer = new StringBuilder();
+    if (!Utils.isEmpty(comment)) {
+      buffer.append("-- ").append(comment).append(Const.CR);
+    }
+    buffer.append("CREATE VIEW ").append(qualifiedName).append(" AS").append(Const.CR);
+    if (fields == null || fields.isEmpty()) {
+      buffer.append("SELECT *");
+    } else {
+      buffer.append("SELECT").append(Const.CR);
+      for (int i = 0; i < fields.size(); i++) {
+        IValueMeta value = fields.getValueMeta(i);
+        if (i > 0) {
+          buffer.append(",").append(Const.CR);
+        }
+        buffer.append("  ").append(value.getName());
+      }
+    }
+    buffer.append(';');
+    return buffer.toString();
+  }
+}
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 542167c3a5..814e908510 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
@@ -266,6 +266,31 @@ public interface IDatabase extends Cloneable {
    */
   String getSqlQueryFields(String tableName);
 
+  /**
+   * Catalog query whose result contains this view's definition (the SELECT, or a full {@code CREATE
+   * VIEW}). The interesting column is named like {@code VIEW_DEFINITION}, {@code TEXT} or {@code
+   * sql}.
+   *
+   * @param schemaName schema or catalog, or {@code null}
+   * @param viewName view name
+   * @return SQL to run, or {@code null} when this dialect has no catalog query
+   */
+  default String getSqlViewDefinition(String schemaName, String viewName) {
+    return null;
+  }
+
+  /**
+   * Catalog query whose result is the {@code CREATE TABLE} / {@code CREATE VIEW} statement (for
+   * example {@code SHOW CREATE TABLE}). Used before synthesizing DDL from column metadata.
+   *
+   * @param schemaName schema or catalog, or {@code null}
+   * @param objectName table or view name
+   * @return SQL to run, or {@code null} when this dialect has no catalog DDL query
+   */
+  default String getSqlObjectDdl(String schemaName, String objectName) {
+    return null;
+  }
+
   /**
    * Get the not found technical key.
    *
diff --git a/core/src/main/java/org/apache/hop/core/database/SqlQueryClassifier.java b/core/src/main/java/org/apache/hop/core/database/SqlQueryClassifier.java
new file mode 100644
index 0000000000..3349daf093
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/database/SqlQueryClassifier.java
@@ -0,0 +1,361 @@
+/*
+ * 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.core.database;
+
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import org.apache.hop.core.util.Utils;
+
+/**
+ * Decide whether a SQL statement returns a result set (run with {@code executeQuery}) or is DDL/DML
+ * ({@code execute}).
+ *
+ * <p>{@code WITH} common table expressions are queries when the statement after the CTE list is
+ * {@code SELECT} (or {@code VALUES} / {@code TABLE} / {@code SHOW} / {@code EXPLAIN}), and DML when
+ * it is {@code INSERT}, {@code UPDATE}, {@code DELETE} or {@code MERGE}.
+ */
+public final class SqlQueryClassifier {
+
+  private static final Set<String> QUERY_STARTERS =
+      Set.of("SELECT", "SHOW", "EXPLAIN", "DESCRIBE", "DESC", "VALUES", "TABLE");
+
+  /**
+   * First keywords of a complete statement. Leftover clauses after a semicolon ({@code WHERE},
+   * {@code AND}, {@code ORDER}, …) are not in this set.
+   */
+  private static final Set<String> STATEMENT_STARTERS = statementStarters();
+
+  private SqlQueryClassifier() {}
+
+  private static Set<String> statementStarters() {
+    Set<String> starters = new HashSet<>(QUERY_STARTERS);
+    starters.addAll(
+        Set.of(
+            "WITH",
+            "INSERT",
+            "UPDATE",
+            "DELETE",
+            "MERGE",
+            "CREATE",
+            "DROP",
+            "ALTER",
+            "TRUNCATE",
+            "GRANT",
+            "REVOKE",
+            "COMMENT",
+            "CALL",
+            "EXEC",
+            "EXECUTE",
+            "SET",
+            "USE",
+            "BEGIN",
+            "COMMIT",
+            "ROLLBACK",
+            "START",
+            "SAVEPOINT",
+            "RELEASE",
+            "DECLARE",
+            "PREPARE",
+            "DEALLOCATE",
+            "ANALYZE",
+            "VACUUM",
+            "COPY",
+            "LOAD",
+            "UNLOAD",
+            "REFRESH",
+            "OPTIMIZE",
+            "REPLACE",
+            "UPSERT",
+            "LOCK",
+            "UNLOCK",
+            "RENAME",
+            "ATTACH",
+            "DETACH",
+            "PRAGMA",
+            "REINDEX",
+            "KILL",
+            "DO",
+            "IF",
+            "WHILE",
+            "FOR",
+            "RETURN",
+            "OPEN",
+            "CLOSE",
+            "FETCH",
+            "MOVE"));
+    return Set.copyOf(starters);
+  }
+
+  /**
+   * @param sql one statement, comments allowed
+   * @return {@code true} when the statement should be executed as a query
+   */
+  public static boolean isQuery(String sql) {
+    if (Utils.isEmpty(sql)) {
+      return false;
+    }
+    int i = skipTrivia(sql, 0);
+    if (i < sql.length() && sql.charAt(i) == '(') {
+      return true;
+    }
+    String first = keywordAt(sql, i);
+    if (first == null) {
+      return false;
+    }
+    int selectAt = i;
+    if ("WITH".equals(first)) {
+      selectAt = indexAfterCteList(sql, i);
+      first = keywordAt(sql, selectAt);
+      if (first == null) {
+        return false;
+      }
+    }
+    if (!QUERY_STARTERS.contains(first)) {
+      return false;
+    }
+    if ("SELECT".equals(first)) {
+      return !hasIntoAtDepthZero(sql, skipKeyword(sql, selectAt));
+    }
+    return true;
+  }
+
+  /**
+   * @param sql one statement, comments allowed
+   * @return {@code true} when {@code sql} starts with a SQL verb (query, DML or DDL), {@code false}
+   *     for leftover clauses such as {@code WHERE} after a semicolon
+   */
+  public static boolean isExecutableStatement(String sql) {
+    if (Utils.isEmpty(sql)) {
+      return false;
+    }
+    int i = skipTrivia(sql, 0);
+    if (i < sql.length() && sql.charAt(i) == '(') {
+      return true;
+    }
+    String first = keywordAt(sql, i);
+    return first != null && STATEMENT_STARTERS.contains(first);
+  }
+
+  /**
+   * Skip {@code WITH [RECURSIVE] name AS (...), ...} and return the index of the main statement.
+   */
+  static int indexAfterCteList(String sql, int withPos) {
+    int i = skipKeyword(sql, withPos);
+    i = skipTrivia(sql, i);
+    if ("RECURSIVE".equals(keywordAt(sql, i))) {
+      i = skipKeyword(sql, i);
+    }
+    while (i < sql.length()) {
+      i = skipTrivia(sql, i);
+      i = skipIdentifier(sql, i);
+      i = skipTrivia(sql, i);
+      if (i < sql.length() && sql.charAt(i) == '(') {
+        i = skipBalancedParens(sql, i);
+        i = skipTrivia(sql, i);
+      }
+      if ("AS".equals(keywordAt(sql, i))) {
+        i = skipKeyword(sql, i);
+        i = skipTrivia(sql, i);
+      }
+      while ("NOT".equals(keywordAt(sql, i)) || "MATERIALIZED".equals(keywordAt(sql, i))) {
+        i = skipKeyword(sql, i);
+        i = skipTrivia(sql, i);
+      }
+      if (i < sql.length() && sql.charAt(i) == '(') {
+        i = skipBalancedParens(sql, i);
+        i = skipTrivia(sql, i);
+      }
+      if (i < sql.length() && sql.charAt(i) == ',') {
+        i++;
+        continue;
+      }
+      break;
+    }
+    return skipTrivia(sql, i);
+  }
+
+  private static boolean hasIntoAtDepthZero(String sql, int from) {
+    int i = from;
+    int depth = 0;
+    while (i < sql.length()) {
+      i = skipTrivia(sql, i);
+      if (i >= sql.length()) {
+        return false;
+      }
+      char c = sql.charAt(i);
+      if (c == '(') {
+        depth++;
+        i++;
+        continue;
+      }
+      if (c == ')') {
+        if (depth > 0) {
+          depth--;
+        }
+        i++;
+        continue;
+      }
+      if (isQuote(c)) {
+        i = skipQuoted(sql, i);
+        continue;
+      }
+      if (depth == 0) {
+        String keyword = keywordAt(sql, i);
+        if ("INTO".equals(keyword)) {
+          return true;
+        }
+        if ("FROM".equals(keyword) || "WHERE".equals(keyword)) {
+          return false;
+        }
+        if (keyword != null) {
+          i = skipKeyword(sql, i);
+          continue;
+        }
+      }
+      i++;
+    }
+    return false;
+  }
+
+  static int skipTrivia(String sql, int i) {
+    int n = sql.length();
+    while (i < n) {
+      char c = sql.charAt(i);
+      if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f') {
+        i++;
+        continue;
+      }
+      if (c == '-' && i + 1 < n && sql.charAt(i + 1) == '-') {
+        i += 2;
+        while (i < n && sql.charAt(i) != '\n' && sql.charAt(i) != '\r') {
+          i++;
+        }
+        continue;
+      }
+      if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') {
+        i += 2;
+        while (i + 1 < n && !(sql.charAt(i) == '*' && sql.charAt(i + 1) == '/')) {
+          i++;
+        }
+        i = Math.min(n, i + 2);
+        continue;
+      }
+      break;
+    }
+    return i;
+  }
+
+  static String keywordAt(String sql, int i) {
+    i = skipTrivia(sql, i);
+    if (i >= sql.length() || !isIdentStart(sql.charAt(i))) {
+      return null;
+    }
+    int start = i;
+    i++;
+    while (i < sql.length() && isIdentPart(sql.charAt(i))) {
+      i++;
+    }
+    return sql.substring(start, i).toUpperCase(Locale.ROOT);
+  }
+
+  private static int skipKeyword(String sql, int i) {
+    i = skipTrivia(sql, i);
+    if (i >= sql.length() || !isIdentStart(sql.charAt(i))) {
+      return i;
+    }
+    i++;
+    while (i < sql.length() && isIdentPart(sql.charAt(i))) {
+      i++;
+    }
+    return i;
+  }
+
+  private static int skipIdentifier(String sql, int i) {
+    i = skipTrivia(sql, i);
+    if (i >= sql.length()) {
+      return i;
+    }
+    if (isQuote(sql.charAt(i))) {
+      return skipQuoted(sql, i);
+    }
+    return skipKeyword(sql, i);
+  }
+
+  private static int skipBalancedParens(String sql, int i) {
+    if (i >= sql.length() || sql.charAt(i) != '(') {
+      return i;
+    }
+    int depth = 0;
+    while (i < sql.length()) {
+      char c = sql.charAt(i);
+      if (isQuote(c)) {
+        i = skipQuoted(sql, i);
+        continue;
+      }
+      if (c == '(') {
+        depth++;
+      } else if (c == ')') {
+        depth--;
+        i++;
+        if (depth == 0) {
+          return i;
+        }
+        continue;
+      } else if (c == '-' && i + 1 < sql.length() && sql.charAt(i + 1) == '-') {
+        i = skipTrivia(sql, i);
+        continue;
+      } else if (c == '/' && i + 1 < sql.length() && sql.charAt(i + 1) == '*') {
+        i = skipTrivia(sql, i);
+        continue;
+      }
+      i++;
+    }
+    return i;
+  }
+
+  private static int skipQuoted(String sql, int i) {
+    char q = sql.charAt(i);
+    char close = q == '[' ? ']' : q;
+    i++;
+    while (i < sql.length()) {
+      char c = sql.charAt(i);
+      if (c == close) {
+        if (close != ']' && i + 1 < sql.length() && sql.charAt(i + 1) == close) {
+          i += 2;
+          continue;
+        }
+        return i + 1;
+      }
+      i++;
+    }
+    return i;
+  }
+
+  private static boolean isQuote(char c) {
+    return c == '\'' || c == '"' || c == '`' || c == '[';
+  }
+
+  private static boolean isIdentStart(char c) {
+    return Character.isLetter(c) || c == '_';
+  }
+
+  private static boolean isIdentPart(char c) {
+    return Character.isLetterOrDigit(c) || c == '_' || c == '$';
+  }
+}
diff --git a/core/src/test/java/org/apache/hop/core/database/BaseDatabaseMetaTest.java b/core/src/test/java/org/apache/hop/core/database/BaseDatabaseMetaTest.java
index dbcada6277..1ad1fbc5c0 100644
--- a/core/src/test/java/org/apache/hop/core/database/BaseDatabaseMetaTest.java
+++ b/core/src/test/java/org/apache/hop/core/database/BaseDatabaseMetaTest.java
@@ -62,6 +62,10 @@ class BaseDatabaseMetaTest {
     // Note - this method should only use native.
     // (each test run in its own thread).
     assertEquals("", nativeMeta.getLimitClause(5));
+    assertEquals(
+        "SELECT VIEW_DEFINITION FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_NAME = 'v' AND TABLE_SCHEMA = 's'",
+        nativeMeta.getSqlViewDefinition("s", "v"));
+    assertNull(nativeMeta.getSqlObjectDdl("s", "t"));
     assertEquals(0, nativeMeta.getNotFoundTK(true));
     assertFalse(nativeMeta.isNeedsPlaceHolder());
     assertEquals(DatabaseMeta.CLOB_LENGTH, nativeMeta.getMaxTextFieldLength());
diff --git a/core/src/test/java/org/apache/hop/core/database/DatabaseObjectDdlTest.java b/core/src/test/java/org/apache/hop/core/database/DatabaseObjectDdlTest.java
new file mode 100644
index 0000000000..0ca46bbf26
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/database/DatabaseObjectDdlTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.core.database;
+
+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 org.apache.hop.core.RowMetaAndData;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.junit.jupiter.api.Test;
+
+class DatabaseObjectDdlTest {
+
+  @Test
+  void extractDefinitionPrefersCreateColumn() throws Exception {
+    RowMeta meta = new RowMeta();
+    meta.addValueMeta(new ValueMetaString("Table"));
+    meta.addValueMeta(new ValueMetaString("Create Table"));
+    RowMetaAndData row = new RowMetaAndData(meta, "customer", "CREATE TABLE customer (id INT)");
+    assertEquals("CREATE TABLE customer (id INT)", DatabaseObjectDdl.extractDefinition(row));
+  }
+
+  @Test
+  void extractDefinitionUsesViewDefinitionColumn() throws Exception {
+    RowMeta meta = new RowMeta();
+    meta.addValueMeta(new ValueMetaString("VIEW_DEFINITION"));
+    RowMetaAndData row = new RowMetaAndData(meta, "SELECT id FROM t");
+    assertEquals("SELECT id FROM t", DatabaseObjectDdl.extractDefinition(row));
+  }
+
+  @Test
+  void asCreateViewStatementWrapsSelect() {
+    String ddl = DatabaseObjectDdl.asCreateViewStatement("\"public\".v", "SELECT 1 AS x");
+    assertTrue(ddl.startsWith("CREATE VIEW \"public\".v AS"));
+    assertTrue(ddl.contains("SELECT 1 AS x"));
+    assertTrue(ddl.endsWith(";"));
+  }
+
+  @Test
+  void asCreateViewStatementKeepsFullCreate() {
+    assertEquals(
+        "CREATE VIEW v AS SELECT 1;",
+        DatabaseObjectDdl.asCreateViewStatement("v", "CREATE VIEW v AS SELECT 1"));
+  }
+
+  @Test
+  void startsWithCreateIsCaseInsensitive() {
+    assertTrue(DatabaseObjectDdl.startsWithCreate("create table t (id int)"));
+    assertFalse(DatabaseObjectDdl.startsWithCreate("SELECT 1"));
+  }
+
+  @Test
+  void synthesizeCreateViewListsColumns() {
+    RowMeta fields = new RowMeta();
+    fields.addValueMeta(new ValueMetaString("id"));
+    fields.addValueMeta(new ValueMetaString("name"));
+    String ddl = DatabaseObjectDdl.synthesizeCreateView("v", fields, "no catalog");
+    assertTrue(ddl.contains("-- no catalog"));
+    assertTrue(ddl.contains("CREATE VIEW v AS"));
+    assertTrue(ddl.contains("id"));
+    assertTrue(ddl.contains("name"));
+  }
+}
diff --git a/core/src/test/java/org/apache/hop/core/database/SqlQueryClassifierTest.java b/core/src/test/java/org/apache/hop/core/database/SqlQueryClassifierTest.java
new file mode 100644
index 0000000000..d976017e1b
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/database/SqlQueryClassifierTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.core.database;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class SqlQueryClassifierTest {
+
+  @Test
+  void selectIsAQuery() {
+    assertTrue(SqlQueryClassifier.isQuery("SELECT * FROM t"));
+    assertTrue(SqlQueryClassifier.isQuery("  select 1"));
+  }
+
+  @Test
+  void showAndExplainAreQueries() {
+    assertTrue(SqlQueryClassifier.isQuery("show annotations from service"));
+    assertTrue(SqlQueryClassifier.isQuery("EXPLAIN SELECT 1"));
+  }
+
+  @Test
+  void dmlAndDdlAreNotQueries() {
+    assertFalse(SqlQueryClassifier.isQuery("INSERT INTO t VALUES (1)"));
+    assertFalse(SqlQueryClassifier.isQuery("UPDATE t SET a = 1"));
+    assertFalse(SqlQueryClassifier.isQuery("DELETE FROM t"));
+    assertFalse(SqlQueryClassifier.isQuery("CREATE TABLE t (id INT)"));
+  }
+
+  @Test
+  void selectIntoIsNotAQuery() {
+    assertFalse(SqlQueryClassifier.isQuery("SELECT * INTO dest FROM src"));
+  }
+
+  @Test
+  void withSelectIsAQuery() {
+    String sql =
+        """
+        WITH customer_360_bv AS (
+                 SELECT customer_360_bv_1.customer_hk,
+                    customer_360_bv_1.cust_email
+                   FROM public.customer_360_bv customer_360_bv_1
+                )
+         SELECT customer_hk,
+            cust_email AS email
+           FROM customer_360_bv
+          WHERE cust_email IS NOT NULL
+        """;
+    assertTrue(SqlQueryClassifier.isQuery(sql));
+  }
+
+  @Test
+  void withRecursiveSelectIsAQuery() {
+    assertTrue(
+        SqlQueryClassifier.isQuery(
+            "WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM t WHERE n < 3) SELECT * FROM t"));
+  }
+
+  @Test
+  void multipleCtesThenSelectIsAQuery() {
+    assertTrue(
+        SqlQueryClassifier.isQuery(
+            "WITH a AS (SELECT 1 AS x), b AS (SELECT 2 AS x) SELECT * FROM a"));
+  }
+
+  @Test
+  void withInsertIsNotAQuery() {
+    assertFalse(
+        SqlQueryClassifier.isQuery("WITH s AS (SELECT 1 AS id) INSERT INTO t SELECT * FROM s"));
+  }
+
+  @Test
+  void withUpdateIsNotAQuery() {
+    assertFalse(SqlQueryClassifier.isQuery("WITH s AS (SELECT 1 AS id) UPDATE t SET a = 1"));
+  }
+
+  @Test
+  void leadingCommentDoesNotHideWithSelect() {
+    assertTrue(SqlQueryClassifier.isQuery("-- run me\nWITH s AS (SELECT 1) SELECT * FROM s"));
+  }
+
+  @Test
+  void leftoverWhereIsNotAnExecutableStatement() {
+    assertFalse(SqlQueryClassifier.isExecutableStatement("WHERE x = 1"));
+    assertFalse(SqlQueryClassifier.isExecutableStatement("AND y = 2"));
+    assertFalse(SqlQueryClassifier.isExecutableStatement("ORDER BY id"));
+    assertFalse(SqlQueryClassifier.isExecutableStatement(""));
+  }
+
+  @Test
+  void queriesAndDmlAreExecutableStatements() {
+    assertTrue(SqlQueryClassifier.isExecutableStatement("SELECT * FROM t"));
+    assertTrue(SqlQueryClassifier.isExecutableStatement("WITH s AS (SELECT 1) SELECT * FROM s"));
+    assertTrue(SqlQueryClassifier.isExecutableStatement("INSERT INTO t VALUES (1)"));
+    assertTrue(SqlQueryClassifier.isExecutableStatement("CREATE TABLE t (id INT)"));
+    assertTrue(SqlQueryClassifier.isExecutableStatement("-- comment\nUPDATE t SET a = 1"));
+  }
+
+  @Test
+  void scriptStatementsMarkWithSelectAsQuery() {
+    ConcreteBaseDatabaseMeta meta = new ConcreteBaseDatabaseMeta();
+    var statements =
+        meta.getSqlScriptStatements(
+            "WITH s AS (SELECT 1 AS x) SELECT * FROM s;\nINSERT INTO t VALUES (1);");
+    assertTrue(statements.get(0).isQuery());
+    assertFalse(statements.get(1).isQuery());
+  }
+}
diff --git a/docs/hop-dev-manual/modules/ROOT/pages/gui-plugins-toolbars.adoc b/docs/hop-dev-manual/modules/ROOT/pages/gui-plugins-toolbars.adoc
index ad857d76c8..aecafef1f9 100644
--- a/docs/hop-dev-manual/modules/ROOT/pages/gui-plugins-toolbars.adoc
+++ b/docs/hop-dev-manual/modules/ROOT/pages/gui-plugins-toolbars.adoc
@@ -327,7 +327,7 @@ Call sites that use a plain `StyledTextComp` / `StyledTextVar` (including Hop We
 
 Because any plugin can attach to `TextComposite.ID_TOOLBAR` and branch on `styleType`, you can ship optional tooling without bloating core dialogs:
 
-* SQL pretty-print / format
+* SQL pretty-print / format (see the Apache Calcite plugin, `plugins/tech/calcite`, `SqlFormatToolbarButton`)
 * SQL lineage or “explain”
 * Visual SQL builders
 * JSON / XML formatters (see the sample JSON format button)
@@ -443,6 +443,9 @@ Escape variable-like tokens in properties with single quotes when needed (`'${VA
 | Sample JSON format button
 | `plugins/transforms/json/.../ui/TextCompositeToolbarJsonFormatButton.java`
 
+| Sample SQL format button
+| `plugins/tech/calcite/.../SqlFormatToolbarButton.java`
+
 | Sample table export buttons
 | `plugins/transforms/excel/.../TableViewExportToExcelToolbarButton.java`, CSV equivalent under textfile
 
diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/apache-calcite-sql-formatter-keywords-and-identifiers-options.png b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/apache-calcite-sql-formatter-keywords-and-identifiers-options.png
new file mode 100644
index 0000000000..778d48ed93
Binary files /dev/null and b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/apache-calcite-sql-formatter-keywords-and-identifiers-options.png differ
diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/apache-calcite-sql-formatter-layout-options.png b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/apache-calcite-sql-formatter-layout-options.png
new file mode 100644
index 0000000000..b4be351ded
Binary files /dev/null and b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/apache-calcite-sql-formatter-layout-options.png differ
diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-configuration-options.png b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-configuration-options.png
new file mode 100644
index 0000000000..e7a39f18b7
Binary files /dev/null and b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-configuration-options.png differ
diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-icon-and-tree.png b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-icon-and-tree.png
new file mode 100644
index 0000000000..a974daf0de
Binary files /dev/null and b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-icon-and-tree.png differ
diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-panel.png b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-panel.png
new file mode 100644
index 0000000000..9a44f91528
Binary files /dev/null and b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-panel.png differ
diff --git a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-popup-dialog.png b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-popup-dialog.png
new file mode 100644
index 0000000000..2cf74343b7
Binary files /dev/null and b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/database/database-perspective-popup-dialog.png differ
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc b/docs/hop-user-manual/modules/ROOT/nav.adoc
index a5c3b57ce9..87896b51dc 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -39,6 +39,7 @@ under the License.
 ** xref:hop-gui/hop-gui-popup-dialog.adoc[Popup Dialog]
 ** xref:hop-gui/notes.adoc[Notes on pipelines and workflows]
 ** xref:hop-gui/perspectives.adoc[Perspectives]
+** xref:hop-gui/perspective-database.adoc[Database perspective]
 ** xref:hop-gui/perspective-search.adoc[Search]
 ** xref:hop-gui/hop-gui-git.adoc[Working with git]
 ** xref:hop-gui/shortcuts.adoc[Keyboard Shortcuts]
@@ -538,6 +539,7 @@ under the License.
 //::=END AUTO GENERATED LINKS
 * xref:samples/samples.adoc[Samples]
 * xref:technology/technology.adoc[Technology]
+** xref:technology/calcite/index.adoc[Apache Calcite]
 ** xref:technology/azure/index.adoc[Azure]
 ** xref:technology/cassandra/index.adoc[Cassandra]
 ** xref:technology/memgraph/index.adoc[Memgraph]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-gui.adoc b/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-gui.adoc
index 19b882db70..f067699de5 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-gui.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/getting-started/hop-gui.adoc
@@ -71,6 +71,8 @@ image:getting-started/gs-hop-gui-overview-2.png[Hop Gui Overview]
 
 ** image:icons/folder.svg[width="24px"] xref:hop-gui/perspective-file-explorer.adoc[File explorer]
 
+** image:icons/database.svg[width="24px"] xref:hop-gui/perspective-database.adoc[Database]
+
 ** image:icons/plugin.svg[width="24px"] xref:hop-gui/perspective-plugin.adoc[Plugins explorer]
 
 ** image:icons/neo4j_logo.svg[width="24px"] xref:hop-gui/perspective-neo4j.adoc[Neo4j]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-database.adoc b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-database.adoc
new file mode 100644
index 0000000000..f3cd9cf453
--- /dev/null
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-database.adoc
@@ -0,0 +1,96 @@
+////
+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.
+////
+:imagesdir: ../assets/images
+:description: Browse relational database connections, run SQL and inspect tables from the Database perspective.
+
+= Database perspective
+
+Icon: image:icons/database.svg[width="24px"]
+
+Keyboard shortcut: `CTRL-SHIFT-D` (macOS: `Cmd-Shift-D`)
+
+== Description
+
+The Database perspective is a non-modal alternative to the SQL editor dialog. Use it to browse the relational connections in the current project, run SQL, preview tables and inspect column and index metadata without blocking the rest of Hop Gui.
+
+It is *not* a full database IDE. There is no visual query builder, autocomplete, or grid editing.
+
+image::hop-gui/database/database-perspective-icon-and-tree.png[The Database perspective: connection tree on the left and a SQL editor tab,width="90%"]
+
+== Connection tree
+
+The left-hand tree lists every relational (`DatabaseMeta`) connection in the project. A filter field above the tree matches connection, schema and table names. Expand/collapse of schema (and catalog) folders is remembered when you filter or refresh the tree.
+
+Toolbar:
+
+* *Connect* — load schemas and their tables (background; does not freeze Hop Gui)
+* *Disconnect* — drop the loaded schema list
+* *Refresh* — reload the selected connected connection, or the connection list
+* *Open SQL editor* — new SQL tab for the selected connection (`SELECT * FROM table` when a table is selected)
+* *Generate DDL* — `CREATE TABLE` for the selected table, opened in a SQL tab
+* *Preview table* — run `SELECT` with a dialect-aware row limit (the **Query row limit** in Configuration → Plugins → Database Perspective, default 1000)
+* *Show object information* — columns, indexes, and a **DDL** tab with `CREATE TABLE` or `CREATE VIEW`. Views use the catalog definition when the database provides it (`INFORMATION_SCHEMA`, `pg_get_viewdef`, `SHOW CREATE TABLE`, and similar).
+
+Double-click a disconnected connection to connect. Double-click a schema (or catalog / folder) to expand or collapse the tables and views underneath. Views use the view icon. Double-click a table or view to preview it (`SELECT * FROM …` plus the connection's limit clause for the configured query row limit).
+
+== SQL editor
+
+Each SQL tab is bound to one connection.
+
+* *Run* on the SQL editor toolbar (`Ctrl+Enter` / `Cmd+Enter`) executes the statement around the caret.
+* *Run all* (`Ctrl+Shift+Enter` / `Cmd+Shift+Enter`) executes every statement in the editor.
+* If the SQL tab's connection is not connected, Hop asks to connect first (`MessageDialogWithToggle`). Check **Don't ask again** to enable auto-connect; that option also lives in the Configuration perspective under **Plugins → Database Perspective**.
+* *Format SQL* (Apache Calcite plugin) pretty-prints the selection, or the whole buffer when nothing is selected. The formatter follows the tab's connection dialect when it can. Layout options live in the Configuration perspective under **Plugins → Apache Calcite SQL formatter**. See xref:technology/calcite/index.adoc[Apache Calcite].
+* If you have a selection, that selection is executed (all statements in it).
+* If you do not, **Run** takes the blank-line-delimited block around the caret, splits on `;`, and runs the statement at the caret. A leftover clause after a semicolon (for example `WHERE` on the next line) is not executed; use **Run all** to send every statement.
+* **Select the executed SQL in the editor** (Configuration perspective, **Plugins → Database Perspective**, on by default) highlights only the statement(s) that were sent.
+* Results appear in a sash under the editor (one tab per query, plus a messages tab). Close hides the results panel; the next run shows it again. **Open in separate window** (left of Close) opens the selected result table in a separate window.
+* Queries are capped at the **Query row limit** (Configuration perspective, **Plugins → Database Perspective**, default 1000). That cap is sent to the driver (`setMaxRows`) as well as applied when reading rows, so a `SELECT *` or a SQL `LIMIT` above the option still does not pull the whole table. When the cap is hit, the Messages tab says so. Statement timeout (`'${HOP_QUERY_PREVIEW_TIMEOUT}'`, seconds; `0` is the driver default) applies to both queries and DDL/DML.
+
+image::hop-gui/database/database-perspective-panel.png[SQL editor with query results, Open in separate window, and the operations status line,width="90%"]
+
+Long-running work (connect, listing, SQL, DDL, table info) is shown as a one-line status at the bottom of the right-hand side (`description - connection - Done/Failed - 23 ms`). *Stop* cancels the current operation (`Statement.cancel`). *Expand* opens the operations list; *Minimize* on that list returns to the status line.
+
+Open and save `.sql` files with the normal File menu. Explorer `.sql` files stay in the File explorer; use the database icon on the content-editor toolbar to open the buffer in this perspective (you pick the connection).
+
+== Configuration
+
+Open the xref:hop-gui/perspective-configuration.adoc[Configuration perspective], then **Plugins → Database Perspective**:
+
+* *Automatically connect when executing SQL* — skip the connect prompt when you run SQL on a disconnected tab
+* *Select the executed SQL in the editor* — highlight the statement that was sent (on by default)
+* *Query row limit* — maximum rows shown for a SELECT and for table preview (default 1000)
+
+image::hop-gui/database/database-perspective-configuration-options.png[Database Perspective options under Configuration, Plugins,width="90%"]
+
+== Floating window and bottom dock
+
+The same workbench can run outside the perspective, like Search:
+
+* *Float* on the connection toolbar (or **Tools → Database window**) opens a non-modal window you can put on another monitor while you stay in Data orchestration.
+* *Dock* opens it as a tab in the bottom dock (next to the terminal and search results).
+
+image::hop-gui/database/database-perspective-popup-dialog.png[The floating Database window,width="90%"]
+
+Each placement has its own workbench instance (connection tree, SQL tabs, operations). The perspective stays available with `Ctrl+Shift+D`. SQL editor tabs (connection, file or buffer, unsaved edits) are remembered per project when you close the window or dock and when you restart Hop Gui. Query results are not stored.
+
+**SQL** on a table-output (or bulk-loader, lookup, update, …) transform dialog opens this floating window on the transform's connection: the schema/table tree refreshes in the background while you edit and run the generated SQL. The same happens from the database explorer (Open SQL, Generate DDL, Truncate).
+
+== See also
+
+* xref:hop-gui/perspectives.adoc[Perspectives]
+* xref:metadata-types/rdbms-connection.adoc[Relational database connections]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspectives.adoc b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspectives.adoc
index 8669243dd9..ab69c9625a 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspectives.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspectives.adoc
@@ -36,6 +36,8 @@ Here are the available perspectives:
 
 * image:icons/folder.svg[width="24px"] xref:hop-gui/perspective-file-explorer.adoc[File explorer]
 
+* image:icons/database.svg[width="24px"] xref:hop-gui/perspective-database.adoc[Database] (also as a floating window or bottom-dock tab)
+
 * image:icons/plugin.svg[width="24px"] xref:hop-gui/perspective-plugin.adoc[Plugins explorer]
 
 * image:icons/neo4j_logo.svg[width="24px"] xref:hop-gui/perspective-neo4j.adoc[Neo4j]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/technology/calcite/index.adoc b/docs/hop-user-manual/modules/ROOT/pages/technology/calcite/index.adoc
new file mode 100644
index 0000000000..9dad720457
--- /dev/null
+++ b/docs/hop-user-manual/modules/ROOT/pages/technology/calcite/index.adoc
@@ -0,0 +1,58 @@
+////
+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.
+////
+:documentationPath: /technology/calcite/
+:language: en_US
+:imagesdir: ../../../assets/images
+:description: The Apache Calcite technology plugin pretty-prints SQL in Hop editors.
+
+= Apache Calcite
+
+https://calcite.apache.org/[Apache Calcite] is a dynamic data management framework with a SQL parser and pretty-printer.
+The Calcite technology plugin uses `org.apache.calcite.sql.pretty.SqlPrettyWriter` to format SQL in Hop Gui.
+
+== Format SQL
+
+When the plugin is installed, a **Format SQL** button appears on:
+
+* SQL editor tabs in the xref:hop-gui/perspective-database.adoc[Database perspective] (and the floating Database window / bottom dock)
+* SQL files opened in the File explorer
+* SQL fields in transform and action dialogs that use the shared text-editor toolbar (Table Input, Execute SQL script, and similar)
+
+Click the button to pretty-print the selection, or the whole buffer when nothing is selected.
+Undo (Ctrl+Z / Cmd+Z) restores the SQL from before the format.
+In a Database SQL editor the formatter picks a Calcite dialect from the tab's relational connection (MySQL, PostgreSQL, Oracle, SQL Server, and others).
+Quoted identifiers are accepted in PostgreSQL/ANSI double quotes (`"public".table`), MySQL backticks, and SQL Server brackets.
+Hop variable expressions such as `'${TABLE_NAME}'` are preserved.
+
+== Options
+
+Open the xref:hop-gui/perspective-configuration.adoc[Configuration perspective], then **Plugins → Apache Calcite SQL formatter**.
+The fields are Calcite's `SqlFormatOptions`: indentation, line length, whether clauses and list items start on a new line, CASE layout, WINDOW layout, keyword case, and whether to quote every identifier.
+
+image::hop-gui/database/apache-calcite-sql-formatter-layout-options.png[Apache Calcite SQL formatter layout options,width="90%"]
+
+image::hop-gui/database/apache-calcite-sql-formatter-keywords-and-identifiers-options.png[Apache Calcite SQL formatter keyword and identifier options,width="90%"]
+
+Changes are saved immediately to hop-config.json and used the next time you click **Format SQL**.
+
+Calcite cannot parse every vendor extension.
+Statements it does not understand are left unchanged when they sit next to statements it can format; if nothing in the buffer can be parsed, an error dialog explains why.
+
+See the official documentation:
+
+* https://calcite.apache.org/javadocAggregate/org/apache/calcite/sql/pretty/SqlPrettyWriter.html[SqlPrettyWriter]
+* xref:dev-manual::gui-plugins-toolbars.adoc[GUI plugins and toolbars] (this plugin is the SQL formatter example)
diff --git a/docs/hop-user-manual/modules/ROOT/pages/technology/technology.adoc b/docs/hop-user-manual/modules/ROOT/pages/technology/technology.adoc
index 79a9ca6a16..d00e983a76 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/technology/technology.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/technology/technology.adoc
@@ -26,6 +26,7 @@ It also provides starting points for you if you want to learn more about the tec
 The technologies supported by Hop currently include:
 
 * xref:technology/arrow/apache-arrow.adoc[Apache Arrow]
+* xref:technology/calcite/index.adoc[Apache Calcite]
 * xref:technology/azure/index.adoc[Azure]
 * xref:technology/cassandra/index.adoc[Apache Cassandra]
 * xref:technology/google/index.adoc[Google]
diff --git a/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoaderDialog.java b/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoaderDialog.java
index 15f0c9d762..410b719076 100644
--- a/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoaderDialog.java
+++ b/plugins/databases/cratedb/src/main/java/org/apache/hop/pipeline/transforms/cratedbbulkloader/CrateDBBulkLoaderDialog.java
@@ -25,7 +25,6 @@ import java.util.Map;
 import java.util.Set;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -45,7 +44,6 @@ import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -57,6 +55,7 @@ import org.apache.hop.ui.core.widget.PasswordTextVar;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabFolder;
@@ -1249,10 +1248,7 @@ public class CrateDBBulkLoaderDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(BaseMessages.getString(PKG, "CrateDBBulkLoaderDialog.NoSQL.DialogMessage"));
diff --git a/plugins/databases/generic/src/main/java/org/apache/hop/databases/generic/GenericDatabaseMeta.java b/plugins/databases/generic/src/main/java/org/apache/hop/databases/generic/GenericDatabaseMeta.java
index 350fa4daca..a78a3bfb14 100644
--- a/plugins/databases/generic/src/main/java/org/apache/hop/databases/generic/GenericDatabaseMeta.java
+++ b/plugins/databases/generic/src/main/java/org/apache/hop/databases/generic/GenericDatabaseMeta.java
@@ -454,6 +454,22 @@ public class GenericDatabaseMeta extends BaseDatabaseMeta implements IDatabase {
     return super.getSqlTableExists(tableName);
   }
 
+  @Override
+  public String getSqlViewDefinition(String schemaName, String viewName) {
+    if (databaseDialect != null) {
+      return databaseDialect.getSqlViewDefinition(schemaName, viewName);
+    }
+    return super.getSqlViewDefinition(schemaName, viewName);
+  }
+
+  @Override
+  public String getSqlObjectDdl(String schemaName, String objectName) {
+    if (databaseDialect != null) {
+      return databaseDialect.getSqlObjectDdl(schemaName, objectName);
+    }
+    return super.getSqlObjectDdl(schemaName, objectName);
+  }
+
   @Override
   public String getLimitClause(int nrRows) {
     if (databaseDialect != null) {
diff --git a/plugins/databases/hive/src/main/java/org/apache/hop/databases/hive/HiveDatabaseMeta.java b/plugins/databases/hive/src/main/java/org/apache/hop/databases/hive/HiveDatabaseMeta.java
index 13ffb70121..cd0e1efdee 100644
--- a/plugins/databases/hive/src/main/java/org/apache/hop/databases/hive/HiveDatabaseMeta.java
+++ b/plugins/databases/hive/src/main/java/org/apache/hop/databases/hive/HiveDatabaseMeta.java
@@ -110,6 +110,11 @@ public class HiveDatabaseMeta extends BaseDatabaseMeta implements IDatabase {
     return "SELECT * FROM " + tableName + " LIMIT 0";
   }
 
+  @Override
+  public String getSqlObjectDdl(String schemaName, String objectName) {
+    return showCreateTableSql(schemaName, objectName);
+  }
+
   @Override
   public String getSqlTableExists(String tableName) {
     return getSqlQueryFields(tableName);
diff --git a/plugins/databases/hive/src/test/java/org/apache/hop/databases/hive/HiveDatabaseMetaTest.java b/plugins/databases/hive/src/test/java/org/apache/hop/databases/hive/HiveDatabaseMetaTest.java
index 5fd73c2800..1fedc1914c 100644
--- a/plugins/databases/hive/src/test/java/org/apache/hop/databases/hive/HiveDatabaseMetaTest.java
+++ b/plugins/databases/hive/src/test/java/org/apache/hop/databases/hive/HiveDatabaseMetaTest.java
@@ -251,6 +251,7 @@ class HiveDatabaseMetaTest {
   void testSqlStatements() {
     assertEquals(" LIMIT 15", nativeMeta.getLimitClause(15));
     assertEquals("SELECT * FROM FOO LIMIT 0", nativeMeta.getSqlQueryFields("FOO"));
+    assertEquals("SHOW CREATE TABLE `db`.`v`", nativeMeta.getSqlObjectDdl("db", "v"));
     assertEquals("SELECT * FROM FOO LIMIT 0", nativeMeta.getSqlTableExists("FOO"));
     assertEquals("SELECT FOO FROM BAR LIMIT 0", nativeMeta.getSqlQueryColumnFields("FOO", "BAR"));
 
diff --git a/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoaderDialog.java b/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoaderDialog.java
index 1153c12b6d..25cce1dbeb 100644
--- a/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoaderDialog.java
+++ b/plugins/databases/monetdb/src/main/java/org/apache/hop/pipeline/transforms/monetdbbulkloader/MonetDbBulkLoaderDialog.java
@@ -38,7 +38,6 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
@@ -50,6 +49,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.pipeline.transform.ITableItemInsertListener;
 import org.eclipse.swt.SWT;
@@ -1126,10 +1126,7 @@ public class MonetDbBulkLoaderDialog extends BaseTransformDialog {
       SqlStatement sql = info.getTableDdl(variables, pipelineMeta, name, false, null, false);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(
diff --git a/plugins/databases/mssqlnative/src/main/java/org/apache/hop/pipeline/transforms/mssqlbulkloader/MsSqlServerBulkLoaderDialog.java b/plugins/databases/mssqlnative/src/main/java/org/apache/hop/pipeline/transforms/mssqlbulkloader/MsSqlServerBulkLoaderDialog.java
index f9a6925158..dc5605a0e2 100644
--- a/plugins/databases/mssqlnative/src/main/java/org/apache/hop/pipeline/transforms/mssqlbulkloader/MsSqlServerBulkLoaderDialog.java
+++ b/plugins/databases/mssqlnative/src/main/java/org/apache/hop/pipeline/transforms/mssqlbulkloader/MsSqlServerBulkLoaderDialog.java
@@ -22,7 +22,6 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -42,7 +41,6 @@ import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
@@ -52,6 +50,7 @@ import org.apache.hop.ui.core.widget.MetaSelectionLine;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabFolder;
@@ -902,10 +901,7 @@ public class MsSqlServerBulkLoaderDialog extends BaseTransformDialog {
         mb.setText(BaseMessages.getString(CONST_SYSTEM_DIALOG_ERROR_TITLE));
         mb.open();
       } else if (sql.hasSql()) {
-        SqlEditor sqlEditor =
-            new SqlEditor(
-                shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-        sqlEditor.open();
+        DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
       } else {
         MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
         mb.setMessage(
diff --git a/plugins/databases/mysql/src/main/java/org/apache/hop/databases/mysql/MySqlDatabaseMeta.java b/plugins/databases/mysql/src/main/java/org/apache/hop/databases/mysql/MySqlDatabaseMeta.java
index fa7240b785..b2b7f165f1 100644
--- a/plugins/databases/mysql/src/main/java/org/apache/hop/databases/mysql/MySqlDatabaseMeta.java
+++ b/plugins/databases/mysql/src/main/java/org/apache/hop/databases/mysql/MySqlDatabaseMeta.java
@@ -146,6 +146,11 @@ public class MySqlDatabaseMeta extends BaseDatabaseMeta implements IDatabase {
     return "SELECT * FROM " + tableName + " LIMIT 0";
   }
 
+  @Override
+  public String getSqlObjectDdl(String schemaName, String objectName) {
+    return showCreateTableSql(schemaName, objectName);
+  }
+
   @Override
   public String getSqlTableExists(String tableName) {
     return getSqlQueryFields(tableName);
diff --git a/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoaderDialog.java b/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoaderDialog.java
index a969f0f3b5..2a7dfe1df3 100644
--- a/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoaderDialog.java
+++ b/plugins/databases/mysql/src/main/java/org/apache/hop/pipeline/transforms/mysqlbulkloader/MySqlBulkLoaderDialog.java
@@ -23,7 +23,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -40,7 +39,6 @@ import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -54,6 +52,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.pipeline.transform.ITableItemInsertListener;
 import org.eclipse.swt.SWT;
@@ -976,10 +975,7 @@ public class MySqlBulkLoaderDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(
diff --git a/plugins/databases/mysql/src/test/java/org/apache/hop/databases/mysql/MySqlDatabaseMetaTest.java b/plugins/databases/mysql/src/test/java/org/apache/hop/databases/mysql/MySqlDatabaseMetaTest.java
index 75740ec8d6..ea9ff050ab 100644
--- a/plugins/databases/mysql/src/test/java/org/apache/hop/databases/mysql/MySqlDatabaseMetaTest.java
+++ b/plugins/databases/mysql/src/test/java/org/apache/hop/databases/mysql/MySqlDatabaseMetaTest.java
@@ -337,6 +337,8 @@ class MySqlDatabaseMetaTest {
   void testSqlStatements() {
     assertEquals(" LIMIT 15", nativeMeta.getLimitClause(15));
     assertEquals("SELECT * FROM FOO LIMIT 0", nativeMeta.getSqlQueryFields("FOO"));
+    assertEquals(
+        "SHOW CREATE TABLE `sales`.`orders`", nativeMeta.getSqlObjectDdl("sales", "orders"));
     assertEquals("SELECT * FROM FOO LIMIT 0", nativeMeta.getSqlTableExists("FOO"));
     assertEquals("SELECT FOO FROM BAR LIMIT 0", nativeMeta.getSqlQueryColumnFields("FOO", "BAR"));
 
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 d4c4ae35be..d16810a9df 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
@@ -404,6 +404,19 @@ public class OracleDatabaseMeta extends BaseDatabaseMeta
     return "SELECT * FROM " + tableName + " WHERE 1=0";
   }
 
+  @Override
+  public String getSqlViewDefinition(String schemaName, String viewName) {
+    if (Utils.isEmpty(viewName)) {
+      return null;
+    }
+    StringBuilder sql = new StringBuilder();
+    sql.append("SELECT TEXT FROM ALL_VIEWS WHERE VIEW_NAME = ").append(quoteSqlString(viewName));
+    if (!Utils.isEmpty(schemaName)) {
+      sql.append(" AND OWNER = ").append(quoteSqlString(schemaName));
+    }
+    return sql.toString();
+  }
+
   @Override
   public String getSqlTableExists(String tableName) {
     return getSqlQueryFields(tableName);
diff --git a/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderDialog.java b/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderDialog.java
index 3fb8c51347..9aaeb15bcb 100644
--- a/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderDialog.java
+++ b/plugins/databases/oracle/src/main/java/org/apache/hop/pipeline/transforms/orabulkloader/OraBulkLoaderDialog.java
@@ -21,7 +21,6 @@ import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -42,7 +41,6 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -53,6 +51,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.pipeline.transform.ITableItemInsertListener;
 import org.eclipse.swt.SWT;
@@ -1314,10 +1313,7 @@ public class OraBulkLoaderDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(
diff --git a/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleDatabaseMetaTest.java b/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleDatabaseMetaTest.java
index e81ea595e8..026ab0aad3 100644
--- a/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleDatabaseMetaTest.java
+++ b/plugins/databases/oracle/src/test/java/org/apache/hop/databases/oracle/OracleDatabaseMetaTest.java
@@ -248,6 +248,9 @@ class OracleDatabaseMetaTest {
     assertEquals(" FETCH FIRST 5 ROWS ONLY", nativeMeta.getLimitClause(5));
     String reusedFieldsQuery = "SELECT * FROM FOO WHERE 1=0";
     assertEquals(reusedFieldsQuery, nativeMeta.getSqlQueryFields("FOO"));
+    assertEquals(
+        "SELECT TEXT FROM ALL_VIEWS WHERE VIEW_NAME = 'CUSTOMER_V' AND OWNER = 'SALES'",
+        nativeMeta.getSqlViewDefinition("SALES", "CUSTOMER_V"));
     assertEquals(reusedFieldsQuery, nativeMeta.getSqlTableExists("FOO"));
     String reusedColumnsQuery = "SELECT FOO FROM BAR WHERE 1=0";
     assertEquals(reusedColumnsQuery, nativeMeta.getSqlQueryColumnFields("FOO", "BAR"));
diff --git a/plugins/databases/postgresql/src/main/java/org/apache/hop/databases/postgresql/PostgreSqlDatabaseMeta.java b/plugins/databases/postgresql/src/main/java/org/apache/hop/databases/postgresql/PostgreSqlDatabaseMeta.java
index 75089eaa72..539dd41e24 100644
--- a/plugins/databases/postgresql/src/main/java/org/apache/hop/databases/postgresql/PostgreSqlDatabaseMeta.java
+++ b/plugins/databases/postgresql/src/main/java/org/apache/hop/databases/postgresql/PostgreSqlDatabaseMeta.java
@@ -284,6 +284,22 @@ public class PostgreSqlDatabaseMeta extends BaseDatabaseMeta implements IDatabas
     return "SELECT * FROM " + tableName + getLimitClause(1);
   }
 
+  @Override
+  public String getSqlViewDefinition(String schemaName, String viewName) {
+    if (Utils.isEmpty(viewName)) {
+      return null;
+    }
+    StringBuilder sql = new StringBuilder();
+    sql.append("SELECT pg_catalog.pg_get_viewdef(c.oid, true) ");
+    sql.append("FROM pg_catalog.pg_class c ");
+    sql.append("JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace ");
+    sql.append("WHERE c.relkind IN ('v', 'm') AND c.relname = ").append(quoteSqlString(viewName));
+    if (!Utils.isEmpty(schemaName)) {
+      sql.append(" AND n.nspname = ").append(quoteSqlString(schemaName));
+    }
+    return sql.toString();
+  }
+
   @Override
   public String getSqlTableExists(String tableName) {
     return getSqlQueryFields(tableName);
diff --git a/plugins/databases/postgresql/src/main/java/org/apache/hop/pipeline/transforms/pgbulkloader/PGBulkLoaderDialog.java b/plugins/databases/postgresql/src/main/java/org/apache/hop/pipeline/transforms/pgbulkloader/PGBulkLoaderDialog.java
index abf24ef136..2aed889b52 100644
--- a/plugins/databases/postgresql/src/main/java/org/apache/hop/pipeline/transforms/pgbulkloader/PGBulkLoaderDialog.java
+++ b/plugins/databases/postgresql/src/main/java/org/apache/hop/pipeline/transforms/pgbulkloader/PGBulkLoaderDialog.java
@@ -21,7 +21,6 @@ import java.util.ArrayList;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
 import org.apache.hop.core.database.Database;
@@ -38,7 +37,6 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -50,6 +48,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.pipeline.transform.ITableItemInsertListener;
 import org.eclipse.swt.SWT;
@@ -827,10 +826,7 @@ public class PGBulkLoaderDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(BaseMessages.getString(PKG, "PGBulkLoaderDialog.NoSQLNeeds.DialogMessage"));
diff --git a/plugins/databases/postgresql/src/test/java/org/apache/hop/databases/postgresql/PostgreSqlDatabaseMetaTest.java b/plugins/databases/postgresql/src/test/java/org/apache/hop/databases/postgresql/PostgreSqlDatabaseMetaTest.java
index 54337c8b0f..84c48b79d6 100644
--- a/plugins/databases/postgresql/src/test/java/org/apache/hop/databases/postgresql/PostgreSqlDatabaseMetaTest.java
+++ b/plugins/databases/postgresql/src/test/java/org/apache/hop/databases/postgresql/PostgreSqlDatabaseMetaTest.java
@@ -711,6 +711,9 @@ public class PostgreSqlDatabaseMetaTest {
   @Test
   void testSqlStatements() {
     assertEquals("SELECT * FROM FOO limit 1", nativeMeta.getSqlQueryFields("FOO"));
+    assertEquals(
+        "SELECT pg_catalog.pg_get_viewdef(c.oid, true) FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind IN ('v', 'm') AND c.relname = E'customer_address' AND n.nspname = E'public'",
+        nativeMeta.getSqlViewDefinition("public", "customer_address"));
     assertEquals("SELECT * FROM FOO limit 1", nativeMeta.getSqlTableExists("FOO"));
     assertEquals("SELECT FOO FROM BAR limit 1", nativeMeta.getSqlColumnExists("FOO", "BAR"));
     assertEquals("SELECT FOO FROM BAR limit 1", nativeMeta.getSqlQueryColumnFields("FOO", "BAR"));
diff --git a/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoaderDialog.java b/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoaderDialog.java
index fe062f8adf..91879c1a5a 100644
--- a/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoaderDialog.java
+++ b/plugins/databases/snowflake/src/main/java/org/apache/hop/pipeline/transforms/snowflake/bulkloader/SnowflakeBulkLoaderDialog.java
@@ -22,7 +22,6 @@ import java.util.ArrayList;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -42,7 +41,6 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -56,6 +54,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CCombo;
@@ -1723,10 +1722,7 @@ public class SnowflakeBulkLoaderDialog extends BaseTransformDialog {
             info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
         if (!sql.hasError()) {
           if (sql.hasSql()) {
-            SqlEditor sqledit =
-                new SqlEditor(
-                    shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-            sqledit.open();
+            DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
           } else {
             MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
             mb.setMessage(
diff --git a/plugins/databases/sqlite/src/main/java/org/apache/hop/databases/sqlite/SqliteDatabaseMeta.java b/plugins/databases/sqlite/src/main/java/org/apache/hop/databases/sqlite/SqliteDatabaseMeta.java
index 91046ace87..d2ece935a0 100644
--- a/plugins/databases/sqlite/src/main/java/org/apache/hop/databases/sqlite/SqliteDatabaseMeta.java
+++ b/plugins/databases/sqlite/src/main/java/org/apache/hop/databases/sqlite/SqliteDatabaseMeta.java
@@ -58,6 +58,21 @@ public class SqliteDatabaseMeta extends BaseDatabaseMeta implements IDatabase {
     return " LIMIT " + nrRows;
   }
 
+  @Override
+  public String getSqlObjectDdl(String schemaName, String objectName) {
+    if (Utils.isEmpty(objectName)) {
+      return null;
+    }
+    return "SELECT sql FROM sqlite_master WHERE name = "
+        + quoteSqlString(objectName)
+        + " AND type IN ('table', 'view')";
+  }
+
+  @Override
+  public String getSqlViewDefinition(String schemaName, String viewName) {
+    return getSqlObjectDdl(schemaName, viewName);
+  }
+
   /**
    * Reading and writing dates as text rather than through the driver's getTimestamp/setTimestamp.
    * See {@link SqliteDateValues} and issue #3910.
diff --git a/plugins/databases/sqlite/src/test/java/org/apache/hop/databases/sqlite/SqliteDatabaseMetaTest.java b/plugins/databases/sqlite/src/test/java/org/apache/hop/databases/sqlite/SqliteDatabaseMetaTest.java
index 243384757c..2087ae1bfd 100644
--- a/plugins/databases/sqlite/src/test/java/org/apache/hop/databases/sqlite/SqliteDatabaseMetaTest.java
+++ b/plugins/databases/sqlite/src/test/java/org/apache/hop/databases/sqlite/SqliteDatabaseMetaTest.java
@@ -94,6 +94,9 @@ class SqliteDatabaseMetaTest {
   @Test
   void testSqlStatements() {
     assertEquals("DELETE FROM FOO", nativeMeta.getTruncateTableStatement("FOO"));
+    assertEquals(
+        "SELECT sql FROM sqlite_master WHERE name = 'v' AND type IN ('table', 'view')",
+        nativeMeta.getSqlObjectDdl(null, "v"));
     assertEquals(
         "ALTER TABLE FOO ADD BAR TEXT",
         nativeMeta.getAddColumnStatement(
diff --git a/plugins/databases/vertica/src/main/java/org/apache/hop/pipeline/transforms/vertica/bulkloader/VerticaBulkLoaderDialog.java b/plugins/databases/vertica/src/main/java/org/apache/hop/pipeline/transforms/vertica/bulkloader/VerticaBulkLoaderDialog.java
index 88c45dc5c4..20044e95cb 100644
--- a/plugins/databases/vertica/src/main/java/org/apache/hop/pipeline/transforms/vertica/bulkloader/VerticaBulkLoaderDialog.java
+++ b/plugins/databases/vertica/src/main/java/org/apache/hop/pipeline/transforms/vertica/bulkloader/VerticaBulkLoaderDialog.java
@@ -24,7 +24,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -44,7 +43,6 @@ import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -55,6 +53,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabFolder;
@@ -1056,10 +1055,7 @@ public class VerticaBulkLoaderDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(BaseMessages.getString(PKG, "VerticaBulkLoaderDialog.NoSQL.DialogMessage"));
diff --git a/plugins/tech/aws/src/main/java/org/apache/hop/pipeline/transforms/redshift/bulkloader/RedshiftBulkLoaderDialog.java b/plugins/tech/aws/src/main/java/org/apache/hop/pipeline/transforms/redshift/bulkloader/RedshiftBulkLoaderDialog.java
index a72e79ad33..a268c82932 100644
--- a/plugins/tech/aws/src/main/java/org/apache/hop/pipeline/transforms/redshift/bulkloader/RedshiftBulkLoaderDialog.java
+++ b/plugins/tech/aws/src/main/java/org/apache/hop/pipeline/transforms/redshift/bulkloader/RedshiftBulkLoaderDialog.java
@@ -25,7 +25,6 @@ import java.util.Map;
 import java.util.Set;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -45,7 +44,6 @@ import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -57,6 +55,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabFolder;
@@ -1187,10 +1186,7 @@ public class RedshiftBulkLoaderDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(
diff --git a/plugins/tech/calcite/pom.xml b/plugins/tech/calcite/pom.xml
new file mode 100644
index 0000000000..d883bda0f5
--- /dev/null
+++ b/plugins/tech/calcite/pom.xml
@@ -0,0 +1,182 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.hop</groupId>
+        <artifactId>hop-plugins-tech</artifactId>
+        <version>2.20.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>hop-tech-calcite</artifactId>
+    <packaging>jar</packaging>
+    <name>Hop Plugins Technology Apache Calcite</name>
+    <description>SQL formatter toolbar plugin using Apache Calcite SqlPrettyWriter</description>
+
+    <properties>
+        <calcite.version>1.42.0</calcite.version>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.calcite</groupId>
+            <artifactId>calcite-core</artifactId>
+            <version>${calcite.version}</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>com.fasterxml.jackson.core</groupId>
+                    <artifactId>jackson-annotations</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.fasterxml.jackson.core</groupId>
+                    <artifactId>jackson-core</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.fasterxml.jackson.core</groupId>
+                    <artifactId>jackson-databind</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.fasterxml.jackson.dataformat</groupId>
+                    <artifactId>jackson-dataformat-yaml</artifactId>
+                </exclusion>
+                <!-- Already in lib/core, or only needed by the Calcite query engine -->
+                <exclusion>
+                    <groupId>com.google.errorprone</groupId>
+                    <artifactId>error_prone_annotations</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.google.guava</groupId>
+                    <artifactId>guava</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.google.protobuf</groupId>
+                    <artifactId>protobuf-java</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.google.uzaygezen</groupId>
+                    <artifactId>uzaygezen-core</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.jayway.jsonpath</groupId>
+                    <artifactId>json-path</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.yahoo.datasketches</groupId>
+                    <artifactId>sketches-core</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>commons-codec</groupId>
+                    <artifactId>commons-codec</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>commons-io</groupId>
+                    <artifactId>commons-io</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>net.hydromatic</groupId>
+                    <artifactId>aggdesigner-algorithm</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.calcite.avatica</groupId>
+                    <artifactId>avatica-metrics</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.commons</groupId>
+                    <artifactId>commons-dbcp2</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.commons</groupId>
+                    <artifactId>commons-lang3</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.commons</groupId>
+                    <artifactId>commons-math3</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.commons</groupId>
+                    <artifactId>commons-text</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.httpcomponents.client5</groupId>
+                    <artifactId>httpclient5</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.httpcomponents.core5</groupId>
+                    <artifactId>httpcore5</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apache.httpcomponents.core5</groupId>
+                    <artifactId>httpcore5-h2</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.apiguardian</groupId>
+                    <artifactId>apiguardian-api</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.checkerframework</groupId>
+                    <artifactId>checker-qual</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.codehaus.janino</groupId>
+                    <artifactId>commons-compiler</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.codehaus.janino</groupId>
+                    <artifactId>janino</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.jooq</groupId>
+                    <artifactId>joou-java-6</artifactId>
+                </exclusion>
+                <!-- Query-engine only; the formatter uses the parser and pretty-printer -->
+                <exclusion>
+                    <groupId>org.locationtech.jts</groupId>
+                    <artifactId>jts-core</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.locationtech.jts.io</groupId>
+                    <artifactId>jts-io-common</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.locationtech.proj4j</groupId>
+                    <artifactId>proj4j</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.calcite</groupId>
+            <artifactId>calcite-server</artifactId>
+            <version>${calcite.version}</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>com.google.guava</groupId>
+                    <artifactId>guava</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>slf4j-api</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/plugins/tech/calcite/src/assembly/assembly.xml b/plugins/tech/calcite/src/assembly/assembly.xml
new file mode 100644
index 0000000000..0cdcaea6c4
--- /dev/null
+++ b/plugins/tech/calcite/src/assembly/assembly.xml
@@ -0,0 +1,37 @@
+<!--
+  ~ 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.
+  -->
+
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0"
+          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+          xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0 http://maven.apache.org/xsd/assembly-2.2.0.xsd">
+    <id>hop-tech-calcite</id>
+    <formats>
+        <format>zip</format>
+    </formats>
+    <baseDirectory>.</baseDirectory>
+    <files>
+        <file>
+            <source>${project.basedir}/src/main/resources/version.xml</source>
+            <outputDirectory>${hop.plugin.libdir}</outputDirectory>
+            <filtered>true</filtered>
+        </file>
+    </files>
+
+    <componentDescriptors>
+        <componentDescriptor>${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml</componentDescriptor>
+    </componentDescriptors>
+</assembly>
diff --git a/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/CalciteSqlDialects.java b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/CalciteSqlDialects.java
new file mode 100644
index 0000000000..e610280173
--- /dev/null
+++ b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/CalciteSqlDialects.java
@@ -0,0 +1,90 @@
+/*
+ * 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.calcite;
+
+import java.util.Locale;
+import org.apache.calcite.config.Lex;
+import org.apache.calcite.sql.dialect.AnsiSqlDialect;
+import org.apache.calcite.sql.dialect.BigQuerySqlDialect;
+import org.apache.calcite.sql.dialect.ClickHouseSqlDialect;
+import org.apache.calcite.sql.dialect.Db2SqlDialect;
+import org.apache.calcite.sql.dialect.DerbySqlDialect;
+import org.apache.calcite.sql.dialect.ExasolSqlDialect;
+import org.apache.calcite.sql.dialect.FirebirdSqlDialect;
+import org.apache.calcite.sql.dialect.H2SqlDialect;
+import org.apache.calcite.sql.dialect.HiveSqlDialect;
+import org.apache.calcite.sql.dialect.HsqldbSqlDialect;
+import org.apache.calcite.sql.dialect.InformixSqlDialect;
+import org.apache.calcite.sql.dialect.MssqlSqlDialect;
+import org.apache.calcite.sql.dialect.MysqlSqlDialect;
+import org.apache.calcite.sql.dialect.OracleSqlDialect;
+import org.apache.calcite.sql.dialect.PostgresqlSqlDialect;
+import org.apache.calcite.sql.dialect.RedshiftSqlDialect;
+import org.apache.calcite.sql.dialect.SnowflakeSqlDialect;
+import org.apache.calcite.sql.dialect.SqliteSqlDialect;
+import org.apache.calcite.sql.dialect.SybaseSqlDialect;
+import org.apache.calcite.sql.dialect.TeradataSqlDialect;
+import org.apache.calcite.sql.dialect.VerticaSqlDialect;
+
+/**
+ * Maps a Hop relational database plugin id to a Calcite {@link CalciteSqlStyle} so identifiers are
+ * quoted the way that dialect expects.
+ */
+public final class CalciteSqlDialects {
+
+  /** ANSI SQL quotes identifiers with double quotes. Calcite {@link Lex#JAVA} uses backticks. */
+  public static final CalciteSqlStyle ANSI =
+      new CalciteSqlStyle(AnsiSqlDialect.DEFAULT, Lex.MYSQL_ANSI);
+
+  private CalciteSqlDialects() {}
+
+  /**
+   * @param databasePluginId Hop {@code DatabaseMeta} plugin id, or {@code null} for ANSI
+   * @return matching style, never {@code null}
+   */
+  public static CalciteSqlStyle of(String databasePluginId) {
+    if (databasePluginId == null || databasePluginId.isBlank()) {
+      return ANSI;
+    }
+    return switch (databasePluginId.toUpperCase(Locale.ROOT)) {
+      case "MYSQL", "MARIADB", "INFOBRIGHT", "INFINIDB", "SINGLESTORE", "DORIS" ->
+          new CalciteSqlStyle(MysqlSqlDialect.DEFAULT, Lex.MYSQL);
+      case "ORACLE", "ORACLERDB" -> new CalciteSqlStyle(OracleSqlDialect.DEFAULT, Lex.ORACLE);
+      case "MSSQL", "MSSQLNATIVE" -> new CalciteSqlStyle(MssqlSqlDialect.DEFAULT, Lex.SQL_SERVER);
+      case "POSTGRESQL", "GREENPLUM", "COCKROACHDB" ->
+          new CalciteSqlStyle(PostgresqlSqlDialect.DEFAULT, Lex.MYSQL_ANSI);
+      case "REDSHIFT" -> new CalciteSqlStyle(RedshiftSqlDialect.DEFAULT, Lex.MYSQL_ANSI);
+      case "GOOGLEBIGQUERY" -> new CalciteSqlStyle(BigQuerySqlDialect.DEFAULT, Lex.BIG_QUERY);
+      case "SNOWFLAKE" -> new CalciteSqlStyle(SnowflakeSqlDialect.DEFAULT, Lex.JAVA);
+      case "HIVE", "CLOUDERA-IMPALA" -> new CalciteSqlStyle(HiveSqlDialect.DEFAULT, Lex.JAVA);
+      case "H2" -> new CalciteSqlStyle(H2SqlDialect.DEFAULT, Lex.JAVA);
+      case "DERBY" -> new CalciteSqlStyle(DerbySqlDialect.DEFAULT, Lex.JAVA);
+      case "DB2" -> new CalciteSqlStyle(Db2SqlDialect.DEFAULT, Lex.JAVA);
+      case "SYBASE", "SYBASEIQ" -> new CalciteSqlStyle(SybaseSqlDialect.DEFAULT, Lex.JAVA);
+      case "VERTICA", "VERTICA5" -> new CalciteSqlStyle(VerticaSqlDialect.DEFAULT, Lex.JAVA);
+      case "EXASOL4" -> new CalciteSqlStyle(ExasolSqlDialect.DEFAULT, Lex.JAVA);
+      case "CLICKHOUSE" -> new CalciteSqlStyle(ClickHouseSqlDialect.DEFAULT, Lex.MYSQL);
+      case "FIREBIRD", "INTERBASE" -> new CalciteSqlStyle(FirebirdSqlDialect.DEFAULT, Lex.JAVA);
+      case "INFORMIX" -> new CalciteSqlStyle(InformixSqlDialect.DEFAULT, Lex.JAVA);
+      case "HYPERSONIC" -> new CalciteSqlStyle(HsqldbSqlDialect.DEFAULT, Lex.JAVA);
+      case "SQLITE" -> new CalciteSqlStyle(SqliteSqlDialect.DEFAULT, Lex.JAVA);
+      case "TERADATA" -> new CalciteSqlStyle(TeradataSqlDialect.DEFAULT, Lex.JAVA);
+      default -> ANSI;
+    };
+  }
+}
diff --git a/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/CalciteSqlFormatter.java b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/CalciteSqlFormatter.java
new file mode 100644
index 0000000000..9caa5c0686
--- /dev/null
+++ b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/CalciteSqlFormatter.java
@@ -0,0 +1,303 @@
+/*
+ * 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.calcite;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.calcite.avatica.util.Casing;
+import org.apache.calcite.avatica.util.Quoting;
+import org.apache.calcite.config.Lex;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlNodeList;
+import org.apache.calcite.sql.parser.SqlParseException;
+import org.apache.calcite.sql.parser.SqlParser;
+import org.apache.calcite.sql.parser.ddl.SqlDdlParserImpl;
+import org.apache.calcite.sql.pretty.SqlPrettyWriter;
+import org.apache.calcite.sql.validate.SqlConformanceEnum;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.calcite.config.CalciteSqlFormatConfig;
+
+/**
+ * Pretty-print SQL with Apache Calcite. Hop variable expressions ({@code ${NAME}}) are replaced
+ * with placeholders for parsing and restored afterwards.
+ *
+ * <p>When the whole script cannot be parsed, each {@code ;}-separated statement is tried on its own
+ * so a valid {@code SELECT} next to dialect-specific DDL still formats.
+ */
+public final class CalciteSqlFormatter {
+
+  private static final Pattern HOP_VARIABLE = Pattern.compile("\\$\\{[^}]+\\}");
+
+  private static final String PLACEHOLDER_PREFIX = "HOPVAR";
+
+  private CalciteSqlFormatter() {}
+
+  public static String format(String sql) throws SqlParseException {
+    return format(sql, null);
+  }
+
+  /**
+   * @param sql SQL to format
+   * @param databasePluginId Hop database plugin id, or {@code null} for ANSI
+   * @return pretty-printed SQL
+   * @throws SqlParseException if no statement in the script could be parsed
+   */
+  public static String format(String sql, String databasePluginId) throws SqlParseException {
+    return format(sql, databasePluginId, CalciteSqlFormatConfig.current());
+  }
+
+  public static String format(
+      String sql, String databasePluginId, CalciteSqlFormatConfig formatConfig)
+      throws SqlParseException {
+    if (StringUtils.isBlank(sql)) {
+      return sql;
+    }
+    CalciteSqlStyle style = CalciteSqlDialects.of(databasePluginId);
+    VariableMask mask = maskVariables(sql);
+    try {
+      return restoreTrailingNewline(
+          sql, restoreVariables(formatParsed(mask.sql, style, formatConfig), mask));
+    } catch (SqlParseException wholeScriptError) {
+      List<String> statements = splitStatements(mask.sql);
+      if (statements.size() <= 1) {
+        throw wholeScriptError;
+      }
+      String formatted = formatStatements(statements, style, formatConfig, wholeScriptError);
+      return restoreTrailingNewline(sql, restoreVariables(formatted, mask));
+    }
+  }
+
+  private static String formatParsed(
+      String sql, CalciteSqlStyle style, CalciteSqlFormatConfig formatConfig)
+      throws SqlParseException {
+    SqlNode node = parse(sql, style.lex());
+    return formatNode(node, style, formatConfig, sql);
+  }
+
+  /**
+   * Parse with the dialect lex, then retry other identifier-quoting styles. Calcite 1.42 {@link
+   * Lex#JAVA} uses backticks, while Hop (PostgreSQL, ANSI, and others) emits {@code
+   * "schema".table}.
+   */
+  private static SqlNode parse(String sql, Lex lex) throws SqlParseException {
+    SqlParseException last = null;
+    for (boolean ddl : new boolean[] {true, false}) {
+      for (Quoting quoting : quotingAttempts(lex)) {
+        try {
+          return SqlParser.create(sql, parserConfig(lex, ddl, quoting)).parseStmtList();
+        } catch (SqlParseException e) {
+          last = e;
+        }
+      }
+    }
+    throw Objects.requireNonNull(last);
+  }
+
+  private static List<Quoting> quotingAttempts(Lex lex) {
+    List<Quoting> attempts = new ArrayList<>();
+    attempts.add(lex.quoting);
+    for (Quoting quoting :
+        List.of(
+            Quoting.DOUBLE_QUOTE,
+            Quoting.BACK_TICK,
+            Quoting.BRACKET,
+            Quoting.BACK_TICK_BACKSLASH)) {
+      if (!attempts.contains(quoting)) {
+        attempts.add(quoting);
+      }
+    }
+    return attempts;
+  }
+
+  private static SqlParser.Config parserConfig(Lex lex, boolean ddl, Quoting quoting) {
+    SqlParser.Config config =
+        SqlParser.config()
+            .withLex(lex)
+            .withQuoting(quoting)
+            .withQuotedCasing(Casing.UNCHANGED)
+            .withUnquotedCasing(Casing.UNCHANGED)
+            .withConformance(SqlConformanceEnum.LENIENT);
+    if (ddl) {
+      config = config.withParserFactory(SqlDdlParserImpl.FACTORY);
+    }
+    return config;
+  }
+
+  private static String formatNode(
+      SqlNode node,
+      CalciteSqlStyle style,
+      CalciteSqlFormatConfig formatConfig,
+      String originalSql) {
+    if (node instanceof SqlNodeList list && list.size() > 1) {
+      StringBuilder buffer = new StringBuilder();
+      for (int i = 0; i < list.size(); i++) {
+        if (i > 0) {
+          buffer.append(";\n\n");
+        }
+        buffer.append(pretty(list.get(i), style, formatConfig).trim());
+      }
+      if (originalSql.trim().endsWith(";")) {
+        buffer.append(';');
+      }
+      return buffer.toString();
+    }
+    SqlNode toFormat = node instanceof SqlNodeList list && list.size() == 1 ? list.get(0) : node;
+    String formatted = pretty(toFormat, style, formatConfig).trim();
+    if (originalSql.trim().endsWith(";")) {
+      formatted = formatted + ';';
+    }
+    return formatted;
+  }
+
+  private static String pretty(
+      SqlNode node, CalciteSqlStyle style, CalciteSqlFormatConfig formatConfig) {
+    SqlPrettyWriter writer =
+        new SqlPrettyWriter(SqlPrettyWriter.config().withDialect(style.dialect()));
+    writer.setFormatOptions(formatConfig.toSqlFormatOptions());
+    return writer.format(node);
+  }
+
+  private static String formatStatements(
+      List<String> statements,
+      CalciteSqlStyle style,
+      CalciteSqlFormatConfig formatConfig,
+      SqlParseException wholeScriptError)
+      throws SqlParseException {
+    List<String> formatted = new ArrayList<>(statements.size());
+    boolean any = false;
+    for (String statement : statements) {
+      try {
+        formatted.add(formatParsed(statement, style, formatConfig).trim());
+        any = true;
+      } catch (SqlParseException ignored) {
+        formatted.add(statement.trim());
+      }
+    }
+    if (!any) {
+      throw wholeScriptError;
+    }
+    return String.join(";\n\n", formatted);
+  }
+
+  /**
+   * Split on top-level semicolons, keeping quotes and comments intact. Empty fragments are dropped.
+   */
+  static List<String> splitStatements(String sql) {
+    List<String> statements = new ArrayList<>();
+    StringBuilder current = new StringBuilder();
+    boolean inSingle = false;
+    boolean inDouble = false;
+    boolean inBacktick = false;
+    boolean inLineComment = false;
+    boolean inBlockComment = false;
+    for (int i = 0; i < sql.length(); i++) {
+      char c = sql.charAt(i);
+      char next = i + 1 < sql.length() ? sql.charAt(i + 1) : 0;
+      if (inLineComment) {
+        current.append(c);
+        if (c == '\n') {
+          inLineComment = false;
+        }
+        continue;
+      }
+      if (inBlockComment) {
+        current.append(c);
+        if (c == '*' && next == '/') {
+          current.append(next);
+          i++;
+          inBlockComment = false;
+        }
+        continue;
+      }
+      if (!inSingle && !inDouble && !inBacktick) {
+        if (c == '-' && next == '-') {
+          current.append(c);
+          inLineComment = true;
+          continue;
+        }
+        if (c == '/' && next == '*') {
+          current.append(c);
+          inBlockComment = true;
+          continue;
+        }
+        if (c == ';') {
+          String statement = current.toString().trim();
+          if (!statement.isEmpty()) {
+            statements.add(statement);
+          }
+          current.setLength(0);
+          continue;
+        }
+      }
+      if (c == '\'' && !inDouble && !inBacktick) {
+        inSingle = !inSingle;
+      } else if (c == '"' && !inSingle && !inBacktick) {
+        inDouble = !inDouble;
+      } else if (c == '`' && !inSingle && !inDouble) {
+        inBacktick = !inBacktick;
+      }
+      current.append(c);
+    }
+    String tail = current.toString().trim();
+    if (!tail.isEmpty()) {
+      statements.add(tail);
+    }
+    return statements;
+  }
+
+  private static VariableMask maskVariables(String sql) {
+    List<String> originals = new ArrayList<>();
+    Matcher matcher = HOP_VARIABLE.matcher(sql);
+    StringBuilder out = new StringBuilder();
+    while (matcher.find()) {
+      originals.add(matcher.group());
+      matcher.appendReplacement(
+          out, Matcher.quoteReplacement(PLACEHOLDER_PREFIX + (originals.size() - 1)));
+    }
+    matcher.appendTail(out);
+    return new VariableMask(out.toString(), originals);
+  }
+
+  private static String restoreVariables(String sql, VariableMask mask) {
+    String result = sql;
+    for (int i = mask.originals.size() - 1; i >= 0; i--) {
+      String original = mask.originals.get(i);
+      String token = PLACEHOLDER_PREFIX + i;
+      result = result.replace('"' + token + '"', original);
+      result = result.replace('`' + token + '`', original);
+      result = result.replace('[' + token + ']', original);
+      result = result.replace(token, original);
+    }
+    return result;
+  }
+
+  private static String restoreTrailingNewline(String original, String formatted) {
+    if (original.endsWith("\n") && !formatted.endsWith("\n")) {
+      return formatted + "\n";
+    }
+    if (!original.endsWith("\n") && formatted.endsWith("\n")) {
+      return formatted.substring(0, formatted.length() - 1);
+    }
+    return formatted;
+  }
+
+  private record VariableMask(String sql, List<String> originals) {}
+}
diff --git a/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/CalciteSqlStyle.java b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/CalciteSqlStyle.java
new file mode 100644
index 0000000000..f2759f08c7
--- /dev/null
+++ b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/CalciteSqlStyle.java
@@ -0,0 +1,27 @@
+/*
+ * 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.calcite;
+
+import org.apache.calcite.config.Lex;
+import org.apache.calcite.sql.SqlDialect;
+
+/**
+ * Parser quoting rules and unparse dialect used by {@link CalciteSqlFormatter} for one Hop database
+ * type.
+ */
+public record CalciteSqlStyle(SqlDialect dialect, Lex lex) {}
diff --git a/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/SqlFormatToolbarButton.java b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/SqlFormatToolbarButton.java
new file mode 100644
index 0000000000..b5006ccd24
--- /dev/null
+++ b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/SqlFormatToolbarButton.java
@@ -0,0 +1,173 @@
+/*
+ * 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.calcite;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementFilter;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.widget.IFindReplaceTarget;
+import org.apache.hop.ui.core.widget.TextComposite;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseSqlEditorTab;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
+
+/**
+ * Pretty-print SQL for:
+ *
+ * <ul>
+ *   <li>Database perspective / SQL editor tabs ({@link IContentEditorWidget} language {@code sql})
+ *   <li>{@link TextComposite} toolbars when {@link TextComposite#getStyleType()} is {@link
+ *       TextComposite#STYLE_TYPE_SQL}
+ * </ul>
+ */
+@GuiPlugin(
+    id = "GUI-CalciteSqlFormatter",
+    name = "Apache Calcite SQL formatter",
+    description = "Pretty-print SQL in editors using Apache Calcite")
+public class SqlFormatToolbarButton {
+  private static final Class<?> PKG = SqlFormatToolbarButton.class;
+
+  public static final String ID_CONTENT_EDITOR_FORMAT_SQL =
+      "ContentEditor-Toolbar-35000-format-sql";
+  public static final String ID_TEXTCOMPOSITE_FORMAT_SQL = "textcomposite-toolbar-20020-format-sql";
+
+  private static final String LANGUAGE_SQL = "sql";
+
+  private SqlFormatToolbarButton() {}
+
+  /**
+   * Only show this toolbar button when the content editor is SQL. Other toolbar item IDs always
+   * return {@code true} so built-in buttons are not hidden.
+   */
+  @GuiToolbarElementFilter(parentId = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID)
+  public static boolean showForSqlEditor(String itemId, Object guiPluginInstance) {
+    if (!ID_CONTENT_EDITOR_FORMAT_SQL.equals(itemId)) {
+      return true;
+    }
+    if (!(guiPluginInstance instanceof IContentEditorWidget editor)) {
+      return false;
+    }
+    return LANGUAGE_SQL.equalsIgnoreCase(editor.getLanguage());
+  }
+
+  /**
+   * Only show this toolbar button when the text composite is SQL. Other toolbar item IDs always
+   * return {@code true} so built-in buttons are not hidden.
+   *
+   * <p>Do not call {@link TextComposite#isEditable()} here. Filters run while the constructor is
+   * still building the toolbar.
+   */
+  @GuiToolbarElementFilter(parentId = TextComposite.ID_TOOLBAR)
+  public static boolean showForSqlTextComposite(String itemId, Object guiPluginInstance) {
+    if (!ID_TEXTCOMPOSITE_FORMAT_SQL.equals(itemId)) {
+      return true;
+    }
+    if (!(guiPluginInstance instanceof TextComposite textComposite)) {
+      return false;
+    }
+    return TextComposite.STYLE_TYPE_SQL.equalsIgnoreCase(textComposite.getStyleType());
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = ID_CONTENT_EDITOR_FORMAT_SQL,
+      toolTip = "i18n::SqlFormatToolbarButton.FormatSql.ToolTip",
+      separator = true,
+      image = "format-sql.svg")
+  public static void formatSql(IContentEditorWidget editor) {
+    if (editor == null) {
+      return;
+    }
+    Control control = editor.getControl();
+    if (control == null || control.isDisposed() || !editor.isEditable()) {
+      return;
+    }
+    String selection = editor.getSelectionText();
+    boolean selected = StringUtils.isNotEmpty(selection);
+    String sql = selected ? selection : editor.getText();
+    if (StringUtils.isBlank(sql)) {
+      return;
+    }
+    try {
+      applyFormatted(
+          editor, sql, CalciteSqlFormatter.format(sql, databasePluginId(editor)), selected);
+    } catch (Exception e) {
+      showFormatError(control.getShell(), e);
+    }
+  }
+
+  @GuiToolbarElement(
+      root = TextComposite.ID_TOOLBAR,
+      id = ID_TEXTCOMPOSITE_FORMAT_SQL,
+      toolTip = "i18n::SqlFormatToolbarButton.FormatSql.ToolTip",
+      separator = true,
+      image = "format-sql.svg")
+  public static void formatSql(TextComposite textComposite) {
+    if (textComposite == null || textComposite.isDisposed() || !textComposite.isEditable()) {
+      return;
+    }
+    String selection = textComposite.getSelectionText();
+    boolean selected = StringUtils.isNotEmpty(selection);
+    String sql = selected ? selection : textComposite.getText();
+    if (StringUtils.isBlank(sql)) {
+      return;
+    }
+    try {
+      applyFormatted(textComposite, sql, CalciteSqlFormatter.format(sql), selected);
+    } catch (Exception e) {
+      showFormatError(textComposite.getShell(), e);
+    }
+  }
+
+  /**
+   * Replace the formatted range through {@link IFindReplaceTarget#insert(String)} so the editor can
+   * undo the change. {@link IFindReplaceTarget#setText(String)} is used to load buffers and does
+   * not record undo.
+   */
+  static void applyFormatted(
+      IFindReplaceTarget target, String original, String formatted, boolean selected) {
+    if (target == null || formatted == null || formatted.equals(original)) {
+      return;
+    }
+    if (!selected) {
+      target.setSelection(0, original == null ? 0 : original.length());
+    }
+    target.insert(formatted);
+    target.updateToolbar();
+  }
+
+  private static String databasePluginId(IContentEditorWidget editor) {
+    DatabaseSqlEditorTab tab = DatabaseSqlEditorTab.fromEditor(editor);
+    if (tab == null || tab.getDatabaseMeta() == null) {
+      return null;
+    }
+    return tab.getDatabaseMeta().getPluginId();
+  }
+
+  private static void showFormatError(Shell shell, Exception e) {
+    new ErrorDialog(
+        shell,
+        BaseMessages.getString(PKG, "SqlFormatToolbarButton.Error.Title"),
+        BaseMessages.getString(PKG, "SqlFormatToolbarButton.Error.Message"),
+        e);
+  }
+}
diff --git a/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/config/CalciteSqlFormatConfig.java b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/config/CalciteSqlFormatConfig.java
new file mode 100644
index 0000000000..8bf2a79fa4
--- /dev/null
+++ b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/config/CalciteSqlFormatConfig.java
@@ -0,0 +1,142 @@
+/*
+ * 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.calcite.config;
+
+import java.util.function.Consumer;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.calcite.sql.pretty.SqlFormatOptions;
+import org.apache.hop.core.Const;
+
+/**
+ * Persisted Apache Calcite {@link SqlFormatOptions} used by the SQL formatter toolbar button.
+ *
+ * <p>Defaults match the formatter's original layout (readable SELECT lists, unquoted identifiers)
+ * rather than Calcite's {@link SqlFormatOptions} no-arg constructor.
+ */
+@Getter
+@Setter
+public class CalciteSqlFormatConfig {
+
+  public static final String HOP_CONFIG_KEY = "calciteSqlFormat";
+
+  private boolean alwaysUseParentheses;
+  private boolean caseClausesOnNewLines = true;
+  private boolean clauseStartsLine = true;
+  private boolean keywordsLowercase;
+  private boolean quoteAllIdentifiers;
+  private boolean selectListItemsOnSeparateLines = true;
+  private boolean whereListItemsOnSeparateLines = true;
+  private boolean windowDeclarationStartsLine = true;
+  private boolean windowListItemsOnSeparateLines = true;
+  private int indentation = 2;
+  private int lineLength;
+
+  public CalciteSqlFormatConfig() {}
+
+  public CalciteSqlFormatConfig(CalciteSqlFormatConfig other) {
+    this.alwaysUseParentheses = other.alwaysUseParentheses;
+    this.caseClausesOnNewLines = other.caseClausesOnNewLines;
+    this.clauseStartsLine = other.clauseStartsLine;
+    this.keywordsLowercase = other.keywordsLowercase;
+    this.quoteAllIdentifiers = other.quoteAllIdentifiers;
+    this.selectListItemsOnSeparateLines = other.selectListItemsOnSeparateLines;
+    this.whereListItemsOnSeparateLines = other.whereListItemsOnSeparateLines;
+    this.windowDeclarationStartsLine = other.windowDeclarationStartsLine;
+    this.windowListItemsOnSeparateLines = other.windowListItemsOnSeparateLines;
+    this.indentation = other.indentation;
+    this.lineLength = other.lineLength;
+  }
+
+  /**
+   * Apply values from the configuration GUI / CLI plugin. {@code null} fields are left unchanged so
+   * {@code hop conf} can set a subset of options.
+   *
+   * @param plugin source values
+   * @return true if any field changed
+   */
+  public boolean applyFrom(CalciteSqlFormatConfigPlugin plugin) {
+    boolean changed = false;
+    changed |= applyBoolean(plugin.getAlwaysUseParentheses(), v -> alwaysUseParentheses = v);
+    changed |= applyBoolean(plugin.getCaseClausesOnNewLines(), v -> caseClausesOnNewLines = v);
+    changed |= applyBoolean(plugin.getClauseStartsLine(), v -> clauseStartsLine = v);
+    changed |= applyBoolean(plugin.getKeywordsLowercase(), v -> keywordsLowercase = v);
+    changed |= applyBoolean(plugin.getQuoteAllIdentifiers(), v -> quoteAllIdentifiers = v);
+    changed |=
+        applyBoolean(
+            plugin.getSelectListItemsOnSeparateLines(), v -> selectListItemsOnSeparateLines = v);
+    changed |=
+        applyBoolean(
+            plugin.getWhereListItemsOnSeparateLines(), v -> whereListItemsOnSeparateLines = v);
+    changed |=
+        applyBoolean(plugin.getWindowDeclarationStartsLine(), v -> windowDeclarationStartsLine = v);
+    changed |=
+        applyBoolean(
+            plugin.getWindowListItemsOnSeparateLines(), v -> windowListItemsOnSeparateLines = v);
+    if (plugin.getIndentation() != null) {
+      int value = Math.max(0, Const.toInt(plugin.getIndentation(), indentation));
+      if (value != indentation) {
+        indentation = value;
+        changed = true;
+      }
+    }
+    if (plugin.getLineLength() != null) {
+      int value = Math.max(0, Const.toInt(plugin.getLineLength(), lineLength));
+      if (value != lineLength) {
+        lineLength = value;
+        changed = true;
+      }
+    }
+    return changed;
+  }
+
+  public SqlFormatOptions toSqlFormatOptions() {
+    return new SqlFormatOptions(
+        alwaysUseParentheses,
+        caseClausesOnNewLines,
+        clauseStartsLine,
+        keywordsLowercase,
+        quoteAllIdentifiers,
+        selectListItemsOnSeparateLines,
+        whereListItemsOnSeparateLines,
+        windowDeclarationStartsLine,
+        windowListItemsOnSeparateLines,
+        indentation,
+        lineLength);
+  }
+
+  /**
+   * Config currently in effect for formatting. Falls back to defaults when Hop config is not
+   * available (unit tests).
+   */
+  public static CalciteSqlFormatConfig current() {
+    try {
+      return CalciteSqlFormatConfigSingleton.getConfig();
+    } catch (Throwable t) {
+      return new CalciteSqlFormatConfig();
+    }
+  }
+
+  private static boolean applyBoolean(Boolean incoming, Consumer<Boolean> setter) {
+    if (incoming == null) {
+      return false;
+    }
+    setter.accept(incoming);
+    return true;
+  }
+}
diff --git a/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigPlugin.java b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigPlugin.java
new file mode 100644
index 0000000000..e18450bcee
--- /dev/null
+++ b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigPlugin.java
@@ -0,0 +1,312 @@
+/*
+ * 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.calcite.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.config.plugin.ConfigPlugin;
+import org.apache.hop.core.config.plugin.IConfigOptions;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHasHopMetadataProvider;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.perspective.configuration.tabs.ConfigPluginOptionsTab;
+import org.eclipse.swt.widgets.Control;
+import picocli.CommandLine;
+
+/**
+ * Configuration-perspective Plugins entry for Apache Calcite {@code SqlFormatOptions}. Values are
+ * stored in hop-config.json and applied when Format SQL is clicked.
+ */
+@Getter
+@Setter
+@ConfigPlugin(
+    id = "CalciteSqlFormatConfigPlugin",
+    description = "Apache Calcite SQL formatter options",
+    category = ConfigPlugin.CATEGORY_CONFIG)
+@GuiPlugin(description = "i18n::CalciteSqlFormatConfigPlugin.Name")
+public class CalciteSqlFormatConfigPlugin
+    implements IConfigOptions, IGuiPluginCompositeWidgetsListener {
+
+  private static final Class<?> PKG = CalciteSqlFormatConfigPlugin.class;
+
+  private static final String PARENT = ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID;
+  private static final String GROUP_LAYOUT = "i18n::CalciteSqlFormatConfigPlugin.Group.Layout";
+  private static final String GROUP_KEYWORDS = "i18n::CalciteSqlFormatConfigPlugin.Group.Keywords";
+
+  @GuiWidgetElement(
+      id = "0100-sql-format-indentation",
+      order = "0100",
+      parentId = PARENT,
+      type = GuiElementType.TEXT,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.Indentation.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.Indentation.Tooltip",
+      group = GROUP_LAYOUT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-indentation"},
+      description = "Number of spaces to indent SQL")
+  private String indentation;
+
+  @GuiWidgetElement(
+      id = "0110-sql-format-line-length",
+      order = "0110",
+      parentId = PARENT,
+      type = GuiElementType.TEXT,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.LineLength.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.LineLength.Tooltip",
+      group = GROUP_LAYOUT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-line-length"},
+      description = "Maximum line length (0 means no maximum)")
+  private String lineLength;
+
+  @GuiWidgetElement(
+      id = "0120-sql-format-clause-starts-line",
+      order = "0120",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.ClauseStartsLine.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.ClauseStartsLine.Tooltip",
+      group = GROUP_LAYOUT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-clause-starts-line"},
+      description = "Start FROM, WHERE, GROUP BY and similar clauses on a new line",
+      negatable = true)
+  private Boolean clauseStartsLine;
+
+  @GuiWidgetElement(
+      id = "0130-sql-format-always-use-parentheses",
+      order = "0130",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.AlwaysUseParentheses.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.AlwaysUseParentheses.Tooltip",
+      group = GROUP_LAYOUT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-always-use-parentheses"},
+      description = "Always wrap expressions in parentheses",
+      negatable = true)
+  private Boolean alwaysUseParentheses;
+
+  @GuiWidgetElement(
+      id = "0140-sql-format-select-list-items-on-separate-lines",
+      order = "0140",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.SelectListItemsOnSeparateLines.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.SelectListItemsOnSeparateLines.Tooltip",
+      group = GROUP_LAYOUT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-select-list-items-on-separate-lines"},
+      description = "Put each SELECT list item on its own line",
+      negatable = true)
+  private Boolean selectListItemsOnSeparateLines;
+
+  @GuiWidgetElement(
+      id = "0150-sql-format-where-list-items-on-separate-lines",
+      order = "0150",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.WhereListItemsOnSeparateLines.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.WhereListItemsOnSeparateLines.Tooltip",
+      group = GROUP_LAYOUT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-where-list-items-on-separate-lines"},
+      description = "Put each WHERE condition on its own line",
+      negatable = true)
+  private Boolean whereListItemsOnSeparateLines;
+
+  @GuiWidgetElement(
+      id = "0160-sql-format-case-clauses-on-new-lines",
+      order = "0160",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.CaseClausesOnNewLines.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.CaseClausesOnNewLines.Tooltip",
+      group = GROUP_LAYOUT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-case-clauses-on-new-lines"},
+      description = "Put WHEN, THEN and ELSE of a CASE on new lines",
+      negatable = true)
+  private Boolean caseClausesOnNewLines;
+
+  @GuiWidgetElement(
+      id = "0170-sql-format-window-declaration-starts-line",
+      order = "0170",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.WindowDeclarationStartsLine.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.WindowDeclarationStartsLine.Tooltip",
+      group = GROUP_LAYOUT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-window-declaration-starts-line"},
+      description = "Start a WINDOW declaration on a new line",
+      negatable = true)
+  private Boolean windowDeclarationStartsLine;
+
+  @GuiWidgetElement(
+      id = "0180-sql-format-window-list-items-on-separate-lines",
+      order = "0180",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.WindowListItemsOnSeparateLines.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.WindowListItemsOnSeparateLines.Tooltip",
+      group = GROUP_LAYOUT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-window-list-items-on-separate-lines"},
+      description = "Put each WINDOW list item on its own line",
+      negatable = true)
+  private Boolean windowListItemsOnSeparateLines;
+
+  @GuiWidgetElement(
+      id = "0200-sql-format-keywords-lowercase",
+      order = "0200",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.KeywordsLowercase.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.KeywordsLowercase.Tooltip",
+      group = GROUP_KEYWORDS,
+      groupOrder = "20",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-keywords-lowercase"},
+      description = "Print SQL keywords in lower case",
+      negatable = true)
+  private Boolean keywordsLowercase;
+
+  @GuiWidgetElement(
+      id = "0210-sql-format-quote-all-identifiers",
+      order = "0210",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::CalciteSqlFormatConfigPlugin.QuoteAllIdentifiers.Label",
+      toolTip = "i18n::CalciteSqlFormatConfigPlugin.QuoteAllIdentifiers.Tooltip",
+      group = GROUP_KEYWORDS,
+      groupOrder = "20",
+      groupType = GuiWidgetGroupType.TABS)
+  @CommandLine.Option(
+      names = {"--sql-format-quote-all-identifiers"},
+      description = "Quote every identifier",
+      negatable = true)
+  private Boolean quoteAllIdentifiers;
+
+  public static CalciteSqlFormatConfigPlugin getInstance() {
+    return new CalciteSqlFormatConfigPlugin(CalciteSqlFormatConfigSingleton.getConfig());
+  }
+
+  public CalciteSqlFormatConfigPlugin() {}
+
+  public CalciteSqlFormatConfigPlugin(CalciteSqlFormatConfig config) {
+    this.indentation = Integer.toString(config.getIndentation());
+    this.lineLength = Integer.toString(config.getLineLength());
+    this.clauseStartsLine = config.isClauseStartsLine();
+    this.alwaysUseParentheses = config.isAlwaysUseParentheses();
+    this.selectListItemsOnSeparateLines = config.isSelectListItemsOnSeparateLines();
+    this.whereListItemsOnSeparateLines = config.isWhereListItemsOnSeparateLines();
+    this.caseClausesOnNewLines = config.isCaseClausesOnNewLines();
+    this.windowDeclarationStartsLine = config.isWindowDeclarationStartsLine();
+    this.windowListItemsOnSeparateLines = config.isWindowListItemsOnSeparateLines();
+    this.keywordsLowercase = config.isKeywordsLowercase();
+    this.quoteAllIdentifiers = config.isQuoteAllIdentifiers();
+  }
+
+  @Override
+  public boolean handleOption(
+      ILogChannel log, IHasHopMetadataProvider metadataProvider, IVariables variables)
+      throws HopException {
+    try {
+      boolean changed = CalciteSqlFormatConfigSingleton.getConfig().applyFrom(this);
+      if (changed) {
+        CalciteSqlFormatConfigSingleton.saveConfig();
+        log.logBasic("Apache Calcite SQL formatter options updated");
+      }
+      return changed;
+    } catch (Exception e) {
+      throw new HopException("Error handling Apache Calcite SQL formatter options", e);
+    }
+  }
+
+  @Override
+  public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
+    // Do nothing
+  }
+
+  @Override
+  public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
+    // Do nothing
+  }
+
+  @Override
+  public void widgetModified(
+      GuiCompositeWidgets compositeWidgets, Control changedWidget, String widgetId) {
+    persistContents(compositeWidgets);
+  }
+
+  @Override
+  public void persistContents(GuiCompositeWidgets compositeWidgets) {
+    compositeWidgets.getWidgetsContents(this, PARENT);
+    CalciteSqlFormatConfigSingleton.getConfig().applyFrom(this);
+    try {
+      CalciteSqlFormatConfigSingleton.saveConfig();
+    } catch (Exception e) {
+      new ErrorDialog(
+          HopGui.getInstance().getShell(),
+          BaseMessages.getString(PKG, "CalciteSqlFormatConfigPlugin.Save.Error.Title"),
+          BaseMessages.getString(PKG, "CalciteSqlFormatConfigPlugin.Save.Error.Message"),
+          e);
+    }
+  }
+}
diff --git a/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigSingleton.java b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigSingleton.java
new file mode 100644
index 0000000000..30ca970aa4
--- /dev/null
+++ b/plugins/tech/calcite/src/main/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigSingleton.java
@@ -0,0 +1,66 @@
+/*
+ * 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.calcite.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.gson.Gson;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.json.HopJson;
+import org.apache.hop.core.logging.LogChannel;
+
+public class CalciteSqlFormatConfigSingleton {
+
+  private static CalciteSqlFormatConfigSingleton configSingleton;
+
+  private CalciteSqlFormatConfig config;
+
+  private CalciteSqlFormatConfigSingleton() {
+    Object configObject =
+        HopConfig.getInstance().getConfigMap().get(CalciteSqlFormatConfig.HOP_CONFIG_KEY);
+    if (configObject == null) {
+      config = new CalciteSqlFormatConfig();
+    } else {
+      try {
+        ObjectMapper mapper = HopJson.newMapper();
+        config = mapper.readValue(new Gson().toJson(configObject), CalciteSqlFormatConfig.class);
+      } catch (Exception e) {
+        LogChannel.GENERAL.logError(
+            "Error reading Apache Calcite SQL formatter configuration, check property '"
+                + CalciteSqlFormatConfig.HOP_CONFIG_KEY
+                + "' in the Hop config json file",
+            e);
+        config = new CalciteSqlFormatConfig();
+      }
+    }
+    HopConfig.getInstance().getConfigMap().put(CalciteSqlFormatConfig.HOP_CONFIG_KEY, config);
+  }
+
+  public static CalciteSqlFormatConfig getConfig() {
+    if (configSingleton == null) {
+      configSingleton = new CalciteSqlFormatConfigSingleton();
+    }
+    return configSingleton.config;
+  }
+
+  public static void saveConfig() throws HopException {
+    HopConfig.getInstance()
+        .saveOption(CalciteSqlFormatConfig.HOP_CONFIG_KEY, configSingleton.config);
+    HopConfig.getInstance().saveToFile();
+  }
+}
diff --git a/plugins/tech/calcite/src/main/resources/format-sql.svg b/plugins/tech/calcite/src/main/resources/format-sql.svg
new file mode 100644
index 0000000000..ff1767cfe3
--- /dev/null
+++ b/plugins/tech/calcite/src/main/resources/format-sql.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
+    <rect x="4" y="4" width="24" height="24" rx="2" fill="#EFF9FE" stroke="#0e3a5a" stroke-width="2"/>
+    <rect x="8" y="9" width="16" height="2.5" rx="1" fill="#0e3a5a"/>
+    <rect x="11" y="15" width="13" height="2.5" rx="1" fill="#BF03B8"/>
+    <rect x="11" y="21" width="10" height="2.5" rx="1" fill="#0e3a5a"/>
+</svg>
diff --git a/plugins/tech/calcite/src/main/resources/org/apache/hop/calcite/config/messages/messages_en_US.properties b/plugins/tech/calcite/src/main/resources/org/apache/hop/calcite/config/messages/messages_en_US.properties
new file mode 100644
index 0000000000..b633c58da9
--- /dev/null
+++ b/plugins/tech/calcite/src/main/resources/org/apache/hop/calcite/config/messages/messages_en_US.properties
@@ -0,0 +1,43 @@
+#
+# 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.
+#
+CalciteSqlFormatConfigPlugin.Name=Apache Calcite SQL formatter
+CalciteSqlFormatConfigPlugin.Group.Layout=Layout
+CalciteSqlFormatConfigPlugin.Group.Keywords=Keywords and identifiers
+CalciteSqlFormatConfigPlugin.Indentation.Label=Indentation (spaces)
+CalciteSqlFormatConfigPlugin.Indentation.Tooltip=Number of spaces to indent nested SQL. Calcite default is 4; Hop default is 2.
+CalciteSqlFormatConfigPlugin.LineLength.Label=Line length
+CalciteSqlFormatConfigPlugin.LineLength.Tooltip=Preferred maximum line length. 0 means no maximum.
+CalciteSqlFormatConfigPlugin.ClauseStartsLine.Label=Clause starts a new line
+CalciteSqlFormatConfigPlugin.ClauseStartsLine.Tooltip=Start FROM, WHERE, GROUP BY, HAVING, WINDOW and ORDER BY on a new line.
+CalciteSqlFormatConfigPlugin.AlwaysUseParentheses.Label=Always use parentheses
+CalciteSqlFormatConfigPlugin.AlwaysUseParentheses.Tooltip=Wrap every expression in parentheses even when operator precedence does not require it.
+CalciteSqlFormatConfigPlugin.SelectListItemsOnSeparateLines.Label=SELECT list items on separate lines
+CalciteSqlFormatConfigPlugin.SelectListItemsOnSeparateLines.Tooltip=Put each item in the SELECT list on its own line.
+CalciteSqlFormatConfigPlugin.WhereListItemsOnSeparateLines.Label=WHERE items on separate lines
+CalciteSqlFormatConfigPlugin.WhereListItemsOnSeparateLines.Tooltip=Put each item in the WHERE list on its own line.
+CalciteSqlFormatConfigPlugin.CaseClausesOnNewLines.Label=CASE clauses on new lines
+CalciteSqlFormatConfigPlugin.CaseClausesOnNewLines.Tooltip=Put WHEN, THEN and ELSE of a CASE expression at the start of a new line.
+CalciteSqlFormatConfigPlugin.WindowDeclarationStartsLine.Label=WINDOW declaration starts a new line
+CalciteSqlFormatConfigPlugin.WindowDeclarationStartsLine.Tooltip=Start a WINDOW declaration on a new line.
+CalciteSqlFormatConfigPlugin.WindowListItemsOnSeparateLines.Label=WINDOW list items on separate lines
+CalciteSqlFormatConfigPlugin.WindowListItemsOnSeparateLines.Tooltip=Put each item in a WINDOW list on its own line.
+CalciteSqlFormatConfigPlugin.KeywordsLowercase.Label=Keywords in lower case
+CalciteSqlFormatConfigPlugin.KeywordsLowercase.Tooltip=Print SQL keywords such as SELECT and FROM in lower case.
+CalciteSqlFormatConfigPlugin.QuoteAllIdentifiers.Label=Quote all identifiers
+CalciteSqlFormatConfigPlugin.QuoteAllIdentifiers.Tooltip=Quote every identifier even when the dialect would accept it unquoted.
+CalciteSqlFormatConfigPlugin.Save.Error.Title=Error
+CalciteSqlFormatConfigPlugin.Save.Error.Message=Error saving Apache Calcite SQL formatter options
diff --git a/plugins/tech/calcite/src/main/resources/org/apache/hop/calcite/messages/messages_en_US.properties b/plugins/tech/calcite/src/main/resources/org/apache/hop/calcite/messages/messages_en_US.properties
new file mode 100644
index 0000000000..a1bb4bc567
--- /dev/null
+++ b/plugins/tech/calcite/src/main/resources/org/apache/hop/calcite/messages/messages_en_US.properties
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+SqlFormatToolbarButton.FormatSql.ToolTip=Format SQL
+SqlFormatToolbarButton.Error.Title=SQL format error
+SqlFormatToolbarButton.Error.Message=Apache Calcite could not parse this SQL. Dialect-specific syntax may not be supported.
diff --git a/plugins/tech/calcite/src/main/resources/version.xml b/plugins/tech/calcite/src/main/resources/version.xml
new file mode 100644
index 0000000000..f59df58ccf
--- /dev/null
+++ b/plugins/tech/calcite/src/main/resources/version.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ 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.
+  -->
+<version>${project.version}</version>
diff --git a/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/CalciteSqlDialectsTest.java b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/CalciteSqlDialectsTest.java
new file mode 100644
index 0000000000..3ec293920d
--- /dev/null
+++ b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/CalciteSqlDialectsTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.calcite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.apache.calcite.config.Lex;
+import org.apache.calcite.sql.dialect.MysqlSqlDialect;
+import org.apache.calcite.sql.dialect.OracleSqlDialect;
+import org.apache.calcite.sql.dialect.PostgresqlSqlDialect;
+import org.junit.jupiter.api.Test;
+
+class CalciteSqlDialectsTest {
+
+  @Test
+  void nullAndUnknownFallBackToAnsi() {
+    assertSame(CalciteSqlDialects.ANSI, CalciteSqlDialects.of(null));
+    assertSame(CalciteSqlDialects.ANSI, CalciteSqlDialects.of(""));
+    assertSame(CalciteSqlDialects.ANSI, CalciteSqlDialects.of("NOT-A-DATABASE"));
+  }
+
+  @Test
+  void mapsCommonHopPluginIds() {
+    assertEquals(Lex.MYSQL, CalciteSqlDialects.of("MYSQL").lex());
+    assertEquals(MysqlSqlDialect.DEFAULT, CalciteSqlDialects.of("mariadb").dialect());
+    assertEquals(Lex.ORACLE, CalciteSqlDialects.of("ORACLE").lex());
+    assertEquals(OracleSqlDialect.DEFAULT, CalciteSqlDialects.of("ORACLERDB").dialect());
+    assertEquals(Lex.SQL_SERVER, CalciteSqlDialects.of("MSSQLNATIVE").lex());
+    assertEquals(PostgresqlSqlDialect.DEFAULT, CalciteSqlDialects.of("POSTGRESQL").dialect());
+    assertEquals(Lex.MYSQL_ANSI, CalciteSqlDialects.of("POSTGRESQL").lex());
+    assertEquals(Lex.MYSQL_ANSI, CalciteSqlDialects.of("REDSHIFT").lex());
+    assertEquals(Lex.MYSQL_ANSI, CalciteSqlDialects.ANSI.lex());
+    assertEquals(Lex.BIG_QUERY, CalciteSqlDialects.of("GOOGLEBIGQUERY").lex());
+    assertEquals(Lex.JAVA, CalciteSqlDialects.of("H2").lex());
+  }
+}
diff --git a/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/CalciteSqlFormatterTest.java b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/CalciteSqlFormatterTest.java
new file mode 100644
index 0000000000..3679f93c36
--- /dev/null
+++ b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/CalciteSqlFormatterTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.calcite;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.calcite.sql.parser.SqlParseException;
+import org.junit.jupiter.api.Test;
+
+class CalciteSqlFormatterTest {
+
+  @Test
+  void formatsSelectListOntoSeparateLines() throws Exception {
+    String formatted = CalciteSqlFormatter.format("select a,b,c from customers where id=1");
+    assertTrue(formatted.contains("SELECT"));
+    assertTrue(formatted.contains("FROM"));
+    assertTrue(formatted.contains("WHERE"));
+    assertTrue(formatted.contains("\n"));
+    assertFalse(formatted.equalsIgnoreCase("select a,b,c from customers where id=1"));
+  }
+
+  @Test
+  void preservesHopVariables() throws Exception {
+    String formatted = CalciteSqlFormatter.format("select * from ${SOURCE_TABLE} where id = ${ID}");
+    assertTrue(formatted.contains("${SOURCE_TABLE}"));
+    assertTrue(formatted.contains("${ID}"));
+    assertFalse(formatted.contains("HOPVAR"));
+  }
+
+  @Test
+  void formatsCreateTable() throws Exception {
+    String formatted =
+        CalciteSqlFormatter.format("create table demo (id int not null, name varchar(40))");
+    assertTrue(formatted.toUpperCase().contains("CREATE TABLE"));
+    assertTrue(
+        formatted.toUpperCase().contains("INTEGER") || formatted.toUpperCase().contains("INT"));
+  }
+
+  @Test
+  void formatsMultipleStatements() throws Exception {
+    String formatted = CalciteSqlFormatter.format("select 1 from dual; select 2 from dual;");
+    assertTrue(formatted.contains("SELECT"));
+    long selects = formatted.toUpperCase().split("SELECT", -1).length - 1;
+    assertEquals(2, selects);
+  }
+
+  @Test
+  void formatsQueryableStatementNextToUnparseable() throws Exception {
+    String formatted = CalciteSqlFormatter.format("select a from t; this is not sql at all;");
+    assertTrue(formatted.toUpperCase().contains("SELECT"));
+    assertTrue(formatted.contains("this is not sql at all"));
+  }
+
+  @Test
+  void rejectsGarbage() {
+    assertThrows(SqlParseException.class, () -> CalciteSqlFormatter.format("definitely not sql"));
+  }
+
+  @Test
+  void blankInputIsUnchanged() throws Exception {
+    assertEquals("  ", CalciteSqlFormatter.format("  "));
+    assertEquals("", CalciteSqlFormatter.format(""));
+  }
+
+  @Test
+  void mysqlStyleUsesBackticksWhenRequested() throws Exception {
+    String formatted = CalciteSqlFormatter.format("select `name` from `customers`", "MYSQL");
+    assertTrue(formatted.contains("SELECT"));
+    assertTrue(formatted.contains("`name`") || formatted.contains("name"));
+  }
+
+  @Test
+  void formatsDoubleQuotedPostgresIdentifiers() throws Exception {
+    String sql = "SELECT * FROM \"public\".customer_address limit 1000";
+    String formatted = CalciteSqlFormatter.format(sql, "POSTGRESQL");
+    assertTrue(formatted.toUpperCase().contains("CUSTOMER_ADDRESS"));
+    assertTrue(formatted.contains("public") || formatted.contains("PUBLIC"));
+  }
+
+  @Test
+  void formatsDoubleQuotedIdentifiersWithoutPluginId() throws Exception {
+    String sql = "SELECT * FROM \"public\".customer_address limit 1000";
+    String formatted = CalciteSqlFormatter.format(sql);
+    assertTrue(formatted.toUpperCase().contains("CUSTOMER_ADDRESS"));
+  }
+
+  @Test
+  void formatsDoubleQuotedIdentifiersWhenDialectLexUsesBackticks() throws Exception {
+    String formatted =
+        CalciteSqlFormatter.format("SELECT * FROM \"public\".customer_address limit 1000", "H2");
+    assertTrue(formatted.toUpperCase().contains("CUSTOMER_ADDRESS"));
+  }
+
+  @Test
+  void splitStatementsSkipsSemicolonInQuotes() {
+    List<String> parts =
+        CalciteSqlFormatter.splitStatements("select 'a;b' from t; select 2 from t");
+    assertEquals(2, parts.size());
+    assertTrue(parts.get(0).contains("'a;b'"));
+  }
+}
diff --git a/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/SqlFormatToolbarButtonTest.java b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/SqlFormatToolbarButtonTest.java
new file mode 100644
index 0000000000..58e11c204a
--- /dev/null
+++ b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/SqlFormatToolbarButtonTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.calcite;
+
+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.assertTrue;
+
+import java.lang.reflect.Method;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.ui.core.widget.IFindReplaceTarget;
+import org.apache.hop.ui.core.widget.TextComposite;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.junit.jupiter.api.Test;
+
+class SqlFormatToolbarButtonTest {
+
+  @Test
+  void contentEditorButtonIsOnTheSqlToolbar() throws Exception {
+    Method method = SqlFormatToolbarButton.class.getMethod("formatSql", IContentEditorWidget.class);
+    GuiToolbarElement element = method.getAnnotation(GuiToolbarElement.class);
+    assertNotNull(element);
+    assertEquals(IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID, element.root());
+    assertEquals(SqlFormatToolbarButton.ID_CONTENT_EDITOR_FORMAT_SQL, element.id());
+    assertEquals("format-sql.svg", element.image());
+    assertTrue(element.separator());
+  }
+
+  @Test
+  void textCompositeButtonIsOnTheSqlToolbar() throws Exception {
+    Method method = SqlFormatToolbarButton.class.getMethod("formatSql", TextComposite.class);
+    GuiToolbarElement element = method.getAnnotation(GuiToolbarElement.class);
+    assertNotNull(element);
+    assertEquals(TextComposite.ID_TOOLBAR, element.root());
+    assertEquals(SqlFormatToolbarButton.ID_TEXTCOMPOSITE_FORMAT_SQL, element.id());
+  }
+
+  @Test
+  void filtersLeaveOtherToolbarItemsAlone() {
+    assertTrue(SqlFormatToolbarButton.showForSqlEditor("ContentEditor-Toolbar-10000-undo", this));
+    assertTrue(
+        SqlFormatToolbarButton.showForSqlTextComposite("textcomposite-toolbar-10000-undo", this));
+  }
+
+  @Test
+  void filtersHideFormatButtonForNonSqlHosts() {
+    assertFalse(
+        SqlFormatToolbarButton.showForSqlEditor(
+            SqlFormatToolbarButton.ID_CONTENT_EDITOR_FORMAT_SQL, this));
+    assertFalse(
+        SqlFormatToolbarButton.showForSqlTextComposite(
+            SqlFormatToolbarButton.ID_TEXTCOMPOSITE_FORMAT_SQL, this));
+  }
+
+  @Test
+  void applyFormattedReplacesWholeBufferThroughInsert() {
+    FakeTarget target = new FakeTarget("select 1");
+    SqlFormatToolbarButton.applyFormatted(target, "select 1", "SELECT\n  1", false);
+    assertEquals("SELECT\n  1", target.getText());
+    assertEquals(1, target.insertCount);
+    assertEquals(0, target.setTextCount);
+    assertTrue(target.toolbarUpdated);
+  }
+
+  @Test
+  void applyFormattedReplacesSelectionThroughInsert() {
+    FakeTarget target = new FakeTarget("keep select 1 tail");
+    target.setSelection(5, 13);
+    SqlFormatToolbarButton.applyFormatted(target, "select 1", "SELECT\n  1", true);
+    assertEquals("keep SELECT\n  1 tail", target.getText());
+    assertEquals(1, target.insertCount);
+    assertEquals(0, target.setTextCount);
+  }
+
+  @Test
+  void applyFormattedIsNoOpWhenUnchanged() {
+    FakeTarget target = new FakeTarget("select 1");
+    SqlFormatToolbarButton.applyFormatted(target, "select 1", "select 1", false);
+    assertEquals("select 1", target.getText());
+    assertEquals(0, target.insertCount);
+    assertFalse(target.toolbarUpdated);
+  }
+
+  private static final class FakeTarget implements IFindReplaceTarget {
+    private String text;
+    private int selStart;
+    private int selEnd;
+    private int insertCount;
+    private int setTextCount;
+    private boolean toolbarUpdated;
+
+    private FakeTarget(String text) {
+      this.text = text;
+    }
+
+    @Override
+    public String getText() {
+      return text;
+    }
+
+    @Override
+    public void setText(String text) {
+      setTextCount++;
+      this.text = text != null ? text : "";
+    }
+
+    @Override
+    public String getSelectionText() {
+      if (selEnd <= selStart || selStart >= text.length()) {
+        return "";
+      }
+      return text.substring(selStart, Math.min(selEnd, text.length()));
+    }
+
+    @Override
+    public int getSelectionCount() {
+      return Math.max(0, selEnd - selStart);
+    }
+
+    @Override
+    public void setSelection(int start, int end) {
+      selStart = Math.max(0, start);
+      selEnd = Math.max(selStart, end);
+    }
+
+    @Override
+    public int getCaretPosition() {
+      return selEnd;
+    }
+
+    @Override
+    public void setCaretPosition(int position) {
+      selStart = position;
+      selEnd = position;
+    }
+
+    @Override
+    public void insert(String replacement) {
+      insertCount++;
+      String safe = replacement != null ? replacement : "";
+      text = text.substring(0, selStart) + safe + text.substring(selEnd);
+      selStart = selStart + safe.length();
+      selEnd = selStart;
+    }
+
+    @Override
+    public boolean isEditable() {
+      return true;
+    }
+
+    @Override
+    public boolean isDisposed() {
+      return false;
+    }
+
+    @Override
+    public boolean setFocus() {
+      return true;
+    }
+
+    @Override
+    public void updateToolbar() {
+      toolbarUpdated = true;
+    }
+  }
+}
diff --git a/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigPluginTest.java b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigPluginTest.java
new file mode 100644
index 0000000000..d9b7693785
--- /dev/null
+++ b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigPluginTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.calcite.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import org.apache.hop.core.config.plugin.ConfigPlugin;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.ui.hopgui.perspective.configuration.tabs.ConfigPluginOptionsTab;
+import org.junit.jupiter.api.Test;
+
+class CalciteSqlFormatConfigPluginTest {
+
+  @Test
+  void isAConfigGuiPluginForThePluginsTab() throws Exception {
+    ConfigPlugin config = CalciteSqlFormatConfigPlugin.class.getAnnotation(ConfigPlugin.class);
+    GuiPlugin gui = CalciteSqlFormatConfigPlugin.class.getAnnotation(GuiPlugin.class);
+    assertNotNull(config);
+    assertEquals(ConfigPlugin.CATEGORY_CONFIG, config.category());
+    assertNotNull(gui);
+    assertEquals("i18n::CalciteSqlFormatConfigPlugin.Name", gui.description());
+    assertNotNull(
+        CalciteSqlFormatConfigPlugin.class.getMethod("getInstance"),
+        "ConfigPluginOptionsTab loads plugins via getInstance()");
+  }
+
+  @Test
+  void everySqlFormatOptionIsAWidgetOnThePluginsParent() throws Exception {
+    int widgets = 0;
+    for (Field field : CalciteSqlFormatConfigPlugin.class.getDeclaredFields()) {
+      GuiWidgetElement element = field.getAnnotation(GuiWidgetElement.class);
+      if (element == null) {
+        continue;
+      }
+      widgets++;
+      assertEquals(ConfigPluginOptionsTab.GUI_WIDGETS_PARENT_ID, element.parentId());
+      assertEquals(GuiWidgetGroupType.TABS, element.groupType());
+      assertTrue(
+          element.type() == GuiElementType.CHECKBOX || element.type() == GuiElementType.TEXT);
+    }
+    assertEquals(11, widgets, "SqlFormatOptions has 11 fields");
+  }
+}
diff --git a/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigTest.java b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigTest.java
new file mode 100644
index 0000000000..6ad1d19eb1
--- /dev/null
+++ b/plugins/tech/calcite/src/test/java/org/apache/hop/calcite/config/CalciteSqlFormatConfigTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.calcite.config;
+
+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 org.apache.calcite.sql.pretty.SqlFormatOptions;
+import org.apache.hop.calcite.CalciteSqlFormatter;
+import org.junit.jupiter.api.Test;
+
+class CalciteSqlFormatConfigTest {
+
+  @Test
+  void toSqlFormatOptionsCopiesEveryField() {
+    CalciteSqlFormatConfig config = new CalciteSqlFormatConfig();
+    config.setAlwaysUseParentheses(true);
+    config.setCaseClausesOnNewLines(false);
+    config.setClauseStartsLine(false);
+    config.setKeywordsLowercase(true);
+    config.setQuoteAllIdentifiers(true);
+    config.setSelectListItemsOnSeparateLines(false);
+    config.setWhereListItemsOnSeparateLines(false);
+    config.setWindowDeclarationStartsLine(false);
+    config.setWindowListItemsOnSeparateLines(false);
+    config.setIndentation(8);
+    config.setLineLength(80);
+
+    SqlFormatOptions options = config.toSqlFormatOptions();
+    assertTrue(options.isAlwaysUseParentheses());
+    assertFalse(options.isCaseClausesOnNewLines());
+    assertFalse(options.isClauseStartsLine());
+    assertTrue(options.isKeywordsLowercase());
+    assertTrue(options.isQuoteAllIdentifiers());
+    assertFalse(options.isSelectListItemsOnSeparateLines());
+    assertFalse(options.isWhereListItemsOnSeparateLines());
+    assertFalse(options.isWindowDeclarationStartsLine());
+    assertFalse(options.isWindowListItemsOnSeparateLines());
+    assertEquals(8, options.getIndentation());
+    assertEquals(80, options.getLineLength());
+  }
+
+  @Test
+  void applyFromIgnoresNullPluginFields() {
+    CalciteSqlFormatConfig config = new CalciteSqlFormatConfig();
+    config.setIndentation(2);
+    config.setKeywordsLowercase(false);
+
+    CalciteSqlFormatConfigPlugin plugin = new CalciteSqlFormatConfigPlugin();
+    plugin.setKeywordsLowercase(true);
+    plugin.setIndentation("6");
+
+    assertTrue(config.applyFrom(plugin));
+    assertTrue(config.isKeywordsLowercase());
+    assertEquals(6, config.getIndentation());
+    assertTrue(config.isSelectListItemsOnSeparateLines());
+  }
+
+  @Test
+  void keywordsLowercaseIsAppliedWhenFormatting() throws Exception {
+    CalciteSqlFormatConfig config = new CalciteSqlFormatConfig();
+    config.setKeywordsLowercase(true);
+    config.setSelectListItemsOnSeparateLines(false);
+    String formatted = CalciteSqlFormatter.format("SELECT a FROM customers", null, config);
+    assertTrue(formatted.contains("select"));
+    assertFalse(formatted.contains("SELECT"));
+  }
+
+  @Test
+  void pluginCopiesFromPersistedConfig() {
+    CalciteSqlFormatConfig config = new CalciteSqlFormatConfig();
+    config.setIndentation(8);
+    config.setQuoteAllIdentifiers(true);
+    CalciteSqlFormatConfigPlugin plugin = new CalciteSqlFormatConfigPlugin(config);
+    assertEquals("8", plugin.getIndentation());
+    assertTrue(plugin.getQuoteAllIdentifiers());
+  }
+}
diff --git a/plugins/tech/pom.xml b/plugins/tech/pom.xml
index 2178ede5c8..4075221327 100644
--- a/plugins/tech/pom.xml
+++ b/plugins/tech/pom.xml
@@ -33,6 +33,7 @@
         <module>avro</module>
         <module>aws</module>
         <module>azure</module>
+        <module>calcite</module>
         <module>cassandra</module>
         <module>databricks</module>
         <module>dropbox</module>
diff --git a/plugins/transforms/combinationlookup/src/main/java/org/apache/hop/pipeline/transforms/combinationlookup/CombinationLookupDialog.java b/plugins/transforms/combinationlookup/src/main/java/org/apache/hop/pipeline/transforms/combinationlookup/CombinationLookupDialog.java
index 8ce1da40df..ec6cb0e2e5 100644
--- a/plugins/transforms/combinationlookup/src/main/java/org/apache/hop/pipeline/transforms/combinationlookup/CombinationLookupDialog.java
+++ b/plugins/transforms/combinationlookup/src/main/java/org/apache/hop/pipeline/transforms/combinationlookup/CombinationLookupDialog.java
@@ -21,7 +21,6 @@ import java.util.ArrayList;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.SqlStatement;
 import org.apache.hop.core.database.Database;
 import org.apache.hop.core.database.DatabaseMeta;
@@ -35,7 +34,6 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
@@ -46,6 +44,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.events.FocusEvent;
@@ -901,15 +900,8 @@ public class CombinationLookupDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell,
-                  SWT.NONE,
-                  variables,
-                  pipelineMeta.findDatabase(info.getConnectionName(), variables),
-                  DbCache.getInstance(),
-                  sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(
+              pipelineMeta.findDatabase(info.getConnectionName(), variables), sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(
diff --git a/plugins/transforms/dimensionlookup/src/main/java/org/apache/hop/pipeline/transforms/dimensionlookup/DimensionLookupDialog.java b/plugins/transforms/dimensionlookup/src/main/java/org/apache/hop/pipeline/transforms/dimensionlookup/DimensionLookupDialog.java
index 70f272dde9..90fb39d3d2 100644
--- a/plugins/transforms/dimensionlookup/src/main/java/org/apache/hop/pipeline/transforms/dimensionlookup/DimensionLookupDialog.java
+++ b/plugins/transforms/dimensionlookup/src/main/java/org/apache/hop/pipeline/transforms/dimensionlookup/DimensionLookupDialog.java
@@ -34,7 +34,6 @@ import java.util.List;
 import org.apache.commons.codec.binary.Hex;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SqlStatement;
 import org.apache.hop.core.database.Database;
@@ -51,7 +50,6 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
 import org.apache.hop.ui.core.dialog.EnterTextDialog;
@@ -64,6 +62,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabFolder;
@@ -1776,10 +1775,7 @@ public class DimensionLookupDialog extends BaseTransformDialog {
         if (!sql.hasError()) {
           if (sql.hasSql()) {
             DatabaseMeta databaseMeta = pipelineMeta.findDatabase(wConnection.getText(), variables);
-            SqlEditor sqledit =
-                new SqlEditor(
-                    shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-            sqledit.open();
+            DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
           } else {
             MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
             mb.setMessage(
diff --git a/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateDialog.java b/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateDialog.java
index 2e14b0a8cd..ed70ee5ffd 100644
--- a/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateDialog.java
+++ b/plugins/transforms/insertupdate/src/main/java/org/apache/hop/pipeline/transforms/insertupdate/InsertUpdateDialog.java
@@ -23,7 +23,6 @@ import java.util.List;
 import java.util.Map;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -41,7 +40,6 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -53,6 +51,7 @@ import org.apache.hop.ui.core.widget.MetaSelectionLine;
 import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.pipeline.transform.ComponentSelectionListener;
 import org.apache.hop.ui.pipeline.transform.ITableItemInsertListener;
@@ -890,10 +889,7 @@ public class InsertUpdateDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(BaseMessages.getString(PKG, "InsertUpdateDialog.NoSQLNeeds.DialogMessage"));
diff --git a/plugins/transforms/sqlfileoutput/src/main/java/org/apache/hop/pipeline/transforms/sqlfileoutput/SQLFileOutputDialog.java b/plugins/transforms/sqlfileoutput/src/main/java/org/apache/hop/pipeline/transforms/sqlfileoutput/SQLFileOutputDialog.java
index 1e91e244bc..1ee1036b7d 100644
--- a/plugins/transforms/sqlfileoutput/src/main/java/org/apache/hop/pipeline/transforms/sqlfileoutput/SQLFileOutputDialog.java
+++ b/plugins/transforms/sqlfileoutput/src/main/java/org/apache/hop/pipeline/transforms/sqlfileoutput/SQLFileOutputDialog.java
@@ -19,7 +19,6 @@ package org.apache.hop.pipeline.transforms.sqlfileoutput;
 
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SqlStatement;
 import org.apache.hop.core.database.Database;
@@ -33,7 +32,6 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
@@ -42,6 +40,7 @@ import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.widget.MetaSelectionLine;
 import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TextVar;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CCombo;
@@ -944,10 +943,7 @@ public class SQLFileOutputDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor editor =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          editor.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(BaseMessages.getString(PKG, "SQLFileOutputDialog.NoSQL.DialogMessage"));
diff --git a/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMergeDialog.java b/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMergeDialog.java
index 7e12fb1888..6bea63292c 100644
--- a/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMergeDialog.java
+++ b/plugins/transforms/synchronizeaftermerge/src/main/java/org/apache/hop/pipeline/transforms/synchronizeaftermerge/SynchronizeAfterMergeDialog.java
@@ -22,7 +22,6 @@ import java.util.ArrayList;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -40,7 +39,6 @@ import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -53,6 +51,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.pipeline.transform.ITableItemInsertListener;
 import org.eclipse.swt.SWT;
@@ -1181,10 +1180,7 @@ public class SynchronizeAfterMergeDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(
diff --git a/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutputDialog.java b/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutputDialog.java
index 5a81ff3cbb..df2960dbff 100644
--- a/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutputDialog.java
+++ b/plugins/transforms/tableoutput/src/main/java/org/apache/hop/pipeline/transforms/tableoutput/TableOutputDialog.java
@@ -22,7 +22,6 @@ import java.util.Collections;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -43,7 +42,6 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -57,6 +55,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabFolder;
@@ -1759,10 +1758,7 @@ public class TableOutputDialog extends BaseTransformDialog {
             info.getSqlStatements(variables, pipelineMeta, transformMeta, prev, pk, autoInc, pk);
         if (!sql.hasError()) {
           if (sql.hasSql()) {
-            SqlEditor sqledit =
-                new SqlEditor(
-                    shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-            sqledit.open();
+            DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
           } else {
             String message = BaseMessages.getString(PKG, "TableOutputDialog.NoSQL.DialogMessage");
             String text = BaseMessages.getString(PKG, "TableOutputDialog.NoSQL.DialogTitle");
diff --git a/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateDialog.java b/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateDialog.java
index fe90b53217..522450e1c8 100644
--- a/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateDialog.java
+++ b/plugins/transforms/update/src/main/java/org/apache/hop/pipeline/transforms/update/UpdateDialog.java
@@ -21,7 +21,6 @@ import java.util.ArrayList;
 import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.SourceToTargetMapping;
 import org.apache.hop.core.SqlStatement;
@@ -38,7 +37,6 @@ import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.database.dialog.DatabaseExplorerDialog;
-import org.apache.hop.ui.core.database.dialog.SqlEditor;
 import org.apache.hop.ui.core.dialog.BaseDialog;
 import org.apache.hop.ui.core.dialog.EnterMappingDialog;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
@@ -51,6 +49,7 @@ import org.apache.hop.ui.core.widget.NamingSchemeTypes;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.pipeline.transform.ITableItemInsertListener;
 import org.eclipse.swt.SWT;
@@ -985,10 +984,7 @@ public class UpdateDialog extends BaseTransformDialog {
           info.getSqlStatements(variables, pipelineMeta, transforminfo, prev, metadataProvider);
       if (!sql.hasError()) {
         if (sql.hasSql()) {
-          SqlEditor sqledit =
-              new SqlEditor(
-                  shell, SWT.NONE, variables, databaseMeta, DbCache.getInstance(), sql.getSql());
-          sqledit.open();
+          DatabaseWorkbenchDialog.openSql(databaseMeta, sql.getSql());
         } else {
           MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_INFORMATION);
           mb.setMessage(BaseMessages.getString(PKG, "UpdateDialog.NoSQLNeeds.DialogMessage"));
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java b/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
index c8ee26bc05..f2fa20c151 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/ContentEditorFacadeImpl.java
@@ -83,6 +83,7 @@ public class ContentEditorFacadeImpl extends ContentEditorFacade {
       remoteObject.listen("focusChanged", true);
       remoteObject.listen("selectionChanged", true);
       remoteObject.listen("findRequested", true);
+      remoteObject.listen("executeRequested", true);
       host.addListener(
           SWT.Dispose,
           event -> {
@@ -228,6 +229,23 @@ public class ContentEditorFacadeImpl extends ContentEditorFacade {
                     });
                 return;
               }
+              if ("executeRequested".equals(event)) {
+                Display current = host.getDisplay();
+                if (current == null || host.isDisposed()) {
+                  return;
+                }
+                current.asyncExec(
+                    () -> {
+                      if (root.isDisposed()) {
+                        return;
+                      }
+                      Runnable action = IContentEditorWidget.executeActionOf(root);
+                      if (action != null) {
+                        action.run();
+                      }
+                    });
+                return;
+              }
               if (!"contentChanged".equals(event) || properties.get("content") == null) {
                 return;
               }
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js b/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
index 4ea5eb7ba3..83cda4c122 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/monaco-editor.js
@@ -88,7 +88,7 @@
       }
     },
     properties: [ "content", "language", "readOnly", "theme" ],
-    events: [ "contentChanged", "focusChanged", "selectionChanged", "findRequested" ],
+    events: [ "contentChanged", "focusChanged", "selectionChanged", "findRequested", "executeRequested" ],
     methods: [ "setSelection", "insert" ]
   });
 
@@ -264,6 +264,18 @@
       }
     },
 
+    _notifyExecute: function() {
+      if (this._destroyed) return;
+      try {
+        var remote = rap.getRemoteObject(this);
+        if (remote) {
+          remote.notify("executeRequested", {});
+        }
+      } catch (e) {
+        console.warn("MonacoEditor: failed to notify execute", e);
+      }
+    },
+
     _scheduleNotify: function() {
       var self = this;
       if (self._debounceId) clearTimeout(self._debounceId);
@@ -344,6 +356,10 @@
             window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.KeyH,
             function() { self._notifyFind(true); }
           );
+          self._editor.addCommand(
+            window.monaco.KeyMod.CtrlCmd | window.monaco.KeyCode.Enter,
+            function() { self._notifyExecute(); }
+          );
         }
 
         self._editor.onDidFocusEditorWidget(function() {
diff --git a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
index c7bc421354..5ccf4cd6e7 100644
--- a/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
+++ b/rcp/src/main/java/org/apache/hop/ui/hopgui/ContentEditorWidget.java
@@ -43,6 +43,7 @@ import org.eclipse.jface.text.IDocumentExtension3;
 import org.eclipse.jface.text.IDocumentListener;
 import org.eclipse.jface.text.ITextOperationTarget;
 import org.eclipse.jface.text.ITextViewerExtension5;
+import org.eclipse.jface.text.IUndoManager;
 import org.eclipse.jface.text.Position;
 import org.eclipse.jface.text.rules.FastPartitioner;
 import org.eclipse.jface.text.rules.RuleBasedPartitionScanner;
@@ -418,12 +419,12 @@ public class ContentEditorWidget implements IContentEditorWidget {
 
   @Override
   public int getCaretPosition() {
-    StyledText textWidget = sourceViewer.getTextWidget();
-    if (textWidget == null || textWidget.isDisposed()) {
-      org.eclipse.swt.graphics.Point range = sourceViewer.getSelectedRange();
-      return range != null ? range.x + range.y : 0;
+    // Document offset, not the StyledText widget offset (those diverge when folding is on).
+    org.eclipse.swt.graphics.Point range = sourceViewer.getSelectedRange();
+    if (range == null) {
+      return 0;
     }
-    return textWidget.getCaretOffset();
+    return range.x + range.y;
   }
 
   @Override
@@ -445,11 +446,20 @@ public class ContentEditorWidget implements IContentEditorWidget {
     if (doc == null || range == null) {
       return;
     }
+    IUndoManager undoManager = sourceViewer.getUndoManager();
+    if (undoManager != null) {
+      undoManager.beginCompoundChange();
+    }
     try {
       doc.replace(range.x, range.y, insertion);
       sourceViewer.setSelectedRange(range.x + insertion.length(), 0);
     } catch (org.eclipse.jface.text.BadLocationException e) {
       // ignore invalid range
+    } finally {
+      if (undoManager != null) {
+        undoManager.endCompoundChange();
+      }
+      updateGui();
     }
   }
 
@@ -772,6 +782,22 @@ public class ContentEditorWidget implements IContentEditorWidget {
           contextMenuWidgets.enableMenuItem(
               ID_CONTEXT_MENU_FIND_REPLACE, sourceViewer.isEditable());
         });
+    // StyledText.handleKey always inserts CR/LF (operator precedence vs Ctrl ignore).
+    // Widget KeyDown is too late; VerifyKey and SWT.Verify must reject the newline.
+    sourceViewer.prependVerifyKeyListener(
+        event -> {
+          if (shouldEatExecuteNewline(event.stateMask, event.keyCode, event.character)) {
+            event.doit = false;
+          }
+        });
+    styledText.addListener(
+        SWT.Verify,
+        event -> {
+          if (IContentEditorWidget.eatExecuteNewlineArmed(control)
+              && IContentEditorWidget.isLineDelimiterText(event.text)) {
+            event.doit = false;
+          }
+        });
     styledText.addListener(
         SWT.KeyDown,
         event -> {
@@ -792,6 +818,23 @@ public class ContentEditorWidget implements IContentEditorWidget {
         });
   }
 
+  /**
+   * Ctrl+Enter should execute SQL, not insert a newline. GTK VerifyKey often has {@code stateMask
+   * == 0}; {@link IContentEditorWidget#DATA_EAT_EXECUTE_NEWLINE} is set from the SQL tab as soon as
+   * Traverse/KeyDown identifies the shortcut.
+   */
+  private boolean shouldEatExecuteNewline(int stateMask, int keyCode, char character) {
+    if (IContentEditorWidget.executeActionOf(control) == null) {
+      return false;
+    }
+    if (IContentEditorWidget.isExecuteKey(stateMask, keyCode, character)
+        || IContentEditorWidget.isExecuteAllKey(stateMask, keyCode, character)) {
+      return true;
+    }
+    return IContentEditorWidget.isExecuteNewline(keyCode, character)
+        && IContentEditorWidget.eatExecuteNewlineArmed(control);
+  }
+
   void installFolding() {
     sourceViewer
         .getDocument()
diff --git a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseExplorerDialog.java b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseExplorerDialog.java
index 62452dc591..60827710af 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseExplorerDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseExplorerDialog.java
@@ -24,7 +24,6 @@ import lombok.Getter;
 import lombok.Setter;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.DbCache;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.database.Catalog;
 import org.apache.hop.core.database.Database;
@@ -56,6 +55,7 @@ import org.apache.hop.ui.core.gui.WindowProperty;
 import org.apache.hop.ui.core.widget.HopTree;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
+import org.apache.hop.ui.hopgui.perspective.database.DatabaseWorkbenchDialog;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.events.SelectionAdapter;
@@ -109,7 +109,6 @@ public class DatabaseExplorerDialog extends Dialog {
   private final PropsUi props;
   private DatabaseMeta dbMeta;
   private final IVariables variables;
-  private final DbCache dbcache;
   private final ILoggingObject loggingObject;
 
   private static final String STRING_CATALOG =
@@ -184,7 +183,6 @@ public class DatabaseExplorerDialog extends Dialog {
 
     props = PropsUi.getInstance();
     log = new LogChannel("DBExplorer");
-    dbcache = DbCache.getInstance();
   }
 
   public boolean open() {
@@ -911,8 +909,7 @@ public class DatabaseExplorerDialog extends Dialog {
               : qualifiedTableName);
 
       String sql = db.getCreateTableStatement(realTableName, r, null, false, null, true);
-      SqlEditor se = new SqlEditor(shell, SWT.NONE, variables, dbMeta, dbcache, sql);
-      se.open();
+      DatabaseWorkbenchDialog.openSql(dbMeta, sql);
     } catch (HopDatabaseException dbe) {
       new ErrorDialog(
           shell,
@@ -961,8 +958,7 @@ public class DatabaseExplorerDialog extends Dialog {
             String sql =
                 targetDatabase.getCreateTableStatement(
                     qualifiedTableName, rowMeta, null, false, null, true);
-            SqlEditor sqlEditor = new SqlEditor(shell, SWT.NONE, variables, dbMeta, dbcache, sql);
-            sqlEditor.open();
+            DatabaseWorkbenchDialog.openSql(targetDatabaseMeta, sql);
           }
         }
       } catch (HopDatabaseException dbe) {
@@ -986,22 +982,11 @@ public class DatabaseExplorerDialog extends Dialog {
         (qualifiedTableName.contains(".")
             ? qualifiedTableName.substring(qualifiedTableName.indexOf(".") + 1)
             : qualifiedTableName);
-    SqlEditor sqlEditor =
-        new SqlEditor(
-            shell, SWT.NONE, variables, dbMeta, dbcache, "SELECT * FROM " + realTableName);
-    sqlEditor.open();
+    DatabaseWorkbenchDialog.openSql(dbMeta, "SELECT * FROM " + realTableName);
   }
 
   public void getTruncate(String truncateTableReference) {
-    SqlEditor sql =
-        new SqlEditor(
-            shell,
-            SWT.NONE,
-            variables,
-            dbMeta,
-            dbcache,
-            "-- TRUNCATE TABLE " + truncateTableReference);
-    sql.open();
+    DatabaseWorkbenchDialog.openSql(dbMeta, "-- TRUNCATE TABLE " + truncateTableReference);
   }
 
   public void dispose() {
diff --git a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseProgressCancelWatcher.java b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseProgressCancelWatcher.java
new file mode 100644
index 0000000000..4666fa66a3
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/DatabaseProgressCancelWatcher.java
@@ -0,0 +1,78 @@
+/*
+ * 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.core.database.dialog;
+
+import java.util.function.Supplier;
+import org.apache.hop.core.IProgressMonitor;
+import org.apache.hop.core.database.Database;
+import org.apache.hop.ui.core.dialog.ProgressMonitorDialog;
+import org.apache.hop.ui.util.EnvironmentUtils;
+
+/**
+ * Background watcher that cancels a running {@link Database} query when the user presses Cancel on
+ * a {@link ProgressMonitorDialog}.
+ *
+ * <p>Hop Web's {@code ProgressMonitorDialog.run} executes inline and never builds a shell, so a
+ * loop of {@code getShell() == null} would never end. Do not start a watcher there.
+ */
+public final class DatabaseProgressCancelWatcher {
+
+  private DatabaseProgressCancelWatcher() {}
+
+  /**
+   * Start a named daemon thread that calls {@link Database#cancelQuery()} when the monitor is
+   * cancelled. No-op on Hop Web.
+   *
+   * @param pmd the progress dialog whose Cancel button is watched
+   * @param database the connection to cancel; may return {@code null} until connect
+   * @param threadName thread name for dumps (e.g. {@code Hop-SqlEditor-CancelWatcher})
+   */
+  public static void startIfDesktop(
+      ProgressMonitorDialog pmd, Supplier<Database> database, String threadName) {
+    if (pmd == null || EnvironmentUtils.getInstance().isWeb()) {
+      return;
+    }
+    Thread thread =
+        new Thread(
+            () -> {
+              IProgressMonitor monitor = pmd.getProgressMonitor();
+              while (pmd.getShell() == null
+                  || (!pmd.getShell().isDisposed() && !monitor.isCanceled())) {
+                try {
+                  Thread.sleep(100);
+                } catch (InterruptedException e) {
+                  Thread.currentThread().interrupt();
+                  return;
+                }
+              }
+              if (monitor.isCanceled()) {
+                Database db = database.get();
+                if (db != null) {
+                  try {
+                    db.cancelQuery();
+                  } catch (Exception ignored) {
+                    // ignore
+                  }
+                }
+              }
+            },
+            threadName);
+    thread.setDaemon(true);
+    thread.start();
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetPreviewTableProgressDialog.java b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetPreviewTableProgressDialog.java
index 2209552a3c..4e47ec3855 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetPreviewTableProgressDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetPreviewTableProgressDialog.java
@@ -98,7 +98,7 @@ public class GetPreviewTableProgressDialog {
     IRunnableWithProgress op = this::runPreviewWithResources;
     try {
       ProgressMonitorDialog pmd = new ProgressMonitorDialog(shell);
-      startCancelWatcherThread(pmd);
+      DatabaseProgressCancelWatcher.startIfDesktop(pmd, () -> db, "Hop-DB-Preview-CancelWatcher");
       pmd.run(true, op);
     } catch (InvocationTargetException e) {
       previewSucceeded = false;
@@ -131,34 +131,6 @@ public class GetPreviewTableProgressDialog {
     }
   }
 
-  private void startCancelWatcherThread(ProgressMonitorDialog pmd) {
-    Runnable run =
-        () -> {
-          IProgressMonitor monitor = pmd.getProgressMonitor();
-          while (pmd.getShell() == null
-              || (!pmd.getShell().isDisposed() && !monitor.isCanceled())) {
-            try {
-              Thread.sleep(100);
-            } catch (InterruptedException e) {
-              Thread.currentThread().interrupt();
-              return;
-            }
-          }
-          if (monitor.isCanceled()) {
-            try {
-              if (db != null) {
-                db.cancelQuery();
-              }
-            } catch (Exception e) {
-              // Ignore
-            }
-          }
-        };
-    Thread cancelWatcher = new Thread(run, "Hop-DB-Preview-CancelWatcher");
-    cancelWatcher.setDaemon(true);
-    cancelWatcher.start();
-  }
-
   private void openWeb() {
     Cursor cursor = new Cursor(shell.getDisplay(), SWT.CURSOR_WAIT);
     try {
diff --git a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetQueryFieldsProgressDialog.java b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetQueryFieldsProgressDialog.java
index 1ac9e60856..be40beca52 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetQueryFieldsProgressDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetQueryFieldsProgressDialog.java
@@ -18,7 +18,6 @@
 package org.apache.hop.ui.core.database.dialog;
 
 import java.lang.reflect.InvocationTargetException;
-import org.apache.hop.core.IProgressMonitor;
 import org.apache.hop.core.IRunnableWithProgress;
 import org.apache.hop.core.database.Database;
 import org.apache.hop.core.database.DatabaseMeta;
@@ -77,32 +76,8 @@ public class GetQueryFieldsProgressDialog {
 
     try {
       final ProgressMonitorDialog pmd = new ProgressMonitorDialog(shell);
-
-      // Run something in the background to cancel active database queries, forecably if needed!
-      Runnable run =
-          () -> {
-            IProgressMonitor monitor = pmd.getProgressMonitor();
-            while (pmd.getShell() == null
-                || (!pmd.getShell().isDisposed() && !monitor.isCanceled())) {
-              try {
-                Thread.sleep(250);
-              } catch (InterruptedException e) {
-                // Ignore
-              }
-            }
-
-            if (monitor.isCanceled()) { // Disconnect and see what happens!
-
-              try {
-                db.cancelQuery();
-              } catch (Exception e) {
-                // Ignore
-              }
-            }
-          };
-      // Dump the cancel looker in the background!
-      new Thread(run).start();
-
+      DatabaseProgressCancelWatcher.startIfDesktop(
+          pmd, () -> db, "Hop-DB-QueryFields-CancelWatcher");
       pmd.run(true, op);
     } catch (InvocationTargetException | InterruptedException e) {
       showErrorDialog(e);
diff --git a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetTableSizeProgressDialog.java b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetTableSizeProgressDialog.java
index 3f545305b5..65bc1b1dd5 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetTableSizeProgressDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/GetTableSizeProgressDialog.java
@@ -18,7 +18,6 @@
 package org.apache.hop.ui.core.database.dialog;
 
 import java.lang.reflect.InvocationTargetException;
-import org.apache.hop.core.IProgressMonitor;
 import org.apache.hop.core.IRunnableWithProgress;
 import org.apache.hop.core.RowMetaAndData;
 import org.apache.hop.core.database.Database;
@@ -90,31 +89,7 @@ public class GetTableSizeProgressDialog {
 
     try {
       final ProgressMonitorDialog pmd = new ProgressMonitorDialog(shell);
-      // Run something in the background to cancel active database queries, forcibly if needed!
-      Runnable run =
-          () -> {
-            IProgressMonitor monitor = pmd.getProgressMonitor();
-            while (pmd.getShell() == null
-                || (!pmd.getShell().isDisposed() && !monitor.isCanceled())) {
-              try {
-                Thread.sleep(100);
-              } catch (InterruptedException e) {
-                // Ignore
-              }
-            }
-
-            if (monitor.isCanceled()) { // Disconnect and see what happens!
-
-              try {
-                db.cancelQuery();
-              } catch (Exception e) {
-                // Ignore
-              }
-            }
-          };
-      // Start the cancel tracker in the background!
-      new Thread(run).start();
-
+      DatabaseProgressCancelWatcher.startIfDesktop(pmd, () -> db, "Hop-DB-TableSize-CancelWatcher");
       pmd.run(true, op);
     } catch (InvocationTargetException | InterruptedException e) {
       showErrorDialog(e);
diff --git a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/SqlEditor.java b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/SqlEditor.java
index 0a56f7033b..774e72331e 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/database/dialog/SqlEditor.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/database/dialog/SqlEditor.java
@@ -308,7 +308,7 @@ public class SqlEditor {
     StringBuilder message = new StringBuilder();
     ProgressMonitorDialog pmd = new ProgressMonitorDialog(shell);
 
-    startSqlCancelWatcherVirtualThread(pmd);
+    DatabaseProgressCancelWatcher.startIfDesktop(pmd, activeDb::get, "Hop-SqlEditor-CancelWatcher");
 
     try {
       pmd.run(true, monitor -> runSqlScriptWithMonitor(monitor, databaseMeta, sqlScript, message));
@@ -328,34 +328,6 @@ public class SqlEditor {
     dialog.open();
   }
 
-  private void startSqlCancelWatcherVirtualThread(ProgressMonitorDialog pmd) {
-    Thread.ofVirtual()
-        .name("Hop-SqlEditor-CancelWatcher")
-        .start(
-            () -> {
-              IProgressMonitor monitor = pmd.getProgressMonitor();
-              while (pmd.getShell() == null
-                  || (!pmd.getShell().isDisposed() && !monitor.isCanceled())) {
-                try {
-                  Thread.sleep(100);
-                } catch (InterruptedException e) {
-                  Thread.currentThread().interrupt();
-                  break;
-                }
-              }
-              if (monitor.isCanceled()) {
-                Database db = activeDb.get();
-                if (db != null) {
-                  try {
-                    db.cancelQuery();
-                  } catch (Exception ignored) {
-                    // ignore
-                  }
-                }
-              }
-            });
-  }
-
   private void runSqlScriptWithMonitor(
       IProgressMonitor monitor,
       DatabaseMeta databaseMeta,
@@ -372,6 +344,7 @@ public class SqlEditor {
       if (timeoutSeconds > 0) {
         db.setStatementQueryTimeoutSeconds(timeoutSeconds);
       }
+      db.setQueryLimit(1000);
       db.connect();
 
       int[] nrStats = {0};
diff --git a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataFileTypeHandler.java b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataFileTypeHandler.java
index 99bd899822..eb9f0ad323 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataFileTypeHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/metadata/MetadataFileTypeHandler.java
@@ -18,7 +18,7 @@
 package org.apache.hop.ui.core.metadata;
 
 import java.util.ArrayList;
-import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.apache.hop.core.exception.HopException;
@@ -207,7 +207,7 @@ public class MetadataFileTypeHandler<T extends IHopMetadata> implements IHopFile
 
   @Override
   public Map<String, Object> getStateProperties() {
-    return Collections.emptyMap();
+    return new HashMap<>();
   }
 
   @Override
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
index c9a49d70e9..230c92cf87 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/StyledTextVar.java
@@ -401,7 +401,10 @@ public class StyledTextVar extends TextComposite {
           String oldText = "";
           int eventType = -1;
 
-          if ((event.length != newText.length()) || (fullSelection)) {
+          // Whole-document setText() (load) has empty replacedText and is skipped. Replacing the
+          // current selection — including Format SQL after select-all — must still be undoable.
+          boolean wholeDocument = event.length == newText.length();
+          if (!wholeDocument || fullSelection || !Utils.isEmpty(repText)) {
             if (!Utils.isEmpty(repText)) {
               oldText =
                   newText.substring(0, event.start)
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java b/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
index 02113f6c9c..2b32c38bfc 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/editor/IContentEditorWidget.java
@@ -18,6 +18,7 @@
 package org.apache.hop.ui.core.widget.editor;
 
 import org.apache.hop.ui.core.widget.IFindReplaceTarget;
+import org.eclipse.swt.SWT;
 import org.eclipse.swt.events.ModifyListener;
 import org.eclipse.swt.widgets.Control;
 import org.jspecify.annotations.Nullable;
@@ -35,6 +36,109 @@ public interface IContentEditorWidget extends IFindReplaceTarget {
 
   public static final String GUI_PLUGIN_CONTEXT_MENU_PARENT_ID = "ContentEditor-ContextMenu";
 
+  /**
+   * SWT widget data key for a {@link Runnable} invoked on Ctrl+Enter / Cmd+Enter (execute the
+   * statement around the caret in a SQL editor).
+   */
+  String DATA_EXECUTE_ACTION = "hop.contentEditor.executeAction";
+
+  /**
+   * Set on the editor control for the duration of a Ctrl+Enter keystroke so StyledText cannot
+   * insert the Return. GTK VerifyKey events often arrive with {@code stateMask == 0}.
+   */
+  String DATA_EAT_EXECUTE_NEWLINE = "hop.contentEditor.eatExecuteNewline";
+
+  /**
+   * Walk ancestors of {@code start} for {@link #DATA_EXECUTE_ACTION}.
+   *
+   * @return the action, or {@code null} when none is registered
+   */
+  static Runnable executeActionOf(Control start) {
+    Control current = start;
+    while (current != null && !current.isDisposed()) {
+      Object data = current.getData(DATA_EXECUTE_ACTION);
+      if (data instanceof Runnable runnable) {
+        return runnable;
+      }
+      current = current.getParent();
+    }
+    return null;
+  }
+
+  static boolean eatExecuteNewlineArmed(Control start) {
+    Control current = start;
+    while (current != null && !current.isDisposed()) {
+      if (Boolean.TRUE.equals(current.getData(DATA_EAT_EXECUTE_NEWLINE))) {
+        return true;
+      }
+      current = current.getParent();
+    }
+    return false;
+  }
+
+  /** Ctrl or Cmd held, Shift not held. */
+  static boolean isExecuteModifier(int stateMask) {
+    if ((stateMask & SWT.SHIFT) != 0) {
+      return false;
+    }
+    return (stateMask & (SWT.MOD1 | SWT.CONTROL | SWT.COMMAND)) != 0;
+  }
+
+  /** Return / Enter, including keypad. */
+  static boolean isExecuteNewline(int keyCode, char character) {
+    int code = keyCode & SWT.KEY_MASK;
+    return keyCode == SWT.CR
+        || keyCode == SWT.KEYPAD_CR
+        || code == SWT.CR
+        || character == SWT.CR
+        || character == SWT.LF;
+  }
+
+  /**
+   * Ctrl/Cmd+Enter without Shift (main or keypad). GTK often reports Return as {@link SWT#CR} with
+   * {@code character == 0} when Control is down.
+   */
+  static boolean isExecuteKey(int stateMask, int keyCode, char character) {
+    return isExecuteModifier(stateMask) && isExecuteNewline(keyCode, character);
+  }
+
+  /** Ctrl or Cmd held together with Shift. */
+  static boolean isExecuteAllModifier(int stateMask) {
+    if ((stateMask & SWT.SHIFT) == 0) {
+      return false;
+    }
+    return (stateMask & (SWT.MOD1 | SWT.CONTROL | SWT.COMMAND)) != 0;
+  }
+
+  /** Ctrl/Cmd+Shift+Enter: run every statement in the SQL editor. */
+  static boolean isExecuteAllKey(int stateMask, int keyCode, char character) {
+    return isExecuteAllModifier(stateMask) && isExecuteNewline(keyCode, character);
+  }
+
+  /** {@link SWT#TRAVERSE_RETURN} with Ctrl/Cmd+Shift. */
+  static boolean isExecuteAllTraverse(int detail, int stateMask) {
+    return detail == SWT.TRAVERSE_RETURN && isExecuteAllModifier(stateMask);
+  }
+
+  /**
+   * True when {@code text} is only a line delimiter (what StyledText inserts for Enter).
+   *
+   * @param text the replacement text from an {@link SWT#Verify} event
+   * @return true if the insert is a newline and should be eaten for Ctrl+Enter
+   */
+  static boolean isLineDelimiterText(@Nullable String text) {
+    return "\n".equals(text) || "\r".equals(text) || "\r\n".equals(text);
+  }
+
+  /**
+   * {@link SWT#TRAVERSE_RETURN} with Ctrl/Cmd. StyledText sets {@code doit=true} for Return when
+   * modifiers are held so the shell can activate its default button; that consumes the key before
+   * KeyDown on GTK.
+   */
+  static boolean isExecuteTraverse(int detail, int stateMask) {
+    return detail == SWT.TRAVERSE_RETURN && isExecuteModifier(stateMask);
+  }
+
   /**
    * The SWT control to attach to a layout (e.g. the editor composite or the AWT bridge canvas).
    *
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiAuditDelegate.java b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiAuditDelegate.java
index 4fd81a83a0..bb69ba6488 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiAuditDelegate.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiAuditDelegate.java
@@ -18,6 +18,7 @@
 package org.apache.hop.ui.hopgui.delegates;
 
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.regex.Pattern;
@@ -365,7 +366,7 @@ public class HopGuiAuditDelegate {
 
             // Also save the state : active, zoom, pane (Explorer split), fileType, ...
             //
-            Map<String, Object> stateProperties = typeHandler.getStateProperties();
+            Map<String, Object> stateProperties = copyStateProperties(typeHandler);
             boolean active =
                 activeFileTypeHandler != null
                     && activeFileTypeHandler.getFilename() != null
@@ -411,4 +412,20 @@ public class HopGuiAuditDelegate {
       }
     }
   }
+
+  /**
+   * Copy handler state into a mutable map. Some handlers return {@link
+   * java.util.Collections#emptyMap()}; the audit list still needs to record active/fileType/pane.
+   */
+  static Map<String, Object> copyStateProperties(IHopFileTypeHandler typeHandler) {
+    Map<String, Object> stateProperties = new HashMap<>();
+    if (typeHandler == null) {
+      return stateProperties;
+    }
+    Map<String, Object> existing = typeHandler.getStateProperties();
+    if (existing != null) {
+      stateProperties.putAll(existing);
+    }
+    return stateProperties;
+  }
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileTypeHandler.java b/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileTypeHandler.java
index 2e39abd317..c54cd35721 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileTypeHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/IHopFileTypeHandler.java
@@ -146,7 +146,10 @@ public interface IHopFileTypeHandler extends IActionContextHandlersProvider {
    * Describe the state properties of the file being handled: zoomLevel, scrollX, scrollY, active,
    * ...
    *
-   * @return The state properties
+   * <p>Callers may copy this map. Returning an immutable empty map is allowed; do not assume the
+   * returned instance will be mutated.
+   *
+   * @return The state properties, never {@code null}
    */
   Map<String, Object> getStateProperties();
 
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/empty/EmptyHopFileTypeHandler.java b/ui/src/main/java/org/apache/hop/ui/hopgui/file/empty/EmptyHopFileTypeHandler.java
index 5c81902c31..64ae7cbb9d 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/empty/EmptyHopFileTypeHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/empty/EmptyHopFileTypeHandler.java
@@ -18,7 +18,7 @@
 package org.apache.hop.ui.hopgui.file.empty;
 
 import java.util.ArrayList;
-import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.apache.hop.core.exception.HopException;
@@ -173,7 +173,7 @@ public class EmptyHopFileTypeHandler implements IHopFileTypeHandler {
 
   @Override
   public Map<String, Object> getStateProperties() {
-    return Collections.emptyMap();
+    return new HashMap<>();
   }
 
   @Override
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseConnectionState.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseConnectionState.java
new file mode 100644
index 0000000000..a04c0520e1
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseConnectionState.java
@@ -0,0 +1,39 @@
+/*
+ * 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.perspective.database;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.database.DatabaseMetaInformation;
+
+/**
+ * In-memory state for one project connection in the database tree. "Connected" means schema/table
+ * metadata has been loaded; JDBC is not kept open (each operation uses its own {@code Database}).
+ */
+@Getter
+@Setter
+public class DatabaseConnectionState {
+  private DatabaseMeta databaseMeta;
+  private boolean connected;
+  private DatabaseMetaInformation information;
+
+  public DatabaseConnectionState(DatabaseMeta databaseMeta) {
+    this.databaseMeta = databaseMeta;
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseIndexInfo.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseIndexInfo.java
new file mode 100644
index 0000000000..78b3b32119
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseIndexInfo.java
@@ -0,0 +1,36 @@
+/*
+ * 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.perspective.database;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+
+/** One index reported by JDBC {@code DatabaseMetaData.getIndexInfo}. */
+@Getter
+@Setter
+public class DatabaseIndexInfo {
+  private String name;
+  private boolean unique;
+  private final List<String> columns = new ArrayList<>();
+
+  public String columnsAsString() {
+    return String.join(", ", columns);
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
new file mode 100644
index 0000000000..870e4bef01
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperation.java
@@ -0,0 +1,113 @@
+/*
+ * 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.perspective.database;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+import lombok.Getter;
+import org.apache.hop.core.IProgressMonitor;
+import org.apache.hop.core.ProgressNullMonitorListener;
+import org.apache.hop.core.database.Database;
+
+/**
+ * One long-running database action (connect, list schemas, execute SQL, …). Cancel calls {@link
+ * Database#cancelQuery()} on the JDBC instance attached to this operation, not the tree.
+ */
+@Getter
+public class DatabaseOperation {
+
+  public enum Status {
+    RUNNING,
+    DONE,
+    FAILED,
+    CANCELLED
+  }
+
+  @FunctionalInterface
+  public interface Work {
+    void run(DatabaseOperation operation) throws Exception;
+  }
+
+  private final String id = UUID.randomUUID().toString();
+  private final String description;
+  private final String connectionName;
+  private final long startTime = System.currentTimeMillis();
+
+  private volatile Status status = Status.RUNNING;
+  private volatile long endTime;
+  private volatile String errorMessage;
+  private volatile boolean cancelled;
+
+  private final AtomicReference<Database> database = new AtomicReference<>();
+
+  public DatabaseOperation(String description, String connectionName) {
+    this.description = description;
+    this.connectionName = connectionName == null ? "" : connectionName;
+  }
+
+  public void attachDatabase(Database db) {
+    database.set(db);
+  }
+
+  public void cancel() {
+    cancelled = true;
+    Database db = database.get();
+    if (db != null) {
+      try {
+        db.cancelQuery();
+      } catch (Exception ignored) {
+        // Best-effort; the worker still sees isCancelled().
+      }
+    }
+  }
+
+  public boolean isCancelled() {
+    return cancelled;
+  }
+
+  public IProgressMonitor newMonitor() {
+    return new CancelMonitor();
+  }
+
+  public void complete() {
+    status = cancelled ? Status.CANCELLED : Status.DONE;
+    endTime = System.currentTimeMillis();
+  }
+
+  public void fail(String message) {
+    status = cancelled ? Status.CANCELLED : Status.FAILED;
+    errorMessage = message;
+    endTime = System.currentTimeMillis();
+  }
+
+  public long elapsedMillis() {
+    long end = endTime > 0 ? endTime : System.currentTimeMillis();
+    return Math.max(0, end - startTime);
+  }
+
+  public boolean isFinished() {
+    return status != Status.RUNNING;
+  }
+
+  private final class CancelMonitor extends ProgressNullMonitorListener {
+    @Override
+    public boolean isCanceled() {
+      return cancelled;
+    }
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanel.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanel.java
new file mode 100644
index 0000000000..024d8af2e9
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseOperationsPanel.java
@@ -0,0 +1,343 @@
+/*
+ * 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.perspective.database;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Consumer;
+import lombok.Getter;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
+import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.hopgui.ToolbarFacade;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.TableColumn;
+import org.eclipse.swt.widgets.TableItem;
+
+/**
+ * Database operations: a one-line status by default, expandable to a table with kill.
+ *
+ * <p>The table lives in the workbench sash. The status bar is a sibling composite so it stays
+ * visible when the sash maximizes the editor tabs.
+ */
+@GuiPlugin
+public class DatabaseOperationsPanel extends Composite {
+
+  public static final Class<?> PKG = DatabasePerspective.class;
+
+  public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID = "DatabaseOperationsPanel-Toolbar";
+  public static final String TOOLBAR_ITEM_KILL = "DatabaseOperations-Toolbar-10000-Kill";
+  public static final String TOOLBAR_ITEM_MINIMIZE = "DatabaseOperations-Toolbar-10010-Minimize";
+
+  public static final String GUI_PLUGIN_STATUS_TOOLBAR_PARENT_ID =
+      "DatabaseOperationsStatus-Toolbar";
+  public static final String TOOLBAR_ITEM_STATUS_KILL =
+      "DatabaseOperationsStatus-Toolbar-10000-Kill";
+  public static final String TOOLBAR_ITEM_STATUS_EXPAND =
+      "DatabaseOperationsStatus-Toolbar-10010-Expand";
+
+  private final List<DatabaseOperation> operations = new ArrayList<>();
+  private final Table table;
+  private final GuiToolbarWidgets toolBarWidgets;
+  private final GuiToolbarWidgets statusToolBarWidgets;
+  @Getter private final Composite statusBar;
+  private final Label statusLabel;
+  private final Runnable tickElapsed = this::refreshElapsed;
+  private boolean timerArmed;
+  private boolean expanded;
+  private Consumer<Boolean> expandedListener;
+
+  public DatabaseOperationsPanel(Composite sashParent, Composite statusParent) {
+    super(sashParent, SWT.NONE);
+    PropsUi.setLook(this);
+    setLayout(new FormLayout());
+
+    IToolbarContainer toolBarContainer =
+        ToolbarFacade.createToolbarContainer(this, SWT.WRAP | SWT.RIGHT | SWT.HORIZONTAL);
+    Control toolBar = toolBarContainer.getControl();
+    toolBar.setLayoutData(new FormDataBuilder().top().fullWidth().result());
+    PropsUi.setLook(toolBar, PropsUi.WIDGET_STYLE_TOOLBAR);
+    toolBarWidgets = new GuiToolbarWidgets();
+    toolBarWidgets.registerGuiPluginObject(this);
+    toolBarWidgets.createToolbarWidgets(toolBarContainer, GUI_PLUGIN_TOOLBAR_PARENT_ID);
+    toolBar.pack();
+
+    table = new Table(this, SWT.BORDER | SWT.FULL_SELECTION | SWT.SINGLE);
+    PropsUi.setLook(table);
+    table.setHeaderVisible(true);
+    table.setLinesVisible(true);
+    table.setLayoutData(
+        new FormDataBuilder().top(toolBar, PropsUi.getMargin()).bottom().fullWidth().result());
+    table.addListener(SWT.Selection, e -> updateKillEnablement());
+
+    addColumn(
+        BaseMessages.getString(PKG, "DatabasePerspective.Operations.Column.Description"), 280);
+    addColumn(BaseMessages.getString(PKG, "DatabasePerspective.Operations.Column.Connection"), 140);
+    addColumn(BaseMessages.getString(PKG, "DatabasePerspective.Operations.Column.Status"), 100);
+    addColumn(BaseMessages.getString(PKG, "DatabasePerspective.Operations.Column.Elapsed"), 80);
+
+    statusBar = new Composite(statusParent, SWT.NONE);
+    statusBar.setLayout(new FormLayout());
+    PropsUi.setLook(statusBar, PropsUi.WIDGET_STYLE_TOOLBAR);
+
+    IToolbarContainer statusToolBarContainer =
+        ToolbarFacade.createToolbarContainer(statusBar, SWT.WRAP | SWT.RIGHT | SWT.HORIZONTAL);
+    Control statusToolBar = statusToolBarContainer.getControl();
+    statusToolBar.setLayoutData(new FormDataBuilder().top().right().bottom().result());
+    PropsUi.setLook(statusToolBar, PropsUi.WIDGET_STYLE_TOOLBAR);
+    statusToolBarWidgets = new GuiToolbarWidgets();
+    statusToolBarWidgets.registerGuiPluginObject(this);
+    statusToolBarWidgets.createToolbarWidgets(
+        statusToolBarContainer, GUI_PLUGIN_STATUS_TOOLBAR_PARENT_ID);
+    statusToolBar.pack();
+
+    statusLabel = new Label(statusBar, SWT.LEFT);
+    PropsUi.setLook(statusLabel, PropsUi.WIDGET_STYLE_TOOLBAR);
+    statusLabel.setLayoutData(
+        new FormDataBuilder()
+            .left()
+            .top()
+            .bottom()
+            .right(statusToolBar, -PropsUi.getMargin())
+            .result());
+
+    updateStatusLine();
+    updateKillEnablement();
+  }
+
+  private void addColumn(String title, int width) {
+    TableColumn column = new TableColumn(table, SWT.NONE);
+    column.setText(title);
+    column.setWidth(width);
+  }
+
+  public void setExpandedListener(Consumer<Boolean> expandedListener) {
+    this.expandedListener = expandedListener;
+  }
+
+  public boolean isExpanded() {
+    return expanded;
+  }
+
+  public void setExpanded(boolean expanded) {
+    if (this.expanded == expanded) {
+      return;
+    }
+    this.expanded = expanded;
+    if (expandedListener != null) {
+      expandedListener.accept(expanded);
+    }
+  }
+
+  public void addOperation(DatabaseOperation operation) {
+    operations.add(0, operation);
+    TableItem item = new TableItem(table, SWT.NONE, 0);
+    fillItem(item, operation);
+    table.setSelection(0);
+    updateStatusLine();
+    updateKillEnablement();
+    armTimer();
+  }
+
+  public void refresh() {
+    if (isDisposed()) {
+      return;
+    }
+    for (int i = 0; i < operations.size() && i < table.getItemCount(); i++) {
+      fillItem(table.getItem(i), operations.get(i));
+    }
+    updateStatusLine();
+    updateKillEnablement();
+    armTimer();
+  }
+
+  private void refreshElapsed() {
+    timerArmed = false;
+    if (isDisposed()) {
+      return;
+    }
+    boolean anyRunning = false;
+    for (int i = 0; i < operations.size() && i < table.getItemCount(); i++) {
+      DatabaseOperation operation = operations.get(i);
+      table.getItem(i).setText(3, formatElapsed(operation.elapsedMillis()));
+      if (!operation.isFinished()) {
+        anyRunning = true;
+      }
+    }
+    updateStatusLine();
+    if (anyRunning) {
+      armTimer();
+    }
+  }
+
+  private void armTimer() {
+    if (timerArmed || isDisposed()) {
+      return;
+    }
+    boolean anyRunning = operations.stream().anyMatch(op -> !op.isFinished());
+    if (!anyRunning) {
+      return;
+    }
+    timerArmed = true;
+    Display display = getDisplay();
+    display.timerExec(500, tickElapsed);
+  }
+
+  private void fillItem(TableItem item, DatabaseOperation operation) {
+    item.setText(0, operation.getDescription());
+    item.setText(1, operation.getConnectionName());
+    item.setText(2, statusLabel(operation));
+    item.setText(3, formatElapsed(operation.elapsedMillis()));
+    item.setData(operation);
+  }
+
+  private void updateStatusLine() {
+    if (statusLabel == null || statusLabel.isDisposed()) {
+      return;
+    }
+    String text = formatStatusLine(currentOperation());
+    statusLabel.setText(text);
+    statusLabel.setToolTipText(text);
+  }
+
+  DatabaseOperation currentOperation() {
+    for (DatabaseOperation operation : operations) {
+      if (!operation.isFinished()) {
+        return operation;
+      }
+    }
+    return operations.isEmpty() ? null : operations.get(0);
+  }
+
+  static String statusLabel(DatabaseOperation operation) {
+    return switch (operation.getStatus()) {
+      case RUNNING -> BaseMessages.getString(PKG, "DatabasePerspective.Operations.Status.Running");
+      case DONE -> BaseMessages.getString(PKG, "DatabasePerspective.Operations.Status.Done");
+      case FAILED -> BaseMessages.getString(PKG, "DatabasePerspective.Operations.Status.Failed");
+      case CANCELLED ->
+          BaseMessages.getString(PKG, "DatabasePerspective.Operations.Status.Cancelled");
+    };
+  }
+
+  static String formatStatusLine(DatabaseOperation operation) {
+    if (operation == null) {
+      return "";
+    }
+    StringBuilder line = new StringBuilder();
+    line.append(Const.NVL(operation.getDescription(), ""));
+    if (!Utils.isEmpty(operation.getConnectionName())) {
+      line.append(" - ").append(operation.getConnectionName());
+    }
+    line.append(" - ").append(statusLabel(operation));
+    line.append(" - ").append(formatElapsed(operation.elapsedMillis()));
+    return line.toString();
+  }
+
+  static String formatElapsed(long millis) {
+    if (millis < 1000) {
+      return millis + " ms";
+    }
+    if (millis < 60_000) {
+      return String.format(Locale.ROOT, "%.1f s", millis / 1000.0);
+    }
+    long seconds = millis / 1000;
+    return String.format(Locale.ROOT, "%d:%02d", seconds / 60, seconds % 60);
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_KILL,
+      toolTip = "i18n::DatabasePerspective.Operations.Kill.Tooltip",
+      image = "ui/images/stop.svg")
+  public void killSelected() {
+    DatabaseOperation operation = selectedOperation();
+    if (operation != null && !operation.isFinished()) {
+      operation.cancel();
+      refresh();
+    }
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_MINIMIZE,
+      toolTip = "i18n::DatabasePerspective.Operations.Minimize.Tooltip",
+      image = "ui/images/minimize-panel.svg")
+  public void minimize() {
+    setExpanded(false);
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_STATUS_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_STATUS_KILL,
+      toolTip = "i18n::DatabasePerspective.Operations.KillCurrent.Tooltip",
+      image = "ui/images/stop.svg")
+  public void killCurrent() {
+    DatabaseOperation operation = currentOperation();
+    if (operation != null && !operation.isFinished()) {
+      operation.cancel();
+      refresh();
+    }
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_STATUS_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_STATUS_EXPAND,
+      toolTip = "i18n::DatabasePerspective.Operations.Expand.Tooltip",
+      image = "ui/images/maximize-panel.svg")
+  public void expand() {
+    setExpanded(true);
+  }
+
+  private DatabaseOperation selectedOperation() {
+    TableItem[] selection = table.getSelection();
+    if (selection.length != 1) {
+      return null;
+    }
+    Object data = selection[0].getData();
+    return data instanceof DatabaseOperation operation ? operation : null;
+  }
+
+  private void updateKillEnablement() {
+    DatabaseOperation selected = selectedOperation();
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_KILL, selected != null && !selected.isFinished());
+    DatabaseOperation current = currentOperation();
+    statusToolBarWidgets.enableToolbarItem(
+        TOOLBAR_ITEM_STATUS_KILL, current != null && !current.isFinished());
+  }
+
+  public void cancelAll() {
+    for (DatabaseOperation operation : operations) {
+      if (!operation.isFinished()) {
+        operation.cancel();
+      }
+    }
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabasePerspective.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabasePerspective.java
new file mode 100644
index 0000000000..35f57e39f3
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabasePerspective.java
@@ -0,0 +1,272 @@
+/*
+ * 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.perspective.database;
+
+import java.util.List;
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.key.GuiKeyboardShortcut;
+import org.apache.hop.core.gui.plugin.key.GuiOsxKeyboardShortcut;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
+import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.perspective.HopPerspectivePlugin;
+import org.apache.hop.ui.hopgui.perspective.IHopPerspective;
+import org.apache.hop.ui.hopgui.perspective.TabClosable;
+import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabFolderEvent;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+
+/**
+ * Thin {@link IHopPerspective} host around {@link DatabaseWorkbench}. Later hosts (floating dialog,
+ * dock tab) implement {@link IDatabaseWorkbenchHost} the same way.
+ */
+@HopPerspectivePlugin(
+    id = "170-HopDatabasePerspective",
+    name = "i18n::DatabasePerspective.Name",
+    description = "i18n::DatabasePerspective.Description",
+    image = "ui/images/database-perspective.svg",
+    documentationUrl = "/hop-gui/perspective-database.html")
+@GuiPlugin(
+    name = "i18n::DatabasePerspective.Name",
+    description = "i18n::DatabasePerspective.GuiPlugin.Description")
+public class DatabasePerspective implements IHopPerspective, TabClosable, IDatabaseWorkbenchHost {
+
+  public static final Class<?> PKG = DatabasePerspective.class;
+
+  private static DatabasePerspective instance;
+
+  private HopGui hopGui;
+  private DatabaseWorkbench workbench;
+
+  public DatabasePerspective() {
+    instance = this;
+  }
+
+  public static DatabasePerspective getInstance() {
+    try {
+      DatabasePerspective fromGui = HopGui.findSessionPerspective(DatabasePerspective.class);
+      if (fromGui != null) {
+        return fromGui;
+      }
+    } catch (Throwable e) {
+      // No HopGui in unit tests
+    }
+    return instance;
+  }
+
+  private boolean isInitialized() {
+    return hopGui != null && workbench != null && !workbench.isDisposed();
+  }
+
+  @Override
+  public String getId() {
+    return "database-perspective";
+  }
+
+  @GuiKeyboardShortcut(control = true, shift = true, key = 'd', global = true)
+  @GuiOsxKeyboardShortcut(command = true, shift = true, key = 'd', global = true)
+  @Override
+  public void activate() {
+    if (!isInitialized()) {
+      return;
+    }
+    hopGui.setActivePerspective(this);
+  }
+
+  @Override
+  public void perspectiveActivated() {
+    if (!isInitialized()) {
+      return;
+    }
+    updateGui(workbench.getActiveFileTypeHandler());
+  }
+
+  @Override
+  public boolean isActive() {
+    return isInitialized() && hopGui.isActivePerspective(this);
+  }
+
+  @Override
+  public void initialize(HopGui hopGui, Composite parent) {
+    this.hopGui = hopGui;
+    workbench = new DatabaseWorkbench(parent, this);
+    workbench.setLayoutData(new FormDataBuilder().fullSize().result());
+
+    HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+    keyHandler.addParentObjectToHandle(this);
+    keyHandler.addParentObjectToHandle(workbench);
+    hopGui.replaceKeyboardShortcutListeners(workbench, keyHandler);
+  }
+
+  @Override
+  public Control getControl() {
+    return workbench;
+  }
+
+  @Override
+  public List<IHopFileType> getSupportedHopFileTypes() {
+    if (!isInitialized()) {
+      return List.of();
+    }
+    return List.of(workbench.getSqlFileType());
+  }
+
+  @Override
+  public IHopFileTypeHandler getActiveFileTypeHandler() {
+    if (!isInitialized()) {
+      return new EmptyHopFileTypeHandler();
+    }
+    return workbench.getActiveFileTypeHandler();
+  }
+
+  @Override
+  public void setActiveFileTypeHandler(IHopFileTypeHandler fileTypeHandler) {
+    if (isInitialized()) {
+      workbench.setActiveFileTypeHandler(fileTypeHandler);
+    }
+  }
+
+  @Override
+  public boolean remove(IHopFileTypeHandler typeHandler) {
+    if (!isInitialized()) {
+      return false;
+    }
+    return workbench.remove(typeHandler);
+  }
+
+  @Override
+  public List<TabItemHandler> getItems() {
+    if (!isInitialized()) {
+      return List.of();
+    }
+    return workbench.getItems();
+  }
+
+  @Override
+  public void closeTab(CTabFolderEvent event, CTabItem tabItem) {
+    if (isInitialized()) {
+      workbench.closeTab(event, tabItem);
+    }
+  }
+
+  @Override
+  public CTabFolder getTabFolder() {
+    return isInitialized() ? workbench.getTabFolder() : null;
+  }
+
+  @Override
+  public void clearSearchFilters() {
+    if (isInitialized()) {
+      workbench.clearSearchFilter();
+    }
+  }
+
+  @Override
+  public List<IGuiContextHandler> getContextHandlers() {
+    return List.of();
+  }
+
+  public void openSqlFile(String filename, DatabaseMeta connection, String buffer, boolean dirty) {
+    if (!isInitialized()) {
+      return;
+    }
+    activate();
+    workbench.openSqlFile(filename, connection, buffer, dirty);
+  }
+
+  public List<String> connectionNames() {
+    if (!isInitialized()) {
+      return List.of();
+    }
+    return workbench.connectionNames();
+  }
+
+  public DatabaseMeta findConnection(String name) {
+    if (!isInitialized()) {
+      return null;
+    }
+    return workbench.findConnection(name);
+  }
+
+  @Override
+  public HopGui getHopGui() {
+    return hopGui;
+  }
+
+  @Override
+  public Shell getShell() {
+    return hopGui.getShell();
+  }
+
+  @Override
+  public Display getDisplay() {
+    return hopGui.getDisplay();
+  }
+
+  @Override
+  public IVariables getVariables() {
+    return hopGui.getVariables();
+  }
+
+  @Override
+  public IHopMetadataProvider getMetadataProvider() {
+    return hopGui.getMetadataProvider();
+  }
+
+  @Override
+  public ILoggingObject getLoggingObject() {
+    return hopGui.getLoggingObject();
+  }
+
+  @Override
+  public void asyncExec(Runnable runnable) {
+    Display display = hopGui.getDisplay();
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    display.asyncExec(
+        () -> {
+          if (workbench == null || workbench.isDisposed()) {
+            return;
+          }
+          runnable.run();
+        });
+  }
+
+  @Override
+  public void updateGui(IHopFileTypeHandler handler) {
+    if (hopGui == null || handler == null) {
+      return;
+    }
+    hopGui.handleFileCapabilities(
+        handler.getFileType(), handler, handler.hasChanged(), false, false);
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseResultsPanel.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseResultsPanel.java
new file mode 100644
index 0000000000..e959df55df
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseResultsPanel.java
@@ -0,0 +1,241 @@
+/*
+ * 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.perspective.database;
+
+import java.util.List;
+import lombok.Getter;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopValueException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.ShowRowsDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+
+/** Result sets and success/failure text under a SQL editor. */
+public class DatabaseResultsPanel extends Composite {
+
+  public static final Class<?> PKG = DatabasePerspective.class;
+
+  @Getter
+  public static class QueryResult {
+    private final int statementNr;
+    private final IRowMeta rowMeta;
+    private final List<Object[]> rows;
+
+    public QueryResult(int statementNr, IRowMeta rowMeta, List<Object[]> rows) {
+      this.statementNr = statementNr;
+      this.rowMeta = rowMeta;
+      this.rows = rows;
+    }
+  }
+
+  private final IVariables variables;
+  private final CTabFolder folder;
+  private final Text messages;
+  private final Button openWindow;
+
+  public DatabaseResultsPanel(Composite parent, IVariables variables, Runnable onClose) {
+    super(parent, SWT.NONE);
+    this.variables = variables;
+    PropsUi.setLook(this);
+    setLayout(new FormLayout());
+
+    Label title = new Label(this, SWT.NONE);
+    PropsUi.setLook(title);
+    title.setText(BaseMessages.getString(PKG, "DatabasePerspective.Results.Title"));
+    title.setLayoutData(new FormDataBuilder().top().left().result());
+
+    Button close = new Button(this, SWT.PUSH);
+    close.setText(BaseMessages.getString(PKG, "DatabasePerspective.Results.Close"));
+    close.setToolTipText(BaseMessages.getString(PKG, "DatabasePerspective.Results.Close.Tooltip"));
+    close.setLayoutData(new FormDataBuilder().top().right().result());
+    close.addListener(SWT.Selection, e -> onClose.run());
+
+    openWindow = new Button(this, SWT.PUSH);
+    openWindow.setText(BaseMessages.getString(PKG, "DatabasePerspective.Results.Float"));
+    openWindow.setToolTipText(
+        BaseMessages.getString(PKG, "DatabasePerspective.Results.Float.Tooltip"));
+    openWindow.setLayoutData(
+        new FormDataBuilder().top().right(close, -PropsUi.getMargin()).result());
+    openWindow.addListener(SWT.Selection, e -> openSelectedInWindow());
+    openWindow.setEnabled(false);
+
+    folder = new CTabFolder(this, SWT.BORDER);
+    PropsUi.setLook(folder, PropsUi.WIDGET_STYLE_TAB);
+    folder.setLayoutData(
+        new FormDataBuilder().top(close, PropsUi.getMargin()).bottom().fullWidth().result());
+
+    CTabItem messagesTab = new CTabItem(folder, SWT.NONE);
+    messagesTab.setText(BaseMessages.getString(PKG, "DatabasePerspective.Results.Messages"));
+    messages =
+        new Text(folder, SWT.MULTI | SWT.READ_ONLY | SWT.H_SCROLL | SWT.V_SCROLL | SWT.BORDER);
+    PropsUi.setLook(messages);
+    messagesTab.setControl(messages);
+    folder.setSelection(0);
+    folder.addListener(SWT.Selection, e -> updateFloatEnablement());
+  }
+
+  public void show(List<QueryResult> queryResults, String messageText) {
+    // Drop previous result-set tabs; keep the messages tab (index 0).
+    CTabItem[] items = folder.getItems();
+    for (int i = items.length - 1; i >= 1; i--) {
+      CTabItem item = items[i];
+      if (item.getControl() != null && !item.getControl().isDisposed()) {
+        item.getControl().dispose();
+      }
+      item.dispose();
+    }
+
+    messages.setText(Const.NVL(messageText, ""));
+
+    CTabItem firstResultTab = null;
+    if (queryResults != null) {
+      for (QueryResult result : queryResults) {
+        CTabItem tab = new CTabItem(folder, SWT.NONE);
+        tab.setText(
+            BaseMessages.getString(
+                PKG,
+                "DatabasePerspective.Results.QueryTab",
+                Integer.toString(result.statementNr),
+                Integer.toString(result.rows == null ? 0 : result.rows.size())));
+        tab.setData(result);
+        tab.setControl(buildTable(result));
+        if (firstResultTab == null) {
+          firstResultTab = tab;
+        }
+      }
+    }
+    folder.setSelection(firstResultTab != null ? firstResultTab : folder.getItem(0));
+    updateFloatEnablement();
+    layout(true, true);
+  }
+
+  private void openSelectedInWindow() {
+    QueryResult result = currentQueryResult();
+    if (result == null || result.getRowMeta() == null || isDisposed()) {
+      return;
+    }
+    List<Object[]> rows = result.getRows() == null ? List.of() : result.getRows();
+    new ShowRowsDialog(
+            getShell(),
+            variables,
+            BaseMessages.getString(
+                PKG,
+                "DatabasePerspective.Results.Float.Title",
+                Integer.toString(result.getStatementNr())),
+            BaseMessages.getString(PKG, "DatabasePerspective.Results.Float.Message"),
+            result.getRowMeta(),
+            rows)
+        .open();
+  }
+
+  private QueryResult currentQueryResult() {
+    if (folder == null || folder.isDisposed()) {
+      return null;
+    }
+    CTabItem selected = folder.getSelection();
+    if (selected == null || selected.isDisposed()) {
+      return null;
+    }
+    Object data = selected.getData();
+    return data instanceof QueryResult result ? result : null;
+  }
+
+  private void updateFloatEnablement() {
+    if (openWindow == null || openWindow.isDisposed()) {
+      return;
+    }
+    QueryResult result = currentQueryResult();
+    openWindow.setEnabled(result != null && result.getRowMeta() != null);
+  }
+
+  private TableView buildTable(QueryResult result) {
+    IRowMeta rowMeta = result.rowMeta;
+    int columns = rowMeta == null ? 0 : rowMeta.size();
+    ColumnInfo[] columnInfos = new ColumnInfo[columns];
+    for (int i = 0; i < columns; i++) {
+      IValueMeta valueMeta = rowMeta.getValueMeta(i);
+      columnInfos[i] =
+          new ColumnInfo(valueMeta.getName(), ColumnInfo.COLUMN_TYPE_TEXT, valueMeta.isNumeric());
+      columnInfos[i].setValueMeta(valueMeta);
+      columnInfos[i].setReadOnly(true);
+      columnInfos[i].setImage(GuiResource.getInstance().getImage(valueMeta));
+    }
+
+    TableView view =
+        new TableView(
+            variables,
+            folder,
+            SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+            columnInfos,
+            0,
+            true,
+            null,
+            PropsUi.getInstance());
+    view.setShowingBlueNullValues(true);
+    view.setShortenDisplayedValues(true);
+    view.setSortable(true);
+    view.setReadonly(true);
+
+    if (result.rows != null && rowMeta != null) {
+      int lineNr = 0;
+      for (int r = 0; r < result.rows.size(); r++) {
+        TableItem item = r == 0 ? view.table.getItem(0) : new TableItem(view.table, SWT.NONE);
+        lineNr++;
+        item.setText(0, Integer.toString(lineNr));
+        Object[] row = result.rows.get(r);
+        if (row == null) {
+          continue;
+        }
+        for (int c = 0; c < rowMeta.size(); c++) {
+          String display;
+          try {
+            display = rowMeta.getValueMeta(c).getString(row[c]);
+          } catch (HopValueException | ArrayIndexOutOfBoundsException e) {
+            display = null;
+          }
+          if (display == null) {
+            item.setText(c + 1, "<null>");
+            item.setForeground(c + 1, GuiResource.getInstance().getColorBlue());
+          } else {
+            view.setCellValue(item, c + 1, display);
+          }
+        }
+      }
+    }
+    if (!view.isDisposed()) {
+      view.optWidth(true, 200);
+    }
+    return view;
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
new file mode 100644
index 0000000000..48724e1366
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlEditorTab.java
@@ -0,0 +1,762 @@
+/*
+ * 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.perspective.database;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import lombok.Getter;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.database.Database;
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.database.SqlScriptStatement;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementFilter;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.util.EnvUtil;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.apache.hop.ui.hopgui.ContentEditorFacade;
+import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.perspective.database.config.DatabasePerspectiveConfig;
+import org.apache.hop.ui.hopgui.perspective.database.config.DatabasePerspectiveConfigSingleton;
+import org.apache.hop.ui.hopgui.shared.SashFormMemory;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Listener;
+
+/** One SQL editor tab: editor, Run, hideable results sash. */
+@GuiPlugin
+public class DatabaseSqlEditorTab implements IHopFileTypeHandler {
+
+  public static final Class<?> PKG = DatabasePerspective.class;
+
+  public static final String TOOLBAR_ITEM_RUN = "ContentEditor-Toolbar-05000-run";
+  public static final String TOOLBAR_ITEM_RUN_ALL = "ContentEditor-Toolbar-05010-run-all";
+
+  static final String DATA_SQL_TAB = DatabaseSqlEditorTab.class.getName();
+
+  public static final int DEFAULT_ROW_LIMIT = DatabasePerspectiveConfig.DEFAULT_QUERY_ROW_LIMIT;
+
+  private static final DatabaseSqlFileType FILE_TYPE = new DatabaseSqlFileType();
+
+  private final IDatabaseWorkbenchHost host;
+  private final DatabaseWorkbench workbench;
+  @Getter private final DatabaseMeta databaseMeta;
+  @Getter private final Composite control;
+  @Getter private CTabItem tabItem;
+
+  private final IContentEditorWidget editor;
+  private final SashForm sash;
+  private final DatabaseResultsPanel resultsPanel;
+
+  private String filename;
+  private String name;
+  private boolean changed;
+
+  /** True while Ctrl/Cmd+Enter is being handled so Traverse and KeyDown cannot both run SQL. */
+  private boolean executeShortcutArmed;
+
+  public DatabaseSqlEditorTab(
+      Composite parent,
+      IDatabaseWorkbenchHost host,
+      DatabaseWorkbench workbench,
+      DatabaseMeta databaseMeta) {
+    this.host = host;
+    this.workbench = workbench;
+    this.databaseMeta = databaseMeta;
+    this.name = untitledName();
+
+    control = new Composite(parent, SWT.NONE);
+    control.setLayout(new FormLayout());
+    PropsUi.setLook(control);
+
+    sash = new SashForm(control, SWT.VERTICAL);
+    sash.setLayoutData(new FormDataBuilder().fullSize().result());
+
+    Composite editorArea = new Composite(sash, SWT.NONE);
+    editorArea.setLayout(new FormLayout());
+    PropsUi.setLook(editorArea);
+
+    editor = ContentEditorFacade.createContentEditor(editorArea, "sql");
+    editor.addModifyListener(
+        e -> {
+          setChanged();
+          host.updateGui(this);
+          workbench.schedulePersistSqlTabs();
+        });
+    editor.getControl().setData(DATA_SQL_TAB, this);
+    editor
+        .getControl()
+        .setData(IContentEditorWidget.DATA_EXECUTE_ACTION, (Runnable) this::executeSql);
+    control.setData(DATA_SQL_TAB, this);
+    control.setData(IContentEditorWidget.DATA_EXECUTE_ACTION, (Runnable) this::executeSql);
+    installExecuteShortcut();
+
+    resultsPanel = new DatabaseResultsPanel(sash, host.getVariables(), this::hideResults);
+    sash.setWeights(70, 30);
+    SashFormMemory.persist(sash, "database-sql-editor-sash", 70, 30);
+    hideResults();
+  }
+
+  public void setTabItem(CTabItem tabItem) {
+    this.tabItem = tabItem;
+    refreshTabPresentation();
+  }
+
+  public void setInitialText(String sql) {
+    editor.setTextSuppressModify(Const.NVL(sql, ""));
+  }
+
+  public String getSqlText() {
+    return editor.getText();
+  }
+
+  private String untitledName() {
+    return BaseMessages.getString(
+        PKG, "DatabasePerspective.SqlTab.Untitled", Const.NVL(databaseMeta.getName(), ""));
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_RUN,
+      toolTip = "i18n::DatabasePerspective.SqlTab.Run.Tooltip",
+      image = "ui/images/run.svg",
+      separator = true)
+  public static void runFromEditor(IContentEditorWidget editor) {
+    DatabaseSqlEditorTab tab = fromEditor(editor);
+    if (tab != null) {
+      tab.executeSql();
+    }
+  }
+
+  @GuiToolbarElement(
+      root = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_RUN_ALL,
+      toolTip = "i18n::DatabasePerspective.SqlTab.RunAll.Tooltip",
+      image = "ui/images/run-all.svg")
+  public static void runAllFromEditor(IContentEditorWidget editor) {
+    DatabaseSqlEditorTab tab = fromEditor(editor);
+    if (tab != null) {
+      tab.executeAllSql();
+    }
+  }
+
+  @GuiToolbarElementFilter(parentId = IContentEditorWidget.GUI_PLUGIN_TOOLBAR_PARENT_ID)
+  public static boolean showRunOnDatabaseSqlEditor(String itemId, Object guiPluginInstance) {
+    if (!TOOLBAR_ITEM_RUN.equals(itemId) && !TOOLBAR_ITEM_RUN_ALL.equals(itemId)) {
+      return true;
+    }
+    if (!(guiPluginInstance instanceof IContentEditorWidget editor)) {
+      return false;
+    }
+    return fromEditor(editor) != null || isInDatabaseWorkbench(editor);
+  }
+
+  /**
+   * Listen for Ctrl+Enter / Cmd+Enter ({@link #executeSql()}) and Ctrl+Shift+Enter /
+   * Cmd+Shift+Enter ({@link #executeAllSql()}) on the editor widget tree.
+   *
+   * <p>SWT does not bubble keys, so the listener has to sit on the focused control (desktop {@code
+   * StyledText}, RAP fallback {@code Text}), not on this tab composite. On GTK, {@code StyledText}
+   * marks Ctrl+Return as {@link SWT#TRAVERSE_RETURN} with {@code doit=true}; {@code
+   * Control.translateTraversal} then consumes the key as the shell default button and never sends
+   * {@link SWT#KeyDown}. Cancelling that traverse is what lets the shortcut fire.
+   *
+   * <p>Hop Web's Monaco editor does not go through SWT keys; it notifies {@code executeRequested}
+   * which looks up {@link IContentEditorWidget#DATA_EXECUTE_ACTION}.
+   */
+  private void installExecuteShortcut() {
+    Listener listener = this::handleExecuteShortcut;
+    Deque<Control> stack = new ArrayDeque<>();
+    stack.push(editor.getControl());
+    while (!stack.isEmpty()) {
+      Control current = stack.pop();
+      if (current.isDisposed()) {
+        continue;
+      }
+      current.addListener(SWT.Traverse, listener);
+      current.addListener(SWT.KeyDown, listener);
+      current.addListener(SWT.Verify, listener);
+      if (current instanceof Composite composite) {
+        for (Control child : composite.getChildren()) {
+          stack.push(child);
+        }
+      }
+    }
+
+    // Display filters run before StyledText.handleKeyDown, so we can mark the newline to eat
+    // before it is inserted. Widget KeyDown listeners are too late.
+    Display display = control.getDisplay();
+    Listener eatFilter =
+        event -> {
+          if (!(event.widget instanceof Control focused) || !isInThisEditor(focused)) {
+            return;
+          }
+          if (IContentEditorWidget.isExecuteKey(event.stateMask, event.keyCode, event.character)) {
+            armEatExecuteNewline();
+          }
+        };
+    display.addFilter(SWT.KeyDown, eatFilter);
+    control.addDisposeListener(
+        e -> {
+          if (!display.isDisposed()) {
+            display.removeFilter(SWT.KeyDown, eatFilter);
+          }
+        });
+  }
+
+  /**
+   * SWT listener for Ctrl/Cmd+Enter. Called from {@link SWT#Traverse} (GTK), {@link SWT#KeyDown}
+   * (Windows/macOS, and GTK after traverse is cancelled), and {@link SWT#Verify} (reject the
+   * newline StyledText always inserts for Return).
+   */
+  private void handleExecuteShortcut(Event event) {
+    if (event.type == SWT.Verify) {
+      if (IContentEditorWidget.isLineDelimiterText(event.text)
+          && (executeShortcutArmed
+              || IContentEditorWidget.eatExecuteNewlineArmed(editor.getControl()))) {
+        event.doit = false;
+      }
+      return;
+    }
+    boolean runAll =
+        event.type == SWT.Traverse
+            ? IContentEditorWidget.isExecuteAllTraverse(event.detail, event.stateMask)
+            : IContentEditorWidget.isExecuteAllKey(event.stateMask, event.keyCode, event.character);
+    boolean runCurrent =
+        !runAll
+            && (event.type == SWT.Traverse
+                ? IContentEditorWidget.isExecuteTraverse(event.detail, event.stateMask)
+                : IContentEditorWidget.isExecuteKey(
+                    event.stateMask, event.keyCode, event.character));
+    if (!runAll && !runCurrent) {
+      return;
+    }
+    event.doit = false;
+    if (event.type == SWT.Traverse) {
+      event.detail = SWT.TRAVERSE_NONE;
+    }
+    armEatExecuteNewline();
+    if (executeShortcutArmed) {
+      return;
+    }
+    executeShortcutArmed = true;
+    try {
+      if (runAll) {
+        executeAllSql();
+      } else {
+        executeSql();
+      }
+    } finally {
+      Display display = host.getDisplay();
+      if (display != null && !display.isDisposed()) {
+        display.asyncExec(this::disarmExecuteShortcut);
+      } else {
+        disarmExecuteShortcut();
+      }
+    }
+  }
+
+  private void armEatExecuteNewline() {
+    Control editorControl = editor.getControl();
+    if (editorControl != null && !editorControl.isDisposed()) {
+      editorControl.setData(IContentEditorWidget.DATA_EAT_EXECUTE_NEWLINE, Boolean.TRUE);
+    }
+  }
+
+  private void disarmExecuteShortcut() {
+    executeShortcutArmed = false;
+    Control editorControl = editor.getControl();
+    if (editorControl != null && !editorControl.isDisposed()) {
+      editorControl.setData(IContentEditorWidget.DATA_EAT_EXECUTE_NEWLINE, null);
+    }
+  }
+
+  private boolean isInThisEditor(Control focused) {
+    Control editorControl = editor.getControl();
+    Control current = focused;
+    while (current != null && !current.isDisposed()) {
+      if (current == editorControl || current == control) {
+        return true;
+      }
+      current = current.getParent();
+    }
+    return false;
+  }
+
+  public static DatabaseSqlEditorTab fromEditor(IContentEditorWidget editor) {
+    if (editor == null || editor.isDisposed()) {
+      return null;
+    }
+    Control current = editor.getControl();
+    while (current != null && !current.isDisposed()) {
+      Object data = current.getData(DATA_SQL_TAB);
+      if (data instanceof DatabaseSqlEditorTab tab) {
+        return tab;
+      }
+      current = current.getParent();
+    }
+    return null;
+  }
+
+  private static boolean isInDatabaseWorkbench(IContentEditorWidget editor) {
+    Control current = editor.getControl();
+    while (current != null && !current.isDisposed()) {
+      if (current instanceof DatabaseWorkbench) {
+        return true;
+      }
+      current = current.getParent();
+    }
+    return false;
+  }
+
+  public void executeSql() {
+    String text = editor.getText();
+    String selection = editor.getSelectionText();
+    int caret = editor.getCaretPosition();
+    SqlExecuteRange.Range range = SqlExecuteRange.rangeToExecute(text, selection, caret);
+    boolean wholeRange = selection != null && !selection.isBlank();
+    executeScript(range, wholeRange ? SqlExecuteRange.ALL_STATEMENTS : caret);
+  }
+
+  /** Run every statement in the editor, ignoring caret and selection. */
+  public void executeAllSql() {
+    String text = editor.getText();
+    executeScript(
+        new SqlExecuteRange.Range(Const.NVL(text, ""), 0, text == null ? 0 : text.length()),
+        SqlExecuteRange.ALL_STATEMENTS);
+  }
+
+  private void executeScript(SqlExecuteRange.Range range, int caretInEditor) {
+    if (range == null || range.isBlank()) {
+      return;
+    }
+    workbench.ensureConnectedForExecute(databaseMeta, () -> runSqlStatements(range, caretInEditor));
+  }
+
+  private void runSqlStatements(SqlExecuteRange.Range range, int caretInEditor) {
+    List<SqlScriptStatement> parsed =
+        databaseMeta.getIDatabase().getSqlScriptStatements(range.script() + Const.CR);
+    List<SqlScriptStatement> statements =
+        SqlExecuteRange.statementsToExecute(range, parsed, caretInEditor);
+    if (statements.isEmpty()) {
+      return;
+    }
+    highlightExecutedSql(range, statements);
+
+    String description =
+        BaseMessages.getString(
+            PKG, "DatabasePerspective.Operation.ExecuteSql", databaseMeta.getName());
+    workbench.runOperation(
+        description,
+        databaseMeta.getName(),
+        operation -> {
+          int timeout = queryTimeoutSeconds();
+          int rowLimit = queryRowLimit();
+          StringBuilder messages = new StringBuilder();
+          List<DatabaseResultsPanel.QueryResult> queryResults = new ArrayList<>();
+          try (Database db =
+              new Database(host.getLoggingObject(), host.getVariables(), databaseMeta)) {
+            operation.attachDatabase(db);
+            if (timeout > 0) {
+              db.setStatementQueryTimeoutSeconds(timeout);
+            }
+            db.setQueryLimit(rowLimit);
+            db.connect();
+            int nr = 0;
+            for (SqlScriptStatement sql : statements) {
+              if (operation.isCancelled()) {
+                messages
+                    .append(BaseMessages.getString(PKG, "DatabasePerspective.SqlTab.Cancelled"))
+                    .append(Const.CR);
+                break;
+              }
+              nr++;
+              if (sql.isQuery()) {
+                List<Object[]> rows = db.getRows(sql.getStatement(), rowLimit);
+                IRowMeta rowMeta = db.getReturnRowMeta();
+                queryResults.add(new DatabaseResultsPanel.QueryResult(nr, rowMeta, rows));
+                messages.append(queryResultMessage(nr, rows.size(), rowLimit)).append(Const.CR);
+              } else {
+                db.execStatement(sql.getStatement());
+                messages
+                    .append(
+                        BaseMessages.getString(
+                            PKG, "DatabasePerspective.SqlTab.Executed", sql.getStatement().trim()))
+                    .append(Const.CR);
+              }
+            }
+          }
+          String messageText = messages.toString();
+          host.asyncExec(
+              () -> {
+                resultsPanel.show(queryResults, messageText);
+                showResults();
+              });
+        });
+  }
+
+  private void highlightExecutedSql(
+      SqlExecuteRange.Range range, List<SqlScriptStatement> statements) {
+    if (!DatabasePerspectiveConfigSingleton.getConfig().isSelectExecutedSql()) {
+      return;
+    }
+    int[] offsets = SqlExecuteRange.editorOffsets(range, statements);
+    if (offsets == null || editor.isDisposed()) {
+      return;
+    }
+    editor.setSelection(offsets[0], offsets[1]);
+  }
+
+  static int queryRowLimit() {
+    return DatabasePerspectiveConfigSingleton.getConfig().resolvedQueryRowLimit();
+  }
+
+  static String queryResultMessage(int statementNr, int rowCount, int rowLimit) {
+    if (rowLimit > 0 && rowCount >= rowLimit) {
+      return BaseMessages.getString(
+          PKG,
+          "DatabasePerspective.SqlTab.QueryRowsLimited",
+          Integer.toString(statementNr),
+          Integer.toString(rowCount),
+          Integer.toString(rowLimit));
+    }
+    return BaseMessages.getString(
+        PKG,
+        "DatabasePerspective.SqlTab.QueryRows",
+        Integer.toString(statementNr),
+        Integer.toString(rowCount));
+  }
+
+  private int queryTimeoutSeconds() {
+    IVariables variables = host.getVariables();
+    String raw =
+        variables.getVariable(
+            Const.HOP_QUERY_PREVIEW_TIMEOUT,
+            EnvUtil.getSystemProperty(Const.HOP_QUERY_PREVIEW_TIMEOUT, "0"));
+    return Math.max(0, Const.toInt(variables.resolve(raw), 0));
+  }
+
+  private void showResults() {
+    sash.setMaximizedControl(null);
+    SashFormMemory.restore(sash, "database-sql-editor-sash", 70, 30);
+  }
+
+  private void hideResults() {
+    sash.setMaximizedControl(sash.getChildren()[0]);
+  }
+
+  private void refreshTabPresentation() {
+    if (tabItem == null || tabItem.isDisposed()) {
+      return;
+    }
+    tabItem.setText(Const.NVL(getName(), ""));
+    tabItem.setImage(GuiResource.getInstance().getImageFile());
+    tabItem.setFont(
+        changed ? GuiResource.getInstance().getFontBold() : tabItem.getParent().getFont());
+  }
+
+  public void setChanged() {
+    if (!changed) {
+      changed = true;
+      refreshTabPresentation();
+    }
+  }
+
+  public void clearChanged() {
+    if (changed) {
+      changed = false;
+      refreshTabPresentation();
+    }
+  }
+
+  @Override
+  public Object getSubject() {
+    return this;
+  }
+
+  @Override
+  public String getName() {
+    if (!Utils.isEmpty(filename)) {
+      try {
+        return HopVfs.getFileObject(filename, getVariables()).getName().getBaseName();
+      } catch (Exception e) {
+        return name;
+      }
+    }
+    return name;
+  }
+
+  @Override
+  public void setName(String name) {
+    this.name = name;
+    refreshTabPresentation();
+  }
+
+  @Override
+  public IHopFileType getFileType() {
+    return FILE_TYPE;
+  }
+
+  @Override
+  public String getFilename() {
+    return filename;
+  }
+
+  @Override
+  public void setFilename(String filename) {
+    this.filename = filename;
+    refreshTabPresentation();
+  }
+
+  @Override
+  public void save() throws HopException {
+    if (Utils.isEmpty(filename)) {
+      throw new HopException("No filename set");
+    }
+    try {
+      try (OutputStream outputStream = HopVfs.getOutputStream(filename, false, getVariables())) {
+        outputStream.write(editor.getText().getBytes(StandardCharsets.UTF_8));
+        outputStream.flush();
+      }
+      clearChanged();
+      host.updateGui(this);
+      workbench.refreshTab(this);
+      workbench.schedulePersistSqlTabs();
+    } catch (Exception e) {
+      throw new HopException("Unable to save file '" + filename + "'", e);
+    }
+  }
+
+  @Override
+  public void saveAs(String newFilename) throws HopException {
+    try {
+      if (!newFilename.toLowerCase().endsWith(FILE_TYPE.getDefaultFileExtension())) {
+        newFilename = newFilename + FILE_TYPE.getDefaultFileExtension();
+      }
+      FileObject fileObject = HopVfs.getFileObject(newFilename, getVariables());
+      if (!HopVfs.startsWithScheme(newFilename, getVariables()) && !newFilename.contains("://")) {
+        newFilename = HopVfs.normalize(newFilename);
+        fileObject = HopVfs.getFileObject(newFilename, getVariables());
+      } else {
+        newFilename = fileObject.getName().getURI();
+      }
+      if (fileObject.exists()) {
+        MessageBox box = new MessageBox(host.getShell(), SWT.YES | SWT.NO | SWT.ICON_QUESTION);
+        box.setText(BaseMessages.getString(PKG, "DatabasePerspective.Save.Overwrite.Title"));
+        box.setMessage(
+            BaseMessages.getString(PKG, "DatabasePerspective.Save.Overwrite.Message", newFilename));
+        if ((box.open() & SWT.YES) == 0) {
+          return;
+        }
+      }
+      setFilename(newFilename);
+      save();
+      host.getHopGui().fileRefreshDelegate.register(newFilename, this);
+    } catch (HopException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new HopException("Error validating file existence for '" + newFilename + "'", e);
+    }
+  }
+
+  public void loadFromVfs() throws HopException {
+    if (Utils.isEmpty(filename)) {
+      return;
+    }
+    try {
+      FileObject file = HopVfs.getFileObject(filename, getVariables());
+      if (!file.exists()) {
+        throw new HopException("File '" + filename + "' doesn't exist");
+      }
+      try (InputStream inputStream = HopVfs.getInputStream(file)) {
+        String contents = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
+        editor.setTextSuppressModify(Const.NVL(contents, ""));
+      }
+      clearChanged();
+    } catch (HopException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new HopException("I/O exception while reading '" + filename + "'", e);
+    }
+  }
+
+  public void applyBuffer(String text, boolean markDirty) {
+    editor.setTextSuppressModify(Const.NVL(text, ""));
+    if (markDirty) {
+      setChanged();
+    } else {
+      clearChanged();
+    }
+  }
+
+  @Override
+  public void start() {}
+
+  @Override
+  public void stop() {}
+
+  @Override
+  public void pause() {}
+
+  @Override
+  public void resume() {}
+
+  @Override
+  public void preview() {}
+
+  @Override
+  public void debug() {}
+
+  @Override
+  public void redraw() {}
+
+  @Override
+  public void updateGui() {
+    host.getDisplay().asyncExec(() -> host.updateGui(this));
+  }
+
+  @Override
+  public void selectAll() {
+    editor.selectAll();
+  }
+
+  @Override
+  public void unselectAll() {
+    editor.unselectAll();
+  }
+
+  @Override
+  public void copySelectedToClipboard() {
+    editor.copy();
+  }
+
+  @Override
+  public void cutSelectedToClipboard() {
+    editor.cut();
+  }
+
+  @Override
+  public void deleteSelected() {}
+
+  @Override
+  public void pasteFromClipboard() {
+    editor.paste();
+  }
+
+  @Override
+  public boolean isCloseable() {
+    try {
+      if (!changed) {
+        return true;
+      }
+      MessageBox messageDialog =
+          new MessageBox(host.getShell(), SWT.ICON_QUESTION | SWT.YES | SWT.NO | SWT.CANCEL);
+      messageDialog.setText(BaseMessages.getString(PKG, "DatabasePerspective.Save.Ask.Title"));
+      messageDialog.setMessage(
+          BaseMessages.getString(PKG, "DatabasePerspective.Save.Ask.Message", getName()));
+      int answer = messageDialog.open();
+      if ((answer & SWT.YES) != 0) {
+        if (Utils.isEmpty(filename)) {
+          host.getHopGui().fileDelegate.fileSaveAs();
+          return !changed;
+        }
+        save();
+        return true;
+      }
+      return (answer & SWT.NO) != 0;
+    } catch (Exception e) {
+      new ErrorDialog(
+          host.getShell(),
+          BaseMessages.getString(PKG, "DatabasePerspective.Error.Title"),
+          BaseMessages.getString(PKG, "DatabasePerspective.Save.Error.Message", getName()),
+          e);
+      return false;
+    }
+  }
+
+  @Override
+  public void close() {
+    workbench.remove(this);
+  }
+
+  @Override
+  public boolean hasChanged() {
+    return changed;
+  }
+
+  @Override
+  public void undo() {
+    editor.undo();
+  }
+
+  @Override
+  public void redo() {
+    editor.redo();
+  }
+
+  @Override
+  public Map<String, Object> getStateProperties() {
+    return new HashMap<>();
+  }
+
+  @Override
+  public void applyStateProperties(Map<String, Object> stateProperties) {}
+
+  @Override
+  public List<IGuiContextHandler> getContextHandlers() {
+    return List.of();
+  }
+
+  @Override
+  public IVariables getVariables() {
+    return host.getVariables();
+  }
+
+  public boolean isSameFile(String otherFilename) {
+    return !Utils.isEmpty(filename) && Objects.equals(filename, otherFilename);
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlFileType.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlFileType.java
new file mode 100644
index 0000000000..b64b726991
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlFileType.java
@@ -0,0 +1,112 @@
+/*
+ * 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.perspective.database;
+
+import java.util.List;
+import java.util.Properties;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.file.IHasFilename;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.apache.hop.ui.hopgui.file.HopFileTypeBase;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.perspective.explorer.file.capabilities.FileTypeCapabilities;
+
+/**
+ * File type of SQL editor tabs in the database perspective. {@link #isHandledBy} is always false so
+ * Explorer keeps the default handler for {@code .sql} files.
+ */
+public class DatabaseSqlFileType extends HopFileTypeBase {
+
+  private static final Properties CAPABILITIES =
+      FileTypeCapabilities.getCapabilities(
+          IHopFileType.CAPABILITY_SAVE,
+          IHopFileType.CAPABILITY_SAVE_AS,
+          IHopFileType.CAPABILITY_CLOSE,
+          IHopFileType.CAPABILITY_FILE_HISTORY,
+          IHopFileType.CAPABILITY_COPY,
+          IHopFileType.CAPABILITY_CUT,
+          IHopFileType.CAPABILITY_PASTE,
+          IHopFileType.CAPABILITY_SELECT,
+          IHopFileType.CAPABILITY_SEARCH);
+
+  @Override
+  public String getName() {
+    return "Database SQL";
+  }
+
+  @Override
+  public String getDefaultFileExtension() {
+    return ".sql";
+  }
+
+  @Override
+  public String[] getFilterExtensions() {
+    return new String[] {"*.sql"};
+  }
+
+  @Override
+  public String[] getFilterNames() {
+    return new String[] {"SQL files"};
+  }
+
+  @Override
+  public Properties getCapabilities() {
+    return CAPABILITIES;
+  }
+
+  @Override
+  public boolean hasCapability(String capability) {
+    Object available = CAPABILITIES.get(capability);
+    return available != null && "true".equalsIgnoreCase(available.toString());
+  }
+
+  @Override
+  public IHopFileTypeHandler openFile(HopGui hopGui, String filename, IVariables variables)
+      throws HopException {
+    return new EmptyHopFileTypeHandler();
+  }
+
+  @Override
+  public IHopFileTypeHandler newFile(HopGui hopGui, IVariables variables) throws HopException {
+    return new EmptyHopFileTypeHandler();
+  }
+
+  @Override
+  public boolean isHandledBy(String filename, boolean checkContent) {
+    return false;
+  }
+
+  @Override
+  public boolean supportsFile(IHasFilename metaObject) {
+    return false;
+  }
+
+  @Override
+  public List<IGuiContextHandler> getContextHandlers() {
+    return List.of();
+  }
+
+  @Override
+  public String getFileTypeImage() {
+    return "ui/images/file.svg";
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
new file mode 100644
index 0000000000..ab5e050208
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseSqlTabMemory.java
@@ -0,0 +1,232 @@
+/*
+ * 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.perspective.database;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.history.AuditList;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.AuditState;
+import org.apache.hop.history.AuditStateMap;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.eclipse.swt.widgets.Display;
+
+/**
+ * Remembers SQL editor tabs across the Database perspective, floating window and bottom dock, and
+ * across Hop Gui restarts. Stored per project namespace in the audit trail (same mechanism as
+ * terminal tabs).
+ *
+ * <p>Query results are not persisted. Untitled or dirty buffers are stored as text (capped). Saved
+ * clean files are reopened from VFS.
+ */
+final class DatabaseSqlTabMemory {
+
+  static final String AUDIT_TYPE = "database-sql-tabs";
+  static final String SELECTION_KEY = "selection";
+  static final String PROP_CONNECTION = "connection";
+  static final String PROP_FILENAME = "filename";
+  static final String PROP_SQL = "sql";
+  static final String PROP_DIRTY = "dirty";
+  static final String PROP_NAME = "name";
+  static final String PROP_INDEX = "index";
+
+  static final int MAX_SQL_CHARS = 512_000;
+  private static final int SAVE_DEBOUNCE_MS = 400;
+
+  private DatabaseSqlTabMemory() {}
+
+  static void restore(DatabaseWorkbench workbench) {
+    if (workbench == null || workbench.isDisposed() || workbench.hasSqlEditorTabs()) {
+      return;
+    }
+    try {
+      String group = HopNamespace.getNamespace();
+      AuditList list = AuditManager.getActive().retrieveList(group, AUDIT_TYPE);
+      AuditStateMap stateMap = AuditManager.getActive().loadAuditStateMap(group, AUDIT_TYPE);
+      List<Snapshot> snapshots = snapshotsFromAudit(list, stateMap);
+      int selected = 0;
+      AuditState selection = stateMap.get(SELECTION_KEY);
+      if (selection != null) {
+        selected = number(selection.getStateMap().get(PROP_INDEX), 0);
+      }
+      workbench.restoringSqlTabs = true;
+      try {
+        int opened = 0;
+        int selectIndex = 0;
+        for (int i = 0; i < snapshots.size(); i++) {
+          Snapshot snapshot = snapshots.get(i);
+          if (workbench.restoreSqlTab(snapshot)) {
+            if (i == selected) {
+              selectIndex = opened;
+            }
+            opened++;
+          }
+        }
+        workbench.selectSqlTabIndex(selectIndex);
+      } finally {
+        workbench.restoringSqlTabs = false;
+      }
+    } catch (Exception e) {
+      LogChannel.UI.logError("Unable to restore Database SQL editor tabs", e);
+    }
+  }
+
+  static void scheduleSave(DatabaseWorkbench workbench) {
+    if (workbench == null || workbench.isDisposed() || workbench.restoringSqlTabs) {
+      return;
+    }
+    Display display = workbench.getDisplay();
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    display.timerExec(-1, workbench.persistSqlTabsRunnable);
+    display.timerExec(SAVE_DEBOUNCE_MS, workbench.persistSqlTabsRunnable);
+  }
+
+  static void saveNow(DatabaseWorkbench workbench) {
+    if (workbench == null || workbench.isDisposed() || workbench.restoringSqlTabs) {
+      return;
+    }
+    Display display = workbench.getDisplay();
+    if (display != null && !display.isDisposed()) {
+      display.timerExec(-1, workbench.persistSqlTabsRunnable);
+    }
+    save(workbench);
+  }
+
+  static void save(DatabaseWorkbench workbench) {
+    if (workbench == null || workbench.isDisposed()) {
+      return;
+    }
+    try {
+      List<Snapshot> snapshots = workbench.snapshotSqlTabs();
+      int selected = workbench.selectedSqlTabIndex();
+      AuditStateMap stateMap = new AuditStateMap();
+      List<String> ids = new ArrayList<>();
+      for (int i = 0; i < snapshots.size(); i++) {
+        String id = "sql-" + i;
+        ids.add(id);
+        stateMap.add(new AuditState(id, snapshots.get(i).toStateMap()));
+      }
+      AuditList list = new AuditList(ids);
+      Map<String, Object> selection = new LinkedHashMap<>();
+      selection.put(PROP_INDEX, selected);
+      stateMap.add(new AuditState(SELECTION_KEY, selection));
+      String group = HopNamespace.getNamespace();
+      AuditManager.getActive().storeList(group, AUDIT_TYPE, list);
+      AuditManager.getActive().saveAuditStateMap(group, AUDIT_TYPE, stateMap);
+    } catch (Exception e) {
+      LogChannel.UI.logError("Unable to save Database SQL editor tabs", e);
+    }
+  }
+
+  static List<Snapshot> snapshotsFromAudit(AuditList list, AuditStateMap stateMap) {
+    List<Snapshot> snapshots = new ArrayList<>();
+    if (list == null || Utils.isEmpty(list.getNames()) || stateMap == null) {
+      return snapshots;
+    }
+    for (String id : list.getNames()) {
+      AuditState state = stateMap.get(id);
+      if (state == null) {
+        continue;
+      }
+      Snapshot snapshot = Snapshot.fromStateMap(state.getStateMap());
+      if (snapshot != null) {
+        snapshots.add(snapshot);
+      }
+    }
+    return snapshots;
+  }
+
+  static int number(Object value, int defaultValue) {
+    if (value instanceof Number n) {
+      return n.intValue();
+    }
+    if (value != null) {
+      try {
+        return Integer.parseInt(value.toString());
+      } catch (NumberFormatException ignored) {
+        return defaultValue;
+      }
+    }
+    return defaultValue;
+  }
+
+  static final class Snapshot {
+    final String connection;
+    final String filename;
+    final String sql;
+    final boolean dirty;
+    final String name;
+
+    Snapshot(String connection, String filename, String sql, boolean dirty, String name) {
+      this.connection = Const.NVL(connection, "");
+      this.filename = Const.NVL(filename, "");
+      this.sql = sql;
+      this.dirty = dirty;
+      this.name = Const.NVL(name, "");
+    }
+
+    Map<String, Object> toStateMap() {
+      Map<String, Object> map = new LinkedHashMap<>();
+      map.put(PROP_CONNECTION, connection);
+      map.put(PROP_FILENAME, filename);
+      map.put(PROP_DIRTY, dirty);
+      map.put(PROP_NAME, name);
+      if (sql != null) {
+        map.put(PROP_SQL, sql.length() > MAX_SQL_CHARS ? sql.substring(0, MAX_SQL_CHARS) : sql);
+      }
+      return map;
+    }
+
+    static Snapshot fromStateMap(Map<String, Object> map) {
+      if (map == null) {
+        return null;
+      }
+      String connection = string(map.get(PROP_CONNECTION));
+      if (Utils.isEmpty(connection)) {
+        return null;
+      }
+      Object dirtyValue = map.get(PROP_DIRTY);
+      boolean dirty =
+          dirtyValue instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(dirtyValue));
+      return new Snapshot(
+          connection,
+          string(map.get(PROP_FILENAME)),
+          string(map.get(PROP_SQL)),
+          dirty,
+          string(map.get(PROP_NAME)));
+    }
+
+    private static String string(Object value) {
+      return value == null ? "" : value.toString();
+    }
+  }
+
+  static Snapshot snapshotOf(DatabaseSqlEditorTab tab) {
+    boolean persistBuffer = Utils.isEmpty(tab.getFilename()) || tab.hasChanged();
+    String sql = persistBuffer ? tab.getSqlText() : null;
+    return new Snapshot(
+        tab.getDatabaseMeta().getName(), tab.getFilename(), sql, tab.hasChanged(), tab.getName());
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoFileType.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoFileType.java
new file mode 100644
index 0000000000..42ccabaa80
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoFileType.java
@@ -0,0 +1,100 @@
+/*
+ * 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.perspective.database;
+
+import java.util.List;
+import java.util.Properties;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.file.IHasFilename;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.apache.hop.ui.hopgui.file.HopFileTypeBase;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.perspective.explorer.file.capabilities.FileTypeCapabilities;
+
+/** File type of table-information tabs (close only; nothing to save). */
+public class DatabaseTableInfoFileType extends HopFileTypeBase {
+
+  private static final Properties CAPABILITIES =
+      FileTypeCapabilities.getCapabilities(IHopFileType.CAPABILITY_CLOSE);
+
+  @Override
+  public String getName() {
+    return "Object information";
+  }
+
+  @Override
+  public String getDefaultFileExtension() {
+    return null;
+  }
+
+  @Override
+  public String[] getFilterExtensions() {
+    return new String[0];
+  }
+
+  @Override
+  public String[] getFilterNames() {
+    return new String[0];
+  }
+
+  @Override
+  public Properties getCapabilities() {
+    return CAPABILITIES;
+  }
+
+  @Override
+  public boolean hasCapability(String capability) {
+    Object available = CAPABILITIES.get(capability);
+    return available != null && "true".equalsIgnoreCase(available.toString());
+  }
+
+  @Override
+  public IHopFileTypeHandler openFile(HopGui hopGui, String filename, IVariables variables)
+      throws HopException {
+    return new EmptyHopFileTypeHandler();
+  }
+
+  @Override
+  public IHopFileTypeHandler newFile(HopGui hopGui, IVariables variables) throws HopException {
+    return new EmptyHopFileTypeHandler();
+  }
+
+  @Override
+  public boolean isHandledBy(String filename, boolean checkContent) {
+    return false;
+  }
+
+  @Override
+  public boolean supportsFile(IHasFilename metaObject) {
+    return false;
+  }
+
+  @Override
+  public List<IGuiContextHandler> getContextHandlers() {
+    return List.of();
+  }
+
+  @Override
+  public String getFileTypeImage() {
+    return "ui/images/table.svg";
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoTab.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoTab.java
new file mode 100644
index 0000000000..308a8c5443
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTableInfoTab.java
@@ -0,0 +1,569 @@
+/*
+ * 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.perspective.database;
+
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import lombok.Getter;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.database.Database;
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.database.DatabaseObjectDdl;
+import org.apache.hop.core.row.IRowMeta;
+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.i18n.BaseMessages;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.core.widget.editor.IContentEditorWidget;
+import org.apache.hop.ui.hopgui.ContentEditorFacade;
+import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
+import org.apache.hop.ui.hopgui.file.IHopFileType;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.TableItem;
+
+/** Object information tab: identity, columns, indexes, DDL. */
+public class DatabaseTableInfoTab implements IHopFileTypeHandler {
+
+  public static final Class<?> PKG = DatabasePerspective.class;
+
+  private static final DatabaseTableInfoFileType FILE_TYPE = new DatabaseTableInfoFileType();
+
+  private final IDatabaseWorkbenchHost host;
+  private final DatabaseWorkbench workbench;
+  @Getter private final DatabaseMeta databaseMeta;
+  @Getter private final String schemaName;
+  @Getter private final String tableName;
+  @Getter private final DatabaseTreeNode.Kind kind;
+  @Getter private final Composite control;
+  @Getter private CTabItem tabItem;
+
+  private TableView columnsView;
+  private TableView indexesView;
+  private IContentEditorWidget ddlEditor;
+
+  public DatabaseTableInfoTab(
+      Composite parent,
+      IDatabaseWorkbenchHost host,
+      DatabaseWorkbench workbench,
+      DatabaseMeta databaseMeta,
+      String schemaName,
+      String tableName) {
+    this(parent, host, workbench, databaseMeta, schemaName, tableName, DatabaseTreeNode.Kind.TABLE);
+  }
+
+  public DatabaseTableInfoTab(
+      Composite parent,
+      IDatabaseWorkbenchHost host,
+      DatabaseWorkbench workbench,
+      DatabaseMeta databaseMeta,
+      String schemaName,
+      String tableName,
+      DatabaseTreeNode.Kind kind) {
+    this.host = host;
+    this.workbench = workbench;
+    this.databaseMeta = databaseMeta;
+    this.schemaName = schemaName;
+    this.tableName = tableName;
+    this.kind = kind == null ? DatabaseTreeNode.Kind.TABLE : kind;
+
+    control = new Composite(parent, SWT.NONE);
+    control.setLayout(new FormLayout());
+    PropsUi.setLook(control);
+
+    Label header = new Label(control, SWT.WRAP);
+    PropsUi.setLook(header);
+    header.setText(headerText());
+    header.setLayoutData(new FormDataBuilder().top().fullWidth().result());
+
+    CTabFolder folder = new CTabFolder(control, SWT.BORDER);
+    PropsUi.setLook(folder, PropsUi.WIDGET_STYLE_TAB);
+    folder.setLayoutData(
+        new FormDataBuilder().top(header, PropsUi.getMargin()).bottom().fullWidth().result());
+
+    CTabItem columnsTab = new CTabItem(folder, SWT.NONE);
+    columnsTab.setText(BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Columns"));
+    columnsView =
+        new TableView(
+            host.getVariables(),
+            folder,
+            SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+            columnInfos(),
+            0,
+            true,
+            null,
+            PropsUi.getInstance());
+    columnsView.setReadonly(true);
+    columnsTab.setControl(columnsView);
+
+    CTabItem indexesTab = new CTabItem(folder, SWT.NONE);
+    indexesTab.setText(BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Indexes"));
+    indexesView =
+        new TableView(
+            host.getVariables(),
+            folder,
+            SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+            indexInfos(),
+            0,
+            true,
+            null,
+            PropsUi.getInstance());
+    indexesView.setReadonly(true);
+    indexesTab.setControl(indexesView);
+
+    CTabItem ddlTab = new CTabItem(folder, SWT.NONE);
+    ddlTab.setText(BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Ddl"));
+    Composite ddlParent = new Composite(folder, SWT.NONE);
+    ddlParent.setLayout(new FormLayout());
+    PropsUi.setLook(ddlParent);
+    ddlEditor = ContentEditorFacade.createContentEditor(ddlParent, "sql");
+    ddlEditor.getControl().setLayoutData(new FormDataBuilder().fullSize().result());
+    ddlEditor.setReadOnly(true);
+    ddlTab.setControl(ddlParent);
+
+    folder.setSelection(0);
+  }
+
+  public void setTabItem(CTabItem tabItem) {
+    this.tabItem = tabItem;
+    if (tabItem != null && !tabItem.isDisposed()) {
+      tabItem.setText(getName());
+      tabItem.setImage(tabImage());
+    }
+  }
+
+  private Image tabImage() {
+    GuiResource resources = GuiResource.getInstance();
+    return switch (kind) {
+      case VIEW -> resources.getImageView();
+      case SYNONYM -> resources.getImageSynonym();
+      default -> resources.getImageTable();
+    };
+  }
+
+  public void loadDetails() {
+    String qualified =
+        databaseMeta.getQuotedSchemaTableCombination(host.getVariables(), schemaName, tableName);
+    String description =
+        BaseMessages.getString(PKG, "DatabasePerspective.Operation.TableInfo", qualified);
+    workbench.runOperation(
+        description,
+        databaseMeta.getName(),
+        operation -> {
+          IRowMeta fields;
+          List<DatabaseIndexInfo> indexes;
+          String ddl;
+          boolean view = kind == DatabaseTreeNode.Kind.VIEW;
+          try (Database db =
+              new Database(host.getLoggingObject(), host.getVariables(), databaseMeta)) {
+            operation.attachDatabase(db);
+            db.connect();
+            if (operation.isCancelled()) {
+              return;
+            }
+            fields = loadFields(db, qualified);
+            indexes = loadIndexes(db, schemaName, tableName);
+            try {
+              ddl = db.getObjectDdl(schemaName, tableName, view, fields);
+            } catch (Exception e) {
+              ddl = "-- " + Const.NVL(e.getMessage(), e.getClass().getSimpleName());
+            }
+          }
+          IRowMeta loadedFields = fields;
+          List<DatabaseIndexInfo> loadedIndexes = indexes;
+          String loadedDdl =
+              view
+                  ? ddl
+                  : withIndexStatements(
+                      databaseMeta, host.getVariables(), schemaName, tableName, ddl, indexes);
+          host.asyncExec(() -> populate(loadedFields, loadedIndexes, loadedDdl));
+        });
+  }
+
+  private IRowMeta loadFields(Database db, String qualified) throws Exception {
+    try {
+      IRowMeta meta = db.getTableFieldsMeta(schemaName, tableName);
+      if (meta != null && meta.size() > 0) {
+        return meta;
+      }
+    } catch (Exception ignored) {
+      // Fall back to the query-based layout.
+    }
+    return db.getTableFields(qualified);
+  }
+
+  static List<DatabaseIndexInfo> loadIndexes(Database db, String schema, String table)
+      throws Exception {
+    Map<String, DatabaseIndexInfo> byName = new LinkedHashMap<>();
+    DatabaseMetaData metaData = db.getDatabaseMetaData();
+    String catalog = null;
+    try {
+      if (metaData.supportsCatalogsInIndexDefinitions()) {
+        catalog = db.getConnection().getCatalog();
+      }
+    } catch (Exception ignored) {
+      // Driver-dependent.
+    }
+    try (ResultSet indexList = metaData.getIndexInfo(catalog, schema, table, false, true)) {
+      if (indexList == null) {
+        return List.of();
+      }
+      while (indexList.next()) {
+        String indexName = indexList.getString("INDEX_NAME");
+        if (Utils.isEmpty(indexName)) {
+          continue;
+        }
+        DatabaseIndexInfo info = byName.computeIfAbsent(indexName, n -> new DatabaseIndexInfo());
+        info.setName(indexName);
+        info.setUnique(!indexList.getBoolean("NON_UNIQUE"));
+        String column = indexList.getString("COLUMN_NAME");
+        if (!Utils.isEmpty(column) && !info.getColumns().contains(column)) {
+          info.getColumns().add(column);
+        }
+      }
+    }
+    return new ArrayList<>(byName.values());
+  }
+
+  private void populate(IRowMeta fields, List<DatabaseIndexInfo> indexes, String ddl) {
+    if (control.isDisposed()) {
+      return;
+    }
+    fillColumns(fields);
+    fillIndexes(indexes);
+    fillDdl(ddl);
+  }
+
+  private void fillDdl(String ddl) {
+    if (ddlEditor == null || ddlEditor.isDisposed()) {
+      return;
+    }
+    ddlEditor.setTextSuppressModify(Const.NVL(ddl, ""));
+  }
+
+  /**
+   * Append {@code CREATE INDEX} statements when the table DDL was synthesized and does not already
+   * describe keys.
+   */
+  static String withIndexStatements(
+      DatabaseMeta meta,
+      IVariables variables,
+      String schema,
+      String table,
+      String ddl,
+      List<DatabaseIndexInfo> indexes) {
+    if (Utils.isEmpty(ddl)
+        || indexes == null
+        || indexes.isEmpty()
+        || catalogDdlIncludesIndexes(ddl)) {
+      return ddl;
+    }
+    String qualified = meta.getQuotedSchemaTableCombination(variables, schema, table);
+    StringBuilder buffer = new StringBuilder(ddl.trim());
+    if (!buffer.toString().endsWith(";")) {
+      buffer.append(';');
+    }
+    for (DatabaseIndexInfo index : indexes) {
+      if (Utils.isEmpty(index.getName()) || index.getColumns().isEmpty()) {
+        continue;
+      }
+      buffer.append(Const.CR).append(Const.CR);
+      buffer.append(index.isUnique() ? "CREATE UNIQUE INDEX " : "CREATE INDEX ");
+      buffer.append(Const.NVL(meta.quoteField(index.getName()), index.getName()));
+      buffer.append(" ON ").append(qualified).append(" (");
+      for (int i = 0; i < index.getColumns().size(); i++) {
+        if (i > 0) {
+          buffer.append(", ");
+        }
+        String column = index.getColumns().get(i);
+        buffer.append(Const.NVL(meta.quoteField(column), column));
+      }
+      buffer.append(");");
+    }
+    return buffer.toString();
+  }
+
+  static boolean catalogDdlIncludesIndexes(String ddl) {
+    if (Utils.isEmpty(ddl)) {
+      return false;
+    }
+    String upper = ddl.toUpperCase(Locale.ROOT);
+    return upper.contains("PRIMARY KEY")
+        || upper.contains("CREATE INDEX")
+        || upper.contains(" UNIQUE KEY")
+        || DatabaseObjectDdl.startsWithCreate(ddl) && upper.contains(" KEY ");
+  }
+
+  private void fillColumns(IRowMeta fields) {
+    columnsView.clearAll(false);
+    if (fields == null) {
+      columnsView.removeEmptyRows();
+      columnsView.setRowNums();
+      return;
+    }
+    for (int i = 0; i < fields.size(); i++) {
+      IValueMeta value = fields.getValueMeta(i);
+      TableItem item =
+          i == 0 ? columnsView.table.getItem(0) : new TableItem(columnsView.table, SWT.NONE);
+      item.setText(1, Const.NVL(value.getName(), ""));
+      item.setText(2, Const.NVL(value.getTypeDesc(), ""));
+      item.setText(3, value.getLength() >= 0 ? Integer.toString(value.getLength()) : "");
+      item.setText(4, value.getPrecision() >= 0 ? Integer.toString(value.getPrecision()) : "");
+      item.setText(5, Const.NVL(value.getComments(), ""));
+    }
+    columnsView.removeEmptyRows();
+    columnsView.setRowNums();
+    columnsView.optWidth(true);
+  }
+
+  private void fillIndexes(List<DatabaseIndexInfo> indexes) {
+    indexesView.clearAll(false);
+    if (indexes == null) {
+      indexesView.removeEmptyRows();
+      indexesView.setRowNums();
+      return;
+    }
+    for (int i = 0; i < indexes.size(); i++) {
+      DatabaseIndexInfo info = indexes.get(i);
+      TableItem item =
+          i == 0 ? indexesView.table.getItem(0) : new TableItem(indexesView.table, SWT.NONE);
+      item.setText(1, Const.NVL(info.getName(), ""));
+      item.setText(
+          2,
+          info.isUnique()
+              ? BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Yes")
+              : BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.No"));
+      item.setText(3, info.columnsAsString());
+    }
+    indexesView.removeEmptyRows();
+    indexesView.setRowNums();
+    indexesView.optWidth(true);
+  }
+
+  private ColumnInfo[] columnInfos() {
+    return new ColumnInfo[] {
+      new ColumnInfo(
+          BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Column.Name"),
+          ColumnInfo.COLUMN_TYPE_TEXT,
+          false),
+      new ColumnInfo(
+          BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Column.Type"),
+          ColumnInfo.COLUMN_TYPE_TEXT,
+          false),
+      new ColumnInfo(
+          BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Column.Length"),
+          ColumnInfo.COLUMN_TYPE_TEXT,
+          true),
+      new ColumnInfo(
+          BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Column.Precision"),
+          ColumnInfo.COLUMN_TYPE_TEXT,
+          true),
+      new ColumnInfo(
+          BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Column.Comments"),
+          ColumnInfo.COLUMN_TYPE_TEXT,
+          false)
+    };
+  }
+
+  private ColumnInfo[] indexInfos() {
+    return new ColumnInfo[] {
+      new ColumnInfo(
+          BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Index.Name"),
+          ColumnInfo.COLUMN_TYPE_TEXT,
+          false),
+      new ColumnInfo(
+          BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Index.Unique"),
+          ColumnInfo.COLUMN_TYPE_TEXT,
+          false),
+      new ColumnInfo(
+          BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Index.Columns"),
+          ColumnInfo.COLUMN_TYPE_TEXT,
+          false)
+    };
+  }
+
+  private String headerText() {
+    String kindLabel =
+        switch (kind) {
+          case VIEW -> BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Kind.View");
+          case SYNONYM -> BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Kind.Synonym");
+          default -> BaseMessages.getString(PKG, "DatabasePerspective.TableInfo.Kind.Table");
+        };
+    return BaseMessages.getString(
+        PKG,
+        "DatabasePerspective.TableInfo.Header",
+        Const.NVL(databaseMeta.getName(), ""),
+        Const.NVL(schemaName, ""),
+        kindLabel,
+        Const.NVL(tableName, ""));
+  }
+
+  public boolean matches(String connectionName, String schema, String table) {
+    return Objects.equals(databaseMeta.getName(), connectionName)
+        && Objects.equals(Const.NVL(schemaName, ""), Const.NVL(schema, ""))
+        && Objects.equals(tableName, table);
+  }
+
+  @Override
+  public Object getSubject() {
+    return this;
+  }
+
+  @Override
+  public String getName() {
+    if (Utils.isEmpty(schemaName)) {
+      return tableName;
+    }
+    return schemaName + "." + tableName;
+  }
+
+  @Override
+  public void setName(String name) {}
+
+  @Override
+  public IHopFileType getFileType() {
+    return FILE_TYPE;
+  }
+
+  @Override
+  public String getFilename() {
+    return null;
+  }
+
+  @Override
+  public void setFilename(String filename) {}
+
+  @Override
+  public void save() {}
+
+  @Override
+  public void saveAs(String filename) {}
+
+  @Override
+  public void start() {}
+
+  @Override
+  public void stop() {}
+
+  @Override
+  public void pause() {}
+
+  @Override
+  public void resume() {}
+
+  @Override
+  public void preview() {}
+
+  @Override
+  public void debug() {}
+
+  @Override
+  public void redraw() {}
+
+  @Override
+  public void updateGui() {
+    host.updateGui(this);
+  }
+
+  @Override
+  public void selectAll() {
+    if (ddlEditor != null && !ddlEditor.isDisposed()) {
+      ddlEditor.selectAll();
+    }
+  }
+
+  @Override
+  public void unselectAll() {
+    if (ddlEditor != null && !ddlEditor.isDisposed()) {
+      ddlEditor.unselectAll();
+    }
+  }
+
+  @Override
+  public void copySelectedToClipboard() {
+    if (ddlEditor != null && !ddlEditor.isDisposed()) {
+      ddlEditor.copy();
+    }
+  }
+
+  @Override
+  public void cutSelectedToClipboard() {}
+
+  @Override
+  public void deleteSelected() {}
+
+  @Override
+  public void pasteFromClipboard() {}
+
+  @Override
+  public boolean isCloseable() {
+    return true;
+  }
+
+  @Override
+  public void close() {
+    workbench.remove(this);
+  }
+
+  @Override
+  public boolean hasChanged() {
+    return false;
+  }
+
+  @Override
+  public void undo() {}
+
+  @Override
+  public void redo() {}
+
+  @Override
+  public Map<String, Object> getStateProperties() {
+    return new HashMap<>();
+  }
+
+  @Override
+  public void applyStateProperties(Map<String, Object> stateProperties) {}
+
+  @Override
+  public List<IGuiContextHandler> getContextHandlers() {
+    return List.of();
+  }
+
+  @Override
+  public IVariables getVariables() {
+    return host.getVariables();
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTreeNode.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTreeNode.java
new file mode 100644
index 0000000000..8a936c9f82
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseTreeNode.java
@@ -0,0 +1,105 @@
+/*
+ * 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.perspective.database;
+
+import java.util.Collection;
+import lombok.Getter;
+
+/** Data stored on a connection-tree item. */
+@Getter
+public class DatabaseTreeNode {
+
+  public enum Kind {
+    CONNECTION,
+    SCHEMA,
+    CATALOG,
+    FOLDER,
+    TABLE,
+    VIEW,
+    SYNONYM
+  }
+
+  private final Kind kind;
+  private final String connectionName;
+  private final String schemaName;
+  private final String objectName;
+  private final boolean connected;
+
+  public DatabaseTreeNode(
+      Kind kind, String connectionName, String schemaName, String objectName, boolean connected) {
+    this.kind = kind;
+    this.connectionName = connectionName;
+    this.schemaName = schemaName;
+    this.objectName = objectName;
+    this.connected = connected;
+  }
+
+  public static DatabaseTreeNode connection(String name, boolean connected) {
+    return new DatabaseTreeNode(Kind.CONNECTION, name, null, name, connected);
+  }
+
+  public static DatabaseTreeNode schema(String connectionName, String schemaName) {
+    return new DatabaseTreeNode(Kind.SCHEMA, connectionName, schemaName, schemaName, true);
+  }
+
+  public static DatabaseTreeNode catalog(String connectionName, String catalogName) {
+    return new DatabaseTreeNode(Kind.CATALOG, connectionName, catalogName, catalogName, true);
+  }
+
+  public static DatabaseTreeNode folder(String connectionName, String folderName) {
+    return new DatabaseTreeNode(Kind.FOLDER, connectionName, null, folderName, true);
+  }
+
+  public static DatabaseTreeNode table(
+      Kind kind, String connectionName, String schemaName, String tableName) {
+    return new DatabaseTreeNode(kind, connectionName, schemaName, tableName, true);
+  }
+
+  public boolean isTableLike() {
+    return kind == Kind.TABLE || kind == Kind.VIEW || kind == Kind.SYNONYM;
+  }
+
+  /**
+   * Classify a schema object so views (and synonyms) can use a distinct tree icon.
+   *
+   * @param name object name
+   * @param views view names in the same schema (any case)
+   * @param synonyms synonym names in the same schema (any case)
+   */
+  public static Kind kindOf(String name, Collection<String> views, Collection<String> synonyms) {
+    if (containsIgnoreCase(views, name)) {
+      return Kind.VIEW;
+    }
+    if (containsIgnoreCase(synonyms, name)) {
+      return Kind.SYNONYM;
+    }
+    return Kind.TABLE;
+  }
+
+  static boolean containsIgnoreCase(Collection<String> names, String name) {
+    if (names == null || name == null) {
+      return false;
+    }
+    for (String candidate : names) {
+      if (name.equalsIgnoreCase(candidate)) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
new file mode 100644
index 0000000000..8bf75598f6
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbench.java
@@ -0,0 +1,1356 @@
+/*
+ * 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.perspective.database;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.database.Catalog;
+import org.apache.hop.core.database.Database;
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.core.database.DatabaseMetaInformation;
+import org.apache.hop.core.database.Schema;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.search.SearchMatcher;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.bus.HopGuiEvents;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.dialog.MessageDialogWithToggle;
+import org.apache.hop.ui.core.gui.GuiMenuWidgets;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
+import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.widget.TreeMemory;
+import org.apache.hop.ui.hopgui.BackgroundThreadFacade;
+import org.apache.hop.ui.hopgui.ToolbarFacade;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.empty.EmptyHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.perspective.TabClosable;
+import org.apache.hop.ui.hopgui.perspective.TabCloseHandler;
+import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
+import org.apache.hop.ui.hopgui.perspective.database.config.DatabasePerspectiveConfig;
+import org.apache.hop.ui.hopgui.perspective.database.config.DatabasePerspectiveConfigSingleton;
+import org.apache.hop.ui.hopgui.shared.SashFormMemory;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabFolder2Adapter;
+import org.eclipse.swt.custom.CTabFolderEvent;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Menu;
+import org.eclipse.swt.widgets.MenuItem;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.Tree;
+import org.eclipse.swt.widgets.TreeItem;
+
+/**
+ * The database workbench UI (connection tree + editor tabs + operations). A plain Composite so a
+ * later host can put it in a perspective, a floating dialog, or a dock tab.
+ */
+@GuiPlugin
+public class DatabaseWorkbench extends Composite implements TabClosable {
+
+  public static final Class<?> PKG = DatabasePerspective.class;
+
+  public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID = "DatabaseWorkbench-Toolbar";
+  public static final String GUI_PLUGIN_CONTEXT_MENU_PARENT_ID = "DatabaseWorkbench-ContextMenu";
+
+  public static final String TOOLBAR_ITEM_CONNECT = "DatabaseWorkbench-Toolbar-10000-Connect";
+  public static final String TOOLBAR_ITEM_DISCONNECT = "DatabaseWorkbench-Toolbar-10010-Disconnect";
+  public static final String TOOLBAR_ITEM_REFRESH = "DatabaseWorkbench-Toolbar-10020-Refresh";
+  public static final String TOOLBAR_ITEM_SQL = "DatabaseWorkbench-Toolbar-10030-SqlEditor";
+  public static final String TOOLBAR_ITEM_DDL = "DatabaseWorkbench-Toolbar-10040-GenerateDdl";
+  public static final String TOOLBAR_ITEM_PREVIEW = "DatabaseWorkbench-Toolbar-10050-Preview";
+  public static final String TOOLBAR_ITEM_SHOW = "DatabaseWorkbench-Toolbar-10060-Show";
+  public static final String TOOLBAR_ITEM_FLOAT = "DatabaseWorkbench-Toolbar-20000-Float";
+  public static final String TOOLBAR_ITEM_DOCK = "DatabaseWorkbench-Toolbar-20010-Dock";
+
+  public static final String CONTEXT_MENU_CONNECT = "DatabaseWorkbench-ContextMenu-10000-Connect";
+  public static final String CONTEXT_MENU_DISCONNECT =
+      "DatabaseWorkbench-ContextMenu-10010-Disconnect";
+  public static final String CONTEXT_MENU_SQL = "DatabaseWorkbench-ContextMenu-10030-SqlEditor";
+  public static final String CONTEXT_MENU_DDL = "DatabaseWorkbench-ContextMenu-10040-GenerateDdl";
+  public static final String CONTEXT_MENU_PREVIEW = "DatabaseWorkbench-ContextMenu-10050-Preview";
+  public static final String CONTEXT_MENU_SHOW = "DatabaseWorkbench-ContextMenu-10060-Show";
+
+  /** {@link TreeMemory} key for schema/catalog expand-collapse across filter and refresh. */
+  public static final String TREE_MEMORY_KEY = "DatabaseWorkbench-Tree";
+
+  private static final int FILTER_DEBOUNCE_MS = 250;
+  private static final String RIGHT_SASH_KEY = "database-workbench-right-sash";
+
+  private final IDatabaseWorkbenchHost host;
+  private final Map<String, DatabaseConnectionState> connections = new LinkedHashMap<>();
+  private final List<TabItemHandler> items = new ArrayList<>();
+
+  private final SashForm horizontalSash;
+  private final Composite rightComposite;
+  private final SashForm rightSash;
+  private final Tree tree;
+  private final Text searchText;
+  private final GuiToolbarWidgets toolBarWidgets;
+  private final GuiMenuWidgets menuWidgets;
+  private final CTabFolder tabFolder;
+  private final DatabaseOperationsPanel operationsPanel;
+  private final DatabaseSqlFileType sqlFileType = new DatabaseSqlFileType();
+
+  private String filterText = "";
+  private SearchMatcher filterMatcher = new SearchMatcher("", false, false, false);
+  private final Runnable applyFilterRunnable = this::rebuildTree;
+
+  /** True while rebuild applies expand state so SWT Expand/Collapse does not rewrite TreeMemory. */
+  private boolean applyingTreeState;
+
+  final Runnable persistSqlTabsRunnable = () -> DatabaseSqlTabMemory.save(this);
+  private final String eventListenerId;
+  volatile boolean restoringSqlTabs;
+
+  public DatabaseWorkbench(Composite parent, IDatabaseWorkbenchHost host) {
+    super(parent, SWT.NONE);
+    this.host = host;
+    this.eventListenerId = getClass().getName() + "-" + System.identityHashCode(this);
+
+    PropsUi.setLook(this);
+    setLayout(new FormLayout());
+
+    horizontalSash = new SashForm(this, SWT.HORIZONTAL);
+    horizontalSash.setLayoutData(new FormDataBuilder().fullSize().result());
+
+    Composite treeComposite = new Composite(horizontalSash, SWT.NONE);
+    treeComposite.setLayout(new FormLayout());
+    PropsUi.setLook(treeComposite);
+
+    searchText = new Text(treeComposite, SWT.SEARCH | SWT.ICON_CANCEL | SWT.ICON_SEARCH);
+    searchText.setMessage(BaseMessages.getString(PKG, "DatabasePerspective.Search.Placeholder"));
+    PropsUi.setLook(searchText);
+    searchText.setLayoutData(new FormDataBuilder().top().fullWidth().result());
+    searchText.addListener(SWT.Modify, e -> scheduleFilterApply());
+    searchText.addListener(
+        SWT.DefaultSelection,
+        e -> {
+          cancelScheduledFilterApply();
+          rebuildTree();
+        });
+
+    Composite treeBorder = new Composite(treeComposite, SWT.BORDER);
+    treeBorder.setLayout(new FormLayout());
+    treeBorder.setLayoutData(
+        new FormDataBuilder().top(searchText, PropsUi.getMargin()).bottom().fullWidth().result());
+
+    IToolbarContainer toolBarContainer =
+        ToolbarFacade.createToolbarContainer(treeBorder, SWT.WRAP | SWT.LEFT | SWT.HORIZONTAL);
+    Control toolBar = toolBarContainer.getControl();
+    toolBar.setLayoutData(new FormDataBuilder().top().fullWidth().result());
+    PropsUi.setLook(toolBar, PropsUi.WIDGET_STYLE_TOOLBAR);
+    toolBarWidgets = new GuiToolbarWidgets();
+    toolBarWidgets.registerGuiPluginObject(this);
+    toolBarWidgets.createToolbarWidgets(toolBarContainer, GUI_PLUGIN_TOOLBAR_PARENT_ID);
+    toolBar.pack();
+
+    tree = new Tree(treeBorder, SWT.SINGLE | SWT.H_SCROLL | SWT.V_SCROLL);
+    PropsUi.setLook(tree);
+    tree.setLayoutData(
+        new FormDataBuilder().top(toolBar, PropsUi.getMargin()).bottom().fullWidth().result());
+    tree.addListener(SWT.Selection, e -> updateToolbar());
+    tree.addListener(SWT.DefaultSelection, e -> onTreeDefaultSelection());
+    tree.addListener(SWT.Expand, e -> rememberTreeExpand((TreeItem) e.item, true));
+    tree.addListener(SWT.Collapse, e -> rememberTreeExpand((TreeItem) e.item, false));
+
+    Menu menu = new Menu(tree);
+    menuWidgets = new GuiMenuWidgets();
+    menuWidgets.registerGuiPluginObject(this);
+    menuWidgets.createMenuWidgets(GUI_PLUGIN_CONTEXT_MENU_PARENT_ID, host.getShell(), menu);
+    tree.setMenu(menu);
+    tree.addListener(SWT.MenuDetect, e -> updateToolbar());
+
+    rightComposite = new Composite(horizontalSash, SWT.NONE);
+    rightComposite.setLayout(new FormLayout());
+    PropsUi.setLook(rightComposite);
+
+    rightSash = new SashForm(rightComposite, SWT.VERTICAL);
+    tabFolder = new CTabFolder(rightSash, SWT.MULTI | SWT.BORDER);
+    PropsUi.setLook(tabFolder, PropsUi.WIDGET_STYLE_TAB);
+    tabFolder.addCTabFolder2Listener(
+        new CTabFolder2Adapter() {
+          @Override
+          public void close(CTabFolderEvent event) {
+            closeTab(event, (CTabItem) event.item);
+          }
+        });
+    tabFolder.addListener(
+        SWT.Selection,
+        e -> {
+          IHopFileTypeHandler handler = getActiveFileTypeHandler();
+          host.updateGui(handler);
+          schedulePersistSqlTabs();
+        });
+    new TabCloseHandler(this, tabFolder);
+
+    operationsPanel = new DatabaseOperationsPanel(rightSash, rightComposite);
+    operationsPanel.setExpandedListener(this::layoutOperationsPane);
+    rightSash.setWeights(80, 20);
+    SashFormMemory.persist(rightSash, RIGHT_SASH_KEY, 80, 20);
+    layoutOperationsPane(false);
+
+    horizontalSash.setWeights(22, 78);
+    SashFormMemory.persist(horizontalSash, "database-workbench-tree-width", 22, 78);
+
+    host.getHopGui()
+        .getEventsHandler()
+        .addEventListener(
+            eventListenerId,
+            e -> host.asyncExec(this::reloadConnections),
+            HopGuiEvents.MetadataChanged.name(),
+            HopGuiEvents.MetadataCreated.name(),
+            HopGuiEvents.MetadataDeleted.name());
+    host.getHopGui()
+        .getEventsHandler()
+        .addEventListener(
+            eventListenerId + "-project",
+            e ->
+                host.asyncExec(
+                    () -> {
+                      closeSqlEditorTabs();
+                      reloadConnections();
+                      DatabaseSqlTabMemory.restore(this);
+                    }),
+            HopGuiEvents.ProjectActivated.name());
+
+    addDisposeListener(
+        e -> {
+          DatabaseSqlTabMemory.saveNow(this);
+          operationsPanel.cancelAll();
+          host.getHopGui().getEventsHandler().removeEventListeners(eventListenerId);
+          host.getHopGui().getEventsHandler().removeEventListeners(eventListenerId + "-project");
+        });
+
+    reloadConnections();
+    DatabaseSqlTabMemory.restore(this);
+  }
+
+  public DatabaseSqlFileType getSqlFileType() {
+    return sqlFileType;
+  }
+
+  public void reloadConnections() {
+    if (isDisposed()) {
+      return;
+    }
+    Map<String, DatabaseConnectionState> previous = new LinkedHashMap<>(connections);
+    connections.clear();
+    try {
+      List<DatabaseMeta> metas =
+          host.getMetadataProvider().getSerializer(DatabaseMeta.class).loadAll();
+      metas.sort(DatabaseMeta.comparator);
+      for (DatabaseMeta meta : metas) {
+        DatabaseConnectionState state = previous.get(meta.getName());
+        if (state == null) {
+          state = new DatabaseConnectionState(meta);
+        } else {
+          state.setDatabaseMeta(meta);
+        }
+        connections.put(meta.getName(), state);
+      }
+    } catch (Exception e) {
+      new ErrorDialog(
+          host.getShell(),
+          BaseMessages.getString(PKG, "DatabasePerspective.Error.Title"),
+          BaseMessages.getString(PKG, "DatabasePerspective.Error.LoadConnections"),
+          e);
+    }
+    rebuildTree();
+  }
+
+  public void clearSearchFilter() {
+    if (searchText != null && !searchText.isDisposed()) {
+      searchText.setText("");
+    }
+    filterText = "";
+    filterMatcher = new SearchMatcher("", false, false, false);
+    rebuildTree();
+  }
+
+  private void scheduleFilterApply() {
+    Display display = getDisplay();
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    display.timerExec(-1, applyFilterRunnable);
+    display.timerExec(FILTER_DEBOUNCE_MS, applyFilterRunnable);
+  }
+
+  private void cancelScheduledFilterApply() {
+    Display display = getDisplay();
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    display.timerExec(-1, applyFilterRunnable);
+  }
+
+  private void rebuildTree() {
+    if (tree.isDisposed()) {
+      return;
+    }
+    filterText = Const.NVL(searchText.getText(), "");
+    filterMatcher = new SearchMatcher(filterText, false, false, false);
+    tree.setRedraw(false);
+    applyingTreeState = true;
+    try {
+      tree.removeAll();
+      for (DatabaseConnectionState state : connections.values()) {
+        if (!connectionMatches(state)) {
+          continue;
+        }
+        TreeItem connectionItem = new TreeItem(tree, SWT.NONE);
+        connectionItem.setText(state.getDatabaseMeta().getName());
+        connectionItem.setImage(GuiResource.getInstance().getImageDatabase());
+        connectionItem.setData(
+            DatabaseTreeNode.connection(state.getDatabaseMeta().getName(), state.isConnected()));
+        if (state.isConnected() && state.getInformation() != null) {
+          fillConnectionChildren(connectionItem, state);
+          connectionItem.setExpanded(true);
+        }
+      }
+      restoreSchemaExpandState();
+      if (!Utils.isEmpty(filterText)) {
+        expandMatchingSchemaItems();
+      }
+    } finally {
+      applyingTreeState = false;
+      tree.setRedraw(true);
+    }
+    updateToolbar();
+  }
+
+  private void rememberTreeExpand(TreeItem item, boolean expanded) {
+    if (applyingTreeState || item == null || item.isDisposed()) {
+      return;
+    }
+    Object data = item.getData();
+    if (data instanceof DatabaseTreeNode node && remembersExpandState(node.getKind())) {
+      TreeMemory.getInstance().storeExpanded(TREE_MEMORY_KEY, item, expanded);
+    }
+  }
+
+  static boolean remembersExpandState(DatabaseTreeNode.Kind kind) {
+    return kind == DatabaseTreeNode.Kind.SCHEMA || kind == DatabaseTreeNode.Kind.CATALOG;
+  }
+
+  private void restoreSchemaExpandState() {
+    for (TreeItem connection : tree.getItems()) {
+      restoreSchemaExpandState(connection);
+    }
+  }
+
+  private void restoreSchemaExpandState(TreeItem parent) {
+    for (TreeItem child : parent.getItems()) {
+      Object data = child.getData();
+      if (data instanceof DatabaseTreeNode node && remembersExpandState(node.getKind())) {
+        child.setExpanded(
+            TreeMemory.getInstance().isExpanded(TREE_MEMORY_KEY, ConstUi.getTreeStrings(child)));
+      }
+      restoreSchemaExpandState(child);
+    }
+  }
+
+  /**
+   * While a filter is active, open schema/catalog folders that still have visible children so
+   * matches are not hidden. Those expands are not stored (see {@link #applyingTreeState}).
+   */
+  private void expandMatchingSchemaItems() {
+    for (TreeItem connection : tree.getItems()) {
+      boolean any = false;
+      for (TreeItem child : connection.getItems()) {
+        Object data = child.getData();
+        if (data instanceof DatabaseTreeNode node
+            && remembersExpandState(node.getKind())
+            && child.getItemCount() > 0) {
+          child.setExpanded(true);
+          any = true;
+        }
+      }
+      if (any) {
+        connection.setExpanded(true);
+      }
+    }
+  }
+
+  private boolean connectionMatches(DatabaseConnectionState state) {
+    if (Utils.isEmpty(filterText)) {
+      return true;
+    }
+    DatabaseMeta meta = state.getDatabaseMeta();
+    if (filterMatcher.matches(meta.getName())) {
+      return true;
+    }
+    DatabaseMetaInformation info = state.getInformation();
+    if (info == null) {
+      return false;
+    }
+    if (info.getSchemas() != null) {
+      for (Schema schema : info.getSchemas()) {
+        if (filterMatcher.matches(schema.getSchemaName())) {
+          return true;
+        }
+        if (schema.getItems() != null) {
+          for (String table : schema.getItems()) {
+            if (filterMatcher.matches(table)) {
+              return true;
+            }
+          }
+        }
+      }
+    }
+    if (info.getTables() != null) {
+      for (String table : info.getTables()) {
+        if (filterMatcher.matches(table)) {
+          return true;
+        }
+      }
+    }
+    if (mapHasMatch(info.getViewMap()) || mapHasMatch(info.getSynonymMap())) {
+      return true;
+    }
+    return false;
+  }
+
+  private boolean mapHasMatch(Map<String, Collection<String>> map) {
+    if (map == null) {
+      return false;
+    }
+    for (Collection<String> names : map.values()) {
+      if (names == null) {
+        continue;
+      }
+      for (String name : names) {
+        if (filterMatcher.matches(name)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  private void fillConnectionChildren(TreeItem connectionItem, DatabaseConnectionState state) {
+    DatabaseMetaInformation info = state.getInformation();
+    String connectionName = state.getDatabaseMeta().getName();
+    Schema[] schemas = info.getSchemas();
+    if (schemas != null && schemas.length > 0) {
+      for (Schema schema : schemas) {
+        if (!schemaOrChildMatches(schema, info)) {
+          continue;
+        }
+        TreeItem schemaItem = new TreeItem(connectionItem, SWT.NONE);
+        schemaItem.setText(Const.NVL(schema.getSchemaName(), ""));
+        schemaItem.setImage(GuiResource.getInstance().getImageSchema());
+        schemaItem.setData(DatabaseTreeNode.schema(connectionName, schema.getSchemaName()));
+        addSchemaObjects(
+            schemaItem, connectionName, schema.getSchemaName(), schema.getItems(), info);
+      }
+      return;
+    }
+    Catalog[] catalogs = info.getCatalogs();
+    if (catalogs != null && catalogs.length > 0) {
+      for (Catalog catalog : catalogs) {
+        TreeItem catalogItem = new TreeItem(connectionItem, SWT.NONE);
+        catalogItem.setText(Const.NVL(catalog.getCatalogName(), ""));
+        catalogItem.setImage(GuiResource.getInstance().getImageFolder());
+        catalogItem.setData(DatabaseTreeNode.catalog(connectionName, catalog.getCatalogName()));
+        addSchemaObjects(
+            catalogItem, connectionName, catalog.getCatalogName(), catalog.getItems(), info);
+      }
+      return;
+    }
+    addFolder(
+        connectionItem,
+        connectionName,
+        BaseMessages.getString(PKG, "DatabasePerspective.Tree.Tables"),
+        info.getTables(),
+        DatabaseTreeNode.Kind.TABLE);
+    addFolder(
+        connectionItem,
+        connectionName,
+        BaseMessages.getString(PKG, "DatabasePerspective.Tree.Views"),
+        info.getViews(),
+        DatabaseTreeNode.Kind.VIEW);
+    addFolder(
+        connectionItem,
+        connectionName,
+        BaseMessages.getString(PKG, "DatabasePerspective.Tree.Synonyms"),
+        info.getSynonyms(),
+        DatabaseTreeNode.Kind.SYNONYM);
+  }
+
+  private boolean schemaOrChildMatches(Schema schema, DatabaseMetaInformation info) {
+    if (Utils.isEmpty(filterText)) {
+      return true;
+    }
+    if (filterMatcher.matches(schema.getSchemaName())) {
+      return true;
+    }
+    if (schema.getItems() != null) {
+      for (String table : schema.getItems()) {
+        if (filterMatcher.matches(table)) {
+          return true;
+        }
+      }
+    }
+    for (String view : namesForSchema(info.getViewMap(), schema.getSchemaName())) {
+      if (filterMatcher.matches(view)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Tables, views and synonyms under a schema (or catalog). Views get {@code view.svg} via {@link
+   * DatabaseTreeNode#kindOf}.
+   */
+  private void addSchemaObjects(
+      TreeItem parent,
+      String connectionName,
+      String schemaName,
+      String[] items,
+      DatabaseMetaInformation info) {
+    Collection<String> views = namesForSchema(info.getViewMap(), schemaName);
+    Collection<String> synonyms = namesForSchema(info.getSynonymMap(), schemaName);
+    List<String> names = new ArrayList<>();
+    if (items != null) {
+      names.addAll(Arrays.asList(items));
+    }
+    for (String view : views) {
+      if (!DatabaseTreeNode.containsIgnoreCase(names, view)) {
+        names.add(view);
+      }
+    }
+    for (String synonym : synonyms) {
+      if (!DatabaseTreeNode.containsIgnoreCase(names, synonym)) {
+        names.add(synonym);
+      }
+    }
+    names.sort(String.CASE_INSENSITIVE_ORDER);
+    for (String name : names) {
+      if (!matchesFilter(name, schemaName, connectionName)) {
+        continue;
+      }
+      DatabaseTreeNode.Kind kind = DatabaseTreeNode.kindOf(name, views, synonyms);
+      TreeItem item = new TreeItem(parent, SWT.NONE);
+      item.setText(name);
+      item.setImage(imageFor(kind));
+      item.setData(DatabaseTreeNode.table(kind, connectionName, schemaName, name));
+    }
+  }
+
+  static Collection<String> namesForSchema(Map<String, Collection<String>> map, String schemaName) {
+    if (map == null || map.isEmpty()) {
+      return List.of();
+    }
+    if (schemaName != null) {
+      Collection<String> exact = map.get(schemaName);
+      if (exact != null) {
+        return exact;
+      }
+      for (Map.Entry<String, Collection<String>> entry : map.entrySet()) {
+        if (schemaName.equalsIgnoreCase(entry.getKey()) && entry.getValue() != null) {
+          return entry.getValue();
+        }
+      }
+    }
+    Collection<String> empty = map.get("");
+    if (empty != null) {
+      return empty;
+    }
+    Collection<String> missing = map.get(null);
+    return missing != null ? missing : List.of();
+  }
+
+  private boolean matchesFilter(String name, String schemaName, String connectionName) {
+    if (Utils.isEmpty(filterText)) {
+      return true;
+    }
+    return filterMatcher.matches(name)
+        || filterMatcher.matches(schemaName)
+        || filterMatcher.matches(connectionName);
+  }
+
+  private void addFolder(
+      TreeItem parent,
+      String connectionName,
+      String folderName,
+      String[] names,
+      DatabaseTreeNode.Kind kind) {
+    if (names == null || names.length == 0) {
+      return;
+    }
+    TreeItem folder = new TreeItem(parent, SWT.NONE);
+    folder.setText(folderName);
+    folder.setImage(GuiResource.getInstance().getImageFolder());
+    folder.setData(DatabaseTreeNode.folder(connectionName, folderName));
+    addTables(folder, connectionName, null, names, kind);
+    folder.setExpanded(true);
+  }
+
+  private void addTables(
+      TreeItem parent,
+      String connectionName,
+      String schemaName,
+      String[] names,
+      DatabaseTreeNode.Kind kind) {
+    if (names == null) {
+      return;
+    }
+    for (String name : names) {
+      if (!Utils.isEmpty(filterText)
+          && !filterMatcher.matches(name)
+          && !filterMatcher.matches(schemaName)
+          && !filterMatcher.matches(connectionName)) {
+        continue;
+      }
+      TreeItem item = new TreeItem(parent, SWT.NONE);
+      item.setText(name);
+      item.setImage(imageFor(kind));
+      item.setData(DatabaseTreeNode.table(kind, connectionName, schemaName, name));
+    }
+  }
+
+  private org.eclipse.swt.graphics.Image imageFor(DatabaseTreeNode.Kind kind) {
+    GuiResource resources = GuiResource.getInstance();
+    return switch (kind) {
+      case VIEW -> resources.getImageView();
+      case SYNONYM -> resources.getImageSynonym();
+      default -> resources.getImageTable();
+    };
+  }
+
+  private DatabaseTreeNode selectedNode() {
+    TreeItem[] selection = tree.getSelection();
+    if (selection.length != 1) {
+      return null;
+    }
+    Object data = selection[0].getData();
+    return data instanceof DatabaseTreeNode node ? node : null;
+  }
+
+  private DatabaseConnectionState selectedState() {
+    DatabaseTreeNode node = selectedNode();
+    if (node == null) {
+      return null;
+    }
+    return connections.get(node.getConnectionName());
+  }
+
+  private void updateToolbar() {
+    DatabaseTreeNode node = selectedNode();
+    DatabaseConnectionState state = selectedState();
+    boolean hasConnection = state != null;
+    boolean connected = state != null && state.isConnected();
+    boolean table = node != null && node.isTableLike();
+
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_CONNECT, hasConnection && !connected);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_DISCONNECT, hasConnection && connected);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_REFRESH, true);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_SQL, hasConnection);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_DDL, table);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_PREVIEW, table);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_SHOW, table);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_FLOAT, true);
+    toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_DOCK, true);
+
+    enableMenu(CONTEXT_MENU_CONNECT, hasConnection && !connected);
+    enableMenu(CONTEXT_MENU_DISCONNECT, hasConnection && connected);
+    enableMenu(CONTEXT_MENU_SQL, hasConnection);
+    enableMenu(CONTEXT_MENU_DDL, table);
+    enableMenu(CONTEXT_MENU_PREVIEW, table);
+    enableMenu(CONTEXT_MENU_SHOW, table);
+  }
+
+  private void enableMenu(String id, boolean enabled) {
+    MenuItem item = menuWidgets.findMenuItem(id);
+    if (item != null) {
+      item.setEnabled(enabled);
+    }
+  }
+
+  private void onTreeDefaultSelection() {
+    TreeItem[] selection = tree.getSelection();
+    TreeItem item = selection.length == 1 ? selection[0] : null;
+    DatabaseTreeNode node = selectedNode();
+    DatabaseConnectionState state = selectedState();
+    if (node != null
+        && node.getKind() == DatabaseTreeNode.Kind.CONNECTION
+        && state != null
+        && !state.isConnected()) {
+      connect(state);
+      return;
+    }
+    if (item != null && item.getItemCount() > 0 && (node == null || !node.isTableLike())) {
+      item.setExpanded(!item.getExpanded());
+      return;
+    }
+    previewTable();
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_CONNECT,
+      toolTip = "i18n::DatabasePerspective.Toolbar.Connect.Tooltip",
+      image = "ui/images/connection.svg")
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = CONTEXT_MENU_CONNECT,
+      label = "i18n::DatabasePerspective.Menu.Connect",
+      image = "ui/images/connection.svg")
+  public void connectSelected() {
+    DatabaseConnectionState state = selectedState();
+    if (state == null) {
+      return;
+    }
+    connect(state);
+  }
+
+  public void connect(DatabaseConnectionState state) {
+    connect(state, null);
+  }
+
+  /**
+   * Load schemas/tables for {@code state}. When {@code afterConnected} is set, it runs on the UI
+   * thread after a successful connect.
+   */
+  public void connect(DatabaseConnectionState state, Runnable afterConnected) {
+    DatabaseMeta meta = state.getDatabaseMeta();
+    String description =
+        BaseMessages.getString(PKG, "DatabasePerspective.Operation.Connect", meta.getName());
+    runOperation(
+        description,
+        meta.getName(),
+        operation -> {
+          DatabaseMetaInformation info = new DatabaseMetaInformation(host.getVariables(), meta);
+          info.getData(host.getLoggingObject(), operation.newMonitor());
+          if (operation.isCancelled()) {
+            return;
+          }
+          host.asyncExec(
+              () -> {
+                state.setInformation(info);
+                state.setConnected(true);
+                rebuildTree();
+                selectConnection(meta.getName());
+                if (afterConnected != null) {
+                  afterConnected.run();
+                }
+              });
+        });
+  }
+
+  /**
+   * If {@code meta} is already connected in this workbench, run {@code afterConnected} immediately.
+   * Otherwise ask (or auto-connect from config), connect, then run {@code afterConnected}.
+   *
+   * @return {@code false} when the user declined to connect
+   */
+  public boolean ensureConnectedForExecute(DatabaseMeta meta, Runnable afterConnected) {
+    if (meta == null || afterConnected == null) {
+      return false;
+    }
+    DatabaseConnectionState state = ensureConnection(meta);
+    if (state.isConnected()) {
+      afterConnected.run();
+      return true;
+    }
+    if (!confirmConnectForExecute(meta)) {
+      return false;
+    }
+    connect(state, afterConnected);
+    return true;
+  }
+
+  /**
+   * @return {@code true} when SQL execution should connect (auto-connect, or the user chose Yes)
+   */
+  boolean confirmConnectForExecute(DatabaseMeta meta) {
+    DatabasePerspectiveConfig config = DatabasePerspectiveConfigSingleton.getConfig();
+    if (config.isAutoConnectWhenExecutingSql()) {
+      return true;
+    }
+    MessageDialogWithToggle dialog =
+        new MessageDialogWithToggle(
+            host.getShell(),
+            BaseMessages.getString(PKG, "DatabasePerspective.ConnectToExecute.Title"),
+            BaseMessages.getString(
+                PKG, "DatabasePerspective.ConnectToExecute.Message", Const.NVL(meta.getName(), "")),
+            SWT.ICON_QUESTION,
+            new String[] {
+              BaseMessages.getString(PKG, "System.Button.Yes"),
+              BaseMessages.getString(PKG, "System.Button.No")
+            },
+            BaseMessages.getString(PKG, "DatabasePerspective.ConnectToExecute.Toggle"),
+            false);
+    int answer = dialog.open();
+    if (dialog.getToggleState()) {
+      config.setAutoConnectWhenExecutingSql(true);
+      try {
+        DatabasePerspectiveConfigSingleton.saveConfig();
+      } catch (Exception e) {
+        new ErrorDialog(
+            host.getShell(),
+            BaseMessages.getString(PKG, "DatabasePerspective.Error.Title"),
+            e.getMessage(),
+            e);
+      }
+    }
+    return (answer & 0xFF) == 0;
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_DISCONNECT,
+      toolTip = "i18n::DatabasePerspective.Toolbar.Disconnect.Tooltip",
+      image = "ui/images/shutdown.svg")
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = CONTEXT_MENU_DISCONNECT,
+      label = "i18n::DatabasePerspective.Menu.Disconnect",
+      image = "ui/images/shutdown.svg")
+  public void disconnectSelected() {
+    DatabaseConnectionState state = selectedState();
+    if (state == null) {
+      return;
+    }
+    state.setConnected(false);
+    state.setInformation(null);
+    rebuildTree();
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_REFRESH,
+      toolTip = "i18n::DatabasePerspective.Toolbar.Refresh.Tooltip",
+      image = "ui/images/refresh.svg")
+  public void refreshSelected() {
+    DatabaseConnectionState state = selectedState();
+    if (state != null && state.isConnected()) {
+      connect(state);
+      return;
+    }
+    reloadConnections();
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_SQL,
+      toolTip = "i18n::DatabasePerspective.Toolbar.SqlEditor.Tooltip",
+      image = "ui/images/script.svg")
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = CONTEXT_MENU_SQL,
+      label = "i18n::DatabasePerspective.Menu.SqlEditor",
+      image = "ui/images/script.svg")
+  public void openSqlEditor() {
+    DatabaseConnectionState state = selectedState();
+    if (state == null) {
+      return;
+    }
+    DatabaseTreeNode node = selectedNode();
+    String sql = "";
+    if (node != null && node.isTableLike()) {
+      String qualified =
+          state
+              .getDatabaseMeta()
+              .getQuotedSchemaTableCombination(
+                  host.getVariables(), node.getSchemaName(), node.getObjectName());
+      sql = "SELECT * FROM " + qualified;
+    }
+    openSqlTab(state.getDatabaseMeta(), sql, null, sql, false);
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_DDL,
+      toolTip = "i18n::DatabasePerspective.Toolbar.GenerateDdl.Tooltip",
+      image = "ui/images/script.svg")
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = CONTEXT_MENU_DDL,
+      label = "i18n::DatabasePerspective.Menu.GenerateDdl",
+      image = "ui/images/script.svg")
+  public void generateDdl() {
+    DatabaseTreeNode node = selectedNode();
+    DatabaseConnectionState state = selectedState();
+    if (node == null || state == null || !node.isTableLike()) {
+      return;
+    }
+    DatabaseMeta meta = state.getDatabaseMeta();
+    String qualified =
+        meta.getQuotedSchemaTableCombination(
+            host.getVariables(), node.getSchemaName(), node.getObjectName());
+    String description =
+        BaseMessages.getString(PKG, "DatabasePerspective.Operation.GenerateDdl", qualified);
+    runOperation(
+        description,
+        meta.getName(),
+        operation -> {
+          String ddl;
+          try (Database db = new Database(host.getLoggingObject(), host.getVariables(), meta)) {
+            operation.attachDatabase(db);
+            db.connect();
+            IRowMeta fields = db.getTableFields(qualified);
+            ddl = db.getCreateTableStatement(node.getObjectName(), fields, null, false, null, true);
+          }
+          String sql = ddl;
+          host.asyncExec(() -> openSqlTab(meta, sql, null, sql, true));
+        });
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_PREVIEW,
+      toolTip = "i18n::DatabasePerspective.Toolbar.Preview.Tooltip",
+      image = "ui/images/preview.svg")
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = CONTEXT_MENU_PREVIEW,
+      label = "i18n::DatabasePerspective.Menu.Preview",
+      image = "ui/images/preview.svg")
+  public void previewTable() {
+    DatabaseTreeNode node = selectedNode();
+    DatabaseConnectionState state = selectedState();
+    if (node == null || state == null || !node.isTableLike()) {
+      return;
+    }
+    DatabaseMeta meta = state.getDatabaseMeta();
+    String sql =
+        previewSelectSql(
+            meta,
+            host.getVariables(),
+            node.getSchemaName(),
+            node.getObjectName(),
+            DatabaseSqlEditorTab.queryRowLimit());
+    DatabaseSqlEditorTab tab = openSqlTab(meta, sql, null, sql, false);
+    tab.executeSql();
+  }
+
+  /**
+   * {@code SELECT * FROM schema.table} plus the dialect's limit clause ({@link
+   * DatabaseMeta#getLimitClause(int)}).
+   */
+  static String previewSelectSql(
+      DatabaseMeta meta, IVariables variables, String schemaName, String tableName, int rowLimit) {
+    String qualified = meta.getQuotedSchemaTableCombination(variables, schemaName, tableName);
+    String limit = Const.NVL(meta.getLimitClause(rowLimit), "");
+    return "SELECT * FROM " + qualified + limit;
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_SHOW,
+      toolTip = "i18n::DatabasePerspective.Toolbar.Show.Tooltip",
+      image = "ui/images/show.svg")
+  @GuiMenuElement(
+      root = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      parentId = GUI_PLUGIN_CONTEXT_MENU_PARENT_ID,
+      id = CONTEXT_MENU_SHOW,
+      label = "i18n::DatabasePerspective.Menu.Show",
+      image = "ui/images/show.svg")
+  public void showTableInfo() {
+    DatabaseTreeNode node = selectedNode();
+    DatabaseConnectionState state = selectedState();
+    if (node == null || state == null || !node.isTableLike()) {
+      return;
+    }
+    openTableInfo(
+        state.getDatabaseMeta(), node.getSchemaName(), node.getObjectName(), node.getKind());
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_FLOAT,
+      toolTip = "i18n::DatabasePerspective.Toolbar.Float.Tooltip",
+      image = "ui/images/detach-panel.svg",
+      separator = true)
+  public void openFloatingWindow() {
+    DatabaseSqlTabMemory.saveNow(this);
+    DatabaseWorkbenchViews.openDialog(host.getHopGui());
+  }
+
+  @GuiToolbarElement(
+      root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+      id = TOOLBAR_ITEM_DOCK,
+      toolTip = "i18n::DatabasePerspective.Toolbar.Dock.Tooltip",
+      image = "ui/images/dock-panel.svg")
+  public void openInBottomDock() {
+    DatabaseSqlTabMemory.saveNow(this);
+    DatabaseWorkbenchViews.openDock(host.getHopGui());
+  }
+
+  public DatabaseSqlEditorTab openSqlTab(
+      DatabaseMeta meta, String sql, String filename, String buffer, boolean dirty) {
+    if (!Utils.isEmpty(filename)) {
+      for (TabItemHandler item : items) {
+        if (item.getTypeHandler() instanceof DatabaseSqlEditorTab tab
+            && tab.isSameFile(filename)
+            && meta.getName().equals(tab.getDatabaseMeta().getName())) {
+          tabFolder.setSelection(item.getTabItem());
+          return tab;
+        }
+      }
+    }
+    CTabItem tabItem = new CTabItem(tabFolder, SWT.CLOSE);
+    DatabaseSqlEditorTab tab = new DatabaseSqlEditorTab(tabFolder, host, this, meta);
+    if (!Utils.isEmpty(filename)) {
+      tab.setFilename(filename);
+      if (buffer != null) {
+        tab.applyBuffer(buffer, dirty);
+      } else {
+        try {
+          tab.loadFromVfs();
+        } catch (Exception e) {
+          new ErrorDialog(
+              host.getShell(),
+              BaseMessages.getString(PKG, "DatabasePerspective.Error.Title"),
+              BaseMessages.getString(PKG, "DatabasePerspective.Error.OpenFile", filename),
+              e);
+        }
+      }
+    } else {
+      tab.setInitialText(Const.NVL(sql, ""));
+    }
+    tab.setTabItem(tabItem);
+    tabItem.setControl(tab.getControl());
+    tabItem.setData(tab);
+    items.add(new TabItemHandler(tabItem, tab));
+    if (!Utils.isEmpty(filename)) {
+      host.getHopGui().fileRefreshDelegate.register(filename, tab);
+    }
+    tabFolder.setSelection(tabItem);
+    host.updateGui(tab);
+    schedulePersistSqlTabs();
+    return tab;
+  }
+
+  public void openTableInfo(DatabaseMeta meta, String schemaName, String tableName) {
+    openTableInfo(meta, schemaName, tableName, DatabaseTreeNode.Kind.TABLE);
+  }
+
+  public void openTableInfo(
+      DatabaseMeta meta, String schemaName, String tableName, DatabaseTreeNode.Kind kind) {
+    for (TabItemHandler item : items) {
+      if (item.getTypeHandler() instanceof DatabaseTableInfoTab tab
+          && tab.matches(meta.getName(), schemaName, tableName)) {
+        tabFolder.setSelection(item.getTabItem());
+        return;
+      }
+    }
+    CTabItem tabItem = new CTabItem(tabFolder, SWT.CLOSE);
+    DatabaseTableInfoTab tab =
+        new DatabaseTableInfoTab(tabFolder, host, this, meta, schemaName, tableName, kind);
+    tab.setTabItem(tabItem);
+    tabItem.setControl(tab.getControl());
+    tabItem.setData(tab);
+    items.add(new TabItemHandler(tabItem, tab));
+    tabFolder.setSelection(tabItem);
+    host.updateGui(tab);
+    tab.loadDetails();
+  }
+
+  public void openSqlFile(String filename, DatabaseMeta meta, String buffer, boolean dirty) {
+    openSqlTab(meta, null, filename, buffer, dirty);
+  }
+
+  /**
+   * Select {@code meta} in the tree, start a background connect/refresh if needed, and open a SQL
+   * tab with {@code sql} immediately so the user can edit and run while schemas load.
+   */
+  public void openSuggestedSql(DatabaseMeta meta, String sql) {
+    if (meta == null || isDisposed()) {
+      return;
+    }
+    DatabaseConnectionState state = ensureConnection(meta);
+    selectConnection(meta.getName());
+    openSqlTab(meta, Const.NVL(sql, ""), null, Const.NVL(sql, ""), true);
+    if (!state.isConnected()) {
+      connect(state);
+    }
+  }
+
+  DatabaseConnectionState ensureConnection(DatabaseMeta meta) {
+    DatabaseConnectionState state = connections.get(meta.getName());
+    if (state == null) {
+      state = new DatabaseConnectionState(meta);
+      connections.put(meta.getName(), state);
+      rebuildTree();
+    } else {
+      state.setDatabaseMeta(meta);
+    }
+    return state;
+  }
+
+  void schedulePersistSqlTabs() {
+    DatabaseSqlTabMemory.scheduleSave(this);
+  }
+
+  boolean hasSqlEditorTabs() {
+    for (TabItemHandler item : items) {
+      if (item.getTypeHandler() instanceof DatabaseSqlEditorTab) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  boolean restoreSqlTab(DatabaseSqlTabMemory.Snapshot snapshot) {
+    DatabaseMeta meta = findConnection(snapshot.connection);
+    if (meta == null) {
+      return false;
+    }
+    if (!Utils.isEmpty(snapshot.filename) && !snapshot.dirty) {
+      openSqlFile(snapshot.filename, meta, null, false);
+      return true;
+    }
+    if (!Utils.isEmpty(snapshot.filename)) {
+      openSqlFile(snapshot.filename, meta, snapshot.sql, true);
+      return true;
+    }
+    DatabaseSqlEditorTab tab =
+        openSqlTab(
+            meta, Const.NVL(snapshot.sql, ""), null, Const.NVL(snapshot.sql, ""), snapshot.dirty);
+    if (!Utils.isEmpty(snapshot.name)) {
+      tab.setName(snapshot.name);
+    }
+    return true;
+  }
+
+  void selectSqlTabIndex(int index) {
+    int sqlIndex = 0;
+    for (TabItemHandler item : items) {
+      if (item.getTypeHandler() instanceof DatabaseSqlEditorTab) {
+        if (sqlIndex == index && item.getTabItem() != null && !item.getTabItem().isDisposed()) {
+          tabFolder.setSelection(item.getTabItem());
+          return;
+        }
+        sqlIndex++;
+      }
+    }
+  }
+
+  List<DatabaseSqlTabMemory.Snapshot> snapshotSqlTabs() {
+    List<DatabaseSqlTabMemory.Snapshot> snapshots = new ArrayList<>();
+    for (TabItemHandler item : items) {
+      if (item.getTypeHandler() instanceof DatabaseSqlEditorTab tab) {
+        snapshots.add(DatabaseSqlTabMemory.snapshotOf(tab));
+      }
+    }
+    return snapshots;
+  }
+
+  int selectedSqlTabIndex() {
+    CTabItem selected = tabFolder.getSelection();
+    int index = 0;
+    for (TabItemHandler item : items) {
+      if (item.getTypeHandler() instanceof DatabaseSqlEditorTab) {
+        if (item.getTabItem() == selected) {
+          return index;
+        }
+        index++;
+      }
+    }
+    return 0;
+  }
+
+  void closeSqlEditorTabs() {
+    restoringSqlTabs = true;
+    try {
+      List<TabItemHandler> copy = new ArrayList<>(items);
+      for (TabItemHandler item : copy) {
+        if (item.getTypeHandler() instanceof DatabaseSqlEditorTab) {
+          disposeTab(item);
+        }
+      }
+    } finally {
+      restoringSqlTabs = false;
+    }
+  }
+
+  void selectConnection(String name) {
+    if (tree == null || tree.isDisposed() || Utils.isEmpty(name)) {
+      return;
+    }
+    for (TreeItem item : tree.getItems()) {
+      Object data = item.getData();
+      if (data instanceof DatabaseTreeNode node
+          && node.getKind() == DatabaseTreeNode.Kind.CONNECTION
+          && name.equals(node.getConnectionName())) {
+        tree.setSelection(item);
+        tree.showItem(item);
+        updateToolbar();
+        return;
+      }
+    }
+  }
+
+  public List<String> connectionNames() {
+    return new ArrayList<>(connections.keySet());
+  }
+
+  public DatabaseMeta findConnection(String name) {
+    DatabaseConnectionState state = connections.get(name);
+    return state == null ? null : state.getDatabaseMeta();
+  }
+
+  private void layoutOperationsPane(boolean expanded) {
+    if (rightSash.isDisposed() || rightComposite.isDisposed()) {
+      return;
+    }
+    Composite statusBar = operationsPanel.getStatusBar();
+    if (expanded) {
+      statusBar.setVisible(false);
+      statusBar.setLayoutData(new FormDataBuilder().left().bottom().height(0).width(0).result());
+      rightSash.setLayoutData(new FormDataBuilder().fullSize().result());
+      rightSash.setMaximizedControl(null);
+      SashFormMemory.restore(rightSash, RIGHT_SASH_KEY, 80, 20);
+    } else {
+      statusBar.setVisible(true);
+      statusBar.setLayoutData(new FormDataBuilder().bottom().fullWidth().result());
+      rightSash.setLayoutData(
+          new FormDataBuilder().top().fullWidth().bottom(statusBar, 0).result());
+      rightSash.setMaximizedControl(tabFolder);
+    }
+    rightComposite.layout(true, true);
+  }
+
+  public void runOperation(String description, String connectionName, DatabaseOperation.Work work) {
+    DatabaseOperation operation = new DatabaseOperation(description, connectionName);
+    operationsPanel.addOperation(operation);
+    BackgroundThreadFacade.start(
+        () -> {
+          try {
+            work.run(operation);
+            operation.complete();
+          } catch (Exception e) {
+            operation.fail(Const.NVL(e.getMessage(), e.toString()));
+          }
+          host.asyncExec(operationsPanel::refresh);
+        },
+        "hop-database-op");
+  }
+
+  public void refreshTab(DatabaseSqlEditorTab tab) {
+    tab.setTabItem(tab.getTabItem());
+    host.updateGui(tab);
+  }
+
+  public IHopFileTypeHandler getActiveFileTypeHandler() {
+    CTabItem selected = tabFolder.getSelection();
+    if (selected == null || selected.isDisposed()) {
+      return new EmptyHopFileTypeHandler();
+    }
+    Object data = selected.getData();
+    if (data instanceof IHopFileTypeHandler handler) {
+      return handler;
+    }
+    return new EmptyHopFileTypeHandler();
+  }
+
+  public void setActiveFileTypeHandler(IHopFileTypeHandler handler) {
+    for (TabItemHandler item : items) {
+      if (item.getTypeHandler() == handler) {
+        tabFolder.setSelection(item.getTabItem());
+        return;
+      }
+    }
+  }
+
+  public List<TabItemHandler> getItems() {
+    return items;
+  }
+
+  public boolean remove(IHopFileTypeHandler handler) {
+    if (handler == null || !handler.isCloseable()) {
+      return false;
+    }
+    TabItemHandler found = null;
+    for (TabItemHandler item : items) {
+      if (item.getTypeHandler() == handler) {
+        found = item;
+        break;
+      }
+    }
+    if (found == null) {
+      return true;
+    }
+    disposeTab(found);
+    return true;
+  }
+
+  private void disposeTab(TabItemHandler item) {
+    items.remove(item);
+    IHopFileTypeHandler handler = item.getTypeHandler();
+    if (handler != null && handler.getFilename() != null) {
+      host.getHopGui().fileRefreshDelegate.remove(handler.getFilename());
+    }
+    if (item.getTabItem() != null && !item.getTabItem().isDisposed()) {
+      item.getTabItem().dispose();
+    }
+    host.updateGui(getActiveFileTypeHandler());
+    schedulePersistSqlTabs();
+  }
+
+  @Override
+  public void closeTab(CTabFolderEvent event, CTabItem tabItem) {
+    if (tabItem == null || tabItem.isDisposed()) {
+      return;
+    }
+    Object data = tabItem.getData();
+    boolean removed = true;
+    if (data instanceof IHopFileTypeHandler handler) {
+      removed = remove(handler);
+    }
+    if (removed && !tabItem.isDisposed()) {
+      tabItem.dispose();
+    }
+    if (!removed && event != null) {
+      event.doit = false;
+    }
+  }
+
+  @Override
+  public CTabFolder getTabFolder() {
+    return tabFolder;
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchDialog.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchDialog.java
new file mode 100644
index 0000000000..b54454fa52
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchDialog.java
@@ -0,0 +1,162 @@
+/*
+ * 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.perspective.database;
+
+import org.apache.hop.core.database.DatabaseMeta;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.WindowProperty;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.HopGuiKeyHandler;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Shell;
+
+/**
+ * Non-modal floating window hosting a {@link DatabaseWorkbench}. One instance per Hop Gui session
+ * (the main shell holds the reference so Hop Web UISessions stay isolated).
+ */
+public class DatabaseWorkbenchDialog {
+
+  public static final Class<?> PKG = DatabasePerspective.class;
+
+  static final String SHELL_DATA_KEY = DatabaseWorkbenchDialog.class.getName();
+
+  private static final int MIN_WIDTH = 720;
+  private static final int MIN_HEIGHT = 480;
+
+  private final HopGui hopGui;
+  private final PropsUi props;
+  private Shell shell;
+  private DatabaseWorkbench workbench;
+
+  DatabaseWorkbenchDialog(HopGui hopGui) {
+    this.hopGui = hopGui;
+    this.props = PropsUi.getInstance();
+  }
+
+  /** Open or focus the floating Database window for this Hop Gui session. */
+  public static void open(HopGui hopGui) {
+    openOrCreate(hopGui);
+  }
+
+  /**
+   * Open the floating Database window on {@code databaseMeta}, connect and refresh the schema/table
+   * tree in the background, and put {@code sql} in an editor tab so it can be edited and executed.
+   *
+   * @param databaseMeta the connection the SQL is for; ignored when {@code null}
+   * @param sql suggested SQL (CREATE TABLE, SELECT, …); may be empty
+   */
+  public static void openSql(DatabaseMeta databaseMeta, String sql) {
+    if (databaseMeta == null) {
+      return;
+    }
+    HopGui hopGui;
+    try {
+      hopGui = HopGui.getInstance();
+    } catch (Throwable e) {
+      return;
+    }
+    DatabaseWorkbenchDialog dialog = openOrCreate(hopGui);
+    if (dialog == null || dialog.workbench == null || dialog.workbench.isDisposed()) {
+      return;
+    }
+    dialog.activate();
+    dialog.workbench.openSuggestedSql(databaseMeta, sql);
+  }
+
+  static DatabaseWorkbenchDialog openOrCreate(HopGui hopGui) {
+    if (hopGui == null || hopGui.getShell() == null || hopGui.getShell().isDisposed()) {
+      return null;
+    }
+    Object existing = hopGui.getShell().getData(SHELL_DATA_KEY);
+    if (existing instanceof DatabaseWorkbenchDialog dialog && dialog.isOpen()) {
+      dialog.activate();
+      return dialog;
+    }
+    DatabaseWorkbenchDialog dialog = new DatabaseWorkbenchDialog(hopGui);
+    hopGui.getShell().setData(SHELL_DATA_KEY, dialog);
+    dialog.openShell();
+    return dialog;
+  }
+
+  boolean isOpen() {
+    return shell != null && !shell.isDisposed();
+  }
+
+  void activate() {
+    if (!isOpen()) {
+      return;
+    }
+    shell.setMinimized(false);
+    shell.setActive();
+    shell.forceActive();
+  }
+
+  private void openShell() {
+    shell = new Shell(hopGui.getShell(), SWT.SHELL_TRIM | SWT.RESIZE | SWT.MAX | SWT.MIN);
+    shell.setText(BaseMessages.getString(PKG, "DatabasePerspective.Dialog.Title"));
+    shell.setImage(GuiResource.getInstance().getImageDatabase());
+    PropsUi.setLook(shell);
+    FormLayout layout = new FormLayout();
+    layout.marginWidth = PropsUi.getMargin();
+    layout.marginHeight = PropsUi.getMargin();
+    shell.setLayout(layout);
+
+    HopGuiDatabaseWorkbenchHost host =
+        new HopGuiDatabaseWorkbenchHost(hopGui, this::isOpen, this::activate);
+    workbench = new DatabaseWorkbench(shell, host);
+    workbench.setLayoutData(new FormDataBuilder().fullSize().result());
+
+    HopGuiKeyHandler keyHandler = HopGuiKeyHandler.getInstance();
+    keyHandler.addParentObjectToHandle(workbench);
+    hopGui.replaceKeyboardShortcutListeners(workbench, keyHandler);
+    hopGui.replaceKeyboardShortcutListeners(shell, keyHandler);
+
+    shell.addDisposeListener(
+        e -> {
+          props.setScreen(new WindowProperty(shell));
+          if (hopGui.getShell() != null
+              && !hopGui.getShell().isDisposed()
+              && hopGui.getShell().getData(SHELL_DATA_KEY) == this) {
+            hopGui.getShell().setData(SHELL_DATA_KEY, null);
+          }
+        });
+
+    restoreSize();
+    shell.open();
+  }
+
+  private void restoreSize() {
+    shell.setMinimumSize(MIN_WIDTH, MIN_HEIGHT);
+    WindowProperty windowProperty = props.getScreen(shell.getText());
+    if (windowProperty != null) {
+      windowProperty.setShell(shell, MIN_WIDTH, MIN_HEIGHT);
+      return;
+    }
+    Rectangle parentBounds = hopGui.getShell().getBounds();
+    int width = Math.max(MIN_WIDTH, (int) (parentBounds.width * 0.8));
+    int height = Math.max(MIN_HEIGHT, (int) (parentBounds.height * 0.8));
+    int x = parentBounds.x + Math.max(0, (parentBounds.width - width) / 2);
+    int y = parentBounds.y + Math.max(0, (parentBounds.height - height) / 2);
+    shell.setBounds(x, y, width, height);
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViews.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViews.java
new file mode 100644
index 0000000000..f158fcf70c
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/DatabaseWorkbenchViews.java
@@ -0,0 +1,108 @@
+/*
+ * 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.perspective.database;
+
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+
+/**
+ * Opens a {@link DatabaseWorkbench} as a floating window or as a tab in the bottom dock, the same
+ * way Search uses {@code SearchEverywhereDialog} and {@code HopGuiSearchResultsPanel}.
+ */
+@GuiPlugin
+public class DatabaseWorkbenchViews {
+
+  public static final Class<?> PKG = DatabasePerspective.class;
+
+  public static final String DOCK_TOOL_ID = "database-workbench";
+
+  public static final String ID_MAIN_MENU_TOOLS_DATABASE_WINDOW =
+      "40025-menu-tools-database-window";
+
+  @GuiMenuElement(
+      root = HopGui.ID_MAIN_MENU,
+      id = ID_MAIN_MENU_TOOLS_DATABASE_WINDOW,
+      label = "i18n::DatabasePerspective.Menu.Tools.Window",
+      parentId = HopGui.ID_MAIN_MENU_TOOLS_PARENT_ID,
+      image = "ui/images/database.svg")
+  public void menuToolsDatabaseWindow() {
+    HopGui hopGui;
+    try {
+      hopGui = HopGui.peekInstance();
+    } catch (Throwable e) {
+      return;
+    }
+    if (hopGui == null) {
+      return;
+    }
+    DatabaseWorkbenchDialog.open(hopGui);
+  }
+
+  /** Open or focus the floating Database window. */
+  public static void openDialog(HopGui hopGui) {
+    DatabaseWorkbenchDialog.open(hopGui);
+  }
+
+  /** Open or focus the Database tab in the bottom dock. */
+  public static void openDock(HopGui hopGui) {
+    if (hopGui == null) {
+      return;
+    }
+    HopGuiBottomDock dock = hopGui.getTerminalPanel();
+    if (dock == null || dock.isDisposed()) {
+      return;
+    }
+    String title = BaseMessages.getString(PKG, "DatabasePerspective.Name");
+    dock.focusOrOpenToolTab(
+        DOCK_TOOL_ID,
+        title,
+        GuiResource.getInstance().getImageDatabase(),
+        true,
+        container -> createDockedWorkbench(container, hopGui));
+  }
+
+  private static Control createDockedWorkbench(Composite container, HopGui hopGui) {
+    HopGuiDatabaseWorkbenchHost host =
+        new HopGuiDatabaseWorkbenchHost(
+            hopGui, () -> !container.isDisposed(), () -> openDock(hopGui));
+    DatabaseWorkbench workbench = new DatabaseWorkbench(container, host);
+    workbench.setLayoutData(new FormDataBuilder().fullSize().result());
+    return workbench;
+  }
+
+  /** True when this dock tab is already open. */
+  public static boolean isDockOpen(HopGui hopGui) {
+    if (hopGui == null) {
+      return false;
+    }
+    HopGuiBottomDock dock = hopGui.getTerminalPanel();
+    if (dock == null || dock.isDisposed()) {
+      return false;
+    }
+    CTabItem item = dock.findToolTab(DOCK_TOOL_ID);
+    return item != null && !item.isDisposed();
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
new file mode 100644
index 0000000000..1536ab21f4
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/HopGuiDatabaseWorkbenchHost.java
@@ -0,0 +1,102 @@
+/*
+ * 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.perspective.database;
+
+import java.util.function.BooleanSupplier;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+
+/** {@link IDatabaseWorkbenchHost} backed by a {@link HopGui} session. */
+public class HopGuiDatabaseWorkbenchHost implements IDatabaseWorkbenchHost {
+
+  private final HopGui hopGui;
+  private final BooleanSupplier alive;
+  private final Runnable onActivate;
+
+  public HopGuiDatabaseWorkbenchHost(HopGui hopGui, BooleanSupplier alive, Runnable onActivate) {
+    this.hopGui = hopGui;
+    this.alive = alive;
+    this.onActivate = onActivate;
+  }
+
+  @Override
+  public HopGui getHopGui() {
+    return hopGui;
+  }
+
+  @Override
+  public Shell getShell() {
+    return hopGui.getShell();
+  }
+
+  @Override
+  public Display getDisplay() {
+    return hopGui.getDisplay();
+  }
+
+  @Override
+  public IVariables getVariables() {
+    return hopGui.getVariables();
+  }
+
+  @Override
+  public IHopMetadataProvider getMetadataProvider() {
+    return hopGui.getMetadataProvider();
+  }
+
+  @Override
+  public ILoggingObject getLoggingObject() {
+    return hopGui.getLoggingObject();
+  }
+
+  @Override
+  public void activate() {
+    if (onActivate != null) {
+      onActivate.run();
+    }
+  }
+
+  @Override
+  public void asyncExec(Runnable runnable) {
+    Display display = hopGui.getDisplay();
+    if (display == null || display.isDisposed()) {
+      return;
+    }
+    display.asyncExec(
+        () -> {
+          if (alive != null && !alive.getAsBoolean()) {
+            return;
+          }
+          runnable.run();
+        });
+  }
+
+  @Override
+  public void updateGui(IHopFileTypeHandler handler) {
+    if (hopGui == null || handler == null) {
+      return;
+    }
+    hopGui.handleFileCapabilities(
+        handler.getFileType(), handler, handler.hasChanged(), false, false);
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/IDatabaseWorkbenchHost.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/IDatabaseWorkbenchHost.java
new file mode 100644
index 0000000000..f51caff563
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/IDatabaseWorkbenchHost.java
@@ -0,0 +1,55 @@
+/*
+ * 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.perspective.database;
+
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Shell;
+
+/**
+ * Services the database workbench needs from whatever hosts it (perspective now; a floating dialog
+ * or dock tab later). Keep Hop Gui specifics here so {@link DatabaseWorkbench} stays a plain {@link
+ * org.eclipse.swt.widgets.Composite}.
+ */
+public interface IDatabaseWorkbenchHost {
+
+  HopGui getHopGui();
+
+  Shell getShell();
+
+  Display getDisplay();
+
+  IVariables getVariables();
+
+  IHopMetadataProvider getMetadataProvider();
+
+  ILoggingObject getLoggingObject();
+
+  /** Bring this workbench to the front (activate the perspective, show the dialog, …). */
+  void activate();
+
+  /** Run {@code runnable} on the UI thread if the host is still alive. */
+  void asyncExec(Runnable runnable);
+
+  /** Refresh main-menu file capabilities for the active tab. */
+  void updateGui(IHopFileTypeHandler handler);
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/SqlExecuteRange.java b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/SqlExecuteRange.java
new file mode 100644
index 0000000000..4cadabb87f
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/database/SqlExecuteRange.java
@@ -0,0 +1,307 @@
+/*
+ * 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.perspective.database;
+
+import java.util.List;
+import org.apache.hop.core.database.SqlQueryClassifier;
+import org.apache.hop.core.database.SqlScriptStatement;
+
+/**
... 2064 lines suppressed ...