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

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


The following commit(s) were added to refs/heads/main by this push:
     new d854c335f2 Database Join add load SQL from a file (#8064)
d854c335f2 is described below

commit d854c335f2d8d4a7ca94e967ba2248af9691fd53
Author: Lance <[email protected]>
AuthorDate: Mon Aug 24 15:26:45 2026 +0800

    Database Join add load SQL from a file (#8064)
    
    Signed-off-by: lance <[email protected]>
---
 .../pages/pipeline/transforms/databasejoin.adoc    |  19 +-
 .../database/0009-database-join-sql-file.hpl       | 247 +++++++++++++++++++++
 .../database/main-0009-database-join.hwf           |  33 +++
 .../database/scripts/0009-database-join-lookup.sql |  19 ++
 .../transforms/databasejoin/DatabaseJoin.java      |   2 +-
 .../transforms/databasejoin/DatabaseJoinData.java  |   8 -
 .../databasejoin/DatabaseJoinDialog.java           |  87 +++++++-
 .../transforms/databasejoin/DatabaseJoinMeta.java  | 159 ++++---------
 .../transforms/databasejoin/ParameterField.java    |  19 +-
 .../databasejoin/cache/DatabaseCache.java          |   2 +-
 .../messages/messages_en_US.properties             |   7 +
 .../messages/messages_zh_CN.properties             |   7 +
 .../databasejoin/DatabaseJoinDataTest.java}        |  37 ++-
 .../databasejoin/DatabaseJoinMetaTest.java         | 141 +++++++++++-
 .../transforms/databasejoin/DatabaseJoinTest.java  |   5 +
 .../databasejoin/ParameterFieldTest.java           |  99 +++++++++
 .../databasejoin/cache/DatabaseCacheTest.java      | 108 +++++++++
 17 files changed, 835 insertions(+), 164 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/databasejoin.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/databasejoin.adoc
index ec4358b538..67fe9dcd96 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/databasejoin.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/databasejoin.adoc
@@ -16,7 +16,9 @@ under the License.
 ////
 :documentationPath: /pipeline/transforms/
 :language: en_US
-:description: The Database Join transform allows you to run a query against a 
database using data from your pipelines fields as parameters..
+:description: The Database Join transform allows you to run a query against a 
database using data from your pipelines fields as parameters.
+:openvar: ${
+:closevar: }
 
 = image:transforms/icons/dbjoin.svg[Database Join transform Icon, 
role="image-doc-icon"] Database Join
 
@@ -69,6 +71,17 @@ image::transforms/databasejoin/databasejoin-grid.png[]
 When the transform runs, the (?) placeholder defined in the SQL query will be 
replaced with the incoming productnr field value from the source hop.
 To define and use multiple parameters, list the fields in order you want them 
to be replaced in the SQL statement.
 
+=== Load SQL from a file
+
+You can keep the query in the SQL editor, or load it from an external `.sql` 
file with *Load SQL from file*.
+Use a VFS path; variables in the path (for example 
`{openvar}PROJECT_HOME{closevar}/sql/lookup_records.sql`) are resolved.
+The file is read once when the transform initializes, not once per incoming 
row.
+When a path is set, the SQL editor shows a read-only preview of the file 
contents.
+Question marks (`?`) in the file still bind to the *Parameters* table in the 
same order as for inline SQL.
+Leave the path empty to edit the query in the dialog (the previous default).
+
+Enable *Replace variables* to substitute Hop variables in the SQL (inline or 
loaded from the file) before the statement is prepared.
+
 == Options
 
 [options="header"]
@@ -85,7 +98,9 @@ If you can't use the cache, consider launching several copies 
of the simultaneou
 A simultaneous launch keeps the database busy through different connections.
 See Launching several copies of a transform.
 |Cache size in rows|The size of the cache (number of rows), 0 means cache 
everything.
-|SQL|SQL query to form the join; use question marks as parameter placeholders
+|SQL|SQL query to form the join; use question marks (`?`) as parameter 
placeholders. When *Load SQL from file* is set, this editor is a read-only 
preview of the file.
+|Load SQL from file|Optional VFS path to a `.sql` file (variables in the path 
are resolved). When set, the query is loaded from this file at transform 
initialization and the SQL editor is read-only. Parameter placeholders in the 
file still bind to the *Parameters* table. Leave empty to use the SQL editor.
+|Replace variables|Substitute Hop variables (for example 
`{openvar}schema{closevar}`) in the SQL before the statement is prepared. 
Applies to inline SQL and to SQL loaded from a file.
 |Number of rows to return|Zero (0) returns all rows; any other number limits 
the number of rows returned.
 |Outer join?|Enable to always return a result, even if the query did not 
return a result
 |Parameters table|Specify the fields containing parameters.
diff --git a/integration-tests/database/0009-database-join-sql-file.hpl 
b/integration-tests/database/0009-database-join-sql-file.hpl
new file mode 100644
index 0000000000..1d8d912fdc
--- /dev/null
+++ b/integration-tests/database/0009-database-join-sql-file.hpl
@@ -0,0 +1,247 @@
+<?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.
+
+-->
+<pipeline>
+  <info>
+    <name>0009-database-join-sql-file</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description/>
+    <extended_description/>
+    <pipeline_version/>
+    <pipeline_type>Normal</pipeline_type>
+    <parameters>
+    </parameters>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2021/04/30 11:01:28.333</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2021/04/30 11:01:28.333</modified_date>
+    <key_for_session_key>H4sIAAAAAAAAAAMAAAAAAAAAAAA=</key_for_session_key>
+    <is_key_private>N</is_key_private>
+  </info>
+  <notepads>
+  </notepads>
+  <order>
+    <hop>
+      <from>Generate rows</from>
+      <to>Database join</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>Database join</from>
+      <to>count rows</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>count rows</from>
+      <to>validate count</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>validate count</from>
+      <to>success</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>validate count</from>
+      <to>failed on count</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <transform>
+    <name>Database join</name>
+    <type>DBJoin</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <connection>unit-test-db</connection>
+    <rowlimit>0</rowlimit>
+    <sql>select value from public.testtable
+where 1 = 0</sql>
+    
<sql_from_file>${PROJECT_HOME}/scripts/0009-database-join-lookup.sql</sql_from_file>
+    <outer_join>N</outer_join>
+    <replace_vars>N</replace_vars>
+    <parameter>
+      <field>
+        <name>key</name>
+        <type>String</type>
+      </field>
+    </parameter>
+    <attributes/>
+    <GUI>
+      <xloc>336</xloc>
+      <yloc>80</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Generate rows</name>
+    <type>RowGenerator</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <fields>
+      <field>
+        <name>key</name>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif>10</nullif>
+        <length>-1</length>
+        <precision>-1</precision>
+        <set_empty_string>N</set_empty_string>
+      </field>
+    </fields>
+    <limit>1</limit>
+    <never_ending>N</never_ending>
+    <interval_in_ms>5000</interval_in_ms>
+    <row_time_field>now</row_time_field>
+    <last_time_field>FiveSecondsAgo</last_time_field>
+    <attributes/>
+    <GUI>
+      <xloc>144</xloc>
+      <yloc>80</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>count rows</name>
+    <type>GroupBy</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <all_rows>N</all_rows>
+    <ignore_aggregate>N</ignore_aggregate>
+    <field_ignore/>
+    <directory>${java.io.tmpdir}</directory>
+    <prefix>grp</prefix>
+    <add_linenr>N</add_linenr>
+    <linenr_fieldname/>
+    <give_back_row>Y</give_back_row>
+    <group>
+      </group>
+    <fields>
+      <field>
+        <aggregate>count</aggregate>
+        <subject/>
+        <type>COUNT_ANY</type>
+        <valuefield/>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>512</xloc>
+      <yloc>80</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>failed on count</name>
+    <type>Abort</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <row_threshold>0</row_threshold>
+    <message/>
+    <always_log_rows>Y</always_log_rows>
+    <abort_option>ABORT_WITH_ERROR</abort_option>
+    <attributes/>
+    <GUI>
+      <xloc>640</xloc>
+      <yloc>176</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>success</name>
+    <type>Dummy</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+    <GUI>
+      <xloc>784</xloc>
+      <yloc>80</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>validate count</name>
+    <type>FilterRows</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <send_true_to>success</send_true_to>
+    <send_false_to>failed on count</send_false_to>
+    <compare>
+      <condition>
+        <negated>N</negated>
+        <leftvalue>count</leftvalue>
+        <function>=</function>
+        <rightvalue/>
+        <value>
+          <name>constant</name>
+          <type>Integer</type>
+          <text>1</text>
+          <length>-1</length>
+          <precision>0</precision>
+          <isnull>N</isnull>
+          <mask>####0;-####0</mask>
+        </value>
+      </condition>
+    </compare>
+    <attributes/>
+    <GUI>
+      <xloc>640</xloc>
+      <yloc>80</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git a/integration-tests/database/main-0009-database-join.hwf 
b/integration-tests/database/main-0009-database-join.hwf
index 9c5bc88d19..24bf32c060 100644
--- a/integration-tests/database/main-0009-database-join.hwf
+++ b/integration-tests/database/main-0009-database-join.hwf
@@ -293,6 +293,32 @@ ok;</script>
       <yloc>48</yloc>
       <attributes_hac/>
     </action>
+    <action>
+      <name>0009-database-join-sql-file</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <clear_files>N</clear_files>
+      <clear_rows>N</clear_rows>
+      <create_parent_folder>N</create_parent_folder>
+      <exec_per_row>N</exec_per_row>
+      <filename>${PROJECT_HOME}/0009-database-join-sql-file.hpl</filename>
+      <loglevel>Basic</loglevel>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <params_from_previous>N</params_from_previous>
+      <run_configuration>local</run_configuration>
+      <set_append_logfile>N</set_append_logfile>
+      <set_logfile>N</set_logfile>
+      <wait_until_finished>Y</wait_until_finished>
+      <parallel>N</parallel>
+      <xloc>2912</xloc>
+      <yloc>48</yloc>
+      <attributes_hac/>
+    </action>
   </actions>
   <hops>
     <hop>
@@ -365,6 +391,13 @@ ok;</script>
       <evaluation>Y</evaluation>
       <unconditional>N</unconditional>
     </hop>
+    <hop>
+      <from>Check input rows limited cache size very big</from>
+      <to>0009-database-join-sql-file</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
   </hops>
   <notepads>
   </notepads>
diff --git a/integration-tests/database/scripts/0009-database-join-lookup.sql 
b/integration-tests/database/scripts/0009-database-join-lookup.sql
new file mode 100644
index 0000000000..b9bf9fc63e
--- /dev/null
+++ b/integration-tests/database/scripts/0009-database-join-lookup.sql
@@ -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.
+*/
+
+select value from public.testtable
+where key = ?
diff --git 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoin.java
 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoin.java
index f4cca36ee9..aa0c89f2c7 100644
--- 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoin.java
+++ 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoin.java
@@ -273,7 +273,7 @@ public class DatabaseJoin extends 
BaseTransform<DatabaseJoinMeta, DatabaseJoinDa
             logDetailed(BaseMessages.getString(PKG, 
"DatabaseJoin.Log.ConnectedToDB"));
           }
 
-          String sql = meta.getSql();
+          String sql = meta.getEffectiveSql(variables);
           if (meta.isReplaceVariables()) {
             sql = resolve(sql);
           }
diff --git 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinData.java
 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinData.java
index fff2eaf409..52fcfec614 100644
--- 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinData.java
+++ 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinData.java
@@ -36,12 +36,4 @@ public class DatabaseJoinData extends BaseTransformData 
implements ITransformDat
   public int[] keynrs; // parameter value index in an input row...
   public Object[] notfound; // Values in case nothing is found...
   public boolean isCanceled;
-
-  public DatabaseJoinData() {
-    super();
-
-    db = null;
-    notfound = null;
-    cache = null;
-  }
 }
diff --git 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDialog.java
 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDialog.java
index 2454a5f1ff..bfb37f8a02 100644
--- 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDialog.java
+++ 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDialog.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.pipeline.transforms.databasejoin;
 
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
@@ -24,10 +25,12 @@ import org.apache.hop.core.Const;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.database.DatabaseMeta;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopFileException;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.value.ValueMetaFactory;
 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.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
@@ -42,6 +45,7 @@ import org.apache.hop.ui.core.widget.SQLStyledTextComp;
 import org.apache.hop.ui.core.widget.StyledTextComp;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextComposite;
+import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.util.EnvironmentUtils;
 import org.eclipse.swt.SWT;
@@ -69,6 +73,8 @@ public class DatabaseJoinDialog extends BaseTransformDialog {
 
   private TextComposite wSql;
 
+  private TextVar wSqlFromFile;
+
   private Text wLimit;
 
   private Button wOuter;
@@ -156,13 +162,57 @@ public class DatabaseJoinDialog extends 
BaseTransformDialog {
     fdCacheSize.top = new FormAttachment(wCache, margin);
     wCacheSize.setLayoutData(fdCacheSize);
 
+    // Load SQL from file
+    Label wlSqlFromFile = new Label(shell, SWT.RIGHT);
+    wlSqlFromFile.setText(BaseMessages.getString(PKG, 
"DatabaseJoinDialog.LoadSqlFromFile"));
+    PropsUi.setLook(wlSqlFromFile);
+    FormData fdlSqlFromFile = new FormData();
+    fdlSqlFromFile.left = new FormAttachment(0, 0);
+    fdlSqlFromFile.right = new FormAttachment(middle, -margin);
+    fdlSqlFromFile.top = new FormAttachment(wCacheSize, margin);
+    wlSqlFromFile.setLayoutData(fdlSqlFromFile);
+    Button wbSqlFromFile = new Button(shell, SWT.PUSH);
+    PropsUi.setLook(wbSqlFromFile);
+    wbSqlFromFile.setText(BaseMessages.getString(PKG, 
"DatabaseJoinDialog.Browse"));
+    FormData fdbSqlFromFile = new FormData();
+    fdbSqlFromFile.right = new FormAttachment(100, 0);
+    fdbSqlFromFile.top = new FormAttachment(wlSqlFromFile, 0, SWT.CENTER);
+    wbSqlFromFile.setLayoutData(fdbSqlFromFile);
+
+    wSqlFromFile = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
+    PropsUi.setLook(wSqlFromFile);
+    wSqlFromFile.addModifyListener(lsMod);
+    FormData fdSqlFromFile = new FormData();
+    fdSqlFromFile.left = new FormAttachment(middle, 0);
+    fdSqlFromFile.right = new FormAttachment(wbSqlFromFile, -margin);
+    fdSqlFromFile.top = new FormAttachment(wlSqlFromFile, 0, SWT.CENTER);
+    wSqlFromFile.setLayoutData(fdSqlFromFile);
+    wbSqlFromFile.addListener(
+        SWT.Selection,
+        e -> {
+          String path =
+              BaseDialog.presentFileDialog(
+                  shell,
+                  wSqlFromFile,
+                  variables,
+                  new String[] {"*.sql", "*"},
+                  new String[] {
+                    BaseMessages.getString(PKG, "DatabaseJoinDialog.SqlFiles"),
+                    BaseMessages.getString(PKG, "System.FileType.AllFiles")
+                  },
+                  false);
+          if (path != null) {
+            loadSqlFromFileAndSetReadOnly();
+          }
+        });
+
     // SQL editor...
     Label wlSql = new Label(shell, SWT.NONE);
     wlSql.setText(BaseMessages.getString(PKG, "DatabaseJoinDialog.SQL.Label"));
     PropsUi.setLook(wlSql);
     FormData fdlSql = new FormData();
     fdlSql.left = new FormAttachment(0, 0);
-    fdlSql.top = new FormAttachment(wCacheSize, margin);
+    fdlSql.top = new FormAttachment(wbSqlFromFile, margin);
     wlSql.setLayoutData(fdlSql);
 
     wSql =
@@ -177,6 +227,12 @@ public class DatabaseJoinDialog extends 
BaseTransformDialog {
     wSql.addLineStyleListener(getSqlReservedWords());
     PropsUi.setLook(wSql, Props.WIDGET_STYLE_FIXED);
     wSql.addModifyListener(lsMod);
+    wSqlFromFile.addModifyListener(
+        e -> {
+          if (Utils.isEmpty(wSqlFromFile.getText())) {
+            wSql.setEditable(true);
+          }
+        });
     FormData fdSql = new FormData();
     fdSql.left = new FormAttachment(0, 0);
     fdSql.top = new FormAttachment(wlSql, margin);
@@ -411,6 +467,28 @@ public class DatabaseJoinDialog extends 
BaseTransformDialog {
             PKG, "DatabaseJoinDialog.Position.Label", "" + lineNumber, "" + 
columnNumber));
   }
 
+  private void loadSqlFromFileAndSetReadOnly() {
+    String path = variables.resolve(wSqlFromFile.getText());
+    if (Utils.isEmpty(path)) {
+      wSql.setEditable(true);
+      return;
+    }
+    try {
+      String content = HopVfs.getTextFileContent(path, StandardCharsets.UTF_8);
+      wSql.setText(content);
+      wSql.setEditable(false);
+    } catch (HopFileException e) {
+      MessageBox mb = new MessageBox(shell, SWT.OK | SWT.ICON_WARNING);
+      mb.setText(BaseMessages.getString(PKG, 
"DatabaseJoinDialog.InvalidConnection.DialogTitle"));
+      mb.setMessage(
+          BaseMessages.getString(PKG, 
"DatabaseJoinDialog.CouldNotLoadSqlFromFile", path)
+              + Const.CR
+              + e.getMessage());
+      mb.open();
+      wSql.setEditable(true);
+    }
+  }
+
   /** Copy information from the meta-data input to the dialog fields. */
   public void getData() {
     logDebug(BaseMessages.getString(PKG, 
"DatabaseJoinDialog.Log.GettingKeyInfo"));
@@ -421,6 +499,12 @@ public class DatabaseJoinDialog extends 
BaseTransformDialog {
     wCacheSize.setText("" + input.getCacheSize());
 
     wSql.setText(Const.NVL(input.getSql(), ""));
+    wSqlFromFile.setText(Const.NVL(input.getSqlFromFile(), ""));
+    if (!Utils.isEmpty(wSqlFromFile.getText())) {
+      loadSqlFromFileAndSetReadOnly();
+    } else {
+      wSql.setEditable(true);
+    }
     wLimit.setText("" + input.getRowLimit());
     wOuter.setSelection(input.isOuterJoin());
     wUseVars.setSelection(input.isReplaceVariables());
@@ -459,6 +543,7 @@ public class DatabaseJoinDialog extends BaseTransformDialog 
{
     input.setCacheSize(Const.toInt(wCacheSize.getText(), 0));
     input.setRowLimit(Const.toIntExpanded(wLimit.getText(), 0));
     input.setSql(wSql.getText());
+    input.setSqlFromFile(wSqlFromFile.getText());
     input.setOuterJoin(wOuter.getSelection());
     input.setReplaceVariables(wUseVars.getSelection());
     logDebug(
diff --git 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinMeta.java
 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinMeta.java
index e301335596..32cebec8a4 100644
--- 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinMeta.java
+++ 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinMeta.java
@@ -17,8 +17,11 @@
 
 package org.apache.hop.pipeline.transforms.databasejoin;
 
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
 import org.apache.hop.core.CheckResult;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.ICheckResult;
@@ -28,6 +31,7 @@ import org.apache.hop.core.database.Database;
 import org.apache.hop.core.database.DatabaseMeta;
 import org.apache.hop.core.exception.HopDatabaseException;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopFileException;
 import org.apache.hop.core.exception.HopPluginException;
 import org.apache.hop.core.exception.HopTransformException;
 import org.apache.hop.core.row.IRowMeta;
@@ -37,6 +41,7 @@ import org.apache.hop.core.row.value.ValueMetaFactory;
 import org.apache.hop.core.row.value.ValueMetaNone;
 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.metadata.api.HopMetadataProperty;
 import org.apache.hop.metadata.api.HopMetadataPropertyType;
@@ -46,6 +51,8 @@ import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.BaseTransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 
+@Getter
+@Setter
 @Transform(
     id = "DBJoin",
     image = "dbjoin.svg",
@@ -85,6 +92,15 @@ public class DatabaseJoinMeta extends 
BaseTransformMeta<DatabaseJoin, DatabaseJo
       hopMetadataPropertyType = HopMetadataPropertyType.RDBMS_SQL)
   private String sql;
 
+  /**
+   * When set, SQL is loaded from this file (VFS path, supports variables). 
SQL editor is read-only.
+   */
+  @HopMetadataProperty(
+      key = "sql_from_file",
+      injectionKey = "SQL_FROM_FILE",
+      injectionKeyDescription = "DatabaseJoinMeta.Injection.SqlFromFile")
+  private String sqlFromFile;
+
   /** Number of rows to return (0=ALL) */
   @HopMetadataProperty(
       key = "rowlimit",
@@ -114,104 +130,26 @@ public class DatabaseJoinMeta extends 
BaseTransformMeta<DatabaseJoin, DatabaseJo
       injectionKeyDescription = "DatabaseJoinMeta.Injection.ReplaceVariables")
   private boolean replaceVariables;
 
-  public DatabaseJoinMeta() {
-    super(); // allocate BaseTransformMeta
-  }
-
-  public String getConnection() {
-    return connection;
-  }
-
-  public void setConnection(String connection) {
-    this.connection = connection;
-  }
-
-  /**
-   * Gets cached
-   *
-   * @return value of cached
-   */
-  public boolean isCached() {
-    return cached;
-  }
-
-  /**
-   * @param cached The cached to set
-   */
-  public void setCached(boolean cached) {
-    this.cached = cached;
-  }
-
-  /**
-   * Gets cacheSize
-   *
-   * @return value of cacheSize
-   */
-  public int getCacheSize() {
-    return cacheSize;
-  }
-
-  /**
-   * @param cacheSize The cacheSize to set
-   */
-  public void setCacheSize(int cacheSize) {
-    this.cacheSize = cacheSize;
-  }
-
-  /**
-   * @return Returns the outerJoin.
-   */
-  public boolean isOuterJoin() {
-    return outerJoin;
-  }
-
-  /**
-   * @param outerJoin The outerJoin to set.
-   */
-  public void setOuterJoin(boolean outerJoin) {
-    this.outerJoin = outerJoin;
-  }
-
-  /**
-   * @return Returns the replacevars.
-   */
-  public boolean isReplaceVariables() {
-    return replaceVariables;
-  }
-
-  /**
-   * @param enabled The replacevars to set.
-   */
-  public void setReplaceVariables(boolean enabled) {
-    this.replaceVariables = enabled;
-  }
-
-  /**
-   * @return Returns the rowLimit.
-   */
-  public int getRowLimit() {
-    return rowLimit;
-  }
-
-  /**
-   * @param rowLimit The rowLimit to set.
-   */
-  public void setRowLimit(int rowLimit) {
-    this.rowLimit = rowLimit;
-  }
-
   /**
-   * @return Returns the sql.
+   * Returns the SQL to execute: either from the inline editor or loaded from 
the file specified by
+   * sqlFromFile (using VFS). Variables are resolved in the file path.
    */
-  public String getSql() {
+  public String getEffectiveSql(IVariables variables) throws HopException {
+    if (!Utils.isEmpty(sqlFromFile)) {
+      String path = variables.resolve(sqlFromFile);
+      try {
+        return HopVfs.getTextFileContent(path, StandardCharsets.UTF_8);
+      } catch (HopFileException e) {
+        throw new HopException(
+            BaseMessages.getString(PKG, 
"DatabaseJoinMeta.Exception.CouldNotLoadSqlFromFile", path),
+            e);
+      }
+    }
     return sql;
   }
 
-  /**
-   * @param sql The sql to set.
-   */
-  public void setSql(String sql) {
-    this.sql = sql;
+  private String resolveSql(IVariables variables) throws HopException {
+    return variables.resolve(Const.NVL(getEffectiveSql(variables), ""));
   }
 
   @Override
@@ -271,17 +209,24 @@ public class DatabaseJoinMeta extends 
BaseTransformMeta<DatabaseJoin, DatabaseJo
       //
       IRowMeta param = getParameterRow(row);
 
+      String sqlToUse;
+      try {
+        sqlToUse = resolveSql(variables);
+      } catch (HopException e) {
+        throw new HopTransformException(e.getMessage(), e);
+      }
+
       // First try without connecting to the database... (can be S L O W)
       // See if it's in the cache...
       //
       IRowMeta add = null;
       try {
-        add = db.getQueryFields(variables.resolve(sql), true, param, new 
Object[param.size()]);
+        add = db.getQueryFields(sqlToUse, true, param, new 
Object[param.size()]);
       } catch (HopDatabaseException dbe) {
         throw new HopTransformException(
             BaseMessages.getString(PKG, 
"DatabaseJoinMeta.Exception.UnableToDetermineQueryFields")
                 + Const.CR
-                + sql,
+                + sqlToUse,
             dbe);
       }
 
@@ -296,7 +241,7 @@ public class DatabaseJoinMeta extends 
BaseTransformMeta<DatabaseJoin, DatabaseJo
         //
         try {
           db.connect();
-          add = db.getQueryFields(variables.resolve(sql), true, param, new 
Object[param.size()]);
+          add = db.getQueryFields(sqlToUse, true, param, new 
Object[param.size()]);
           for (int i = 0; i < add.size(); i++) {
             IValueMeta v = add.getValueMeta(i);
             v.setOrigin(name);
@@ -347,13 +292,13 @@ public class DatabaseJoinMeta extends 
BaseTransformMeta<DatabaseJoin, DatabaseJo
 
       try {
         db.connect();
-        if (!Utils.isEmpty(sql)) {
+        String sqlToUse = resolveSql(variables);
+        if (!Utils.isEmpty(sqlToUse)) {
           IRowMeta param = getParameterRow(prev);
 
           errorMessage = "";
 
-          IRowMeta r =
-              db.getQueryFields(variables.resolve(sql), true, param, new 
Object[param.size()]);
+          IRowMeta r = db.getQueryFields(sqlToUse, true, param, new 
Object[param.size()]);
           if (r != null) {
             cr =
                 new CheckResult(
@@ -368,7 +313,7 @@ public class DatabaseJoinMeta extends 
BaseTransformMeta<DatabaseJoin, DatabaseJo
             remarks.add(cr);
           }
 
-          int q = db.countParameters(variables.resolve(sql));
+          int q = db.countParameters(sqlToUse);
           if (q != parameters.size()) {
             errorMessage =
                 BaseMessages.getString(
@@ -494,8 +439,8 @@ public class DatabaseJoinMeta extends 
BaseTransformMeta<DatabaseJoin, DatabaseJo
 
       try {
         db.connect();
-        fields = db.getQueryFields(variables.resolve(sql), true, param, new 
Object[param.size()]);
-      } catch (HopDatabaseException dbe) {
+        fields = db.getQueryFields(resolveSql(variables), true, param, new 
Object[param.size()]);
+      } catch (HopException dbe) {
         logError(
             BaseMessages.getString(PKG, 
"DatabaseJoinMeta.Log.DatabaseErrorOccurred")
                 + dbe.getMessage());
@@ -549,7 +494,7 @@ public class DatabaseJoinMeta extends 
BaseTransformMeta<DatabaseJoin, DatabaseJo
                   outvalue.getName(),
                   outvalue.getName(),
                   transformMeta.getName(),
-                  variables.resolve(sql),
+                  resolveSql(variables),
                   BaseMessages.getString(PKG, 
"DatabaseJoinMeta.DatabaseImpact.Title"));
           impact.add(di);
         }
@@ -565,12 +510,4 @@ public class DatabaseJoinMeta extends 
BaseTransformMeta<DatabaseJoin, DatabaseJo
   public boolean supportsErrorHandling() {
     return true;
   }
-
-  public List<ParameterField> getParameters() {
-    return parameters;
-  }
-
-  public void setParameters(List<ParameterField> parameters) {
-    this.parameters = parameters;
-  }
 }
diff --git 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/ParameterField.java
 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/ParameterField.java
index 953bbcfa32..c09125b807 100644
--- 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/ParameterField.java
+++ 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/ParameterField.java
@@ -6,7 +6,7 @@
  * (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
+ *      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,
@@ -17,12 +17,16 @@
 
 package org.apache.hop.pipeline.transforms.databasejoin;
 
+import lombok.Getter;
+import lombok.NoArgsConstructor;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.row.IValueMeta;
 import org.apache.hop.core.row.value.ValueMetaFactory;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 
 /** The parameters field. */
+@Getter
+@NoArgsConstructor
 public class ParameterField implements Cloneable {
 
   /** The target field name */
@@ -38,12 +42,7 @@ public class ParameterField implements Cloneable {
       injectionKeyDescription = "DatabaseJoinMeta.Injection.Parameter.Type")
   private String type = 
ValueMetaFactory.getValueMetaName(IValueMeta.TYPE_NONE);
 
-  public ParameterField() {
-    super();
-  }
-
   public ParameterField(ParameterField cloned) {
-    super();
     this.name = cloned.name;
     this.type = cloned.type;
   }
@@ -53,18 +52,10 @@ public class ParameterField implements Cloneable {
     return new ParameterField(this);
   }
 
-  public String getName() {
-    return name;
-  }
-
   public void setName(final String name) {
     this.name = StringUtils.stripToNull(name);
   }
 
-  public String getType() {
-    return this.type;
-  }
-
   public void setType(final String type) {
     this.type = type;
   }
diff --git 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/cache/DatabaseCache.java
 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/cache/DatabaseCache.java
index ef7a375231..6124d0ee7a 100644
--- 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/cache/DatabaseCache.java
+++ 
b/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/cache/DatabaseCache.java
@@ -36,7 +36,7 @@ public class DatabaseCache {
   }
 
   public List<Object[]> getRowsFromCache(RowMetaAndData key) {
-    return cache.getOrDefault(key, null);
+    return cache.get(key);
   }
 
   public void putRowsIntoCache(RowMetaAndData key, List<Object[]> values) {
diff --git 
a/plugins/transforms/databasejoin/src/main/resources/org/apache/hop/pipeline/transforms/databasejoin/messages/messages_en_US.properties
 
b/plugins/transforms/databasejoin/src/main/resources/org/apache/hop/pipeline/transforms/databasejoin/messages/messages_en_US.properties
index 10319c4747..90ff031e2c 100644
--- 
a/plugins/transforms/databasejoin/src/main/resources/org/apache/hop/pipeline/transforms/databasejoin/messages/messages_en_US.properties
+++ 
b/plugins/transforms/databasejoin/src/main/resources/org/apache/hop/pipeline/transforms/databasejoin/messages/messages_en_US.properties
@@ -44,6 +44,10 @@ DatabaseJoinDialog.Param.Label=The parameters to use\:
 DatabaseJoinDialog.Position.Label=Line {0} Column {1}
 DatabaseJoinDialog.Shell.Title=Database join
 DatabaseJoinDialog.SQL.Label=SQL
+DatabaseJoinDialog.LoadSqlFromFile=Load SQL from file
+DatabaseJoinDialog.Browse=Browse...
+DatabaseJoinDialog.SqlFiles=SQL files
+DatabaseJoinDialog.CouldNotLoadSqlFromFile=Could not load SQL from file: {0}
 DatabaseJoinDialog.TransformName.Label=Transform name
 DatabaseJoinDialog.useVarsjoin.Label=Replace variables
 DatabaseJoinDialog.useVarsjoin.Tooltip=Replace variables in SQL script
@@ -64,6 +68,7 @@ DatabaseJoinMeta.CheckResult.ReceivingInfo=Transform is 
receiving info from othe
 DatabaseJoinMeta.DatabaseImpact.Title=read from one or more database tables 
via SQL statement
 DatabaseJoinMeta.Exception.ErrorObtainingFields=Error obtaining fields for 
this transform
 DatabaseJoinMeta.Exception.UnableToDetermineQueryFields=Unable to determine 
the fields of query\:
+DatabaseJoinMeta.Exception.CouldNotLoadSqlFromFile=Could not load SQL from 
file: {0}
 DatabaseJoinMeta.Injection.Cache=Enable cache ? (Y/N)
 DatabaseJoinMeta.Injection.CacheSize=Cache size in rows
 DatabaseJoinMeta.Injection.Connection=Connection
@@ -75,5 +80,7 @@ DatabaseJoinMeta.Injection.Parameters=The parameter fields to 
use
 DatabaseJoinMeta.Injection.ReplaceVariables=Replace variables in SQL script ? 
(Y/N)
 DatabaseJoinMeta.Injection.RowLimit=Number of rows to return
 DatabaseJoinMeta.Injection.SQL=SQL
+DatabaseJoinMeta.Injection.SqlFromFile=The SQL file to load the query from 
(VFS path, supports variables)
 DatabaseJoinMeta.keyword=database,join,sql,lookup,query
 DatabaseJoinMeta.Log.DatabaseErrorOccurred=A database error occurred\:
+System.FileType.AllFiles=All files
diff --git 
a/plugins/transforms/databasejoin/src/main/resources/org/apache/hop/pipeline/transforms/databasejoin/messages/messages_zh_CN.properties
 
b/plugins/transforms/databasejoin/src/main/resources/org/apache/hop/pipeline/transforms/databasejoin/messages/messages_zh_CN.properties
index 86211f1d5c..1453ee9d70 100644
--- 
a/plugins/transforms/databasejoin/src/main/resources/org/apache/hop/pipeline/transforms/databasejoin/messages/messages_zh_CN.properties
+++ 
b/plugins/transforms/databasejoin/src/main/resources/org/apache/hop/pipeline/transforms/databasejoin/messages/messages_zh_CN.properties
@@ -46,6 +46,10 @@ DatabaseJoinDialog.Param.Label=The parameters to use\:
 DatabaseJoinDialog.Position.Label=\u884C {0} \u5217 {1}
 DatabaseJoinDialog.Shell.Title=Database join
 DatabaseJoinDialog.SQL.Label=SQL\:
+DatabaseJoinDialog.LoadSqlFromFile=\u4ECE\u6587\u4EF6\u52A0\u8F7D SQL
+DatabaseJoinDialog.Browse=\u6D4F\u89C8...
+DatabaseJoinDialog.SqlFiles=SQL \u6587\u4EF6
+DatabaseJoinDialog.CouldNotLoadSqlFromFile=\u65E0\u6CD5\u4ECE\u6587\u4EF6\u52A0\u8F7D
 SQL: {0}
 DatabaseJoinDialog.TransformName.Label=Transform \u540D\u79F0\:
 DatabaseJoinDialog.useVarsjoin.Label=\u66FF\u6362\u53D8\u91CF\:
 DatabaseJoinDialog.useVarsjoin.Tooltip=\u66FF\u6362 SQL 
\u811A\u672C\u4E2D\u7684\u53D8\u91CF
@@ -66,6 +70,7 @@ DatabaseJoinMeta.CheckResult.ReceivingInfo=Transform is 
receiving info from othe
 DatabaseJoinMeta.DatabaseImpact.Title=read from one or more database tables 
via SQL statement
 DatabaseJoinMeta.Exception.ErrorObtainingFields=Error obtaining fields for 
this transform
 DatabaseJoinMeta.Exception.UnableToDetermineQueryFields=Unable to determine 
the fields of query\:
+DatabaseJoinMeta.Exception.CouldNotLoadSqlFromFile=\u65E0\u6CD5\u4ECE\u6587\u4EF6\u52A0\u8F7D
 SQL: {0}
 DatabaseJoinMeta.Injection.Cache=\u542F\u7528\u7F13\u5B58?(Y/N)
 DatabaseJoinMeta.Injection.CacheSize=\u7F13\u5B58\u5927\u5C0F
 DatabaseJoinMeta.Injection.Connection=\u8FDE\u63A5
@@ -77,5 +82,7 @@ 
DatabaseJoinMeta.Injection.Parameters=\u8981\u4F7F\u7528\u7684\u53C2\u6570\u5B57
 DatabaseJoinMeta.Injection.ReplaceVariables=\u66FF\u6362 SQL 
\u811A\u672C\u4E2D\u7684\u53D8\u91CF?(Y/N)
 DatabaseJoinMeta.Injection.RowLimit=\u8981\u8FD4\u56DE\u7684\u884C\u6570
 DatabaseJoinMeta.Injection.SQL=SQL
+DatabaseJoinMeta.Injection.SqlFromFile=\u8981\u52A0\u8F7D\u67E5\u8BE2\u7684 
SQL \u6587\u4EF6\u8DEF\u5F84\uFF08\u652F\u6301\u53D8\u91CF\uFF09
 DatabaseJoinMeta.keyword=database,db,join
 DatabaseJoinMeta.Log.DatabaseErrorOccurred=A database error occurred\:
+System.FileType.AllFiles=\u6240\u6709\u6587\u4EF6
diff --git 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinData.java
 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDataTest.java
similarity index 51%
copy from 
plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinData.java
copy to 
plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDataTest.java
index fff2eaf409..2c07dafb4a 100644
--- 
a/plugins/transforms/databasejoin/src/main/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinData.java
+++ 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinDataTest.java
@@ -17,31 +17,22 @@
 
 package org.apache.hop.pipeline.transforms.databasejoin;
 
-import java.sql.PreparedStatement;
-import org.apache.hop.core.database.Database;
-import org.apache.hop.core.row.IRowMeta;
-import org.apache.hop.pipeline.transform.BaseTransformData;
-import org.apache.hop.pipeline.transform.ITransformData;
-import org.apache.hop.pipeline.transforms.databasejoin.cache.DatabaseCache;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 
-@SuppressWarnings("java:S1104")
-public class DatabaseJoinData extends BaseTransformData implements 
ITransformData {
-  public Database db;
-  public PreparedStatement pstmt;
-  public DatabaseCache cache;
+import org.junit.jupiter.api.Test;
 
-  IRowMeta outputRowMeta;
-  IRowMeta lookupRowMeta;
+/** Unit test for {@link DatabaseJoinData} */
+class DatabaseJoinDataTest {
 
-  public int[] keynrs; // parameter value index in an input row...
-  public Object[] notfound; // Values in case nothing is found...
-  public boolean isCanceled;
-
-  public DatabaseJoinData() {
-    super();
-
-    db = null;
-    notfound = null;
-    cache = null;
+  @Test
+  void defaultsAreUnset() {
+    DatabaseJoinData data = new DatabaseJoinData();
+    assertNull(data.db);
+    assertNull(data.pstmt);
+    assertNull(data.cache);
+    assertNull(data.notfound);
+    assertNull(data.keynrs);
+    assertFalse(data.isCanceled);
   }
 }
diff --git 
a/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinMetaTest.java
 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinMetaTest.java
index 42a78d08d0..ca599feefe 100644
--- 
a/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinMetaTest.java
+++ 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinMetaTest.java
@@ -16,27 +16,35 @@
  */
 package org.apache.hop.pipeline.transforms.databasejoin;
 
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Random;
 import java.util.UUID;
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.hop.core.HopEnvironment;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
 import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
 import org.apache.hop.pipeline.transforms.loadsave.LoadSaveTester;
 import org.apache.hop.pipeline.transforms.loadsave.initializer.IInitializer;
 import 
org.apache.hop.pipeline.transforms.loadsave.validator.IFieldLoadSaveValidator;
 import 
org.apache.hop.pipeline.transforms.loadsave.validator.ListLoadSaveValidator;
+import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 
+/** Unit test for {@link DatabaseJoinMeta} */
 class DatabaseJoinMetaTest implements IInitializer<DatabaseJoinMeta> {
   LoadSaveTester<DatabaseJoinMeta> loadSaveTester;
   Class<DatabaseJoinMeta> testMetaClass = DatabaseJoinMeta.class;
@@ -54,7 +62,15 @@ class DatabaseJoinMetaTest implements 
IInitializer<DatabaseJoinMeta> {
   void setUpLoadSave() throws Exception {
     List<String> attributes =
         Arrays.asList(
-            "sql", "rowLimit", "outerJoin", "replaceVariables", "connection", 
"parameters");
+            "sql",
+            "sqlFromFile",
+            "rowLimit",
+            "outerJoin",
+            "replaceVariables",
+            "connection",
+            "cached",
+            "cacheSize",
+            "parameters");
 
     Map<String, String> getterMap = new HashMap<>();
 
@@ -88,8 +104,127 @@ class DatabaseJoinMetaTest implements 
IInitializer<DatabaseJoinMeta> {
     loadSaveTester.testSerialization();
   }
 
+  @Test
+  void getEffectiveSqlUsesInlineWhenNoFile() throws Exception {
+    DatabaseJoinMeta meta = new DatabaseJoinMeta();
+    meta.setSql("SELECT 1");
+    Assertions.assertEquals("SELECT 1", meta.getEffectiveSql(new Variables()));
+  }
+
+  @Test
+  void getEffectiveSqlLoadsFromFile() throws Exception {
+    Path file = Files.createTempFile("databasejoin-", ".sql");
+    try {
+      String sql = "SELECT * FROM lookup WHERE id = ?";
+      Files.writeString(file, sql);
+      DatabaseJoinMeta meta = new DatabaseJoinMeta();
+      meta.setSql("SELECT 1");
+      meta.setSqlFromFile(file.toAbsolutePath().toString());
+      Assertions.assertEquals(sql, meta.getEffectiveSql(new Variables()));
+    } finally {
+      Files.deleteIfExists(file);
+    }
+  }
+
+  @Test
+  void getEffectiveSqlResolvesVariablesInPath() throws Exception {
+    Path file = Files.createTempFile("databasejoin-", ".sql");
+    try {
+      Files.writeString(file, "SELECT 2");
+      DatabaseJoinMeta meta = new DatabaseJoinMeta();
+      meta.setSqlFromFile("${SQL_FILE}");
+      Variables variables = new Variables();
+      variables.setVariable("SQL_FILE", file.toAbsolutePath().toString());
+      Assertions.assertEquals("SELECT 2", meta.getEffectiveSql(variables));
+    } finally {
+      Files.deleteIfExists(file);
+    }
+  }
+
+  @Test
+  void getEffectiveSqlMissingFileThrows() {
+    DatabaseJoinMeta meta = new DatabaseJoinMeta();
+    meta.setSqlFromFile("/this/path/does/not/exist-databasejoin.sql");
+    Assertions.assertThrows(HopException.class, () -> meta.getEffectiveSql(new 
Variables()));
+  }
+
+  @Test
+  void getEffectiveSqlEmptyFilePathUsesInlineSql() throws Exception {
+    DatabaseJoinMeta meta = new DatabaseJoinMeta();
+    meta.setSql("SELECT 1");
+    meta.setSqlFromFile("");
+    Assertions.assertEquals("SELECT 1", meta.getEffectiveSql(new Variables()));
+  }
+
+  @Test
+  void setDefaultResetsLookupOptions() {
+    DatabaseJoinMeta meta = new DatabaseJoinMeta();
+    meta.setSql("SELECT 1");
+    meta.setRowLimit(10);
+    meta.setOuterJoin(true);
+    meta.setReplaceVariables(true);
+    ParameterField field = new ParameterField();
+    field.setName("id");
+    meta.getParameters().add(field);
+
+    meta.setDefault();
+
+    Assertions.assertEquals("", meta.getSql());
+    Assertions.assertEquals(0, meta.getRowLimit());
+    Assertions.assertFalse(meta.isOuterJoin());
+    Assertions.assertFalse(meta.isReplaceVariables());
+    Assertions.assertTrue(meta.getParameters().isEmpty());
+  }
+
+  @Test
+  void supportsErrorHandling() {
+    Assertions.assertTrue(new DatabaseJoinMeta().supportsErrorHandling());
+  }
+
+  @Test
+  void getFieldsDoesNothingWhenConnectionIsMissing() throws Exception {
+    DatabaseJoinMeta meta = new DatabaseJoinMeta();
+    IRowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("id"));
+    meta.getFields(row, "join", null, null, new Variables(), null);
+    Assertions.assertEquals(1, row.size());
+    Assertions.assertEquals("id", row.getValueMeta(0).getName());
+  }
+
+  @Test
+  void getParameterRowSelectsMatchingIncomingFieldsInOrder() {
+    DatabaseJoinMeta meta = new DatabaseJoinMeta();
+    ParameterField id = new ParameterField();
+    id.setName("id");
+    ParameterField missing = new ParameterField();
+    missing.setName("missing");
+    ParameterField name = new ParameterField();
+    name.setName("name");
+    meta.setParameters(List.of(id, missing, name));
+
+    IRowMeta incoming = new RowMeta();
+    incoming.addValueMeta(new ValueMetaString("name"));
+    incoming.addValueMeta(new ValueMetaInteger("id"));
+    incoming.addValueMeta(new ValueMetaString("extra"));
+
+    IRowMeta param = meta.getParameterRow(incoming);
+    Assertions.assertEquals(2, param.size());
+    Assertions.assertEquals("id", param.getValueMeta(0).getName());
+    Assertions.assertEquals("name", param.getValueMeta(1).getName());
+  }
+
+  @Test
+  void getParameterRowHandlesNullIncoming() {
+    DatabaseJoinMeta meta = new DatabaseJoinMeta();
+    ParameterField field = new ParameterField();
+    field.setName("id");
+    meta.getParameters().add(field);
+    IRowMeta param = meta.getParameterRow(null);
+    Assertions.assertNotNull(param);
+    Assertions.assertTrue(param.isEmpty());
+  }
+
   public class ParameterFieldLoadSaveValidator implements 
IFieldLoadSaveValidator<ParameterField> {
-    final Random rand = new Random();
 
     @Override
     public ParameterField getTestObject() {
diff --git 
a/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinTest.java
 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinTest.java
index 3712a775c0..3566da85ac 100644
--- 
a/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinTest.java
+++ 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/DatabaseJoinTest.java
@@ -42,6 +42,11 @@ import 
org.apache.hop.pipeline.transform.TransformPartitioningMeta;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+/**
+ * Unit test for {@link DatabaseJoin}
+ *
+ * @author lance 2026/8/22 7:43
+ */
 class DatabaseJoinTest {
 
   DatabaseJoinMeta mockTransformMetaInterface;
diff --git 
a/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/ParameterFieldTest.java
 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/ParameterFieldTest.java
new file mode 100644
index 0000000000..df524061c9
--- /dev/null
+++ 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/ParameterFieldTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline.transforms.databasejoin;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.value.ValueMetaFactory;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/** Unit test for {@link ParameterField} */
+class ParameterFieldTest {
+
+  @BeforeAll
+  static void setUpClass() throws Exception {
+    HopEnvironment.init();
+  }
+
+  @Test
+  void defaultTypeIsNone() {
+    ParameterField field = new ParameterField();
+    assertNull(field.getName());
+    assertEquals(ValueMetaFactory.getValueMetaName(IValueMeta.TYPE_NONE), 
field.getType());
+  }
+
+  @Test
+  void setNameStripsBlankToNull() {
+    ParameterField field = new ParameterField();
+    field.setName("  id  ");
+    assertEquals("id", field.getName());
+    field.setName("   ");
+    assertNull(field.getName());
+    field.setName(null);
+    assertNull(field.getName());
+  }
+
+  @Test
+  void setTypeAcceptsNameAndId() {
+    ParameterField field = new ParameterField();
+    field.setType("Integer");
+    assertEquals("Integer", field.getType());
+    field.setType(IValueMeta.TYPE_STRING);
+    assertEquals(ValueMetaFactory.getValueMetaName(IValueMeta.TYPE_STRING), 
field.getType());
+  }
+
+  @Test
+  void cloneCopiesValuesIndependently() {
+    ParameterField original = new ParameterField();
+    original.setName("id");
+    original.setType(IValueMeta.TYPE_INTEGER);
+
+    ParameterField copy = (ParameterField) original.clone();
+    assertNotSame(original, copy);
+    assertEquals(original.getName(), copy.getName());
+    assertEquals(original.getType(), copy.getType());
+
+    copy.setName("other");
+    copy.setType(IValueMeta.TYPE_STRING);
+    assertEquals("id", original.getName());
+    assertEquals(ValueMetaFactory.getValueMetaName(IValueMeta.TYPE_INTEGER), 
original.getType());
+  }
+
+  @Test
+  void copyConstructorCopiesValues() {
+    ParameterField original = new ParameterField();
+    original.setName("code");
+    original.setType("String");
+    ParameterField copy = new ParameterField(original);
+    assertEquals("code", copy.getName());
+    assertEquals("String", copy.getType());
+  }
+
+  @Test
+  void toStringIncludesNameAndType() {
+    ParameterField field = new ParameterField();
+    field.setName("id");
+    field.setType("Integer");
+    assertEquals("id:Integer", field.toString());
+  }
+}
diff --git 
a/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/cache/DatabaseCacheTest.java
 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/cache/DatabaseCacheTest.java
new file mode 100644
index 0000000000..2c789d512b
--- /dev/null
+++ 
b/plugins/transforms/databasejoin/src/test/java/org/apache/hop/pipeline/transforms/databasejoin/cache/DatabaseCacheTest.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.pipeline.transforms.databasejoin.cache;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.RowMetaAndData;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Unit test for {@link DatabaseCache} */
+class DatabaseCacheTest {
+
+  private IRowMeta lookupMeta;
+
+  @BeforeAll
+  static void setUpClass() throws Exception {
+    HopEnvironment.init();
+  }
+
+  @BeforeEach
+  void setUp() {
+    lookupMeta = new RowMeta();
+    lookupMeta.addValueMeta(new ValueMetaInteger("id"));
+  }
+
+  @Test
+  void newCacheIsEmpty() {
+    DatabaseCache cache = new DatabaseCache(10);
+    assertTrue(cache.isEmpty());
+    assertNull(cache.getRowsFromCache(lookupMeta, new Object[] {1L}));
+  }
+
+  @Test
+  void putAndGetByLookupRow() {
+    DatabaseCache cache = new DatabaseCache(10);
+    List<Object[]> rows = rows("one");
+    cache.putRowsIntoCache(lookupMeta, new Object[] {1L}, rows);
+
+    assertFalse(cache.isEmpty());
+    assertSame(rows, cache.getRowsFromCache(lookupMeta, new Object[] {1L}));
+    assertNull(cache.getRowsFromCache(lookupMeta, new Object[] {2L}));
+  }
+
+  @Test
+  void putAndGetByRowMetaAndData() {
+    DatabaseCache cache = new DatabaseCache(10);
+    List<Object[]> rows = rows("one");
+    cache.putRowsIntoCache(new RowMetaAndData(lookupMeta, 1L), rows);
+
+    assertSame(rows, cache.getRowsFromCache(new RowMetaAndData(lookupMeta, 
1L)));
+  }
+
+  @Test
+  void maxSizeZeroDoesNotEvict() {
+    DatabaseCache cache = new DatabaseCache(0);
+    cache.putRowsIntoCache(lookupMeta, new Object[] {1L}, rows("a"));
+    cache.putRowsIntoCache(lookupMeta, new Object[] {2L}, rows("b"));
+    cache.putRowsIntoCache(lookupMeta, new Object[] {3L}, rows("c"));
+
+    assertEquals("a", cache.getRowsFromCache(lookupMeta, new Object[] 
{1L}).getFirst()[0]);
+    assertEquals("b", cache.getRowsFromCache(lookupMeta, new Object[] 
{2L}).getFirst()[0]);
+    assertEquals("c", cache.getRowsFromCache(lookupMeta, new Object[] 
{3L}).getFirst()[0]);
+  }
+
+  @Test
+  void maxSizeEvictsEldestEntry() {
+    DatabaseCache cache = new DatabaseCache(1);
+    cache.putRowsIntoCache(lookupMeta, new Object[] {1L}, rows("a"));
+    cache.putRowsIntoCache(lookupMeta, new Object[] {2L}, rows("b"));
+
+    assertNull(cache.getRowsFromCache(lookupMeta, new Object[] {1L}));
+    assertEquals("b", cache.getRowsFromCache(lookupMeta, new Object[] 
{2L}).getFirst()[0]);
+    assertFalse(cache.isEmpty());
+  }
+
+  private static List<Object[]> rows(Object value) {
+    List<Object[]> result = new ArrayList<>();
+    result.add(new Object[] {value});
+    return result;
+  }
+}

Reply via email to