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 7023f0ee7e issue #7393 : Merge Rows option to automatically line up
input fields (#7540)
7023f0ee7e is described below
commit 7023f0ee7e1b0db37dc1f7fae32066fdf5466bae
Author: Matt Casters <[email protected]>
AuthorDate: Fri Jul 17 13:48:59 2026 +0200
issue #7393 : Merge Rows option to automatically line up input fields
(#7540)
* issue #7393 : Merge Rows option to automatically line up input fields
* issue #7393 : restore integration tests, docs, and TEST_FILTER support
Bring over the pieces missed when re-creating the PR after the main merge:
align/align-off integration tests and golden data, mergerows docs, and
TEST_FILTER support for the IT runner scripts (applied on top of current
main without clobbering Spark client / CLIENT_UNZIP changes).
* issue #7393 : Merge Rows option to automatically line up input fields
* issue #7393 : honor TEST_FILTER under default IT runner
Force the classic per-workflow path when TEST_FILTER is set so filtered
Docker integration tests actually run only matching main*.hwf files, and
document the option for run-tests-docker.sh.
---
.../integration-tests/integration-tests-base.yaml | 2 +
.../integration-tests-beam-base.yaml | 2 +
.../modules/ROOT/pages/integration-testing.adoc | 6 +
.../ROOT/pages/pipeline/transforms/mergerows.adoc | 6 +
integration-tests/scripts/run-tests-docker.sh | 28 +-
integration-tests/scripts/run-tests.sh | 64 ++++-
.../transforms/0077-merge-rows-align-off.hpl | 291 +++++++++++++++++++++
.../transforms/0077-merge-rows-align.hpl | 218 +++++++++++++++
.../datasets/golden-merge-rows-align.csv | 5 +
.../transforms/main-0077-merge-rows.hwf | 6 +
.../metadata/dataset/golden-merge-rows-align.json | 48 ++++
.../unit-test/0077-merge-rows-align UNIT.json | 44 ++++
.../unit-test/0077-merge-rows-align-off UNIT.json | 40 +++
.../pipeline/transforms/mergerows/MergeRows.java | 159 +++++------
.../transforms/mergerows/MergeRowsAlignment.java | 139 ++++++++++
.../transforms/mergerows/MergeRowsData.java | 7 +-
.../transforms/mergerows/MergeRowsDialog.java | 23 ++
.../transforms/mergerows/MergeRowsMeta.java | 101 ++++---
.../mergerows/messages/messages_en_US.properties | 25 +-
.../mergerows/MergeRowsAlignmentTest.java | 137 ++++++++++
.../transforms/mergerows/MergeRowsLogicTest.java | 135 ++++++++++
.../mergerows/MergeRowsMetaCheckTest.java | 33 ++-
.../transforms/mergerows/MergeRowsMetaTest.java | 27 ++
23 files changed, 1410 insertions(+), 136 deletions(-)
diff --git a/docker/integration-tests/integration-tests-base.yaml
b/docker/integration-tests/integration-tests-base.yaml
index 51d1a1028a..2f1c9277c1 100644
--- a/docker/integration-tests/integration-tests-base.yaml
+++ b/docker/integration-tests/integration-tests-base.yaml
@@ -31,4 +31,6 @@ services:
- ../../integration-tests/:/files
environment:
- FLASK_ENV=docker
+ # Optional workflow basename filter (substring or glob). Set by
run-tests-docker.sh.
+ - TEST_FILTER=${TEST_FILTER:-}
command: [ "bash", "-c", "/files/scripts/run-tests.sh ${PROJECT_NAME}" ]
diff --git a/docker/integration-tests/integration-tests-beam-base.yaml
b/docker/integration-tests/integration-tests-beam-base.yaml
index eb2537f4a0..8521fd7ee8 100644
--- a/docker/integration-tests/integration-tests-beam-base.yaml
+++ b/docker/integration-tests/integration-tests-beam-base.yaml
@@ -33,4 +33,6 @@ services:
- FLASK_ENV=docker
# Keep driver Spark client pack in sync with the fat jar (empty =
default pack)
- HOP_SPARK_CLIENT_VERSION=${HOP_SPARK_CLIENT_VERSION:-}
+ # Optional workflow basename filter (substring or glob). Set by
run-tests-docker.sh.
+ - TEST_FILTER=${TEST_FILTER:-}
command: [ "bash", "-c", "/files/scripts/run-tests.sh ${PROJECT_NAME}" ]
diff --git a/docs/hop-dev-manual/modules/ROOT/pages/integration-testing.adoc
b/docs/hop-dev-manual/modules/ROOT/pages/integration-testing.adoc
index 96546e9cd1..16c9553ed9 100644
--- a/docs/hop-dev-manual/modules/ROOT/pages/integration-testing.adoc
+++ b/docs/hop-dev-manual/modules/ROOT/pages/integration-testing.adoc
@@ -96,6 +96,12 @@ cd integration-tests/scripts
# Optionally you can run a single project by adding the PROJECT_NAME variable
to the script
./run-tests-docker.sh PROJECT_NAME=database
+
+# Optionally filter which main*.hwf workflows run (basename substring or shell
glob;
+# comma-separated lists are supported). Quote globs so the shell does not
expand them.
+
+./run-tests-docker.sh PROJECT_NAME=transforms TEST_FILTER=0077-merge-rows
+./run-tests-docker.sh PROJECT_NAME=transforms TEST_FILTER='*0077*'
----
+
. Surefire Reports
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/mergerows.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/mergerows.adoc
index a6e451b24f..7dd38f3390 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/mergerows.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/mergerows.adoc
@@ -78,11 +78,17 @@ In the subsequent transform, you can use the flag field
generated by **Merge row
|Flag field name|Specify the name of the flag field on the output stream.
|Difference field name|Specify the name of the field to contain the difference
in case the status is **changed**.
The difference will be in the form of a "changes" array in JSON format.
+|Align input layouts|When enabled (the default), the transform builds a
**reference-authoritative** union of the reference and compare field layouts
before comparing.
+Missing fields on either side are filled with null.
+Shared fields keep the reference data type; compatible compare values are
converted to that type.
+Compare-only fields are appended to the output layout.
+Disable this option to require identical layouts (legacy strict mode).
|Keys to match|Specify fields containing the keys on which to match. Click
"Get key fields" to insert all the fields from the reference rows
|Values to compare|Specify fields containing the values to compare. Click "Get
value fields" to insert all the fields from the compare rows.
Key fields do not need to be repeated here.
|===
+
== Passing through fields
It's possible to pass through fields from the reference or compare data
streams.
diff --git a/integration-tests/scripts/run-tests-docker.sh
b/integration-tests/scripts/run-tests-docker.sh
index ffac427059..2d095aad19 100755
--- a/integration-tests/scripts/run-tests-docker.sh
+++ b/integration-tests/scripts/run-tests-docker.sh
@@ -24,11 +24,14 @@
EXECUTED_COMPOSE_FILES=("${DOCKER_FILES_DIR}/integration-tests-base.yaml")
for ARGUMENT in "$@"; do
- KEY=$(echo $ARGUMENT | cut -f1 -d=)
- VALUE=$(echo $ARGUMENT | cut -f2 -d=)
+ # Quote so glob characters in values (e.g. TEST_FILTER='*0077*') are
preserved.
+ # cut -f2- keeps values that themselves contain '='.
+ KEY=$(echo "${ARGUMENT}" | cut -f1 -d=)
+ VALUE=$(echo "${ARGUMENT}" | cut -f2- -d=)
case "$KEY" in
PROJECT_NAME) PROJECT_NAME=${VALUE} ;;
+ TEST_FILTER) TEST_FILTER=${VALUE} ;;
JENKINS_USER) JENKINS_USER=${VALUE} ;;
JENKINS_UID) JENKINS_UID=${VALUE} ;;
JENKINS_GROUP) JENKINS_GROUP=${VALUE} ;;
@@ -65,6 +68,17 @@ if [ -z "${PROJECT_NAME}" ]; then
PROJECT_NAME="*"
fi
+# Optional filter for main*.hwf basenames (substring or glob, comma-separated).
+# Passed into the test container as TEST_FILTER; when set, run-tests.sh uses
the
+# classic per-workflow runner so only matching main*.hwf files execute.
Examples:
+# ./run-tests-docker.sh PROJECT_NAME=transforms TEST_FILTER=0077-merge-rows
+# ./run-tests-docker.sh PROJECT_NAME=transforms TEST_FILTER='*0077*'
+# ./run-tests-docker.sh PROJECT_NAME=transforms
TEST_FILTER='0077-merge-rows,0076-other'
+if [ -z "${TEST_FILTER}" ]; then
+ TEST_FILTER=""
+fi
+export TEST_FILTER
+
if [ -z "${JENKINS_USER}" ]; then
JENKINS_USER="jenkins"
fi
@@ -204,20 +218,24 @@ for d in "${CURRENT_DIR}"/../${PROJECT_NAME}/; do
# Check if specific compose exists
+ if [ -n "${TEST_FILTER}" ]; then
+ echo "TEST_FILTER: ${TEST_FILTER}"
+ fi
+
if [ -f "${DOCKER_FILES_DIR}/integration-tests-${PROJECT_NAME}.yaml" ];
then
echo "Project compose exists."
EXECUTED_COMPOSE_FILES=("${EXECUTED_COMPOSE_FILES[@]}"
"${DOCKER_FILES_DIR}/integration-tests-${PROJECT_NAME}.yaml")
# Rebuild project images so SPARK_VERSION (and similar) build args
take effect
if [ "${PROJECT_NAME}" = "spark" ]; then
echo "Spark IT cluster version: ${SPARK_VERSION} (hadoop
${HADOOP_VERSION})"
- PROJECT_NAME=${PROJECT_NAME} SPARK_VERSION=${SPARK_VERSION}
HADOOP_VERSION=${HADOOP_VERSION} SPARK_BASE_URL=${SPARK_BASE_URL} \
+ PROJECT_NAME=${PROJECT_NAME} TEST_FILTER=${TEST_FILTER}
SPARK_VERSION=${SPARK_VERSION} HADOOP_VERSION=${HADOOP_VERSION}
SPARK_BASE_URL=${SPARK_BASE_URL} \
docker compose -f
${DOCKER_FILES_DIR}/integration-tests-${PROJECT_NAME}.yaml up --build
--abort-on-container-exit
else
- PROJECT_NAME=${PROJECT_NAME} docker compose -f
${DOCKER_FILES_DIR}/integration-tests-${PROJECT_NAME}.yaml up
--abort-on-container-exit
+ PROJECT_NAME=${PROJECT_NAME} TEST_FILTER=${TEST_FILTER} docker
compose -f ${DOCKER_FILES_DIR}/integration-tests-${PROJECT_NAME}.yaml up
--abort-on-container-exit
fi
else
echo "Project compose does not exists."
- PROJECT_NAME=${PROJECT_NAME} docker compose -f
${DOCKER_FILES_DIR}/integration-tests-base.yaml up --abort-on-container-exit
+ PROJECT_NAME=${PROJECT_NAME} TEST_FILTER=${TEST_FILTER} docker compose
-f ${DOCKER_FILES_DIR}/integration-tests-base.yaml up --abort-on-container-exit
fi
fi
fi
diff --git a/integration-tests/scripts/run-tests.sh
b/integration-tests/scripts/run-tests.sh
index 3d5fa333f6..6db92c699c 100755
--- a/integration-tests/scripts/run-tests.sh
+++ b/integration-tests/scripts/run-tests.sh
@@ -121,9 +121,60 @@ if [ -z "${PROJECT_NAME}" ]; then
PROJECT_NAME="*"
fi
+# Optional filter for main*.hwf workflows (substring or glob against basename).
+# Comma-separated list is supported. Examples:
+# TEST_FILTER=0077-merge-rows
+# TEST_FILTER='*0077*','*0078*'
+# TEST_FILTER=main-0077-merge-rows.hwf
+if [ -z "${TEST_FILTER}" ]; then
+ TEST_FILTER=""
+fi
+
#set global variables
SPACER="==========================================="
+# Return 0 if the workflow file should run under the current TEST_FILTER.
+should_run_workflow() {
+ local file="$1"
+ local base
+ base=$(basename "$file")
+
+ if [ -z "${TEST_FILTER}" ]; then
+ return 0
+ fi
+
+ local old_ifs=$IFS
+ IFS=','
+ local pattern
+ # shellcheck disable=SC2086
+ for pattern in ${TEST_FILTER}; do
+ # trim whitespace
+ pattern="${pattern#"${pattern%%[![:space:]]*}"}"
+ pattern="${pattern%"${pattern##*[![:space:]]}"}"
+ [ -z "${pattern}" ] && continue
+
+ # No glob meta-characters: treat as basename substring
+ if [[ "${pattern}" != *[\*\?[]* ]]; then
+ case "${base}" in
+ *"${pattern}"*)
+ IFS=$old_ifs
+ return 0
+ ;;
+ esac
+ else
+ # Glob match against basename
+ case "${base}" in
+ ${pattern})
+ IFS=$old_ifs
+ return 0
+ ;;
+ esac
+ fi
+ done
+ IFS=$old_ifs
+ return 1
+}
+
# Set up a temporary folder
export TMP_FOLDER=/tmp/hop-it-$$
rm -rf "${TMP_FOLDER}"
@@ -198,8 +249,9 @@ for d in "${CURRENT_DIR}"/../${PROJECT_NAME}/; do
SUITE_RUN_CONFIG="hop-local"
fi
- # Prefer single-JVM suite runner when available (unless isolation mode
is requested)
- if [ "${HOP_IT_PER_TEST_JVM}" != "true" ] && [ -f "${RUNNER_PIPELINE}"
]; then
+ # Prefer single-JVM suite runner when available (unless isolation mode
is requested).
+ # TEST_FILTER is only applied on the classic per-workflow path, so force
that mode when set.
+ if [ "${HOP_IT_PER_TEST_JVM}" != "true" ] && [ -z "${TEST_FILTER}" ] &&
[ -f "${RUNNER_PIPELINE}" ]; then
echo ${SPACER}
echo "Running project tests in single JVM via run-project-tests.hpl
(run config: ${SUITE_RUN_CONFIG})"
@@ -250,8 +302,16 @@ for d in "${CURRENT_DIR}"/../${PROJECT_NAME}/; do
# Classic path: one hop-run JVM per main-*.hwf
#
+ if [ -n "${TEST_FILTER}" ]; then
+ echo "TEST_FILTER is set: ${TEST_FILTER}"
+ fi
+
find "$d" -name 'main*.hwf' | sort | while read -r f; do
+ if ! should_run_workflow "$f"; then
+ continue
+ fi
+
#cleanup temp files
rm -f /tmp/test_output
rm -f /tmp/test_output_err
diff --git a/integration-tests/transforms/0077-merge-rows-align-off.hpl
b/integration-tests/transforms/0077-merge-rows-align-off.hpl
new file mode 100644
index 0000000000..a3b5a269bd
--- /dev/null
+++ b/integration-tests/transforms/0077-merge-rows-align-off.hpl
@@ -0,0 +1,291 @@
+<?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>0077-merge-rows-align-off</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Legacy strict layout mode (align off) with matching
streams</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>2026/07/10 12:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/10 12:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>source1</from>
+ <to>Merge rows (diff)</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>source2</from>
+ <to>Merge rows (diff)</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Merge rows (diff)</from>
+ <to>validate</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Merge rows (diff)</name>
+ <type>MergeRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <align_input_layouts>N</align_input_layouts>
+ <keys>
+ <key>id</key>
+ </keys>
+ <values>
+ <value>name</value>
+ <value>score</value>
+ </values>
+ <flag_field>flagfield</flag_field>
+ <reference>source1</reference>
+ <compare>source2</compare>
+ <attributes/>
+ <GUI>
+ <xloc>240</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>source1</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>1</item>
+ <item>James</item>
+ <item>3.6</item>
+ </line>
+ <line>
+ <item>2</item>
+ <item>Marie</item>
+ <item>8.3</item>
+ </line>
+ <line>
+ <item>3</item>
+ <item>Michael</item>
+ <item>9.1</item>
+ </line>
+ <line>
+ <item>4</item>
+ <item>Patrcia</item>
+ <item>4.2</item>
+ </line>
+ <line>
+ <item>5</item>
+ <item>Robert</item>
+ <item>5.0</item>
+ </line>
+ <line>
+ <item>6</item>
+ <item>Linda</item>
+ <item>1.6</item>
+ </line>
+ <line>
+ <item>7</item>
+ <item>David</item>
+ <item>7.2</item>
+ </line>
+ <line>
+ <item>8</item>
+ <item>Elizabeth</item>
+ <item>6.9</item>
+ </line>
+ <line>
+ <item>9</item>
+ <item>Willian</item>
+ <item>4.2</item>
+ </line>
+ <line>
+ <item>10</item>
+ <item>Barbara</item>
+ <item>0.5</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>id</name>
+ <type>Integer</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>name</name>
+ <type>String</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>score</name>
+ <format>#.#</format>
+ <type>Number</type>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>source2</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>1</item>
+ <item>James</item>
+ <item>3.6</item>
+ </line>
+ <line>
+ <item>3</item>
+ <item>Michael</item>
+ <item>9.1</item>
+ </line>
+ <line>
+ <item>4</item>
+ <item>Patricia</item>
+ <item>4.2</item>
+ </line>
+ <line>
+ <item>5</item>
+ <item>Robert</item>
+ <item>5.0</item>
+ </line>
+ <line>
+ <item>7</item>
+ <item>David</item>
+ <item>7.2</item>
+ </line>
+ <line>
+ <item>8</item>
+ <item>Elizabeth</item>
+ <item>6.9</item>
+ </line>
+ <line>
+ <item>9</item>
+ <item>William</item>
+ <item>4.2</item>
+ </line>
+ <line>
+ <item>10</item>
+ <item>Barbara</item>
+ <item>0.5</item>
+ </line>
+ <line>
+ <item>11</item>
+ <item>Richard</item>
+ <item>9.6</item>
+ </line>
+ <line>
+ <item>12</item>
+ <item>Susan</item>
+ <item>5.4</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>id</name>
+ <type>Integer</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>name</name>
+ <type>String</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>score</name>
+ <format>#.#</format>
+ <type>Number</type>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>144</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>validate</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>416</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/transforms/0077-merge-rows-align.hpl
b/integration-tests/transforms/0077-merge-rows-align.hpl
new file mode 100644
index 0000000000..bc89f6ac58
--- /dev/null
+++ b/integration-tests/transforms/0077-merge-rows-align.hpl
@@ -0,0 +1,218 @@
+<?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>0077-merge-rows-align</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Merge rows with mismatched layouts and automatic alignment
enabled</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>2026/07/10 12:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/10 12:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>source1</from>
+ <to>Merge rows (diff)</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>source2</from>
+ <to>Merge rows (diff)</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Merge rows (diff)</from>
+ <to>validate</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>Merge rows (diff)</name>
+ <type>MergeRows</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <align_input_layouts>Y</align_input_layouts>
+ <keys>
+ <key>id</key>
+ </keys>
+ <values>
+ <value>status</value>
+ </values>
+ <flag_field>flagfield</flag_field>
+ <reference>source1</reference>
+ <compare>source2</compare>
+ <attributes/>
+ <GUI>
+ <xloc>240</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>source1</name>
+ <type>DataGrid</type>
+ <description>Reference stream: id, name, status</description>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>1</item>
+ <item>Alice</item>
+ <item>active</item>
+ </line>
+ <line>
+ <item>2</item>
+ <item>Bob</item>
+ <item>active</item>
+ </line>
+ <line>
+ <item>3</item>
+ <item>Carol</item>
+ <item>inactive</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>id</name>
+ <type>Integer</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>name</name>
+ <type>String</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>status</name>
+ <type>String</type>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>source2</name>
+ <type>DataGrid</type>
+ <description>Compare stream: id, status, region (different fields and
order)</description>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>1</item>
+ <item>active</item>
+ <item>US</item>
+ </line>
+ <line>
+ <item>2</item>
+ <item>retired</item>
+ <item>EU</item>
+ </line>
+ <line>
+ <item>4</item>
+ <item>pending</item>
+ <item>APAC</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>id</name>
+ <type>Integer</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>status</name>
+ <type>String</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>region</name>
+ <type>String</type>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>144</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>validate</name>
+ <type>Dummy</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ <GUI>
+ <xloc>416</xloc>
+ <yloc>96</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git a/integration-tests/transforms/datasets/golden-merge-rows-align.csv
b/integration-tests/transforms/datasets/golden-merge-rows-align.csv
new file mode 100644
index 0000000000..ae776bc7d0
--- /dev/null
+++ b/integration-tests/transforms/datasets/golden-merge-rows-align.csv
@@ -0,0 +1,5 @@
+id,name,status,region,flagfield
+1,,active,US,identical
+2,,retired,EU,changed
+3,Carol,inactive,,deleted
+4,,pending,APAC,new
diff --git a/integration-tests/transforms/main-0077-merge-rows.hwf
b/integration-tests/transforms/main-0077-merge-rows.hwf
index 381b71ece9..f840fc8433 100644
--- a/integration-tests/transforms/main-0077-merge-rows.hwf
+++ b/integration-tests/transforms/main-0077-merge-rows.hwf
@@ -64,6 +64,12 @@ limitations under the License.
<test_name>
<name>0077-merge-rows-passthrough UNIT</name>
</test_name>
+ <test_name>
+ <name>0077-merge-rows-align UNIT</name>
+ </test_name>
+ <test_name>
+ <name>0077-merge-rows-align-off UNIT</name>
+ </test_name>
</test_names>
<parallel>N</parallel>
<xloc>272</xloc>
diff --git
a/integration-tests/transforms/metadata/dataset/golden-merge-rows-align.json
b/integration-tests/transforms/metadata/dataset/golden-merge-rows-align.json
new file mode 100644
index 0000000000..7daa2be65c
--- /dev/null
+++ b/integration-tests/transforms/metadata/dataset/golden-merge-rows-align.json
@@ -0,0 +1,48 @@
+{
+ "base_filename": "golden-merge-rows-align.csv",
+ "name": "golden-merge-rows-align",
+ "description": "Golden data for merge rows with layout alignment",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 5,
+ "field_precision": 0,
+ "field_name": "id",
+ "field_format": "####0;-####0"
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "name",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "status",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "region",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "flagfield",
+ "field_format": ""
+ }
+ ],
+ "folder_name": ""
+}
diff --git
a/integration-tests/transforms/metadata/unit-test/0077-merge-rows-align
UNIT.json
b/integration-tests/transforms/metadata/unit-test/0077-merge-rows-align
UNIT.json
new file mode 100644
index 0000000000..8d2a1a62cf
--- /dev/null
+++ b/integration-tests/transforms/metadata/unit-test/0077-merge-rows-align
UNIT.json
@@ -0,0 +1,44 @@
+{
+ "database_replacements": [],
+ "autoOpening": true,
+ "description": "Merge rows with automatic layout alignment on mismatched
streams",
+ "persist_filename": "",
+ "test_type": "UNIT_TEST",
+ "variableValues": [],
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "id",
+ "data_set_field": "id"
+ },
+ {
+ "transform_field": "name",
+ "data_set_field": "name"
+ },
+ {
+ "transform_field": "status",
+ "data_set_field": "status"
+ },
+ {
+ "transform_field": "region",
+ "data_set_field": "region"
+ },
+ {
+ "transform_field": "flagfield",
+ "data_set_field": "flagfield"
+ }
+ ],
+ "field_order": [
+ "id"
+ ],
+ "data_set_name": "golden-merge-rows-align",
+ "transform_name": "validate"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0077-merge-rows-align UNIT",
+ "trans_test_tweaks": [],
+ "pipeline_filename": "./0077-merge-rows-align.hpl"
+}
diff --git
a/integration-tests/transforms/metadata/unit-test/0077-merge-rows-align-off
UNIT.json
b/integration-tests/transforms/metadata/unit-test/0077-merge-rows-align-off
UNIT.json
new file mode 100644
index 0000000000..381e807e70
--- /dev/null
+++ b/integration-tests/transforms/metadata/unit-test/0077-merge-rows-align-off
UNIT.json
@@ -0,0 +1,40 @@
+{
+ "database_replacements": [],
+ "autoOpening": true,
+ "description": "Merge rows with layout alignment disabled (legacy strict
mode)",
+ "persist_filename": "",
+ "test_type": "UNIT_TEST",
+ "variableValues": [],
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "id",
+ "data_set_field": "id"
+ },
+ {
+ "transform_field": "name",
+ "data_set_field": "name"
+ },
+ {
+ "transform_field": "score",
+ "data_set_field": "score"
+ },
+ {
+ "transform_field": "flagfield",
+ "data_set_field": "flagfield"
+ }
+ ],
+ "field_order": [
+ "id"
+ ],
+ "data_set_name": "golden-merge-rows",
+ "transform_name": "validate"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0077-merge-rows-align-off UNIT",
+ "trans_test_tweaks": [],
+ "pipeline_filename": "./0077-merge-rows-align-off.hpl"
+}
diff --git
a/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRows.java
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRows.java
index 8f0694b2f6..c1dab031a5 100644
---
a/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRows.java
+++
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRows.java
@@ -41,15 +41,16 @@ import org.json.simple.JSONObject;
/**
* Merge rows from 2 sorted streams to detect changes. Use this as feed for a
dimension in case you
- * have no time stamps in your source system.
+ * have no time stamps in your source system. Optionally aligns mismatched
input layouts before
+ * comparing.
*/
public class MergeRows extends BaseTransform<MergeRowsMeta, MergeRowsData> {
private static final Class<?> PKG = MergeRowsMeta.class;
- private static final String VALUE_IDENTICAL = "identical";
- private static final String VALUE_CHANGED = "changed";
- private static final String VALUE_NEW = "new";
- private static final String VALUE_DELETED = "deleted";
+ static final String VALUE_IDENTICAL = "identical";
+ static final String VALUE_CHANGED = "changed";
+ static final String VALUE_NEW = "new";
+ static final String VALUE_DELETED = "deleted";
public MergeRows(
TransformMeta transformMeta,
@@ -66,35 +67,53 @@ public class MergeRows extends BaseTransform<MergeRowsMeta,
MergeRowsData> {
if (first) {
first = false;
- // Find the appropriate RowSet
- //
List<IStream> infoStreams = meta.getTransformIOMeta().getInfoStreams();
- // oneRowSet is the "Reference" stream
data.oneRowSet = findInputRowSet(infoStreams.get(0).getTransformName());
- // twoRowSet is the "Comparison" stream
data.twoRowSet = findInputRowSet(infoStreams.get(1).getTransformName());
- data.one = getRowFrom(data.oneRowSet);
- data.two = getRowFrom(data.twoRowSet);
+ data.referenceRowMeta = data.oneRowSet.getRowMeta();
+ if (data.referenceRowMeta == null) {
+ data.referenceRowMeta =
+ getPipelineMeta().getTransformFields(this,
meta.getReferenceTransform());
+ }
+ data.compareRowMeta = data.twoRowSet.getRowMeta();
+ if (data.compareRowMeta == null) {
+ data.compareRowMeta =
+ getPipelineMeta().getTransformFields(this,
meta.getCompareTransform());
+ }
- try {
- checkInputLayoutValid(data.oneRowSet.getRowMeta(),
data.twoRowSet.getRowMeta());
- } catch (HopRowException e) {
- throw new HopException(
- BaseMessages.getString(PKG,
"MergeRows.Exception.InvalidLayoutDetected"), e);
+ if (meta.isAlignInputLayouts()) {
+ data.schemaMapping =
+ MergeRowsAlignment.buildSchemaMapping(data.referenceRowMeta,
data.compareRowMeta);
+ data.oneRowMeta = data.schemaMapping.getOutputRowMeta();
+ data.twoRowMeta = data.oneRowMeta;
+ } else {
+ try {
+ checkInputLayoutValid(data.referenceRowMeta, data.compareRowMeta);
+ } catch (HopRowException e) {
+ throw new HopException(
+ BaseMessages.getString(PKG,
"MergeRows.Exception.InvalidLayoutDetected"), e);
+ }
+ data.oneRowMeta = data.referenceRowMeta;
+ data.twoRowMeta = data.compareRowMeta;
}
- if (data.one != null) {
- // Find the key indexes:
+ data.one = getAlignedRow(0, data.oneRowSet);
+ data.two = getAlignedRow(1, data.twoRowSet);
+
+ IRowMeta keyLookupMeta = data.oneRowMeta;
+ if (data.one != null || data.two != null) {
data.keyNrs = new int[meta.getKeyFields().size()];
for (int i = 0; i < data.keyNrs.length; i++) {
- data.keyNrs[i] =
data.oneRowSet.getRowMeta().indexOfValue(meta.getKeyFields().get(i));
+ data.keyNrs[i] =
keyLookupMeta.indexOfValue(meta.getKeyFields().get(i));
if (data.keyNrs[i] < 0) {
String message =
BaseMessages.getString(
PKG,
- "MergeRows.Exception.UnableToFindFieldInReferenceStream",
+ meta.isAlignInputLayouts()
+ ?
"MergeRows.Exception.UnableToFindFieldInAlignedStream"
+ :
"MergeRows.Exception.UnableToFindFieldInReferenceStream",
meta.getKeyFields().get(i));
logError(message);
throw new HopTransformException(message);
@@ -102,15 +121,17 @@ public class MergeRows extends
BaseTransform<MergeRowsMeta, MergeRowsData> {
}
}
- if (data.two != null) {
+ if (data.one != null || data.two != null) {
data.valueNrs = new int[meta.getValueFields().size()];
for (int i = 0; i < data.valueNrs.length; i++) {
- data.valueNrs[i] =
data.twoRowSet.getRowMeta().indexOfValue(meta.getValueFields().get(i));
+ data.valueNrs[i] =
keyLookupMeta.indexOfValue(meta.getValueFields().get(i));
if (data.valueNrs[i] < 0) {
String message =
BaseMessages.getString(
PKG,
- "MergeRows.Exception.UnableToFindFieldInReferenceStream",
+ meta.isAlignInputLayouts()
+ ?
"MergeRows.Exception.UnableToFindFieldInAlignedStream"
+ :
"MergeRows.Exception.UnableToFindFieldInReferenceStream",
meta.getValueFields().get(i));
logError(message);
throw new HopTransformException(message);
@@ -118,21 +139,12 @@ public class MergeRows extends
BaseTransform<MergeRowsMeta, MergeRowsData> {
}
}
- // Calculate the indices for the passthrough fields.
- //
+ // Passthrough indexes use the original (unaligned) row metas.
data.passThroughIndexes = new ArrayList<>();
- data.oneRowMeta = data.oneRowSet.getRowMeta();
- if (data.oneRowMeta == null) {
- data.oneRowMeta = getPipelineMeta().getTransformFields(this,
meta.getReferenceTransform());
- }
- data.twoRowMeta = data.twoRowSet.getRowMeta();
- if (data.twoRowMeta == null) {
- data.twoRowMeta = getPipelineMeta().getTransformFields(this,
meta.getCompareTransform());
- }
for (PassThroughField field : meta.getPassThroughFields()) {
int index;
if (field.isReferenceField()) {
- index = data.oneRowMeta.indexOfValue(field.getSourceField());
+ index = data.referenceRowMeta.indexOfValue(field.getSourceField());
if (index < 0) {
throw new HopTransformException(
"Unable to find passthrough field '"
@@ -141,7 +153,7 @@ public class MergeRows extends BaseTransform<MergeRowsMeta,
MergeRowsData> {
+ meta.getReferenceTransform());
}
} else {
- index = data.twoRowMeta.indexOfValue(field.getSourceField());
+ index = data.compareRowMeta.indexOfValue(field.getSourceField());
if (index < 0) {
throw new HopTransformException(
"Unable to find passthrough field '"
@@ -170,7 +182,7 @@ public class MergeRows extends BaseTransform<MergeRowsMeta,
MergeRowsData> {
meta.getFields(
data.outputRowMeta,
getTransformName(),
- new IRowMeta[] {data.oneRowMeta, data.twoRowMeta},
+ new IRowMeta[] {data.referenceRowMeta, data.compareRowMeta},
null,
this,
metadataProvider);
@@ -181,34 +193,28 @@ public class MergeRows extends
BaseTransform<MergeRowsMeta, MergeRowsData> {
String differenceJson = "{\"changes\":[]}";
boolean getDifference = StringUtils.isNotEmpty(meta.getDiffJsonField());
- // Remember the rows used to compare: copy rows 'one' and 'two'.
copyOneTwo();
- if (data.one == null && data.two != null) { // Record 2 is flagged as new!
+ if (data.one == null && data.two != null) {
outputRow = data.two;
flagField = VALUE_NEW;
+ data.two = getAlignedRow(1, data.twoRowSet);
- // Also get a next row from compare rowset...
- data.two = getRowFrom(data.twoRowSet);
-
- } else if (data.one != null && data.two == null) { // Record 1 is flagged
as deleted!
+ } else if (data.one != null && data.two == null) {
outputRow = data.one;
flagField = VALUE_DELETED;
+ data.one = getAlignedRow(0, data.oneRowSet);
- // Also get a next row from reference rowset...
- data.one = getRowFrom(data.oneRowSet);
-
- } else { // OK, Here is the real start of the compare code!
-
- int compare = data.oneRowSet.getRowMeta().compare(data.one, data.two,
data.keyNrs);
- if (compare == 0) { // The Key matches, we CAN compare the two rows...
+ } else {
+ int compare = data.oneRowMeta.compare(data.one, data.two, data.keyNrs);
+ if (compare == 0) {
int compareValues = 0;
if (getDifference) {
JSONObject j = new JSONObject();
JSONArray jChanges = new JSONArray();
j.put("changes", jChanges);
for (int valueNr : data.valueNrs) {
- IValueMeta valueMeta =
data.oneRowSet.getRowMeta().getValueMeta(valueNr);
+ IValueMeta valueMeta = data.oneRowMeta.getValueMeta(valueNr);
Object refData = data.one[valueNr];
Object cmpData = data.two[valueNr];
int compareValue = valueMeta.compare(refData, cmpData);
@@ -226,39 +232,34 @@ public class MergeRows extends
BaseTransform<MergeRowsMeta, MergeRowsData> {
}
}
} else {
- compareValues = data.oneRowSet.getRowMeta().compare(data.one,
data.two, data.valueNrs);
+ compareValues = data.oneRowMeta.compare(data.one, data.two,
data.valueNrs);
}
if (compareValues == 0) {
- outputRow = data.two; // documented behavior: use the comparison
stream
+ outputRow = data.two;
flagField = VALUE_IDENTICAL;
} else {
- // Return the compare (most recent) row
- //
outputRow = data.two;
flagField = VALUE_CHANGED;
}
- // Get a new row from both streams...
- data.one = getRowFrom(data.oneRowSet);
- data.two = getRowFrom(data.twoRowSet);
+ data.one = getAlignedRow(0, data.oneRowSet);
+ data.two = getAlignedRow(1, data.twoRowSet);
} else {
- if (compare < 0) { // one < two
+ if (compare < 0) {
outputRow = data.one;
flagField = VALUE_DELETED;
- data.one = getRowFrom(data.oneRowSet);
+ data.one = getAlignedRow(0, data.oneRowSet);
} else {
outputRow = data.two;
flagField = VALUE_NEW;
- data.two = getRowFrom(data.twoRowSet);
+ data.two = getAlignedRow(1, data.twoRowSet);
}
}
}
int extraPassthroughFields = meta.getPassThroughFields().size();
- // Optionally add the difference JSON field
- //
outputRow = RowDataUtil.resizeArray(outputRow, data.outputRowMeta.size());
int tailIndex = data.outputRowMeta.size() - 2 - extraPassthroughFields;
if (getDifference && differenceJson != null) {
@@ -268,22 +269,14 @@ public class MergeRows extends
BaseTransform<MergeRowsMeta, MergeRowsData> {
}
outputRow[tailIndex++] = flagField;
- // Copy the passthrough fields
- //
for (int i = 0; i < extraPassthroughFields; i++) {
int sourceIndex = data.passThroughIndexes.get(i);
PassThroughField field = meta.getPassThroughFields().get(i);
if (field.isReferenceField()) {
- // If the record is deleted, identical or changed we copy data
- // from the reference row.
- //
if (data.oneCopy != null && !VALUE_NEW.equals(flagField)) {
outputRow[tailIndex] = data.oneCopy[sourceIndex];
}
} else {
- // If the record is new, identical or changed we copy data
- // from the compared-to row.
- //
if (data.twoCopy != null && !VALUE_DELETED.equals(flagField)) {
outputRow[tailIndex] = data.twoCopy[sourceIndex];
}
@@ -291,7 +284,6 @@ public class MergeRows extends BaseTransform<MergeRowsMeta,
MergeRowsData> {
tailIndex++;
}
- // send the row to the next transforms...
putRow(data.outputRowMeta, outputRow);
if (checkFeedback(getLinesRead()) && isBasic()) {
@@ -301,20 +293,32 @@ public class MergeRows extends
BaseTransform<MergeRowsMeta, MergeRowsData> {
return true;
}
+ private Object[] getAlignedRow(int streamIndex, org.apache.hop.core.IRowSet
rowSet)
+ throws HopTransformException {
+ Object[] row = getRowFrom(rowSet);
+ if (streamIndex == 0) {
+ data.oneOriginal = row;
+ } else {
+ data.twoOriginal = row;
+ }
+ if (row == null || data.schemaMapping == null) {
+ return row;
+ }
+ return data.schemaMapping.mapRow(streamIndex, row);
+ }
+
private void copyOneTwo() throws HopValueException {
data.oneCopy = null;
data.twoCopy = null;
- // We only need the exact current record values in case we want to pass
through fields
- //
if (meta.getPassThroughFields().isEmpty()) {
return;
}
- if (data.one != null) {
- data.oneCopy = data.oneRowMeta.cloneRow(data.one);
+ if (data.oneOriginal != null) {
+ data.oneCopy = data.referenceRowMeta.cloneRow(data.oneOriginal);
}
- if (data.two != null) {
- data.twoCopy = data.twoRowMeta.cloneRow(data.two);
+ if (data.twoOriginal != null) {
+ data.twoCopy = data.compareRowMeta.cloneRow(data.twoOriginal);
}
}
@@ -342,7 +346,6 @@ public class MergeRows extends BaseTransform<MergeRowsMeta,
MergeRowsData> {
*
* @param referenceRowMeta Reference row
* @param compareRowMeta Row to compare to
- * @return true when templates are compatible.
* @throws HopRowException in case there is a compatibility error.
*/
static void checkInputLayoutValid(IRowMeta referenceRowMeta, IRowMeta
compareRowMeta)
diff --git
a/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsAlignment.java
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsAlignment.java
new file mode 100644
index 0000000000..8395f8162a
--- /dev/null
+++
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsAlignment.java
@@ -0,0 +1,139 @@
+/*
+ * 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.mergerows;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.exception.HopTransformException;
+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.row.value.ValueMetaFactory;
+import org.apache.hop.i18n.BaseMessages;
+
+/** Reference-authoritative schema union and row mapping for {@link
MergeRows}. */
+@Getter
+@Setter
+public final class MergeRowsAlignment {
+ private static final Class<?> PKG = MergeRowsMeta.class;
+
+ private MergeRowsAlignment() {}
+
+ /** Union of reference and compare layouts plus per-stream field index
mappings. */
+ @Getter
+ public static final class SchemaMapping {
+ private final IRowMeta outputRowMeta;
+ private final IRowMeta compareRowMeta;
+ private final int[] referenceMapping;
+ private final int[] compareMapping;
+
+ private SchemaMapping(
+ IRowMeta outputRowMeta,
+ IRowMeta compareRowMeta,
+ int[] referenceMapping,
+ int[] compareMapping) {
+ this.outputRowMeta = outputRowMeta;
+ this.compareRowMeta = compareRowMeta;
+ this.referenceMapping = referenceMapping;
+ this.compareMapping = compareMapping;
+ }
+
+ public Object[] mapRow(int streamIndex, Object[] sourceRow) throws
HopTransformException {
+ Object[] outputRow = new Object[outputRowMeta.size()];
+ if (sourceRow == null) {
+ return outputRow;
+ }
+ if (streamIndex == 0) {
+ mapReferenceRow(sourceRow, outputRow);
+ } else if (streamIndex == 1) {
+ mapCompareRow(sourceRow, outputRow);
+ }
+ return outputRow;
+ }
+
+ private void mapReferenceRow(Object[] sourceRow, Object[] outputRow) {
+ for (int sourceIndex = 0; sourceIndex < referenceMapping.length;
sourceIndex++) {
+ int outputIndex = referenceMapping[sourceIndex];
+ if (outputIndex >= 0 && outputIndex < outputRow.length) {
+ outputRow[outputIndex] = sourceRow[sourceIndex];
+ }
+ }
+ }
+
+ private void mapCompareRow(Object[] sourceRow, Object[] outputRow)
+ throws HopTransformException {
+ for (int sourceIndex = 0; sourceIndex < compareMapping.length;
sourceIndex++) {
+ int outputIndex = compareMapping[sourceIndex];
+ if (outputIndex < 0 || outputIndex >= outputRow.length) {
+ continue;
+ }
+ IValueMeta compareField = compareRowMeta.getValueMeta(sourceIndex);
+ IValueMeta outputField = outputRowMeta.getValueMeta(outputIndex);
+ Object value = sourceRow[sourceIndex];
+ if (compareField.getType() != outputField.getType()) {
+ value = convertCompareValue(compareField, outputField, value);
+ }
+ outputRow[outputIndex] = value;
+ }
+ }
+
+ private Object convertCompareValue(
+ IValueMeta compareField, IValueMeta referenceField, Object value)
+ throws HopTransformException {
+ try {
+ return referenceField.convertData(compareField, value);
+ } catch (HopValueException e) {
+ throw new HopTransformException(
+ BaseMessages.getString(
+ PKG,
+ "MergeRowsAlignment.Exception.UnableToConvertCompareField",
+ compareField.getName(),
+ ValueMetaFactory.getValueMetaName(compareField.getType()),
+ ValueMetaFactory.getValueMetaName(referenceField.getType())),
+ e);
+ }
+ }
+ }
+
+ public static SchemaMapping buildSchemaMapping(
+ IRowMeta referenceRowMeta, IRowMeta compareRowMeta) {
+ IRowMeta outputRowMeta = referenceRowMeta.clone();
+ HashSet<String> fieldNames = new
HashSet<>(Arrays.asList(outputRowMeta.getFieldNames()));
+
+ int[] referenceMapping = new int[referenceRowMeta.size()];
+ for (int fieldIndex = 0; fieldIndex < referenceRowMeta.size();
fieldIndex++) {
+ String name = referenceRowMeta.getValueMeta(fieldIndex).getName();
+ referenceMapping[fieldIndex] = outputRowMeta.indexOfValue(name);
+ }
+
+ int[] compareMapping = new int[compareRowMeta.size()];
+ for (int fieldIndex = 0; fieldIndex < compareRowMeta.size(); fieldIndex++)
{
+ IValueMeta compareField = compareRowMeta.getValueMeta(fieldIndex);
+ String name = compareField.getName();
+ if (!fieldNames.contains(name)) {
+ outputRowMeta.addValueMeta(compareField.clone());
+ fieldNames.add(name);
+ }
+ compareMapping[fieldIndex] = outputRowMeta.indexOfValue(name);
+ }
+
+ return new SchemaMapping(outputRowMeta, compareRowMeta, referenceMapping,
compareMapping);
+ }
+}
diff --git
a/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsData.java
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsData.java
index b65426267d..253f13d64b 100644
---
a/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsData.java
+++
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsData.java
@@ -29,13 +29,18 @@ public class MergeRowsData extends BaseTransformData
implements ITransformData {
public Object[] one;
public Object[] two;
+ public Object[] oneOriginal;
+ public Object[] twoOriginal;
public int[] keyNrs;
public int[] valueNrs;
public IRowSet oneRowSet;
- public IRowMeta oneRowMeta;
public IRowSet twoRowSet;
+ public IRowMeta referenceRowMeta;
+ public IRowMeta compareRowMeta;
+ public IRowMeta oneRowMeta;
public IRowMeta twoRowMeta;
+ public MergeRowsAlignment.SchemaMapping schemaMapping;
public List<Integer> passThroughIndexes;
public Object[] oneCopy;
diff --git
a/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsDialog.java
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsDialog.java
index 6bcf8975d1..e020a7cc96 100644
---
a/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsDialog.java
+++
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsDialog.java
@@ -64,6 +64,7 @@ public class MergeRowsDialog extends BaseTransformDialog {
private CCombo wCompare;
private Text wFlagField;
private Text wDiffField;
+ private Button wAlignInputLayouts;
private TableView wKeys;
private TableView wValues;
@@ -202,6 +203,26 @@ public class MergeRowsDialog extends BaseTransformDialog {
fdDiffField.right = new FormAttachment(100, 0);
wDiffField.setLayoutData(fdDiffField);
+ Label wlAlign = new Label(wSourcesComp, SWT.RIGHT);
+ wlAlign.setText(BaseMessages.getString(PKG,
"MergeRowsDialog.AlignInputLayouts.Label"));
+ PropsUi.setLook(wlAlign);
+ FormData fdlAlign = new FormData();
+ fdlAlign.left = new FormAttachment(0, 0);
+ fdlAlign.right = new FormAttachment(middle, -margin);
+ fdlAlign.top = new FormAttachment(wDiffField, margin);
+ wlAlign.setLayoutData(fdlAlign);
+ wAlignInputLayouts = new Button(wSourcesComp, SWT.CHECK);
+ PropsUi.setLook(wAlignInputLayouts);
+ wAlignInputLayouts.setText(
+ BaseMessages.getString(PKG, "MergeRowsDialog.AlignInputLayouts.Text"));
+ wAlignInputLayouts.setSelection(true);
+ wAlignInputLayouts.addListener(SWT.Selection, e -> input.setChanged());
+ FormData fdAlign = new FormData();
+ fdAlign.top = new FormAttachment(wlAlign, 0, SWT.CENTER);
+ fdAlign.left = new FormAttachment(middle, 0);
+ fdAlign.right = new FormAttachment(100, 0);
+ wAlignInputLayouts.setLayoutData(fdAlign);
+
FormData fdSourcesComp = new FormData();
fdSourcesComp.left = new FormAttachment(0, 0);
fdSourcesComp.top = new FormAttachment(0, 0);
@@ -447,6 +468,7 @@ public class MergeRowsDialog extends BaseTransformDialog {
wCompare.setText(Const.NVL(infoStreams.get(1).getTransformName(), ""));
wFlagField.setText(Const.NVL(input.getFlagField(), ""));
wDiffField.setText(Const.NVL(input.getDiffJsonField(), ""));
+ wAlignInputLayouts.setSelection(input.isAlignInputLayouts());
for (int i = 0; i < input.getKeyFields().size(); i++) {
TableItem item = new TableItem(wKeys.table, SWT.NONE);
@@ -512,6 +534,7 @@ public class MergeRowsDialog extends BaseTransformDialog {
infoStreams.get(1).setTransformMeta(pipelineMeta.findTransform(wCompare.getText()));
meta.setFlagField(wFlagField.getText());
meta.setDiffJsonField(wDiffField.getText());
+ meta.setAlignInputLayouts(wAlignInputLayouts.getSelection());
meta.getKeyFields().clear();
for (TableItem item : wKeys.getNonEmptyItems()) {
diff --git
a/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMeta.java
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMeta.java
index 5441f024b1..b8db0961d7 100644
---
a/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMeta.java
+++
b/plugins/transforms/mergerows/src/main/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMeta.java
@@ -99,6 +99,13 @@ public class MergeRowsMeta extends
BaseTransformMeta<MergeRows, MergeRowsData> {
injectionKeyDescription = "MergeRows.Injection.DIFF_FIELD")
private String diffJsonField;
+ @HopMetadataProperty(
+ key = "align_input_layouts",
+ defaultBoolean = false,
+ injectionKey = "ALIGN_INPUT_LAYOUTS",
+ injectionKeyDescription = "MergeRows.Injection.ALIGN_INPUT_LAYOUTS")
+ private boolean alignInputLayouts = true;
+
@HopMetadataProperty(
groupKey = "passthrough-fields",
key = "passthrough-field",
@@ -122,6 +129,8 @@ public class MergeRowsMeta extends
BaseTransformMeta<MergeRows, MergeRowsData> {
this.valueFields = new ArrayList<>(m.valueFields);
this.referenceTransform = m.referenceTransform;
this.compareTransform = m.compareTransform;
+ this.diffJsonField = m.diffJsonField;
+ this.alignInputLayouts = m.alignInputLayouts;
m.getPassThroughFields().forEach(f -> this.passThroughFields.add(new
PassThroughField(f)));
}
@@ -133,6 +142,7 @@ public class MergeRowsMeta extends
BaseTransformMeta<MergeRows, MergeRowsData> {
@Override
public void setDefault() {
flagField = "flagfield";
+ alignInputLayouts = true;
}
@Override
@@ -156,11 +166,16 @@ public class MergeRowsMeta extends
BaseTransformMeta<MergeRows, MergeRowsData> {
// So we just merge in the info fields.
//
if (info != null) {
- boolean found = false;
- for (int i = 0; i < info.length && !found; i++) {
- if (info[i] != null) {
- r.mergeRowMeta(info[i], name);
- found = true;
+ if (alignInputLayouts && info.length == 2 && info[0] != null && info[1]
!= null) {
+ r.mergeRowMeta(
+ MergeRowsAlignment.buildSchemaMapping(info[0],
info[1]).getOutputRowMeta(), name);
+ } else {
+ boolean found = false;
+ for (int i = 0; i < info.length && !found; i++) {
+ if (info[i] != null) {
+ r.mergeRowMeta(info[i], name);
+ found = true;
+ }
}
}
}
@@ -255,42 +270,54 @@ public class MergeRowsMeta extends
BaseTransformMeta<MergeRows, MergeRowsData> {
remarks.add(cr);
}
- IRowMeta referenceRowMeta = null;
- IRowMeta compareRowMeta = null;
- try {
- referenceRowMeta =
- pipelineMeta.getPrevTransformFields(variables,
referenceStream.getTransformName());
- compareRowMeta =
- pipelineMeta.getPrevTransformFields(variables,
compareStream.getTransformName());
- } catch (HopTransformException kse) {
- new CheckResult(
- ICheckResult.TYPE_RESULT_ERROR,
- BaseMessages.getString(PKG,
"MergeRowsMeta.CheckResult.ErrorGettingPrevTransformFields"),
- transformMeta);
- }
- if (referenceRowMeta != null && compareRowMeta != null) {
- boolean rowsMatch = false;
+ if (!alignInputLayouts) {
+ IRowMeta referenceRowMeta = null;
+ IRowMeta compareRowMeta = null;
try {
- MergeRows.checkInputLayoutValid(referenceRowMeta, compareRowMeta);
- rowsMatch = true;
- } catch (HopRowException kre) {
- // Ignore
- }
- if (rowsMatch) {
- cr =
- new CheckResult(
- ICheckResult.TYPE_RESULT_OK,
- BaseMessages.getString(PKG,
"MergeRowsMeta.CheckResult.RowDefinitionMatch"),
- transformMeta);
- remarks.add(cr);
- } else {
- cr =
+ referenceRowMeta =
+ pipelineMeta.getPrevTransformFields(variables,
referenceStream.getTransformName());
+ compareRowMeta =
+ pipelineMeta.getPrevTransformFields(variables,
compareStream.getTransformName());
+ } catch (HopTransformException kse) {
+ remarks.add(
new CheckResult(
ICheckResult.TYPE_RESULT_ERROR,
- BaseMessages.getString(PKG,
"MergeRowsMeta.CheckResult.RowDefinitionNotMatch"),
- transformMeta);
- remarks.add(cr);
+ BaseMessages.getString(
+ PKG,
"MergeRowsMeta.CheckResult.ErrorGettingPrevTransformFields"),
+ transformMeta));
}
+ if (referenceRowMeta != null && compareRowMeta != null) {
+ boolean rowsMatch = false;
+ try {
+ MergeRows.checkInputLayoutValid(referenceRowMeta, compareRowMeta);
+ rowsMatch = true;
+ } catch (HopRowException kre) {
+ // Ignore
+ }
+ if (rowsMatch) {
+ cr =
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_OK,
+ BaseMessages.getString(PKG,
"MergeRowsMeta.CheckResult.RowDefinitionMatch"),
+ transformMeta);
+ remarks.add(cr);
+ } else {
+ cr =
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ BaseMessages.getString(PKG,
"MergeRowsMeta.CheckResult.RowDefinitionNotMatch"),
+ transformMeta);
+ remarks.add(cr);
+ }
+ }
+ } else if (referenceStream.getTransformName() != null
+ && compareStream.getTransformName() != null) {
+ cr =
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_OK,
+ BaseMessages.getString(PKG,
"MergeRowsMeta.CheckResult.AlignmentEnabled"),
+ transformMeta);
+ remarks.add(cr);
}
}
diff --git
a/plugins/transforms/mergerows/src/main/resources/org/apache/hop/pipeline/transforms/mergerows/messages/messages_en_US.properties
b/plugins/transforms/mergerows/src/main/resources/org/apache/hop/pipeline/transforms/mergerows/messages/messages_en_US.properties
index 17ac21b5fc..eedbed63d1 100644
---
a/plugins/transforms/mergerows/src/main/resources/org/apache/hop/pipeline/transforms/mergerows/messages/messages_en_US.properties
+++
b/plugins/transforms/mergerows/src/main/resources/org/apache/hop/pipeline/transforms/mergerows/messages/messages_en_US.properties
@@ -15,17 +15,19 @@
# limitations under the License.
#
-MergeRows.Description=Merge two streams of rows, sorted on a certain key. The
two streams are compared and the equals, changed, deleted and new rows are
flagged.
+MergeRows.Description=Merge two streams of rows, sorted on a certain key. The
two streams are compared and the equals, changed, deleted and new rows are
flagged. Optionally aligns mismatched input layouts before comparing.
MergeRows.Exception.InvalidLayoutDetected=Invalid layout detected in input
streams, keys and values to merge have to be of identical structure and be in
the same place in the rows
MergeRows.Exception.UnableToFindFieldInReferenceStream=Unable to find field
[{0}] in reference stream.
+MergeRows.Exception.UnableToFindFieldInAlignedStream=Unable to find field
[{0}] in the aligned input layout.
MergeRows.Injection.FLAG_FIELD=The name of the flag field.
MergeRows.Injection.DIFF_FIELD=The name of the field for the difference JSON.
MergeRows.Injection.KEY_FIELDS=Specify key fields to compare.
MergeRows.Injection.KEY_FIELD=The key field to match with
MergeRows.Injection.VALUE_FIELDS=Specify value fields to compare.
MergeRows.Injection.VALUE_FIELD=The field to compare
-MergeRows.Injection.PASSTHROUGH_FIELDS = The fields to pass through to the
result
-MergeRows.Injection.PASSTHROUGH_FIELD = One fields to pass through to the
result
+MergeRows.Injection.ALIGN_INPUT_LAYOUTS=Automatically align reference and
compare row layouts before comparing
+MergeRows.Injection.PASSTHROUGH_FIELDS=The fields to pass through to the result
+MergeRows.Injection.PASSTHROUGH_FIELD=One field to pass through to the result
MergeRows.LineNumber=linenr
MergeRows.Log.BothTrueAndFalseNeeded=Both the ''true'' and the ''false''
transforms need to be supplied, or neither
MergeRows.Log.DataInfo=ONE\: {0} / TWO\:
@@ -37,6 +39,8 @@ MergeRowsDialog.ErrorGettingFields.DialogMessage=Unable to
get the fields becaus
MergeRowsDialog.ErrorGettingFields.DialogTitle=Error getting fields
MergeRowsDialog.FlagField.Label=Flag field name
MergeRowsDialog.DiffField.Label=Difference JSON field name
+MergeRowsDialog.AlignInputLayouts.Label=Align input layouts\:
+MergeRowsDialog.AlignInputLayouts.Text=Automatically align reference and
compare row layouts before comparing
MergeRowsDialog.KeyFields.Button=Get &key fields
MergeRowsDialog.Keys.Label=Keys to match \:
MergeRowsDialog.MergeRowsWarningDialog.DialogMessage=If the incoming data is
not sorted on the specified keys, the output results may not be correct. We
recommend sorting the incoming data within the pipeline.
@@ -48,6 +52,7 @@ MergeRowsDialog.Shell.Label=Merge rows (diff)
MergeRowsDialog.TransformName.Label=Transform name
MergeRowsDialog.ValueFields.Button=Get &value fields
MergeRowsDialog.Values.Label=Values to compare \:
+MergeRowsMeta.CheckResult.AlignmentEnabled=Input layout alignment is enabled;
reference and compare streams may use different field layouts.
MergeRowsMeta.CheckResult.ErrorGettingPrevTransformFields=Error when
retrieving fields from previous transforms
MergeRowsMeta.CheckResult.OneSourceTransformMissing=Please specify both the
'reference' AND 'compare' source transforms.
MergeRowsMeta.CheckResult.RowDefinitionMatch=The field layout from both input
transforms match
@@ -58,13 +63,15 @@ MergeRowsMeta.Exception.FlagFieldNotSpecified=The flag
field is not specified.
MergeRowsMeta.Exception.UnableToLoadTransformMeta=Unable to load transform
info from XML
MergeRowsMeta.InfoStream.FirstStream.Description=Reference stream to merge
MergeRowsMeta.InfoStream.SecondStream.Description=Compare (changed data)
stream to merge
-MergeRowsMeta.keyword=merge,diff,compare,changed,deleted
-MergeRowsDialog.ExtraFields.Button = Get fields to pass through
+MergeRowsMeta.keyword=merge,diff,compare,changed,deleted,align,layout
+MergeRowsDialog.ExtraFields.Button=Get fields to pass through
MergeRowsDialog.Extra.Label=Extra fields to pass through from sources to the
output\:
MergeRowsDialog.ExtraColumn.Reference.Label=Reference?
MergeRowsDialog.ExtraColumn.Field.Label=Source field
MergeRowsDialog.ExtraColumn.RenameTo.Label=Rename to
-MergeRowsDialog.SourcesTab.CTabItem = Sources
-MergeRowsDialog.KeysTab.CTabItem = Keys
-MergeRowsDialog.ValuesTab.CTabItem = Values
-MergeRowsDialog.ExtraTab.CTabItem = Extra
+MergeRowsDialog.SourcesTab.CTabItem=Sources
+MergeRowsDialog.KeysTab.CTabItem=Keys
+MergeRowsDialog.ValuesTab.CTabItem=Values
+MergeRowsDialog.ExtraTab.CTabItem=Extra
+MergeRowsAlignment.Exception.UnableToConvertCompareField=Unable to convert
compare field ''{0}'' from {1} to reference type {2}
+
diff --git
a/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsAlignmentTest.java
b/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsAlignmentTest.java
new file mode 100644
index 0000000000..09f93ebd35
--- /dev/null
+++
b/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsAlignmentTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.mergerows;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopTransformException;
+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.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class MergeRowsAlignmentTest {
+
+ @BeforeAll
+ static void initHop() throws HopException {
+ HopEnvironment.init();
+ }
+
+ @Test
+ void buildSchemaMappingUnionsFieldsAndNullFillsMissingColumns() throws
HopTransformException {
+ IRowMeta reference = row("hub_hk", "attr_a");
+ IRowMeta compare = row("hub_hk", "attr_b");
+
+ MergeRowsAlignment.SchemaMapping mapping =
+ MergeRowsAlignment.buildSchemaMapping(reference, compare);
+
+ assertEquals(3, mapping.getOutputRowMeta().size());
+ Object[] mappedReference = mapping.mapRow(0, new Object[] {"hk-1",
"value-a"});
+ Object[] mappedCompare = mapping.mapRow(1, new Object[] {"hk-1",
"value-b"});
+
+ assertEquals("hk-1", mappedReference[index(mapping, "hub_hk")]);
+ assertEquals("value-a", mappedReference[index(mapping, "attr_a")]);
+ assertNull(mappedReference[index(mapping, "attr_b")]);
+
+ assertEquals("hk-1", mappedCompare[index(mapping, "hub_hk")]);
+ assertNull(mappedCompare[index(mapping, "attr_a")]);
+ assertEquals("value-b", mappedCompare[index(mapping, "attr_b")]);
+ }
+
+ @Test
+ void buildSchemaMappingKeepsReferenceTypeForSharedFields() {
+ IRowMeta reference = new RowMeta();
+ reference.addValueMeta(new ValueMetaString("shared"));
+
+ IRowMeta compare = new RowMeta();
+ compare.addValueMeta(new ValueMetaInteger("shared"));
+
+ MergeRowsAlignment.SchemaMapping mapping =
+ MergeRowsAlignment.buildSchemaMapping(reference, compare);
+
+ IValueMeta sharedField =
mapping.getOutputRowMeta().getValueMeta(index(mapping, "shared"));
+ assertEquals(IValueMeta.TYPE_STRING, sharedField.getType());
+ }
+
+ @Test
+ void mapCompareRowConvertsCompatibleValuesToReferenceType() throws
HopTransformException {
+ IRowMeta reference = new RowMeta();
+ reference.addValueMeta(new ValueMetaInteger("amount"));
+
+ IRowMeta compare = new RowMeta();
+ compare.addValueMeta(new ValueMetaString("amount"));
+
+ MergeRowsAlignment.SchemaMapping mapping =
+ MergeRowsAlignment.buildSchemaMapping(reference, compare);
+
+ Object[] mappedCompare = mapping.mapRow(1, new Object[] {"42"});
+ assertEquals(42L, mappedCompare[index(mapping, "amount")]);
+ }
+
+ @Test
+ void mapCompareRowFailsWithClearErrorForIncompatibleConversion() {
+ IRowMeta reference = new RowMeta();
+ reference.addValueMeta(new ValueMetaInteger("amount"));
+
+ IRowMeta compare = new RowMeta();
+ compare.addValueMeta(new ValueMetaString("amount"));
+
+ MergeRowsAlignment.SchemaMapping mapping =
+ MergeRowsAlignment.buildSchemaMapping(reference, compare);
+
+ HopTransformException error =
+ assertThrows(
+ HopTransformException.class, () -> mapping.mapRow(1, new Object[]
{"not-a-number"}));
+ assertTrue(error.getMessage().contains("amount"));
+ assertTrue(error.getMessage().contains("String"));
+ assertTrue(error.getMessage().contains("Integer"));
+ }
+
+ @Test
+ void compareOnlyFieldKeepsCompareType() {
+ IRowMeta reference = row("hub_hk");
+ IRowMeta compare = row("hub_hk", "attr_b");
+
+ MergeRowsAlignment.SchemaMapping mapping =
+ MergeRowsAlignment.buildSchemaMapping(reference, compare);
+
+ assertEquals(
+ IValueMeta.TYPE_STRING,
+ mapping.getOutputRowMeta().getValueMeta(index(mapping,
"attr_b")).getType());
+ }
+
+ private static int index(MergeRowsAlignment.SchemaMapping mapping, String
fieldName) {
+ return mapping.getOutputRowMeta().indexOfValue(fieldName);
+ }
+
+ private static IRowMeta row(String... fieldNames) {
+ RowMeta rowMeta = new RowMeta();
+ for (String fieldName : fieldNames) {
+ rowMeta.addValueMeta(new ValueMetaString(fieldName));
+ }
+ return rowMeta;
+ }
+}
diff --git
a/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsLogicTest.java
b/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsLogicTest.java
new file mode 100644
index 0000000000..c451f383e2
--- /dev/null
+++
b/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsLogicTest.java
@@ -0,0 +1,135 @@
+/*
+ * 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.mergerows;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopRowException;
+import org.apache.hop.core.exception.HopValueException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class MergeRowsLogicTest {
+
+ @BeforeAll
+ static void initHop() throws HopException {
+ HopEnvironment.init();
+ }
+
+ @Test
+ void alignedRowsProduceChangedFlagWhenValuesDiffer() throws Exception {
+ IRowMeta reference = row("id", "name");
+ IRowMeta compare = row("id", "extra");
+
+ MergeRowsAlignment.SchemaMapping mapping =
+ MergeRowsAlignment.buildSchemaMapping(reference, compare);
+ IRowMeta aligned = mapping.getOutputRowMeta();
+
+ Object[] refRow = mapping.mapRow(0, new Object[] {"1", "Alice"});
+ Object[] cmpRow = mapping.mapRow(1, new Object[] {"1", "extra-value"});
+
+ int[] keyNrs = new int[] {aligned.indexOfValue("id")};
+ int[] valueNrs = new int[] {aligned.indexOfValue("name")};
+
+ assertEquals(0, aligned.compare(refRow, cmpRow, keyNrs));
+ assertEquals(MergeRows.VALUE_CHANGED, flagForRows(aligned, refRow, cmpRow,
keyNrs, valueNrs));
+ }
+
+ @Test
+ void alignedRowsProduceIdenticalFlagWhenValuesMatch() throws Exception {
+ IRowMeta reference = row("id", "name", "status");
+ IRowMeta compare = row("id", "name");
+
+ MergeRowsAlignment.SchemaMapping mapping =
+ MergeRowsAlignment.buildSchemaMapping(reference, compare);
+ IRowMeta aligned = mapping.getOutputRowMeta();
+
+ Object[] refRow = mapping.mapRow(0, new Object[] {"1", "Alice", "active"});
+ Object[] cmpRow = mapping.mapRow(1, new Object[] {"1", "Alice"});
+
+ int[] keyNrs = new int[] {aligned.indexOfValue("id")};
+ int[] valueNrs = new int[] {aligned.indexOfValue("name")};
+
+ assertEquals(MergeRows.VALUE_IDENTICAL, flagForRows(aligned, refRow,
cmpRow, keyNrs, valueNrs));
+ }
+
+ @Test
+ void alignedRowsProduceNewAndDeletedFlagsForKeyOrdering() throws Exception {
+ IRowMeta reference = row("id", "name");
+ IRowMeta compare = row("id", "name");
+
+ MergeRowsAlignment.SchemaMapping mapping =
+ MergeRowsAlignment.buildSchemaMapping(reference, compare);
+ IRowMeta aligned = mapping.getOutputRowMeta();
+ int[] keyNrs = new int[] {aligned.indexOfValue("id")};
+ int[] valueNrs = new int[] {aligned.indexOfValue("name")};
+
+ Object[] refOnly = mapping.mapRow(0, new Object[] {"1", "Alice"});
+ Object[] cmpLater = mapping.mapRow(1, new Object[] {"2", "Bob"});
+
+ assertEquals(
+ MergeRows.VALUE_DELETED, flagForRows(aligned, refOnly, cmpLater,
keyNrs, valueNrs));
+
+ Object[] refLater = mapping.mapRow(0, new Object[] {"2", "Bob"});
+ Object[] cmpOnly = mapping.mapRow(1, new Object[] {"1", "Alice"});
+
+ assertEquals(MergeRows.VALUE_NEW, flagForRows(aligned, refLater, cmpOnly,
keyNrs, valueNrs));
+ }
+
+ @Test
+ void checkInputLayoutValidRejectsMismatchedLayouts() {
+ IRowMeta reference = row("id", "name");
+ IRowMeta compare = row("id", "extra");
+
+ assertThrows(HopRowException.class, () ->
MergeRows.checkInputLayoutValid(reference, compare));
+ }
+
+ private static String flagForRows(
+ IRowMeta aligned, Object[] referenceRow, Object[] compareRow, int[]
keyNrs, int[] valueNrs)
+ throws HopValueException {
+ if (referenceRow == null && compareRow != null) {
+ return MergeRows.VALUE_NEW;
+ }
+ if (referenceRow != null && compareRow == null) {
+ return MergeRows.VALUE_DELETED;
+ }
+
+ int compare = aligned.compare(referenceRow, compareRow, keyNrs);
+ if (compare == 0) {
+ if (aligned.compare(referenceRow, compareRow, valueNrs) == 0) {
+ return MergeRows.VALUE_IDENTICAL;
+ }
+ return MergeRows.VALUE_CHANGED;
+ }
+ return compare < 0 ? MergeRows.VALUE_DELETED : MergeRows.VALUE_NEW;
+ }
+
+ private static IRowMeta row(String... fieldNames) {
+ RowMeta rowMeta = new RowMeta();
+ for (String fieldName : fieldNames) {
+ rowMeta.addValueMeta(new ValueMetaString(fieldName));
+ }
+ return rowMeta;
+ }
+}
diff --git
a/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMetaCheckTest.java
b/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMetaCheckTest.java
index 4599efc94d..3e1020ba79 100644
---
a/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMetaCheckTest.java
+++
b/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMetaCheckTest.java
@@ -92,7 +92,29 @@ class MergeRowsMetaCheckTest {
}
@Test
- void testCheckInputRowsBothEmpty() throws HopTransformException {
+ void testCheckWithAlignmentEnabledReportsOkWithoutLayoutValidation() {
+ meta.setAlignInputLayouts(true);
+
+ meta.check(
+ remarks,
+ pipelineMeta,
+ transformMeta,
+ null,
+ new String[0],
+ new String[0],
+ (RowMeta) null,
+ new Variables(),
+ null);
+
+ assertNotNull(remarks);
+ assertTrue(remarks.size() >= 2);
+ assertEquals(ICheckResult.TYPE_RESULT_OK, remarks.get(0).getType());
+ assertEquals(ICheckResult.TYPE_RESULT_OK, remarks.get(1).getType());
+ }
+
+ @Test
+ void testCheckInputRowsBothEmptyStrict() throws HopTransformException {
+ meta.setAlignInputLayouts(false);
when(pipelineMeta.getPrevTransformFields(any(IVariables.class),
eq(REFERENCE_TRANSFORM_NAME)))
.thenReturn(generateRowMetaEmpty());
when(pipelineMeta.getPrevTransformFields(any(IVariables.class),
eq(COMPARISON_TRANSFORM_NAME)))
@@ -115,7 +137,8 @@ class MergeRowsMetaCheckTest {
}
@Test
- void testCheckInputRowsBothNonEmpty() throws HopTransformException {
+ void testCheckInputRowsBothNonEmptyStrict() throws HopTransformException {
+ meta.setAlignInputLayouts(false);
when(pipelineMeta.getPrevTransformFields(any(IVariables.class),
eq(REFERENCE_TRANSFORM_NAME)))
.thenReturn(generateRowMeta10Strings());
when(pipelineMeta.getPrevTransformFields(any(IVariables.class),
eq(COMPARISON_TRANSFORM_NAME)))
@@ -138,7 +161,8 @@ class MergeRowsMetaCheckTest {
}
@Test
- void testCheckInputRowsEmptyAndNonEmpty() throws HopTransformException {
+ void testCheckInputRowsEmptyAndNonEmptyStrict() throws HopTransformException
{
+ meta.setAlignInputLayouts(false);
when(pipelineMeta.getPrevTransformFields(any(),
eq(REFERENCE_TRANSFORM_NAME)))
.thenReturn(generateRowMetaEmpty());
when(pipelineMeta.getPrevTransformFields(any(),
eq(COMPARISON_TRANSFORM_NAME)))
@@ -161,7 +185,8 @@ class MergeRowsMetaCheckTest {
}
@Test
- void testCheckInputRowsDifferentRowMetaTypes() throws HopTransformException {
+ void testCheckInputRowsDifferentRowMetaTypesStrict() throws
HopTransformException {
+ meta.setAlignInputLayouts(false);
when(pipelineMeta.getPrevTransformFields(any(),
eq(REFERENCE_TRANSFORM_NAME)))
.thenReturn(generateRowMeta10MixedTypes());
when(pipelineMeta.getPrevTransformFields(any(),
eq(COMPARISON_TRANSFORM_NAME)))
diff --git
a/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMetaTest.java
b/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMetaTest.java
index 3083170635..52e5c11766 100644
---
a/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMetaTest.java
+++
b/plugins/transforms/mergerows/src/test/java/org/apache/hop/pipeline/transforms/mergerows/MergeRowsMetaTest.java
@@ -19,6 +19,8 @@
package org.apache.hop.pipeline.transforms.mergerows;
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.HopEnvironment;
import org.apache.hop.core.plugins.PluginRegistry;
@@ -52,5 +54,30 @@ class MergeRowsMetaTest {
assertEquals(2, meta.getValueFields().size());
assertEquals("name", meta.getValueFields().get(0));
assertEquals("score", meta.getValueFields().get(1));
+ // Missing align_input_layouts in XML uses defaultBoolean=false
+ // This keeps previously built pipelines compatible.
+ assertFalse(meta.isAlignInputLayouts());
+ }
+
+ @Test
+ void testDefaultAlignInputLayoutsEnabled() {
+ MergeRowsMeta meta = new MergeRowsMeta();
+ meta.setDefault();
+ assertTrue(meta.isAlignInputLayouts());
+ }
+
+ @Test
+ void testCloneCopiesDiffAndAlign() {
+ MergeRowsMeta meta = new MergeRowsMeta();
+ meta.setFlagField("flag");
+ meta.setDiffJsonField("difference");
+ meta.setAlignInputLayouts(false);
+ meta.getKeyFields().add("id");
+
+ MergeRowsMeta copy = meta.clone();
+ assertEquals("flag", copy.getFlagField());
+ assertEquals("difference", copy.getDiffJsonField());
+ assertFalse(copy.isAlignInputLayouts());
+ assertEquals(1, copy.getKeyFields().size());
}
}