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 8b24a40ea6 issue #7488 : Sorted Schema Merge transform (#7489)
8b24a40ea6 is described below
commit 8b24a40ea6917fd8c798d42900b1b0f759a7e705
Author: Matt Casters <[email protected]>
AuthorDate: Mon Jul 13 16:21:16 2026 +0200
issue #7488 : Sorted Schema Merge transform (#7489)
Port the Sorted Schema Merge transform from hop-data-vault into Hop as a
Flow transform that merges pre-sorted input streams with different schemas
into one globally sorted sparse output stream. Includes unit tests, docs,
and an integration test with a golden data set.
---
assemblies/plugins/pom.xml | 6 +
.../images/transforms/icons/sortedschemamerge.svg | 23 ++
docs/hop-user-manual/modules/ROOT/nav.adoc | 1 +
.../modules/ROOT/pages/pipeline/transforms.adoc | 1 +
.../pipeline/transforms/sortedschemamerge.adoc | 63 ++++
.../transforms/0106-sorted-schema-merge.hpl | 355 +++++++++++++++++++++
.../datasets/golden-sorted-schema-merge.csv | 11 +
.../transforms/main-0106-sorted-schema-merge.hwf | 80 +++++
.../dataset/golden-sorted-schema-merge.json | 64 ++++
.../unit-test/0106-sorted-schema-merge UNIT.json | 53 +++
plugins/transforms/pom.xml | 1 +
plugins/transforms/sortedschemamerge/pom.xml | 32 ++
.../sortedschemamerge/src/assembly/assembly.xml | 46 +++
.../sortedschemamerge/SortedSchemaMerge.java | 162 ++++++++++
.../sortedschemamerge/SortedSchemaMergeData.java | 40 +++
.../sortedschemamerge/SortedSchemaMergeDialog.java | 206 ++++++++++++
.../sortedschemamerge/SortedSchemaMergeInput.java | 44 +++
.../sortedschemamerge/SortedSchemaMergeLogic.java | 159 +++++++++
.../sortedschemamerge/SortedSchemaMergeMeta.java | 281 ++++++++++++++++
.../SortedSchemaMergeMetaFactory.java | 62 ++++
.../sortedschemamerge/SortedSchemaMergeRow.java | 54 ++++
.../SortedSchemaMergeSortKey.java | 49 +++
.../messages/messages_en_US.properties | 44 +++
.../src/main/resources/sortedschemamerge.svg | 23 ++
.../src/main/resources/version.xml | 20 ++
.../SortedSchemaMergeLogicTest.java | 158 +++++++++
26 files changed, 2038 insertions(+)
diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index ef42a43597..6fc89979ce 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -1459,6 +1459,12 @@
<version>${project.version}</version>
<type>zip</type>
</dependency>
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-transform-sortedschemamerge</artifactId>
+ <version>${project.version}</version>
+ <type>zip</type>
+ </dependency>
<dependency>
<groupId>org.apache.hop</groupId>
<artifactId>hop-transform-splitfieldtorows</artifactId>
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/sortedschemamerge.svg
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/sortedschemamerge.svg
new file mode 100644
index 0000000000..9beaf28777
--- /dev/null
+++
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/sortedschemamerge.svg
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
+ width="42px" height="42px" viewBox="0 0 42 42" enable-background="new 0 0
42 42">
+ <!-- Two source schemas (different column layouts) -->
+ <path fill="#0E3A5A" d="M18.5,3.5H2.5v12h16V3.5z M16.5,13.5h-3v-8h3V13.5z
M12.5,13.5h-3v-8h3V13.5z M8.5,13.5h-4v-8h4V13.5z"/>
+ <rect x="4.5" y="5.5" fill="#C9E8FB" width="4" height="8"/>
+ <rect x="9.5" y="5.5" fill="#C9E8FB" width="3" height="8"/>
+ <path fill="#0E3A5A" d="M18.5,26.5H2.5v12h16V26.5z M16.5,36.5h-4v-8h4V36.5z
M11.5,36.5h-3v-8h3V36.5z M7.5,36.5h-3v-8h3V36.5z"/>
+ <rect x="4.5" y="28.5" fill="#C9E8FB" width="3" height="8"/>
+ <rect x="8.5" y="28.5" fill="#C9E8FB" width="3" height="8"/>
+ <rect x="12.5" y="28.5" fill="#C9E8FB" width="4" height="8"/>
+ <!-- Merge funnel into sparse sorted output -->
+ <path fill="#0E3A5A" d="M20,9.5h6.5v-3h8v3H39v7.5h-4.5v3H25.5v-3H20V9.5z"/>
+ <rect x="22" y="11.5" fill="#FF9C04" width="5" height="1.5"/>
+ <rect x="22" y="13.7" fill="#FF9C04" width="3.5" height="1.5"/>
+ <path fill="#5CC0C4"
d="M20,32.5h6.5v3h8v-3H39v-7.5h-4.5v-3H25.5v3H20V32.5z"/>
+ <!-- Output sparse row bar -->
+ <path fill="#0E3A5A" d="M39.5,18.5h-14v5h14V18.5z"/>
+ <rect x="26.5" y="19.7" fill="#FFFFFF" width="3" height="2.6"/>
+ <rect x="30.5" y="19.7" fill="#FF9C04" width="3" height="2.6"/>
+ <rect x="34.5" y="19.7" fill="#C9E8FB" width="3.5" height="2.6"/>
+</svg>
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index 422e970bae..43c7a9a476 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -251,6 +251,7 @@ under the License.
*** xref:pipeline/transforms/snowflakebulkloader.adoc[Snowflake Bulk Loader]
*** xref:pipeline/transforms/sort.adoc[Sort Rows]
*** xref:pipeline/transforms/sortedmerge.adoc[Sorted Merge]
+*** xref:pipeline/transforms/sortedschemamerge.adoc[Sorted Schema Merge]
*** xref:pipeline/transforms/splitfields.adoc[Split Fields]
*** xref:pipeline/transforms/splitfieldtorows.adoc[Split fields to rows]
*** xref:pipeline/transforms/splunkinput.adoc[Splunk Input]
diff --git a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
index 40e598f0b1..bbb11f3fa9 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
@@ -207,6 +207,7 @@ The pages nested under this topic contain information on
how to use the transfor
* xref:pipeline/transforms/snowflakebulkloader.adoc[Snowflake Bulk Loader]
* xref:pipeline/transforms/sort.adoc[Sort Rows]
* xref:pipeline/transforms/sortedmerge.adoc[Sorted Merge]
+* xref:pipeline/transforms/sortedschemamerge.adoc[Sorted Schema Merge]
* xref:pipeline/transforms/splitfields.adoc[Split Fields]
* xref:pipeline/transforms/splitfieldtorows.adoc[Split fields to rows]
* xref:pipeline/transforms/splunkinput.adoc[Splunk Input]
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/sortedschemamerge.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/sortedschemamerge.adoc
new file mode 100644
index 0000000000..bfbd09c0e2
--- /dev/null
+++
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/sortedschemamerge.adoc
@@ -0,0 +1,63 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+ http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+:documentationPath: /pipeline/transforms/
+:language: en_US
+:description: The Sorted Schema Merge transform merges pre-sorted input
streams with different schemas into one sorted stream with a unified sparse row
layout.
+
+= image:transforms/icons/sortedschemamerge.svg[Sorted Schema Merge transform
Icon, role="image-doc-icon"] Sorted Schema Merge
+
+[%noheader,cols="3a,1a", role="table-no-borders" ]
+|===
+|
+== Description
+
+The Sorted Schema Merge transform combines the behavior of
xref:pipeline/transforms/sortedmerge.adoc[Sorted Merge] and
xref:pipeline/transforms/streamschemamerge.adoc[Stream Schema Merge]:
+
+* It merges multiple *pre-sorted* input streams into one *globally sorted*
output stream.
+* Input streams may have *different field layouts*. The transform builds a
unified output schema that is the union of all input fields.
+* Fields that are not present on a given input stream are left *null* (sparse
row layout) on rows that come from that stream.
+
+A typical use case is combining data from multiple Data Vault satellites that
share business keys and load timestamps, then using
xref:pipeline/transforms/repeatfields.adoc[Repeat Fields] to fill previously
known attribute values when building an SCD2 table.
+
+IMPORTANT: Each input stream must already be sorted on the configured sort
keys (for example with `ORDER BY` in
xref:pipeline/transforms/tableinput.adoc[Table Input], or with
xref:pipeline/transforms/sort.adoc[Sort Rows]). This transform does *not*
re-sort within each stream; it only merges already-sorted streams.
+
+|
+== Supported Engines
+[%noheader,cols="2,1a",frame=none, role="table-supported-engines"]
+!===
+!Hop Engine! image:check_mark.svg[Supported, 24]
+!Spark! image:cross.svg[Not Supported, 24]
+!Flink! image:cross.svg[Not Supported, 24]
+!Dataflow! image:cross.svg[Not Supported, 24]
+!===
+|===
+
+== Options
+
+[options="header"]
+|===
+|Option|Description
+|Transform name|Name of the transform; must be unique in a single pipeline.
+|Sort keys|The key fields used to compare rows across input streams, including
ascending or descending direction. Every sort key must exist on every input
stream.
+|Input transforms|The list of upstream transforms that feed this transform.
Use *Get* to fill the list from connected hops. At least two input streams are
required.
+|===
+
+== Notes
+
+* When the same field name appears on more than one stream with different data
types, the merged field type is coerced to `String` (same behavior as Stream
Schema Merge).
+* Rows with equal sort keys keep a stable order based on the input stream
index.
+* Safe mode row layout checks are not applied across streams with different
schemas (`excludeFromRowLayoutVerification`).
diff --git a/integration-tests/transforms/0106-sorted-schema-merge.hpl
b/integration-tests/transforms/0106-sorted-schema-merge.hpl
new file mode 100644
index 0000000000..3bc607416d
--- /dev/null
+++ b/integration-tests/transforms/0106-sorted-schema-merge.hpl
@@ -0,0 +1,355 @@
+<?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>0106-sorted-schema-merge</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>Merge four pre-sorted Data Grid streams with different
schemas into one sparse sorted stream.</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 22:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/10 22:00:00.000</modified_date>
+ </info>
+ <notepads>
+ </notepads>
+ <order>
+ <hop>
+ <from>source-a</from>
+ <to>Sorted Schema Merge</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>source-b</from>
+ <to>Sorted Schema Merge</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>source-c</from>
+ <to>Sorted Schema Merge</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>source-d</from>
+ <to>Sorted Schema Merge</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Sorted Schema Merge</from>
+ <to>validate</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <transform>
+ <name>source-a</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>apple</item>
+ <item>A</item>
+ </line>
+ <line>
+ <item>5</item>
+ <item>elderberry</item>
+ <item>A</item>
+ </line>
+ <line>
+ <item>9</item>
+ <item>grape</item>
+ <item>A</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>id</name>
+ <format>0</format>
+ <type>Integer</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>attr_a</name>
+ <type>String</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>src</name>
+ <type>String</type>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>48</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>source-b</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>2</item>
+ <item>banana</item>
+ <item>B</item>
+ </line>
+ <line>
+ <item>6</item>
+ <item>fig</item>
+ <item>B</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>id</name>
+ <format>0</format>
+ <type>Integer</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>attr_b</name>
+ <type>String</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>src</name>
+ <type>String</type>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>144</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>source-c</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>3</item>
+ <item>cherry</item>
+ <item>C</item>
+ </line>
+ <line>
+ <item>7</item>
+ <item>guava</item>
+ <item>C</item>
+ </line>
+ <line>
+ <item>10</item>
+ <item>honeydew</item>
+ <item>C</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>id</name>
+ <format>0</format>
+ <type>Integer</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>attr_c</name>
+ <type>String</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>src</name>
+ <type>String</type>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>source-d</name>
+ <type>DataGrid</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <data>
+ <line>
+ <item>4</item>
+ <item>date</item>
+ <item>Y</item>
+ <item>D</item>
+ </line>
+ <line>
+ <item>8</item>
+ <item>kiwi</item>
+ <item>N</item>
+ <item>D</item>
+ </line>
+ </data>
+ <fields>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>id</name>
+ <format>0</format>
+ <type>Integer</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>attr_d</name>
+ <type>String</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>flag</name>
+ <type>String</type>
+ </field>
+ <field>
+ <length>-1</length>
+ <precision>-1</precision>
+ <set_empty_string>N</set_empty_string>
+ <name>src</name>
+ <type>String</type>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>96</xloc>
+ <yloc>336</yloc>
+ </GUI>
+ </transform>
+ <transform>
+ <name>Sorted Schema Merge</name>
+ <type>SortedSchemaMerge</type>
+ <description/>
+ <distribute>Y</distribute>
+ <custom_distribution/>
+ <copies>1</copies>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <sort_keys>
+ <sort_key>
+ <name>id</name>
+ <ascending>Y</ascending>
+ </sort_key>
+ </sort_keys>
+ <inputs>
+ <input>
+ <name>source-a</name>
+ </input>
+ <input>
+ <name>source-b</name>
+ </input>
+ <input>
+ <name>source-c</name>
+ </input>
+ <input>
+ <name>source-d</name>
+ </input>
+ </inputs>
+ <attributes/>
+ <GUI>
+ <xloc>320</xloc>
+ <yloc>192</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>528</xloc>
+ <yloc>192</yloc>
+ </GUI>
+ </transform>
+ <transform_error_handling>
+ </transform_error_handling>
+ <attributes/>
+</pipeline>
diff --git
a/integration-tests/transforms/datasets/golden-sorted-schema-merge.csv
b/integration-tests/transforms/datasets/golden-sorted-schema-merge.csv
new file mode 100644
index 0000000000..11e920ea29
--- /dev/null
+++ b/integration-tests/transforms/datasets/golden-sorted-schema-merge.csv
@@ -0,0 +1,11 @@
+id,attr_a,attr_b,attr_c,attr_d,flag,src
+1,apple,,,,,A
+2,,banana,,,,B
+3,,,cherry,,,C
+4,,,,date,Y,D
+5,elderberry,,,,,A
+6,,fig,,,,B
+7,,,guava,,,C
+8,,,,kiwi,N,D
+9,grape,,,,,A
+10,,,honeydew,,,C
diff --git a/integration-tests/transforms/main-0106-sorted-schema-merge.hwf
b/integration-tests/transforms/main-0106-sorted-schema-merge.hwf
new file mode 100644
index 0000000000..57af5a588d
--- /dev/null
+++ b/integration-tests/transforms/main-0106-sorted-schema-merge.hwf
@@ -0,0 +1,80 @@
+<?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.
+
+-->
+<workflow>
+ <name>main-0106-sorted-schema-merge</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description/>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2026/07/10 22:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2026/07/10 22:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>96</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>0106-sorted-schema-merge UNIT</name>
+ <description/>
+ <type>RunPipelineTests</type>
+ <attributes/>
+ <test_names>
+ <test_name>
+ <name>0106-sorted-schema-merge UNIT</name>
+ </test_name>
+ </test_names>
+ <parallel>N</parallel>
+ <xloc>304</xloc>
+ <yloc>64</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0106-sorted-schema-merge UNIT</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git
a/integration-tests/transforms/metadata/dataset/golden-sorted-schema-merge.json
b/integration-tests/transforms/metadata/dataset/golden-sorted-schema-merge.json
new file mode 100644
index 0000000000..50abf039af
--- /dev/null
+++
b/integration-tests/transforms/metadata/dataset/golden-sorted-schema-merge.json
@@ -0,0 +1,64 @@
+{
+ "base_filename": "golden-sorted-schema-merge.csv",
+ "name": "golden-sorted-schema-merge",
+ "description": "Golden sparse sorted output for Sorted Schema Merge with
four input streams",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 5,
+ "field_precision": 0,
+ "field_format": "####0;-####0",
+ "field_name": "id"
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "attr_a",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "attr_b",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "attr_c",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "attr_d",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "flag",
+ "field_format": ""
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_name": "src",
+ "field_format": ""
+ }
+ ],
+ "folder_name": ""
+}
diff --git
a/integration-tests/transforms/metadata/unit-test/0106-sorted-schema-merge
UNIT.json
b/integration-tests/transforms/metadata/unit-test/0106-sorted-schema-merge
UNIT.json
new file mode 100644
index 0000000000..9c36dfd586
--- /dev/null
+++ b/integration-tests/transforms/metadata/unit-test/0106-sorted-schema-merge
UNIT.json
@@ -0,0 +1,53 @@
+{
+ "database_replacements": [],
+ "autoOpening": true,
+ "description": "Validate Sorted Schema Merge produces a globally sorted
sparse union of four Data Grid streams",
+ "persist_filename": "",
+ "test_type": "UNIT_TEST",
+ "variableValues": [],
+ "basePath": "${HOP_UNIT_TESTS_FOLDER}",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "id",
+ "data_set_field": "id"
+ },
+ {
+ "transform_field": "attr_a",
+ "data_set_field": "attr_a"
+ },
+ {
+ "transform_field": "attr_b",
+ "data_set_field": "attr_b"
+ },
+ {
+ "transform_field": "attr_c",
+ "data_set_field": "attr_c"
+ },
+ {
+ "transform_field": "attr_d",
+ "data_set_field": "attr_d"
+ },
+ {
+ "transform_field": "flag",
+ "data_set_field": "flag"
+ },
+ {
+ "transform_field": "src",
+ "data_set_field": "src"
+ }
+ ],
+ "field_order": [
+ "id",
+ "src"
+ ],
+ "data_set_name": "golden-sorted-schema-merge",
+ "transform_name": "validate"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0106-sorted-schema-merge UNIT",
+ "trans_test_tweaks": [],
+ "pipeline_filename": "./0106-sorted-schema-merge.hpl"
+}
diff --git a/plugins/transforms/pom.xml b/plugins/transforms/pom.xml
index f2f3e86993..ec796c1588 100644
--- a/plugins/transforms/pom.xml
+++ b/plugins/transforms/pom.xml
@@ -138,6 +138,7 @@
<module>setvariable</module>
<module>sort</module>
<module>sortedmerge</module>
+ <module>sortedschemamerge</module>
<module>splitfieldtorows</module>
<module>splunk</module>
<module>sql</module>
diff --git a/plugins/transforms/sortedschemamerge/pom.xml
b/plugins/transforms/sortedschemamerge/pom.xml
new file mode 100644
index 0000000000..564196fea1
--- /dev/null
+++ b/plugins/transforms/sortedschemamerge/pom.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ ~
+ -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-plugins-transforms</artifactId>
+ <version>2.19.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>hop-transform-sortedschemamerge</artifactId>
+ <packaging>jar</packaging>
+ <name>Hop Plugins Transforms Sorted Schema Merge</name>
+
+</project>
diff --git a/plugins/transforms/sortedschemamerge/src/assembly/assembly.xml
b/plugins/transforms/sortedschemamerge/src/assembly/assembly.xml
new file mode 100644
index 0000000000..df50dedff4
--- /dev/null
+++ b/plugins/transforms/sortedschemamerge/src/assembly/assembly.xml
@@ -0,0 +1,46 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ ~
+ -->
+
+<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.2.0
http://maven.apache.org/xsd/assembly-2.2.0.xsd">
+ <id>hop-transform-sortedschemamerge</id>
+ <formats>
+ <format>zip</format>
+ </formats>
+ <baseDirectory>.</baseDirectory>
+ <files>
+ <file>
+ <source>${project.basedir}/src/main/resources/version.xml</source>
+ <outputDirectory>${hop.plugin.libdir}</outputDirectory>
+ <filtered>true</filtered>
+ </file>
+ </files>
+
+ <fileSets>
+ <fileSet>
+ <directory>${project.basedir}/src/main/samples</directory>
+ <outputDirectory>config/projects/samples/</outputDirectory>
+ </fileSet>
+ </fileSets>
+
+
+ <componentDescriptors>
+
<componentDescriptor>${maven.multiModuleProjectDirectory}/assemblies/shared/hop-plugin-libs.xml</componentDescriptor>
+ </componentDescriptors>
+</assembly>
\ No newline at end of file
diff --git
a/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMerge.java
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMerge.java
new file mode 100644
index 0000000000..b4e0073496
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMerge.java
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.hop.pipeline.transforms.sortedschemamerge;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.apache.hop.core.IRowSet;
+import org.apache.hop.core.exception.HopException;
+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.i18n.BaseMessages;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.BaseTransform;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+/**
+ * Merges multiple pre-sorted input streams with different schemas into one
globally sorted stream
+ * with a unified sparse row layout.
+ */
+public class SortedSchemaMerge extends BaseTransform<SortedSchemaMergeMeta,
SortedSchemaMergeData> {
+
+ private static final Class<?> PKG = SortedSchemaMergeMeta.class;
+
+ public SortedSchemaMerge(
+ TransformMeta transformMeta,
+ SortedSchemaMergeMeta meta,
+ SortedSchemaMergeData data,
+ int copyNr,
+ PipelineMeta pipelineMeta,
+ Pipeline pipeline) {
+ super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+ }
+
+ @Override
+ public boolean processRow() throws HopException {
+ Object[] row = getRowSorted();
+ if (row == null) {
+ setOutputDone();
+ return false;
+ }
+
+ putRow(data.outputRowMeta, row);
+ if (checkFeedback(getLinesRead()) && isBasic()) {
+ logBasic(BaseMessages.getString(PKG, "SortedSchemaMerge.Log.LineNumber",
getLinesRead()));
+ }
+ return true;
+ }
+
+ @Override
+ public boolean init() {
+ if (!super.init()) {
+ return false;
+ }
+ if (meta.getSortKeys() == null || meta.getSortKeys().isEmpty()) {
+ logError(BaseMessages.getString(PKG,
"SortedSchemaMerge.Error.NoSortKeys"));
+ return false;
+ }
+ return true;
+ }
+
+ private synchronized Object[] getRowSorted() throws HopException {
+ if (first) {
+ first = false;
+ initializeSortedMerge();
+ }
+
+ if (data.sortedBuffer == null || data.sortedBuffer.isEmpty()) {
+ return null;
+ }
+
+ SortedSchemaMergeRow smallestRow = data.sortedBuffer.remove(0);
+ Object[] outputRow =
+ data.schemaMapping.mapRow(smallestRow.getStreamIndex(),
smallestRow.getRowData());
+
+ Object[] extraRow = getRowFrom(smallestRow.getRowSet());
+ if (extraRow != null) {
+ SortedSchemaMergeRow add =
+ new SortedSchemaMergeRow(
+ smallestRow.getStreamIndex(),
+ smallestRow.getRowSet(),
+ smallestRow.getRowSet().getRowMeta(),
+ extraRow);
+ int index = Collections.binarySearch(data.sortedBuffer, add,
data.comparator);
+ if (index < 0) {
+ data.sortedBuffer.add(-index - 1, add);
+ } else {
+ data.sortedBuffer.add(index, add);
+ }
+ }
+
+ if (getPipeline().isSafeModeEnabled()) {
+ safeModeChecking(smallestRow.getRowMeta());
+ }
+
+ return outputRow;
+ }
+
+ private void initializeSortedMerge() throws HopException {
+ List<IRowSet> inputRowSets = getInputRowSets();
+ if (inputRowSets == null || inputRowSets.size() < 2) {
+ throw new HopTransformException(
+ BaseMessages.getString(PKG, "SortedSchemaMerge.Error.TooFewInputs"));
+ }
+
+ data.sortedBuffer = new ArrayList<>();
+ List<IRowMeta> inputLayouts = new ArrayList<>();
+ for (int i = inputRowSets.size() - 1; i >= 0 && !isStopped(); i--) {
+ IRowSet rowSet = inputRowSets.get(i);
+ Object[] row = getRowFrom(rowSet);
+ if (row != null) {
+ int streamIndex = inputLayouts.size();
+ inputLayouts.add(rowSet.getRowMeta());
+ data.sortedBuffer.add(
+ new SortedSchemaMergeRow(streamIndex, rowSet, rowSet.getRowMeta(),
row));
+ }
+ }
+
+ if (data.sortedBuffer.isEmpty()) {
+ return;
+ }
+
+ IRowMeta[] inputRowMetas = inputLayouts.toArray(new IRowMeta[0]);
+ data.schemaMapping =
SortedSchemaMergeLogic.buildSchemaMapping(inputRowMetas);
+ data.outputRowMeta = data.schemaMapping.getOutputRowMeta().clone();
+ for (int i = 0; i < data.outputRowMeta.size(); i++) {
+ data.outputRowMeta.getValueMeta(i).setOrigin(getTransformName());
+ }
+
+ data.sortKeyIndices =
+ SortedSchemaMergeLogic.resolveSortKeyIndices(inputRowMetas,
meta.getSortKeys());
+ data.comparator =
+ (left, right) -> {
+ try {
+ return SortedSchemaMergeLogic.compareRows(
+ left, right, data.sortKeyIndices, meta.getSortKeys());
+ } catch (HopValueException e) {
+ throw new IllegalStateException("Error comparing sorted rows", e);
+ }
+ };
+
+ data.sortedBuffer.sort(data.comparator);
+ }
+}
diff --git
a/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeData.java
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeData.java
new file mode 100644
index 0000000000..ce5d707c0b
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeData.java
@@ -0,0 +1,40 @@
+/*
+ * 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.sortedschemamerge;
+
+import java.util.Comparator;
+import java.util.List;
+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.sortedschemamerge.SortedSchemaMergeLogic.SchemaMapping;
+
+@SuppressWarnings("java:S1104")
+public class SortedSchemaMergeData extends BaseTransformData implements
ITransformData {
+
+ public SchemaMapping schemaMapping;
+ public IRowMeta outputRowMeta;
+ public int[][] sortKeyIndices;
+ public List<SortedSchemaMergeRow> sortedBuffer;
+ public Comparator<SortedSchemaMergeRow> comparator;
+
+ public SortedSchemaMergeData() {
+ super();
+ }
+}
diff --git
a/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeDialog.java
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeDialog.java
new file mode 100644
index 0000000000..da7ec299d9
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeDialog.java
@@ -0,0 +1,206 @@
+/*
+ * 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.sortedschemamerge;
+
+import org.apache.hop.core.Const;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.TableItem;
+
+public class SortedSchemaMergeDialog extends BaseTransformDialog {
+
+ private static final Class<?> PKG = SortedSchemaMergeMeta.class;
+
+ private final SortedSchemaMergeMeta input;
+ private String[] previousTransforms;
+ private TableView wInputs;
+ private TableView wSortKeys;
+
+ public SortedSchemaMergeDialog(
+ Shell parent,
+ IVariables variables,
+ SortedSchemaMergeMeta transformMeta,
+ PipelineMeta pipelineMeta) {
+ super(parent, variables, transformMeta, pipelineMeta);
+ input = transformMeta;
+ }
+
+ @Override
+ public String open() {
+ createShell(BaseMessages.getString(PKG, "SortedSchemaMergeDialog.Title"));
+
+ buildButtonBar()
+ .ok(e -> ok())
+ .get(e -> getSourceTransformNames())
+ .cancel(e -> cancel())
+ .build();
+
+ changed = input.hasChanged();
+ ModifyListener lsMod = e -> input.setChanged();
+ previousTransforms = pipelineMeta.getPrevTransformNames(transformName);
+
+ Label wlSortKeys = new Label(shell, SWT.NONE);
+ wlSortKeys.setText(BaseMessages.getString(PKG,
"SortedSchemaMergeDialog.SortKeys.Label"));
+ PropsUi.setLook(wlSortKeys);
+ FormData fdlSortKeys = new FormData();
+ fdlSortKeys.left = new FormAttachment(0, 0);
+ fdlSortKeys.top = new FormAttachment(wSpacer, margin);
+ wlSortKeys.setLayoutData(fdlSortKeys);
+
+ ColumnInfo[] sortKeyColumns =
+ new ColumnInfo[] {
+ new ColumnInfo(
+ BaseMessages.getString(PKG,
"SortedSchemaMergeDialog.SortKeys.Column.Field"),
+ ColumnInfo.COLUMN_TYPE_TEXT,
+ false),
+ new ColumnInfo(
+ BaseMessages.getString(PKG,
"SortedSchemaMergeDialog.SortKeys.Column.Ascending"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ new String[] {"Y", "N"},
+ true)
+ };
+
+ wSortKeys =
+ new TableView(
+ variables,
+ shell,
+ SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+ sortKeyColumns,
+ 1,
+ lsMod,
+ props);
+ FormData fdSortKeys = new FormData();
+ fdSortKeys.left = new FormAttachment(0, 0);
+ fdSortKeys.top = new FormAttachment(wlSortKeys, margin);
+ fdSortKeys.right = new FormAttachment(100, 0);
+ fdSortKeys.bottom = new FormAttachment(55, 0);
+ wSortKeys.setLayoutData(fdSortKeys);
+
+ Label wlNote = new Label(shell, SWT.WRAP);
+ wlNote.setText(BaseMessages.getString(PKG,
"SortedSchemaMergeDialog.SortedInputNote.Label"));
+ PropsUi.setLook(wlNote);
+ FormData fdlNote = new FormData();
+ fdlNote.left = new FormAttachment(0, 0);
+ fdlNote.right = new FormAttachment(100, 0);
+ fdlNote.bottom = new FormAttachment(wOk, -margin);
+ wlNote.setLayoutData(fdlNote);
+
+ Label wlInputs = new Label(shell, SWT.NONE);
+ wlInputs.setText(BaseMessages.getString(PKG,
"SortedSchemaMergeDialog.Inputs.Label"));
+ PropsUi.setLook(wlInputs);
+ FormData fdlInputs = new FormData();
+ fdlInputs.left = new FormAttachment(0, 0);
+ fdlInputs.top = new FormAttachment(wSortKeys, margin);
+ wlInputs.setLayoutData(fdlInputs);
+
+ ColumnInfo[] inputColumns =
+ new ColumnInfo[] {
+ new ColumnInfo(
+ BaseMessages.getString(PKG,
"SortedSchemaMergeDialog.Inputs.Column.Transform"),
+ ColumnInfo.COLUMN_TYPE_CCOMBO,
+ previousTransforms,
+ false)
+ };
+
+ wInputs =
+ new TableView(
+ variables,
+ shell,
+ SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+ inputColumns,
+ 1,
+ lsMod,
+ props);
+ FormData fdInputs = new FormData();
+ fdInputs.left = new FormAttachment(0, 0);
+ fdInputs.top = new FormAttachment(wlInputs, margin);
+ fdInputs.right = new FormAttachment(100, 0);
+ fdInputs.bottom = new FormAttachment(wlNote, -margin);
+ wInputs.setLayoutData(fdInputs);
+
+ populateDialog();
+ input.setChanged(changed);
+ focusTransformName();
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+ return transformName;
+ }
+
+ private void populateDialog() {
+ for (SortedSchemaMergeSortKey sortKey : input.getSortKeys()) {
+ TableItem ti = new TableItem(wSortKeys.table, SWT.NONE);
+ ti.setText(1, Const.NVL(sortKey.getFieldName(), ""));
+ ti.setText(2, sortKey.isAscending() ? "Y" : "N");
+ }
+ wSortKeys.optimizeTableView();
+
+ for (SortedSchemaMergeInput mergeInput : input.getInputs()) {
+ TableItem ti = new TableItem(wInputs.table, SWT.NONE);
+ ti.setText(1, Const.NVL(mergeInput.getTransformName(), ""));
+ }
+ wInputs.optimizeTableView();
+ }
+
+ private void getSourceTransformNames() {
+ wInputs.removeAll();
+ Table table = wInputs.table;
+ for (String previousTransform : previousTransforms) {
+ TableItem ti = new TableItem(table, SWT.NONE);
+ ti.setText(1, previousTransform);
+ }
+ wInputs.optimizeTableView();
+ }
+
+ private void cancel() {
+ transformName = null;
+ input.setChanged(changed);
+ dispose();
+ }
+
+ private void ok() {
+ transformName = wTransformName.getText();
+
+ input.getSortKeys().clear();
+ for (TableItem item : wSortKeys.getNonEmptyItems()) {
+ SortedSchemaMergeSortKey sortKey = new SortedSchemaMergeSortKey();
+ sortKey.setFieldName(item.getText(1));
+ sortKey.setAscending(!"N".equalsIgnoreCase(item.getText(2)));
+ input.getSortKeys().add(sortKey);
+ }
+
+ input.getInputs().clear();
+ for (TableItem item : wInputs.getNonEmptyItems()) {
+ input.getInputs().add(new SortedSchemaMergeInput(item.getText(1)));
+ }
+
+ dispose();
+ }
+}
diff --git
a/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeInput.java
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeInput.java
new file mode 100644
index 0000000000..e1560ec807
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeInput.java
@@ -0,0 +1,44 @@
+/*
+ * 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.sortedschemamerge;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+
+/** Reference to an upstream transform that feeds a sorted input stream. */
+@Getter
+@Setter
+@NoArgsConstructor
+public class SortedSchemaMergeInput {
+
+ @HopMetadataProperty(key = "name")
+ private String transformName;
+
+ public SortedSchemaMergeInput(String transformName) {
+ this.transformName = transformName;
+ }
+
+ public SortedSchemaMergeInput(SortedSchemaMergeInput other) {
+ if (other != null) {
+ transformName = other.transformName;
+ }
+ }
+}
diff --git
a/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeLogic.java
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeLogic.java
new file mode 100644
index 0000000000..40d4534e89
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeLogic.java
@@ -0,0 +1,159 @@
+/*
+ * 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.sortedschemamerge;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import org.apache.hop.core.exception.HopPluginException;
+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.core.util.Utils;
+
+/** Schema union and row mapping helpers for {@link SortedSchemaMerge}. */
+public final class SortedSchemaMergeLogic {
+
+ private SortedSchemaMergeLogic() {}
+
+ /** Union of all input row layouts plus per-stream field index mappings. */
+ public static final class SchemaMapping {
+ private final IRowMeta outputRowMeta;
+ private final int[][] streamMappings;
+
+ private SchemaMapping(IRowMeta outputRowMeta, int[][] streamMappings) {
+ this.outputRowMeta = outputRowMeta;
+ this.streamMappings = streamMappings;
+ }
+
+ public IRowMeta getOutputRowMeta() {
+ return outputRowMeta;
+ }
+
+ public int[][] getStreamMappings() {
+ return streamMappings;
+ }
+
+ public Object[] mapRow(int streamIndex, Object[] sourceRow) {
+ Object[] outputRow = new Object[outputRowMeta.size()];
+ if (sourceRow == null || streamIndex < 0 || streamIndex >=
streamMappings.length) {
+ return outputRow;
+ }
+ int[] mapping = streamMappings[streamIndex];
+ for (int sourceIndex = 0; sourceIndex < mapping.length; sourceIndex++) {
+ int outputIndex = mapping[sourceIndex];
+ if (outputIndex >= 0 && outputIndex < outputRow.length) {
+ outputRow[outputIndex] = sourceRow[sourceIndex];
+ }
+ }
+ return outputRow;
+ }
+ }
+
+ public static SchemaMapping buildSchemaMapping(IRowMeta[] inputRowMetas)
+ throws HopPluginException {
+ if (inputRowMetas == null || inputRowMetas.length == 0) {
+ throw new HopPluginException("At least one input row layout is
required");
+ }
+
+ int[][] mappings = new int[inputRowMetas.length][];
+ IRowMeta outputRowMeta = inputRowMetas[0].clone();
+ HashSet<String> fieldNames = new HashSet<>();
+ Collections.addAll(fieldNames, outputRowMeta.getFieldNames());
+
+ for (int streamIndex = 0; streamIndex < inputRowMetas.length;
streamIndex++) {
+ IRowMeta inputRowMeta = inputRowMetas[streamIndex];
+ int[] streamMapping = new int[inputRowMeta.size()];
+ for (int fieldIndex = 0; fieldIndex < inputRowMeta.size(); fieldIndex++)
{
+ IValueMeta field = inputRowMeta.getValueMeta(fieldIndex);
+ String name = field.getName();
+ if (!fieldNames.contains(name)) {
+ outputRowMeta.addValueMeta(field);
+ fieldNames.add(name);
+ }
+ int outputIndex = outputRowMeta.indexOfValue(name);
+ streamMapping[fieldIndex] = outputIndex;
+ IValueMeta outputField = outputRowMeta.getValueMeta(outputIndex);
+ if (outputField.getType() != field.getType()) {
+ IValueMeta updatedField =
+ ValueMetaFactory.cloneValueMeta(outputField,
IValueMeta.TYPE_STRING);
+ outputRowMeta.setValueMeta(outputIndex, updatedField);
+ }
+ }
+ mappings[streamIndex] = streamMapping;
+ }
+
+ return new SchemaMapping(outputRowMeta, mappings);
+ }
+
+ public static int[][] resolveSortKeyIndices(
+ IRowMeta[] inputRowMetas, List<SortedSchemaMergeSortKey> sortKeys)
+ throws HopTransformException {
+ if (sortKeys == null || sortKeys.isEmpty()) {
+ throw new HopTransformException("At least one sort key is required");
+ }
+
+ int[][] indices = new int[inputRowMetas.length][];
+ for (int streamIndex = 0; streamIndex < inputRowMetas.length;
streamIndex++) {
+ IRowMeta rowMeta = inputRowMetas[streamIndex];
+ int[] streamIndices = new int[sortKeys.size()];
+ for (int keyIndex = 0; keyIndex < sortKeys.size(); keyIndex++) {
+ SortedSchemaMergeSortKey sortKey = sortKeys.get(keyIndex);
+ if (sortKey == null || Utils.isEmpty(sortKey.getFieldName())) {
+ throw new HopTransformException("Sort key name is required for all
streams");
+ }
+ int fieldIndex = rowMeta.indexOfValue(sortKey.getFieldName());
+ if (fieldIndex < 0) {
+ throw new HopTransformException(
+ "Sort key [" + sortKey.getFieldName() + "] not found in input
stream " + streamIndex);
+ }
+ streamIndices[keyIndex] = fieldIndex;
+ }
+ indices[streamIndex] = streamIndices;
+ }
+ return indices;
+ }
+
+ public static int compareRows(
+ SortedSchemaMergeRow left,
+ SortedSchemaMergeRow right,
+ int[][] sortKeyIndices,
+ List<SortedSchemaMergeSortKey> sortKeys)
+ throws HopValueException {
+ for (int keyIndex = 0; keyIndex < sortKeys.size(); keyIndex++) {
+ SortedSchemaMergeSortKey sortKey = sortKeys.get(keyIndex);
+ int leftFieldIndex = sortKeyIndices[left.getStreamIndex()][keyIndex];
+ int rightFieldIndex = sortKeyIndices[right.getStreamIndex()][keyIndex];
+ IValueMeta leftMeta = left.getRowMeta().getValueMeta(leftFieldIndex);
+ IValueMeta rightMeta = right.getRowMeta().getValueMeta(rightFieldIndex);
+ Object leftValue = left.getRowData()[leftFieldIndex];
+ Object rightValue = right.getRowData()[rightFieldIndex];
+ int compare = leftMeta.compare(leftValue, rightMeta, rightValue);
+ if (!sortKey.isAscending()) {
+ compare = -compare;
+ }
+ if (compare != 0) {
+ return compare;
+ }
+ }
+ return Integer.compare(left.getStreamIndex(), right.getStreamIndex());
+ }
+}
diff --git
a/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeMeta.java
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeMeta.java
new file mode 100644
index 0000000000..44ec30c660
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeMeta.java
@@ -0,0 +1,281 @@
+/*
+ * 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.sortedschemamerge;
+
+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;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.exception.HopPluginException;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.PipelineMeta.PipelineType;
+import org.apache.hop.pipeline.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+@Transform(
+ id = "SortedSchemaMerge",
+ image = "sortedschemamerge.svg",
+ name = "i18n::SortedSchemaMerge.Name",
+ description = "i18n::SortedSchemaMerge.Description",
+ categoryDescription =
"i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Flow",
+ keywords = "i18n::SortedSchemaMerge.keyword",
+ documentationUrl = "/pipeline/transforms/sortedschemamerge.html")
+@Getter
+@Setter
+public class SortedSchemaMergeMeta
+ extends BaseTransformMeta<SortedSchemaMerge, SortedSchemaMergeData> {
+
+ private static final Class<?> PKG = SortedSchemaMergeMeta.class;
+
+ @HopMetadataProperty(key = "input", groupKey = "inputs")
+ private List<SortedSchemaMergeInput> inputs = new ArrayList<>();
+
+ @HopMetadataProperty(key = "sort_key", groupKey = "sort_keys")
+ private List<SortedSchemaMergeSortKey> sortKeys = new ArrayList<>();
+
+ public SortedSchemaMergeMeta() {}
+
+ public SortedSchemaMergeMeta(SortedSchemaMergeMeta other) {
+ if (other == null) {
+ return;
+ }
+ for (SortedSchemaMergeInput input : other.inputs) {
+ inputs.add(new SortedSchemaMergeInput(input));
+ }
+ for (SortedSchemaMergeSortKey sortKey : other.sortKeys) {
+ sortKeys.add(new SortedSchemaMergeSortKey(sortKey));
+ }
+ }
+
+ @Override
+ public SortedSchemaMergeMeta clone() {
+ return new SortedSchemaMergeMeta(this);
+ }
+
+ @Override
+ public boolean excludeFromRowLayoutVerification() {
+ return true;
+ }
+
+ @Override
+ public void getFields(
+ PipelineMeta pipelineMeta,
+ IRowMeta inputRowMeta,
+ String name,
+ IRowMeta[] info,
+ TransformMeta nextTransform,
+ IVariables variables,
+ IHopMetadataProvider metadataProvider)
+ throws HopTransformException {
+ IRowMeta[] inputLayouts = resolveInputLayouts(pipelineMeta, name, info,
variables);
+ if (inputLayouts == null || inputLayouts.length == 0) {
+ return;
+ }
+ mergeOutputFields(inputRowMeta, name, inputLayouts);
+ }
+
+ @Override
+ public void getFields(
+ IRowMeta inputRowMeta,
+ String name,
+ IRowMeta[] info,
+ TransformMeta nextTransform,
+ IVariables variables,
+ IHopMetadataProvider metadataProvider)
+ throws HopTransformException {
+ IRowMeta[] inputLayouts = resolveInputLayouts(null, name, info, variables);
+ if (inputLayouts == null || inputLayouts.length == 0) {
+ return;
+ }
+ mergeOutputFields(inputRowMeta, name, inputLayouts);
+ }
+
+ private static IRowMeta[] resolveInputLayouts(
+ PipelineMeta pipelineMeta, String transformName, IRowMeta[] info,
IVariables variables)
+ throws HopTransformException {
+ if (info != null && info.length > 0 && !isEmptyLayout(info)) {
+ return info;
+ }
+ if (pipelineMeta == null) {
+ return info;
+ }
+ TransformMeta transformMeta = pipelineMeta.findTransform(transformName);
+ if (transformMeta == null) {
+ return info;
+ }
+ String[] inputNames = pipelineMeta.getPrevTransformNames(transformMeta);
+ if (inputNames == null || inputNames.length == 0) {
+ return info;
+ }
+ List<IRowMeta> layouts = new ArrayList<>();
+ for (String inputName : inputNames) {
+ if (Utils.isEmpty(inputName)) {
+ continue;
+ }
+ IRowMeta layout = pipelineMeta.getTransformFields(variables, inputName);
+ if (layout != null && !layout.isEmpty()) {
+ layouts.add(layout);
+ }
+ }
+ return layouts.isEmpty() ? info : layouts.toArray(new IRowMeta[0]);
+ }
+
+ private static boolean isEmptyLayout(IRowMeta[] layouts) {
+ for (IRowMeta layout : layouts) {
+ if (layout != null && !layout.isEmpty()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static void mergeOutputFields(IRowMeta inputRowMeta, String name,
IRowMeta[] inputLayouts)
+ throws HopTransformException {
+ try {
+ SortedSchemaMergeLogic.SchemaMapping mapping =
+ SortedSchemaMergeLogic.buildSchemaMapping(inputLayouts);
+ IRowMeta outputRowMeta = mapping.getOutputRowMeta();
+ for (int i = 0; i < outputRowMeta.size(); i++) {
+ outputRowMeta.getValueMeta(i).setOrigin(name);
+ }
+ inputRowMeta.mergeRowMeta(outputRowMeta);
+ } catch (HopPluginException e) {
+ throw new HopTransformException("Unable to resolve merged output
fields", e);
+ }
+ }
+
+ @Override
+ public void check(
+ List<ICheckResult> remarks,
+ PipelineMeta pipelineMeta,
+ TransformMeta transformMeta,
+ IRowMeta prev,
+ String[] input,
+ String[] output,
+ IRowMeta info,
+ IVariables variables,
+ IHopMetadataProvider metadataProvider) {
+ if (inputs == null || inputs.size() < 2) {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ BaseMessages.getString(PKG,
"SortedSchemaMergeMeta.CheckResult.TooFewInputs"),
+ transformMeta));
+ } else {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_OK,
+ BaseMessages.getString(PKG,
"SortedSchemaMergeMeta.CheckResult.InputCountOk"),
+ transformMeta));
+ }
+
+ if (sortKeys == null || sortKeys.isEmpty()) {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ BaseMessages.getString(PKG,
"SortedSchemaMergeMeta.CheckResult.NoSortKeys"),
+ transformMeta));
+ return;
+ }
+
+ List<IRowMeta> inputLayouts = resolveCheckInputLayouts(pipelineMeta,
input, prev, variables);
+ if (!inputLayouts.isEmpty()) {
+ StringBuilder missing = new StringBuilder();
+ for (SortedSchemaMergeSortKey sortKey : sortKeys) {
+ if (sortKey == null || Utils.isEmpty(sortKey.getFieldName())) {
+ continue;
+ }
+ for (IRowMeta rowMeta : inputLayouts) {
+ if (rowMeta != null && rowMeta.indexOfValue(sortKey.getFieldName())
< 0) {
+
missing.append("\t\t").append(sortKey.getFieldName()).append(Const.CR);
+ break;
+ }
+ }
+ }
+ if (!missing.isEmpty()) {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ BaseMessages.getString(
+ PKG, "SortedSchemaMergeMeta.CheckResult.SortKeysMissing",
missing.toString()),
+ transformMeta));
+ } else {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_OK,
+ BaseMessages.getString(PKG,
"SortedSchemaMergeMeta.CheckResult.SortKeysOk"),
+ transformMeta));
+ }
+ }
+
+ if (input != null && input.length > 0) {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_OK,
+ BaseMessages.getString(PKG,
"SortedSchemaMergeMeta.CheckResult.ReceivingRowsOk"),
+ transformMeta));
+ } else {
+ remarks.add(
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ BaseMessages.getString(PKG,
"SortedSchemaMergeMeta.CheckResult.ReceivingRowsError"),
+ transformMeta));
+ }
+ }
+
+ @Override
+ public PipelineType[] getSupportedPipelineTypes() {
+ return new PipelineType[] {PipelineType.Normal};
+ }
+
+ private static List<IRowMeta> resolveCheckInputLayouts(
+ PipelineMeta pipelineMeta, String[] input, IRowMeta prev, IVariables
variables) {
+ List<IRowMeta> layouts = new ArrayList<>();
+ if (input != null) {
+ for (String inputName : input) {
+ if (Utils.isEmpty(inputName)) {
+ continue;
+ }
+ try {
+ IRowMeta layout = pipelineMeta.getTransformFields(variables,
inputName);
+ if (layout != null && !layout.isEmpty()) {
+ layouts.add(layout);
+ }
+ } catch (HopTransformException ignored) {
+ // Pipeline metadata may be incomplete while the dialog is being
edited.
+ }
+ }
+ }
+ if (layouts.isEmpty() && prev != null && !prev.isEmpty()) {
+ layouts.add(prev);
+ }
+ return layouts;
+ }
+}
diff --git
a/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeMetaFactory.java
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeMetaFactory.java
new file mode 100644
index 0000000000..b6eb6f2a0f
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeMetaFactory.java
@@ -0,0 +1,62 @@
+/*
+ * 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.sortedschemamerge;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.util.Utils;
+
+/** Builds {@link SortedSchemaMergeMeta} instances for generated pipelines. */
+public final class SortedSchemaMergeMetaFactory {
+
+ private SortedSchemaMergeMetaFactory() {}
+
+ public static SortedSchemaMergeMeta create(
+ List<String> inputTransformNames, List<SortedSchemaMergeSortKey>
sortKeys) {
+ SortedSchemaMergeMeta meta = new SortedSchemaMergeMeta();
+ if (inputTransformNames != null) {
+ for (String transformName : inputTransformNames) {
+ if (!Utils.isEmpty(transformName)) {
+ meta.getInputs().add(new SortedSchemaMergeInput(transformName));
+ }
+ }
+ }
+ if (sortKeys != null) {
+ for (SortedSchemaMergeSortKey sortKey : sortKeys) {
+ if (sortKey != null) {
+ meta.getSortKeys().add(new SortedSchemaMergeSortKey(sortKey));
+ }
+ }
+ }
+ return meta;
+ }
+
+ public static List<SortedSchemaMergeSortKey> sortKeys(String... fieldNames) {
+ List<SortedSchemaMergeSortKey> keys = new ArrayList<>();
+ if (fieldNames == null) {
+ return keys;
+ }
+ for (String fieldName : fieldNames) {
+ if (!Utils.isEmpty(fieldName)) {
+ keys.add(new SortedSchemaMergeSortKey(fieldName, true));
+ }
+ }
+ return keys;
+ }
+}
diff --git
a/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeRow.java
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeRow.java
new file mode 100644
index 0000000000..d0fdd8a19e
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeRow.java
@@ -0,0 +1,54 @@
+/*
+ * 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.sortedschemamerge;
+
+import org.apache.hop.core.IRowSet;
+import org.apache.hop.core.row.IRowMeta;
+
+/** Buffered row from one sorted input stream during multi-way merge. */
+public final class SortedSchemaMergeRow {
+
+ private final int streamIndex;
+ private final IRowSet rowSet;
+ private final IRowMeta rowMeta;
+ private final Object[] rowData;
+
+ public SortedSchemaMergeRow(int streamIndex, IRowSet rowSet, IRowMeta
rowMeta, Object[] rowData) {
+ this.streamIndex = streamIndex;
+ this.rowSet = rowSet;
+ this.rowMeta = rowMeta;
+ this.rowData = rowData;
+ }
+
+ public int getStreamIndex() {
+ return streamIndex;
+ }
+
+ public IRowSet getRowSet() {
+ return rowSet;
+ }
+
+ public IRowMeta getRowMeta() {
+ return rowMeta;
+ }
+
+ public Object[] getRowData() {
+ return rowData;
+ }
+}
diff --git
a/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeSortKey.java
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeSortKey.java
new file mode 100644
index 0000000000..ea87958c26
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeSortKey.java
@@ -0,0 +1,49 @@
+/*
+ * 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.sortedschemamerge;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+
+/** Sort key used when merging pre-sorted input streams. */
+@Getter
+@Setter
+@NoArgsConstructor
+public class SortedSchemaMergeSortKey {
+
+ @HopMetadataProperty(key = "name")
+ private String fieldName;
+
+ @HopMetadataProperty(key = "ascending")
+ private boolean ascending = true;
+
+ public SortedSchemaMergeSortKey(String fieldName, boolean ascending) {
+ this.fieldName = fieldName;
+ this.ascending = ascending;
+ }
+
+ public SortedSchemaMergeSortKey(SortedSchemaMergeSortKey other) {
+ if (other != null) {
+ fieldName = other.fieldName;
+ ascending = other.ascending;
+ }
+ }
+}
diff --git
a/plugins/transforms/sortedschemamerge/src/main/resources/org/apache/hop/pipeline/transforms/sortedschemamerge/messages/messages_en_US.properties
b/plugins/transforms/sortedschemamerge/src/main/resources/org/apache/hop/pipeline/transforms/sortedschemamerge/messages/messages_en_US.properties
new file mode 100644
index 0000000000..84cd66c0be
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/resources/org/apache/hop/pipeline/transforms/sortedschemamerge/messages/messages_en_US.properties
@@ -0,0 +1,44 @@
+#
+#
+# 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.
+#
+#
+
+SortedSchemaMerge.Name = Sorted Schema Merge
+SortedSchemaMerge.Description = Merge pre-sorted input streams with different
schemas into one sorted sparse stream
+SortedSchemaMerge.keyword = sorted schema merge stream union sparse
+
+SortedSchemaMerge.Log.LineNumber = Line number {0}
+SortedSchemaMerge.Error.TooFewInputs = At least two sorted input streams are
required
+SortedSchemaMerge.Error.NoSortKeys = At least one sort key is required
+SortedSchemaMerge.Error.NoInputRowSets = No input row sets were found
+
+SortedSchemaMergeDialog.Title = Sorted Schema Merge
+SortedSchemaMergeDialog.TransformName.Label = Transform name
+SortedSchemaMergeDialog.Inputs.Label = Input transforms
+SortedSchemaMergeDialog.Inputs.Column.Transform = Transform
+SortedSchemaMergeDialog.SortKeys.Label = Sort keys
+SortedSchemaMergeDialog.SortKeys.Column.Field = Field
+SortedSchemaMergeDialog.SortKeys.Column.Ascending = Ascending
+SortedSchemaMergeDialog.SortedInputNote.Label = Each input stream must already
be sorted on the sort keys (for example via ORDER BY in Table Input).
+
+SortedSchemaMergeMeta.CheckResult.TooFewInputs = Configure at least two input
transforms
+SortedSchemaMergeMeta.CheckResult.InputCountOk = At least two input transforms
are configured
+SortedSchemaMergeMeta.CheckResult.NoSortKeys = No sort keys are defined
+SortedSchemaMergeMeta.CheckResult.SortKeysMissing = The following sort keys
were not found in the input streams:{0}
+SortedSchemaMergeMeta.CheckResult.SortKeysOk = All sort keys were found in the
input streams
+SortedSchemaMergeMeta.CheckResult.ReceivingRowsOk = Receiving rows from
upstream transforms
+SortedSchemaMergeMeta.CheckResult.ReceivingRowsError = Not receiving rows from
upstream transforms. Connect at least two sorted input hops.
diff --git
a/plugins/transforms/sortedschemamerge/src/main/resources/sortedschemamerge.svg
b/plugins/transforms/sortedschemamerge/src/main/resources/sortedschemamerge.svg
new file mode 100644
index 0000000000..9beaf28777
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/main/resources/sortedschemamerge.svg
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
+ width="42px" height="42px" viewBox="0 0 42 42" enable-background="new 0 0
42 42">
+ <!-- Two source schemas (different column layouts) -->
+ <path fill="#0E3A5A" d="M18.5,3.5H2.5v12h16V3.5z M16.5,13.5h-3v-8h3V13.5z
M12.5,13.5h-3v-8h3V13.5z M8.5,13.5h-4v-8h4V13.5z"/>
+ <rect x="4.5" y="5.5" fill="#C9E8FB" width="4" height="8"/>
+ <rect x="9.5" y="5.5" fill="#C9E8FB" width="3" height="8"/>
+ <path fill="#0E3A5A" d="M18.5,26.5H2.5v12h16V26.5z M16.5,36.5h-4v-8h4V36.5z
M11.5,36.5h-3v-8h3V36.5z M7.5,36.5h-3v-8h3V36.5z"/>
+ <rect x="4.5" y="28.5" fill="#C9E8FB" width="3" height="8"/>
+ <rect x="8.5" y="28.5" fill="#C9E8FB" width="3" height="8"/>
+ <rect x="12.5" y="28.5" fill="#C9E8FB" width="4" height="8"/>
+ <!-- Merge funnel into sparse sorted output -->
+ <path fill="#0E3A5A" d="M20,9.5h6.5v-3h8v3H39v7.5h-4.5v3H25.5v-3H20V9.5z"/>
+ <rect x="22" y="11.5" fill="#FF9C04" width="5" height="1.5"/>
+ <rect x="22" y="13.7" fill="#FF9C04" width="3.5" height="1.5"/>
+ <path fill="#5CC0C4"
d="M20,32.5h6.5v3h8v-3H39v-7.5h-4.5v-3H25.5v3H20V32.5z"/>
+ <!-- Output sparse row bar -->
+ <path fill="#0E3A5A" d="M39.5,18.5h-14v5h14V18.5z"/>
+ <rect x="26.5" y="19.7" fill="#FFFFFF" width="3" height="2.6"/>
+ <rect x="30.5" y="19.7" fill="#FF9C04" width="3" height="2.6"/>
+ <rect x="34.5" y="19.7" fill="#C9E8FB" width="3.5" height="2.6"/>
+</svg>
diff --git
a/plugins/transforms/sortedschemamerge/src/main/resources/version.xml
b/plugins/transforms/sortedschemamerge/src/main/resources/version.xml
new file mode 100644
index 0000000000..6be576acae
--- /dev/null
+++ b/plugins/transforms/sortedschemamerge/src/main/resources/version.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ ~
+ -->
+
+<version>${project.version}</version>
\ No newline at end of file
diff --git
a/plugins/transforms/sortedschemamerge/src/test/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeLogicTest.java
b/plugins/transforms/sortedschemamerge/src/test/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeLogicTest.java
new file mode 100644
index 0000000000..9a1305bb15
--- /dev/null
+++
b/plugins/transforms/sortedschemamerge/src/test/java/org/apache/hop/pipeline/transforms/sortedschemamerge/SortedSchemaMergeLogicTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.sortedschemamerge;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopPluginException;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.row.IRowMeta;
+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 SortedSchemaMergeLogicTest {
+
+ @BeforeAll
+ static void initHop() throws HopException {
+ HopEnvironment.init();
+ }
+
+ @Test
+ void buildSchemaMappingUnionsFieldsAndMapsSparseRows() throws
HopPluginException {
+ IRowMeta streamA = new RowMeta();
+ streamA.addValueMeta(new ValueMetaString("hub_hk"));
+ streamA.addValueMeta(new ValueMetaString("attr_a"));
+
+ IRowMeta streamB = new RowMeta();
+ streamB.addValueMeta(new ValueMetaString("hub_hk"));
+ streamB.addValueMeta(new ValueMetaString("attr_b"));
+
+ SortedSchemaMergeLogic.SchemaMapping mapping =
+ SortedSchemaMergeLogic.buildSchemaMapping(new IRowMeta[] {streamA,
streamB});
+
+ assertEquals(3, mapping.getOutputRowMeta().size());
+ assertTrue(mapping.getOutputRowMeta().indexOfValue("hub_hk") >= 0);
+ assertTrue(mapping.getOutputRowMeta().indexOfValue("attr_a") >= 0);
+ assertTrue(mapping.getOutputRowMeta().indexOfValue("attr_b") >= 0);
+
+ Object[] mappedA = mapping.mapRow(0, new Object[] {"hk-1", "value-a"});
+ Object[] mappedB = mapping.mapRow(1, new Object[] {"hk-1", "value-b"});
+
+ assertEquals("hk-1",
mappedA[mapping.getOutputRowMeta().indexOfValue("hub_hk")]);
+ assertEquals("value-a",
mappedA[mapping.getOutputRowMeta().indexOfValue("attr_a")]);
+ assertEquals(null,
mappedA[mapping.getOutputRowMeta().indexOfValue("attr_b")]);
+
+ assertEquals("hk-1",
mappedB[mapping.getOutputRowMeta().indexOfValue("hub_hk")]);
+ assertEquals(null,
mappedB[mapping.getOutputRowMeta().indexOfValue("attr_a")]);
+ assertEquals("value-b",
mappedB[mapping.getOutputRowMeta().indexOfValue("attr_b")]);
+ }
+
+ @Test
+ void buildSchemaMappingCoercesConflictingTypesToString() throws
HopPluginException {
+ IRowMeta streamA = new RowMeta();
+ streamA.addValueMeta(new ValueMetaString("shared"));
+
+ IRowMeta streamB = new RowMeta();
+ streamB.addValueMeta(new ValueMetaInteger("shared"));
+
+ SortedSchemaMergeLogic.SchemaMapping mapping =
+ SortedSchemaMergeLogic.buildSchemaMapping(new IRowMeta[] {streamA,
streamB});
+
+ assertEquals(
+ ValueMetaString.TYPE_STRING,
+ mapping
+ .getOutputRowMeta()
+ .getValueMeta(mapping.getOutputRowMeta().indexOfValue("shared"))
+ .getType());
+ }
+
+ @Test
+ void resolveSortKeyIndicesRequiresFieldOnEveryStream() {
+ IRowMeta streamA = row("hub_hk", "load_dts");
+ IRowMeta streamB = row("hub_hk");
+
+ List<SortedSchemaMergeSortKey> sortKeys =
+ SortedSchemaMergeMetaFactory.sortKeys("hub_hk", "load_dts");
+
+ assertThrows(
+ HopTransformException.class,
+ () ->
+ SortedSchemaMergeLogic.resolveSortKeyIndices(
+ new IRowMeta[] {streamA, streamB}, sortKeys));
+ }
+
+ @Test
+ void compareRowsUsesSortKeysAndStreamIndexTieBreak() throws Exception {
+ IRowMeta streamA = row("hub_hk", "load_dts");
+ IRowMeta streamB = row("hub_hk", "load_dts");
+
+ List<SortedSchemaMergeSortKey> sortKeys =
+ SortedSchemaMergeMetaFactory.sortKeys("hub_hk", "load_dts");
+ int[][] sortKeyIndices =
+ SortedSchemaMergeLogic.resolveSortKeyIndices(new IRowMeta[] {streamA,
streamB}, sortKeys);
+
+ SortedSchemaMergeRow left =
+ new SortedSchemaMergeRow(0, null, streamA, new Object[] {"hk-a",
"2024-01-01"});
+ SortedSchemaMergeRow right =
+ new SortedSchemaMergeRow(1, null, streamB, new Object[] {"hk-b",
"2024-01-02"});
+
+ assertTrue(SortedSchemaMergeLogic.compareRows(left, right, sortKeyIndices,
sortKeys) < 0);
+
+ SortedSchemaMergeRow tieLeft =
+ new SortedSchemaMergeRow(0, null, streamA, new Object[] {"hk-same",
"2024-01-01"});
+ SortedSchemaMergeRow tieRight =
+ new SortedSchemaMergeRow(1, null, streamB, new Object[] {"hk-same",
"2024-01-01"});
+
+ assertTrue(SortedSchemaMergeLogic.compareRows(tieLeft, tieRight,
sortKeyIndices, sortKeys) < 0);
+ }
+
+ @Test
+ void compareRowsHonorsDescendingSortKey() throws Exception {
+ IRowMeta layout = row("seq");
+
+ List<SortedSchemaMergeSortKey> sortKeys = List.of(new
SortedSchemaMergeSortKey("seq", false));
+ int[][] sortKeyIndices =
+ SortedSchemaMergeLogic.resolveSortKeyIndices(new IRowMeta[] {layout},
sortKeys);
+
+ SortedSchemaMergeRow low = new SortedSchemaMergeRow(0, null, layout, new
Object[] {1L});
+ SortedSchemaMergeRow high = new SortedSchemaMergeRow(0, null, layout, new
Object[] {2L});
+
+ assertTrue(SortedSchemaMergeLogic.compareRows(high, low, sortKeyIndices,
sortKeys) < 0);
+ }
+
+ private static IRowMeta row(String... names) {
+ IRowMeta rowMeta = new RowMeta();
+ for (String name : names) {
+ if ("seq".equals(name)) {
+ rowMeta.addValueMeta(new ValueMetaInteger(name));
+ } else {
+ rowMeta.addValueMeta(new ValueMetaString(name));
+ }
+ }
+ return rowMeta;
+ }
+}