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 b34f0f4a9e Issue #3219 : Add Binary File Output transform (#7707)
b34f0f4a9e is described below
commit b34f0f4a9ee104231c13866bdabf7e31bd015d57
Author: Matt Casters <[email protected]>
AuthorDate: Fri Jul 31 09:05:22 2026 +0200
Issue #3219 : Add Binary File Output transform (#7707)
* Issue #3219 : Add Binary File Output transform
Write raw binary field content to a file with the filename taken from
an input field so each row can target a different file. This provides a
proper alternative to Text File Output workarounds for BLOB/binary
export. Includes GUI, unit tests, integration tests, and documentation.
* issue #3219 spotless
---
assemblies/plugins/pom.xml | 6 +
.../images/transforms/icons/binaryfileoutput.svg | 15 +
docs/hop-user-manual/modules/ROOT/nav.adoc | 1 +
.../modules/ROOT/pages/pipeline/transforms.adoc | 1 +
.../pipeline/transforms/binaryfileoutput.adoc | 71 +++++
.../transforms/0107-binary-file-output-verify.hpl | 253 +++++++++++++++++
.../transforms/0107-binary-file-output.hpl | 156 +++++++++++
.../transforms/files/random-data.binary | Bin 0 -> 1024 bytes
.../transforms/main-0107-binary-file-output.hwf | 138 ++++++++++
plugins/transforms/binaryfileoutput/pom.xml | 32 +++
.../binaryfileoutput/src/assembly/assembly.xml | 46 ++++
.../binaryfileoutput/BinaryFileOutput.java | 203 ++++++++++++++
.../binaryfileoutput/BinaryFileOutputData.java | 35 +++
.../binaryfileoutput/BinaryFileOutputDialog.java | 281 +++++++++++++++++++
.../binaryfileoutput/BinaryFileOutputMeta.java | 170 ++++++++++++
.../src/main/resources/binaryfileoutput.svg | 15 +
.../messages/messages_en_US.properties | 63 +++++
.../src/main/resources/version.xml | 20 ++
.../binaryfileoutput/BinaryFileOutputMetaTest.java | 62 +++++
.../binaryfileoutput/BinaryFileOutputTest.java | 304 +++++++++++++++++++++
plugins/transforms/pom.xml | 1 +
21 files changed, 1873 insertions(+)
diff --git a/assemblies/plugins/pom.xml b/assemblies/plugins/pom.xml
index a30482a610..af5781ff32 100644
--- a/assemblies/plugins/pom.xml
+++ b/assemblies/plugins/pom.xml
@@ -740,6 +740,12 @@
<version>${project.version}</version>
<type>zip</type>
</dependency>
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-transform-binaryfileoutput</artifactId>
+ <version>${project.version}</version>
+ <type>zip</type>
+ </dependency>
<dependency>
<groupId>org.apache.hop</groupId>
<artifactId>hop-transform-blockingtransform</artifactId>
diff --git
a/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/binaryfileoutput.svg
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/binaryfileoutput.svg
new file mode 100644
index 0000000000..15bfc66077
--- /dev/null
+++
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/binaryfileoutput.svg
@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="42px" height="42px" viewBox="0
0 42 42">
+ <!-- Binary File Output: document with binary-style dashes and export
arrow -->
+ <polygon fill="#0E3A5A" points="9.148,5.794 9.148,8.877 3.923,3.652
2.579,4.996 7.805,10.221 4.659,10.221 4.659,12.122
+ 11.049,12.121 11.049,5.794 "/>
+ <path fill="#C9E8FB"
d="M11.468,33.541h17.694l-0.058-20.152h-4.257V9.131h-6.251l-7.128,7.128V33.541z"/>
+ <polygon fill="#0E3A5A" points="30.867,35.24 30.805,13.398 29.095,11.689
26.547,11.689 26.547,9.141 24.837,7.431 20.295,7.431
+ 18.596,9.131 24.847,9.131 24.847,13.388 29.104,13.388
29.162,33.541 11.468,33.541 11.468,16.258 9.768,17.959 9.768,35.24 "/>
+ <!-- binary-looking content bars -->
+ <rect x="14.365" y="17.552" fill="#0E3A5A" width="11.902" height="0.801"/>
+ <rect x="14.365" y="20.935" fill="#0E3A5A" width="7.5" height="0.801"/>
+ <rect x="14.365" y="24.318" fill="#0E3A5A" width="11.902" height="0.801"/>
+ <rect x="14.365" y="27.701" fill="#0E3A5A" width="5.2" height="0.801"/>
+ <!-- small binary indicator (1/0 style) -->
+ <circle fill="#5CC0C4" cx="27.2" cy="28.5" r="1.2"/>
+</svg>
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index ba8f2e6e25..4c459403d3 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -95,6 +95,7 @@ under the License.
*** xref:pipeline/transforms/beamgcpsubscriber.adoc[Beam GCP Pub/Sub:
subscribe]
*** xref:pipeline/transforms/beamkafkaconsume.adoc[Beam Kafka consume]
*** xref:pipeline/transforms/beamkafkaproduce.adoc[Beam Kafka produce]
+*** xref:pipeline/transforms/binaryfileoutput.adoc[Binary File Output]
*** xref:pipeline/transforms/beamkinesisconsume.adoc[Beam Kinesis consume]
*** xref:pipeline/transforms/beamkinesisproduce.adoc[Beam Kinesis produce]
*** xref:pipeline/transforms/beamtimestamp.adoc[Beam timestamp]
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 ea18f1b1fe..c4f117eec5 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
@@ -49,6 +49,7 @@ The pages nested under this topic contain information on how
to use the transfor
* xref:pipeline/transforms/beamfileoutput.adoc[Beam file output]
* xref:pipeline/transforms/beamkafkaconsume.adoc[Beam Kafka consume]
* xref:pipeline/transforms/beamkafkaproduce.adoc[Beam Kafka produce]
+* xref:pipeline/transforms/binaryfileoutput.adoc[Binary File Output]
* xref:pipeline/transforms/beamkinesisconsume.adoc[Beam Kinesis consume]
* xref:pipeline/transforms/beamkinesisproduce.adoc[Beam Kinesis produce]
* xref:pipeline/transforms/beamtimestamp.adoc[Beam timestamp]
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/binaryfileoutput.adoc
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/binaryfileoutput.adoc
new file mode 100644
index 0000000000..1a7a752bb3
--- /dev/null
+++
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/binaryfileoutput.adoc
@@ -0,0 +1,71 @@
+////
+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 Binary File Output transform writes a binary field to a
file. The filename is taken from an input field so each row can write a
different file.
+
+= image:transforms/icons/binaryfileoutput.svg[Binary File Output transform
Icon, role="image-doc-icon"] Binary File Output
+
+[%noheader,cols="3a,1a", role="table-no-borders" ]
+|===
+|
+== Description
+
+The Binary File Output transform writes raw binary content from a stream field
to a file.
+
+Use this transform when you need a byte-for-byte write of BLOB/binary data,
for example:
+
+* Exporting a database BLOB column to individual files
+* Saving content loaded with xref:pipeline/transforms/loadfileinput.adoc[Load
file content in memory]
+* Writing binary payloads received from HTTP/REST transforms
+
+The target filename is read from an input field, so every row can be written
to a different file.
+
+TIP: Prefer this transform over
xref:pipeline/transforms/textfileoutput.adoc[Text File Output] for binary data.
Text File Output applies text encoding and formatting and can corrupt raw bytes.
+
+|
+== Supported Engines
+[%noheader,cols="2,1a",frame=none, role="table-supported-engines"]
+!===
+!Hop Engine! image:check_mark.svg[Supported, 24]
+!Single Threaded! image:check_mark.svg[Supported, 24]
+!Native Spark! image:question_mark.svg[Maybe Supported, 24]
+!Beam Spark! image:question_mark.svg[Maybe Supported, 24]
+!Beam Flink! image:question_mark.svg[Maybe Supported, 24]
+!Beam Dataflow! image:question_mark.svg[Maybe Supported, 24]
+!===
+|===
+
+== Options
+
+[options="header"]
+|===
+|Option|Description
+|Transform name|Name of the transform. This name has to be unique in a single
pipeline.
+|Binary field|The input field that contains the binary content to write. A
Binary data type is preferred.
+|Filename field|The input field that contains the target filename (local path
or VFS URI) for the current row.
+|Create parent folder|Create the parent folder of the target file when it does
not exist.
+|Overwrite existing file|Overwrite the target file when it already exists.
When disabled, an existing file causes an error for that row.
+|Add filename to result|Add written filenames to the list of result filenames
that can be used by the next workflow action.
+|===
+
+== Notes
+
+* Null binary content fails the row (or is sent to the error hop when error
handling is configured).
+* An empty binary value (`0` bytes) writes an empty file.
+* No character encoding is applied: bytes are written exactly as stored in the
field.
+* Supports transform error handling so failed rows can be routed without
stopping the pipeline.
diff --git a/integration-tests/transforms/0107-binary-file-output-verify.hpl
b/integration-tests/transforms/0107-binary-file-output-verify.hpl
new file mode 100644
index 0000000000..8c2ca3369d
--- /dev/null
+++ b/integration-tests/transforms/0107-binary-file-output-verify.hpl
@@ -0,0 +1,253 @@
+<?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>
+ <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>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>-1</pipeline_status>
+ <parameters/>
+ <name>New pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/07/30 22:17:24.432</created_date>
+ <modified_date>2026/07/30 22:17:24.432</modified_date>
+ </info>
+ <transform>
+ <type>LoadFileInput</type>
+ <name>random-data.binary</name>
+ <include>N</include>
+ <include_field/>
+ <rownum>Y</rownum>
+ <rownum_field>nr</rownum_field>
+ <limit>0</limit>
+ <encoding/>
+ <DynamicFilenameField/>
+ <IsInFields>N</IsInFields>
+ <addresultfile>Y</addresultfile>
+ <IsIgnoreEmptyFile>N</IsIgnoreEmptyFile>
+ <IsIgnoreMissingPath>N</IsIgnoreMissingPath>
+ <shortFileFieldName/>
+ <extensionFieldName/>
+ <pathFieldName/>
+ <hiddenFieldName/>
+ <lastModificationTimeFieldName/>
+ <uriNameFieldName/>
+ <rootUriNameFieldName/>
+ <files>
+ <file>
+ <name>${PROJECT_HOME}/files/random-data.binary</name>
+ <filemask/>
+ <exclude_filemask/>
+ <file_required>N</file_required>
+ <include_subfolders>N</include_subfolders>
+ </file>
+ </files>
+ <fields>
+ <field>
+ <name>binary</name>
+ <type>Binary</type>
+ <length>-1</length>
+ <format/>
+ <trim_type>none</trim_type>
+ <element_type>content</element_type>
+ <precision>-1</precision>
+ <currency/>
+ <decimal/>
+ <group/>
+ <repeat>N</repeat>
+ </field>
+ </fields>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>224</xloc>
+ <yloc>128</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>LoadFileInput</type>
+ <name>random-copy.binary</name>
+ <include>N</include>
+ <include_field/>
+ <rownum>Y</rownum>
+ <rownum_field>nr</rownum_field>
+ <limit>0</limit>
+ <encoding/>
+ <DynamicFilenameField/>
+ <IsInFields>N</IsInFields>
+ <addresultfile>Y</addresultfile>
+ <IsIgnoreEmptyFile>N</IsIgnoreEmptyFile>
+ <IsIgnoreMissingPath>N</IsIgnoreMissingPath>
+ <shortFileFieldName/>
+ <extensionFieldName/>
+ <pathFieldName/>
+ <hiddenFieldName/>
+ <lastModificationTimeFieldName/>
+ <uriNameFieldName/>
+ <rootUriNameFieldName/>
+ <files>
+ <file>
+ <name>${PROJECT_HOME}/files/random-copy.binary</name>
+ <filemask/>
+ <exclude_filemask/>
+ <file_required>N</file_required>
+ <include_subfolders>N</include_subfolders>
+ </file>
+ </files>
+ <fields>
+ <field>
+ <name>binary</name>
+ <type>Binary</type>
+ <length>-1</length>
+ <format/>
+ <trim_type>none</trim_type>
+ <element_type>content</element_type>
+ <precision>-1</precision>
+ <currency/>
+ <decimal/>
+ <group/>
+ <repeat>N</repeat>
+ </field>
+ </fields>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>224</xloc>
+ <yloc>240</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>MergeRows</type>
+ <name>Merge rows (diff)</name>
+ <flag_field>flagfield</flag_field>
+ <keys>
+ <key>nr</key>
+ </keys>
+ <values>
+ <value>binary</value>
+ </values>
+ <reference>random-data.binary</reference>
+ <compare>random-copy.binary</compare>
+ <diff-field/>
+ <align_input_layouts>Y</align_input_layouts>
+ <passthrough-fields/>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>384</xloc>
+ <yloc>192</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>FilterRows</type>
+ <name>not identical?</name>
+ <compare>
+ <condition>
+ <negated>N</negated>
+ <operator>-</operator>
+ <leftvalue>flagfield</leftvalue>
+ <function><></function>
+ <value>
+ <name>constant</name>
+ <type>String</type>
+ <text>identical</text>
+ <length>-1</length>
+ <precision>-1</precision>
+ <isnull>N</isnull>
+ <mask/>
+ </value>
+ <conditions/>
+ </condition>
+ </compare>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>528</xloc>
+ <yloc>192</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>Abort</type>
+ <name>Error: not identical</name>
+ <row_threshold>0</row_threshold>
+ <message>The binary file output didn't do its job! The files are not
identical!</message>
+ <always_log_rows>Y</always_log_rows>
+ <abort_option>ABORT_WITH_ERROR</abort_option>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>656</xloc>
+ <yloc>192</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <order>
+ <hop>
+ <from>random-data.binary</from>
+ <to>Merge rows (diff)</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>random-copy.binary</from>
+ <to>Merge rows (diff)</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>Merge rows (diff)</from>
+ <to>not identical?</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>not identical?</from>
+ <to>Error: not identical</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <notepads/>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/integration-tests/transforms/0107-binary-file-output.hpl
b/integration-tests/transforms/0107-binary-file-output.hpl
new file mode 100644
index 0000000000..57de3fd5e7
--- /dev/null
+++ b/integration-tests/transforms/0107-binary-file-output.hpl
@@ -0,0 +1,156 @@
+<?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>
+ <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>
+ <pipeline_type>Normal</pipeline_type>
+ <pipeline_status>-1</pipeline_status>
+ <parameters/>
+ <name>New pipeline</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/07/30 22:12:59.055</created_date>
+ <modified_date>2026/07/30 22:12:59.055</modified_date>
+ </info>
+ <transform>
+ <type>LoadFileInput</type>
+ <name>Load file content in memory</name>
+ <include>Y</include>
+ <include_field>filename</include_field>
+ <rownum>N</rownum>
+ <rownum_field/>
+ <limit>0</limit>
+ <encoding/>
+ <DynamicFilenameField/>
+ <IsInFields>N</IsInFields>
+ <addresultfile>Y</addresultfile>
+ <IsIgnoreEmptyFile>N</IsIgnoreEmptyFile>
+ <IsIgnoreMissingPath>N</IsIgnoreMissingPath>
+ <shortFileFieldName/>
+ <extensionFieldName/>
+ <pathFieldName/>
+ <hiddenFieldName/>
+ <lastModificationTimeFieldName/>
+ <uriNameFieldName/>
+ <rootUriNameFieldName/>
+ <files>
+ <file>
+ <name>${PROJECT_HOME}/files/random-data.binary</name>
+ <filemask/>
+ <exclude_filemask/>
+ <file_required>N</file_required>
+ <include_subfolders>N</include_subfolders>
+ </file>
+ </files>
+ <fields>
+ <field>
+ <name>random-data</name>
+ <type>Binary</type>
+ <length>-1</length>
+ <format/>
+ <trim_type>none</trim_type>
+ <element_type>content</element_type>
+ <precision>-1</precision>
+ <currency/>
+ <decimal/>
+ <group/>
+ <repeat>N</repeat>
+ </field>
+ </fields>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>208</xloc>
+ <yloc>112</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>BinaryFileOutput</type>
+ <name>Binary File Output</name>
+ <binaryfield>random-data</binaryfield>
+ <filenamefield>filename</filenamefield>
+ <createparentfolder>Y</createparentfolder>
+ <overwritefile>Y</overwritefile>
+ <addresultfilenames>N</addresultfilenames>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>544</xloc>
+ <yloc>112</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <transform>
+ <type>ReplaceString</type>
+ <name>random-copy.binary</name>
+ <fields>
+ <field>
+ <in_stream_name>filename</in_stream_name>
+ <out_stream_name/>
+ <use_regex>N</use_regex>
+ <replace_string>random-data</replace_string>
+ <replace_by_string>random-copy</replace_by_string>
+ <set_empty_string>N</set_empty_string>
+ <replace_field_by_string/>
+ <whole_word>N</whole_word>
+ <case_sensitive>N</case_sensitive>
+ <is_unicode>N</is_unicode>
+ </field>
+ </fields>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <GUI>
+ <xloc>384</xloc>
+ <yloc>112</yloc>
+ </GUI>
+ <partitioning>
+ <method>none</method>
+ <schema_name/>
+ </partitioning>
+ <attributes/>
+ </transform>
+ <order>
+ <hop>
+ <from>Load file content in memory</from>
+ <to>random-copy.binary</to>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>random-copy.binary</from>
+ <to>Binary File Output</to>
+ <enabled>Y</enabled>
+ </hop>
+ </order>
+ <notepads/>
+ <attributes/>
+ <transform_error_handling/>
+</pipeline>
diff --git a/integration-tests/transforms/files/random-data.binary
b/integration-tests/transforms/files/random-data.binary
new file mode 100644
index 0000000000..956b4c67e1
Binary files /dev/null and
b/integration-tests/transforms/files/random-data.binary differ
diff --git a/integration-tests/transforms/main-0107-binary-file-output.hwf
b/integration-tests/transforms/main-0107-binary-file-output.hwf
new file mode 100644
index 0000000000..5960f52640
--- /dev/null
+++ b/integration-tests/transforms/main-0107-binary-file-output.hwf
@@ -0,0 +1,138 @@
+<?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>New workflow</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <created_user>-</created_user>
+ <modified_user>-</modified_user>
+ <created_date>2026/07/30 22:12:21.141</created_date>
+ <modified_date>2026/07/30 22:12:21.141</modified_date>
+ <parameters/>
+ <actions>
+ <action>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <intervalSeconds>0</intervalSeconds>
+ <intervalMinutes>60</intervalMinutes>
+ <DayOfMonth>1</DayOfMonth>
+ <weekDay>1</weekDay>
+ <minutes>0</minutes>
+ <hour>12</hour>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <xloc>112</xloc>
+ <yloc>80</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename>${PROJECT_HOME}/0107-binary-file-output.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <clear_rows>N</clear_rows>
+ <clear_files>N</clear_files>
+ <create_parent_folder>N</create_parent_folder>
+ <set_logfile>N</set_logfile>
+ <set_append_logfile>N</set_append_logfile>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <run_configuration>local</run_configuration>
+ <name>0107-binary-file-output</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <xloc>288</xloc>
+ <yloc>80</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename>${PROJECT_HOME}/0107-binary-file-output-verify.hpl</filename>
+ <params_from_previous>N</params_from_previous>
+ <exec_per_row>N</exec_per_row>
+ <clear_rows>N</clear_rows>
+ <clear_files>N</clear_files>
+ <create_parent_folder>N</create_parent_folder>
+ <set_logfile>N</set_logfile>
+ <set_append_logfile>N</set_append_logfile>
+ <add_date>N</add_date>
+ <add_time>N</add_time>
+ <loglevel>Basic</loglevel>
+ <wait_until_finished>Y</wait_until_finished>
+ <parameters>
+ <pass_all_parameters>Y</pass_all_parameters>
+ </parameters>
+ <run_configuration>local</run_configuration>
+ <name>0107-binary-file-output-verify</name>
+ <description/>
+ <type>PIPELINE</type>
+ <attributes/>
+ <xloc>512</xloc>
+ <yloc>80</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ <action>
+ <filename>${PROJECT_HOME}/files/random-copy.binary</filename>
+ <fail_if_file_not_exists>Y</fail_if_file_not_exists>
+ <name>Delete files/random-copy.binary</name>
+ <description/>
+ <type>DELETE_FILE</type>
+ <attributes/>
+ <xloc>736</xloc>
+ <yloc>80</yloc>
+ <parallel>N</parallel>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>0107-binary-file-output</to>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>0107-binary-file-output</from>
+ <to>0107-binary-file-output-verify</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ <hop>
+ <from>0107-binary-file-output-verify</from>
+ <to>Delete files/random-copy.binary</to>
+ <evaluation>Y</evaluation>
+ <unconditional>N</unconditional>
+ <enabled>Y</enabled>
+ </hop>
+ </hops>
+ <notepads/>
+ <attributes/>
+</workflow>
diff --git a/plugins/transforms/binaryfileoutput/pom.xml
b/plugins/transforms/binaryfileoutput/pom.xml
new file mode 100644
index 0000000000..aa355f79b9
--- /dev/null
+++ b/plugins/transforms/binaryfileoutput/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-binaryfileoutput</artifactId>
+ <packaging>jar</packaging>
+ <name>Hop Plugins Transforms Binary File Output</name>
+
+</project>
diff --git a/plugins/transforms/binaryfileoutput/src/assembly/assembly.xml
b/plugins/transforms/binaryfileoutput/src/assembly/assembly.xml
new file mode 100644
index 0000000000..a76174873e
--- /dev/null
+++ b/plugins/transforms/binaryfileoutput/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-binaryfileoutput</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>
diff --git
a/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutput.java
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutput.java
new file mode 100644
index 0000000000..c42552521d
--- /dev/null
+++
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutput.java
@@ -0,0 +1,203 @@
+/*
+ * 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.binaryfileoutput;
+
+import java.io.OutputStream;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.ResultFile;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.lineage.LineageFileIoEmitter;
+import org.apache.hop.lineage.model.FileIoOperation;
+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;
+
+/** Write raw binary field content to a file. Filename is taken from an input
field per row. */
+public class BinaryFileOutput extends BaseTransform<BinaryFileOutputMeta,
BinaryFileOutputData> {
+ private static final Class<?> PKG = BinaryFileOutputMeta.class;
+
+ public BinaryFileOutput(
+ TransformMeta transformMeta,
+ BinaryFileOutputMeta meta,
+ BinaryFileOutputData data,
+ int copyNr,
+ PipelineMeta pipelineMeta,
+ Pipeline pipeline) {
+ super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+ }
+
+ @Override
+ public boolean processRow() throws HopException {
+ Object[] r = getRow();
+ if (r == null) {
+ setOutputDone();
+ return false;
+ }
+
+ if (first) {
+ first = false;
+ processFirst();
+ }
+
+ try {
+ writeBinaryFile(r);
+ putRow(data.outputRowMeta, r);
+ incrementLinesOutput();
+
+ if (checkFeedback(getLinesRead()) && isBasic()) {
+ logBasic(
+ BaseMessages.getString(
+ PKG, "BinaryFileOutput.LineNumber",
Long.toString(getLinesRead())));
+ }
+ } catch (Exception e) {
+ if (getTransformMeta().isDoingErrorHandling()) {
+ putError(data.outputRowMeta, r, 1L, e.toString(), null,
"BINARYFILEOUTPUT001");
+ } else {
+ logError(
+ BaseMessages.getString(PKG,
"BinaryFileOutput.ErrorInTransformRunning", e.getMessage()),
+ e);
+ setErrors(1);
+ stopAll();
+ setOutputDone();
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private void processFirst() throws HopException {
+ data.outputRowMeta = getInputRowMeta().clone();
+
+ if (Utils.isEmpty(meta.getBinaryField())) {
+ throw new HopException(
+ BaseMessages.getString(PKG,
"BinaryFileOutput.Error.BinaryFieldMissing"));
+ }
+ if (Utils.isEmpty(meta.getFilenameField())) {
+ throw new HopException(
+ BaseMessages.getString(PKG,
"BinaryFileOutput.Error.FilenameFieldMissing"));
+ }
+
+ data.indexOfBinaryField =
getInputRowMeta().indexOfValue(meta.getBinaryField());
+ if (data.indexOfBinaryField < 0) {
+ throw new HopException(
+ BaseMessages.getString(
+ PKG, "BinaryFileOutput.Exception.CouldnotFindField",
meta.getBinaryField()));
+ }
+
+ data.indexOfFilenameField =
getInputRowMeta().indexOfValue(meta.getFilenameField());
+ if (data.indexOfFilenameField < 0) {
+ throw new HopException(
+ BaseMessages.getString(
+ PKG, "BinaryFileOutput.Exception.CouldnotFindField",
meta.getFilenameField()));
+ }
+ }
+
+ private void writeBinaryFile(Object[] r) throws HopException {
+ String filename = getInputRowMeta().getString(r,
data.indexOfFilenameField);
+ if (Utils.isEmpty(filename)) {
+ throw new HopException(BaseMessages.getString(PKG,
"BinaryFileOutput.Error.FilenameEmpty"));
+ }
+
+ byte[] content = getInputRowMeta().getBinary(r, data.indexOfBinaryField);
+ if (content == null) {
+ throw new HopException(BaseMessages.getString(PKG,
"BinaryFileOutput.Error.BinaryNull"));
+ }
+
+ try (FileObject file = HopVfs.getFileObject(filename, variables)) {
+ ensureParentFolder(file);
+ if (file.exists() && !meta.isOverwriteFile()) {
+ throw new HopException(
+ BaseMessages.getString(PKG, "BinaryFileOutput.Error.FileExists",
filename));
+ }
+
+ try (OutputStream out = HopVfs.getOutputStream(file, false)) {
+ out.write(content);
+ }
+
+ long written = content.length;
+ dataVolumeOut = (dataVolumeOut != null ? dataVolumeOut : 0L) + written;
+
+ if (!data.isBeamContext() && written > 0) {
+ try {
+ LineageFileIoEmitter.emitTransformFileIo(
+ this, FileIoOperation.WRITE, null, file, written, true, null);
+ } catch (Exception ignored) {
+ // optional lineage
+ }
+ }
+
+ if (meta.isAddResultFilenames()) {
+ ResultFile resultFile =
+ new ResultFile(
+ ResultFile.FILE_TYPE_GENERAL,
+ file,
+ getPipelineMeta().getName(),
+ getTransformName());
+ resultFile.setComment(BaseMessages.getString(PKG,
"BinaryFileOutput.Log.FileAddedResult"));
+ addResultFile(resultFile);
+ if (isDetailed()) {
+ logDetailed(
+ BaseMessages.getString(
+ PKG, "BinaryFileOutput.Log.FilenameAddResult",
file.toString()));
+ }
+ }
+
+ if (isDetailed()) {
+ logDetailed(
+ BaseMessages.getString(
+ PKG, "BinaryFileOutput.Log.FileWritten",
Long.toString(written), filename));
+ }
+ } catch (HopException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new HopException(
+ BaseMessages.getString(
+ PKG, "BinaryFileOutput.Error.WriteFailed", filename,
e.getMessage()),
+ e);
+ }
+ }
+
+ private void ensureParentFolder(FileObject file) throws Exception {
+ FileObject parent = file.getParent();
+ if (parent == null) {
+ return;
+ }
+ try {
+ if (!parent.exists()) {
+ if (!meta.isCreateParentFolder()) {
+ throw new HopException(
+ BaseMessages.getString(
+ PKG, "BinaryFileOutput.Error.ParentFolderNotExists",
parent.toString()));
+ }
+ parent.createFolder();
+ if (isDetailed()) {
+ logDetailed(
+ BaseMessages.getString(
+ PKG, "BinaryFileOutput.Log.ParentFolderCreated",
parent.toString()));
+ }
+ }
+ } finally {
+ parent.close();
+ }
+ }
+}
diff --git
a/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputData.java
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputData.java
new file mode 100644
index 0000000000..afcd84df9c
--- /dev/null
+++
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputData.java
@@ -0,0 +1,35 @@
+/*
+ * 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.binaryfileoutput;
+
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.pipeline.transform.BaseTransformData;
+import org.apache.hop.pipeline.transform.ITransformData;
+
+@SuppressWarnings("java:S1104")
+public class BinaryFileOutputData extends BaseTransformData implements
ITransformData {
+ public int indexOfBinaryField;
+ public int indexOfFilenameField;
+ public IRowMeta outputRowMeta;
+
+ public BinaryFileOutputData() {
+ super();
+ indexOfBinaryField = -1;
+ indexOfFilenameField = -1;
+ }
+}
diff --git
a/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputDialog.java
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputDialog.java
new file mode 100644
index 0000000000..6c01ac30c5
--- /dev/null
+++
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputDialog.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.binaryfileoutput;
+
+import org.apache.hop.core.exception.HopException;
+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.pipeline.PipelineMeta;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CCombo;
+import org.eclipse.swt.events.FocusEvent;
+import org.eclipse.swt.events.FocusListener;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.graphics.Cursor;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+
+public class BinaryFileOutputDialog extends BaseTransformDialog {
+ private static final Class<?> PKG = BinaryFileOutputMeta.class;
+
+ private CCombo wBinaryField;
+ private CCombo wFilenameField;
+ private Button wCreateParentFolder;
+ private Button wOverwriteFile;
+ private Button wAddResult;
+
+ private final BinaryFileOutputMeta input;
+ private boolean gotPreviousFields;
+
+ public BinaryFileOutputDialog(
+ Shell parent,
+ IVariables variables,
+ BinaryFileOutputMeta transformMeta,
+ PipelineMeta pipelineMeta) {
+ super(parent, variables, transformMeta, pipelineMeta);
+ input = transformMeta;
+ }
+
+ @Override
+ public String open() {
+ createShell(BaseMessages.getString(PKG,
"BinaryFileOutputDialog.Shell.Title"));
+
+ buildButtonBar().ok(e -> ok()).cancel(e -> cancel()).build();
+
+ ModifyListener lsMod = e -> input.setChanged();
+ SelectionAdapter lsButtonChanged =
+ new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ input.setChanged();
+ }
+ };
+ changed = input.hasChanged();
+
+ Control lastControl = wSpacer;
+
+ // Binary field
+ Label wlBinaryField = new Label(shell, SWT.RIGHT);
+ wlBinaryField.setText(BaseMessages.getString(PKG,
"BinaryFileOutputDialog.BinaryField.Label"));
+ PropsUi.setLook(wlBinaryField);
+ FormData fdlBinaryField = new FormData();
+ fdlBinaryField.left = new FormAttachment(0, 0);
+ fdlBinaryField.right = new FormAttachment(middle, -margin);
+ fdlBinaryField.top = new FormAttachment(lastControl, margin);
+ wlBinaryField.setLayoutData(fdlBinaryField);
+
+ wBinaryField = new CCombo(shell, SWT.BORDER | SWT.READ_ONLY);
+ PropsUi.setLook(wBinaryField);
+ wBinaryField.setToolTipText(
+ BaseMessages.getString(PKG,
"BinaryFileOutputDialog.BinaryField.Tooltip"));
+ wBinaryField.addModifyListener(lsMod);
+ FormData fdBinaryField = new FormData();
+ fdBinaryField.left = new FormAttachment(middle, 0);
+ fdBinaryField.top = new FormAttachment(lastControl, margin);
+ fdBinaryField.right = new FormAttachment(100, 0);
+ wBinaryField.setLayoutData(fdBinaryField);
+ wBinaryField.addFocusListener(fieldFocusListener());
+ lastControl = wBinaryField;
+
+ // Filename field
+ Label wlFilenameField = new Label(shell, SWT.RIGHT);
+ wlFilenameField.setText(
+ BaseMessages.getString(PKG,
"BinaryFileOutputDialog.FilenameField.Label"));
+ PropsUi.setLook(wlFilenameField);
+ FormData fdlFilenameField = new FormData();
+ fdlFilenameField.left = new FormAttachment(0, 0);
+ fdlFilenameField.right = new FormAttachment(middle, -margin);
+ fdlFilenameField.top = new FormAttachment(lastControl, margin);
+ wlFilenameField.setLayoutData(fdlFilenameField);
+
+ wFilenameField = new CCombo(shell, SWT.BORDER | SWT.READ_ONLY);
+ PropsUi.setLook(wFilenameField);
+ wFilenameField.setToolTipText(
+ BaseMessages.getString(PKG,
"BinaryFileOutputDialog.FilenameField.Tooltip"));
+ wFilenameField.addModifyListener(lsMod);
+ FormData fdFilenameField = new FormData();
+ fdFilenameField.left = new FormAttachment(middle, 0);
+ fdFilenameField.top = new FormAttachment(lastControl, margin);
+ fdFilenameField.right = new FormAttachment(100, 0);
+ wFilenameField.setLayoutData(fdFilenameField);
+ wFilenameField.addFocusListener(fieldFocusListener());
+ lastControl = wFilenameField;
+
+ // Create parent folder
+ Label wlCreateParentFolder = new Label(shell, SWT.RIGHT);
+ wlCreateParentFolder.setText(
+ BaseMessages.getString(PKG,
"BinaryFileOutputDialog.CreateParentFolder.Label"));
+ PropsUi.setLook(wlCreateParentFolder);
+ FormData fdlCreateParentFolder = new FormData();
+ fdlCreateParentFolder.left = new FormAttachment(0, 0);
+ fdlCreateParentFolder.right = new FormAttachment(middle, -margin);
+ fdlCreateParentFolder.top = new FormAttachment(lastControl, margin);
+ wlCreateParentFolder.setLayoutData(fdlCreateParentFolder);
+
+ wCreateParentFolder = new Button(shell, SWT.CHECK);
+ PropsUi.setLook(wCreateParentFolder);
+ wCreateParentFolder.setToolTipText(
+ BaseMessages.getString(PKG,
"BinaryFileOutputDialog.CreateParentFolder.Tooltip"));
+ wCreateParentFolder.addSelectionListener(lsButtonChanged);
+ FormData fdCreateParentFolder = new FormData();
+ fdCreateParentFolder.left = new FormAttachment(middle, 0);
+ fdCreateParentFolder.top = new FormAttachment(wlCreateParentFolder, 0,
SWT.CENTER);
+ wCreateParentFolder.setLayoutData(fdCreateParentFolder);
+ lastControl = wlCreateParentFolder;
+
+ // Overwrite existing file
+ Label wlOverwriteFile = new Label(shell, SWT.RIGHT);
+ wlOverwriteFile.setText(BaseMessages.getString(PKG,
"BinaryFileOutputDialog.Overwrite.Label"));
+ PropsUi.setLook(wlOverwriteFile);
+ FormData fdlOverwriteFile = new FormData();
+ fdlOverwriteFile.left = new FormAttachment(0, 0);
+ fdlOverwriteFile.right = new FormAttachment(middle, -margin);
+ fdlOverwriteFile.top = new FormAttachment(lastControl, margin);
+ wlOverwriteFile.setLayoutData(fdlOverwriteFile);
+
+ wOverwriteFile = new Button(shell, SWT.CHECK);
+ PropsUi.setLook(wOverwriteFile);
+ wOverwriteFile.setToolTipText(
+ BaseMessages.getString(PKG,
"BinaryFileOutputDialog.Overwrite.Tooltip"));
+ wOverwriteFile.addSelectionListener(lsButtonChanged);
+ FormData fdOverwriteFile = new FormData();
+ fdOverwriteFile.left = new FormAttachment(middle, 0);
+ fdOverwriteFile.top = new FormAttachment(wlOverwriteFile, 0, SWT.CENTER);
+ wOverwriteFile.setLayoutData(fdOverwriteFile);
+ lastControl = wlOverwriteFile;
+
+ // Add filename to result
+ Label wlAddResult = new Label(shell, SWT.RIGHT);
+ wlAddResult.setText(BaseMessages.getString(PKG,
"BinaryFileOutputDialog.AddResult.Label"));
+ PropsUi.setLook(wlAddResult);
+ FormData fdlAddResult = new FormData();
+ fdlAddResult.left = new FormAttachment(0, 0);
+ fdlAddResult.right = new FormAttachment(middle, -margin);
+ fdlAddResult.top = new FormAttachment(lastControl, margin);
+ wlAddResult.setLayoutData(fdlAddResult);
+
+ wAddResult = new Button(shell, SWT.CHECK);
+ PropsUi.setLook(wAddResult);
+ wAddResult.setToolTipText(
+ BaseMessages.getString(PKG,
"BinaryFileOutputDialog.AddResult.Tooltip"));
+ wAddResult.addSelectionListener(lsButtonChanged);
+ FormData fdAddResult = new FormData();
+ fdAddResult.left = new FormAttachment(middle, 0);
+ fdAddResult.top = new FormAttachment(wlAddResult, 0, SWT.CENTER);
+ wAddResult.setLayoutData(fdAddResult);
+
+ getData();
+ input.setChanged(changed);
+ focusTransformName();
+ BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+
+ return transformName;
+ }
+
+ private FocusListener fieldFocusListener() {
+ return new FocusListener() {
+ @Override
+ public void focusLost(FocusEvent e) {
+ // Do nothing
+ }
+
+ @Override
+ public void focusGained(FocusEvent e) {
+ Cursor busy = new Cursor(shell.getDisplay(), SWT.CURSOR_WAIT);
+ shell.setCursor(busy);
+ getFields();
+ shell.setCursor(null);
+ busy.dispose();
+ }
+ };
+ }
+
+ public void getData() {
+ if (input.getBinaryField() != null) {
+ wBinaryField.setText(input.getBinaryField());
+ }
+ if (input.getFilenameField() != null) {
+ wFilenameField.setText(input.getFilenameField());
+ }
+ wCreateParentFolder.setSelection(input.isCreateParentFolder());
+ wOverwriteFile.setSelection(input.isOverwriteFile());
+ wAddResult.setSelection(input.isAddResultFilenames());
+ }
+
+ private void cancel() {
+ transformName = null;
+ input.setChanged(changed);
+ dispose();
+ }
+
+ private void ok() {
+ if (Utils.isEmpty(wTransformName.getText())) {
+ return;
+ }
+ input.setBinaryField(wBinaryField.getText());
+ input.setFilenameField(wFilenameField.getText());
+ input.setCreateParentFolder(wCreateParentFolder.getSelection());
+ input.setOverwriteFile(wOverwriteFile.getSelection());
+ input.setAddResultFilenames(wAddResult.getSelection());
+ transformName = wTransformName.getText();
+ dispose();
+ }
+
+ private void getFields() {
+ if (gotPreviousFields) {
+ return;
+ }
+ try {
+ String binaryValue = wBinaryField.getText();
+ String filenameValue = wFilenameField.getText();
+ wBinaryField.removeAll();
+ wFilenameField.removeAll();
+ IRowMeta r = pipelineMeta.getPrevTransformFields(variables,
transformName);
+ if (r != null) {
+ String[] fields = r.getFieldNames();
+ wBinaryField.setItems(fields);
+ wFilenameField.setItems(fields);
+ }
+ if (binaryValue != null) {
+ wBinaryField.setText(binaryValue);
+ }
+ if (filenameValue != null) {
+ wFilenameField.setText(filenameValue);
+ }
+ gotPreviousFields = true;
+ } catch (HopException ke) {
+ new ErrorDialog(
+ shell,
+ BaseMessages.getString(PKG,
"BinaryFileOutputDialog.FailedToGetFields.DialogTitle"),
+ BaseMessages.getString(PKG,
"BinaryFileOutputDialog.FailedToGetFields.DialogMessage"),
+ ke);
+ }
+ }
+}
diff --git
a/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMeta.java
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMeta.java
new file mode 100644
index 0000000000..b38697d6cf
--- /dev/null
+++
b/plugins/transforms/binaryfileoutput/src/main/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMeta.java
@@ -0,0 +1,170 @@
+/*
+ * 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.binaryfileoutput;
+
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.hop.core.CheckResult;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.annotations.Transform;
+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.transform.BaseTransformMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+
+@Transform(
+ id = "BinaryFileOutput",
+ image = "binaryfileoutput.svg",
+ name = "i18n::BinaryFileOutput.Name",
+ description = "i18n::BinaryFileOutput.Description",
+ categoryDescription =
"i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Output",
+ keywords = "i18n::BinaryFileOutputMeta.keyword",
+ documentationUrl = "/pipeline/transforms/binaryfileoutput.html")
+@Getter
+@Setter
+public class BinaryFileOutputMeta
+ extends BaseTransformMeta<BinaryFileOutput, BinaryFileOutputData> {
+ private static final Class<?> PKG = BinaryFileOutputMeta.class;
+
+ /** Field containing the binary content to write */
+ @HopMetadataProperty(
+ key = "binaryfield",
+ injectionKey = "BINARY_FIELD",
+ injectionKeyDescription = "BinaryFileOutput.Injection.BinaryField")
+ private String binaryField;
+
+ /** Field containing the target filename */
+ @HopMetadataProperty(
+ key = "filenamefield",
+ injectionKey = "FILENAME_FIELD",
+ injectionKeyDescription = "BinaryFileOutput.Injection.FilenameField")
+ private String filenameField;
+
+ /** Create parent folder if it does not exist */
+ @HopMetadataProperty(
+ key = "createparentfolder",
+ injectionKey = "CREATE_PARENT_FOLDER",
+ injectionKeyDescription =
"BinaryFileOutput.Injection.CreateParentFolder")
+ private boolean createParentFolder;
+
+ /** Overwrite the target file when it already exists */
+ @HopMetadataProperty(
+ key = "overwritefile",
+ injectionKey = "OVERWRITE_FILE",
+ injectionKeyDescription = "BinaryFileOutput.Injection.OverwriteFile")
+ private boolean overwriteFile;
+
+ /** Add written filenames to the result filenames */
+ @HopMetadataProperty(
+ key = "addresultfilenames",
+ injectionKey = "ADD_RESULT_FILENAMES",
+ injectionKeyDescription =
"BinaryFileOutput.Injection.AddResultFilenames")
+ private boolean addResultFilenames;
+
+ public BinaryFileOutputMeta() {
+ super();
+ }
+
+ @Override
+ public Object clone() {
+ return super.clone();
+ }
+
+ @Override
+ public void setDefault() {
+ binaryField = null;
+ filenameField = null;
+ createParentFolder = true;
+ overwriteFile = true;
+ addResultFilenames = false;
+ }
+
+ @Override
+ public void check(
+ List<ICheckResult> remarks,
+ PipelineMeta pipelineMeta,
+ TransformMeta transformMeta,
+ IRowMeta prev,
+ String[] input,
+ String[] output,
+ IRowMeta info,
+ IVariables variables,
+ IHopMetadataProvider metadataProvider) {
+ CheckResult cr;
+
+ if (Utils.isEmpty(binaryField)) {
+ cr =
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ BaseMessages.getString(PKG,
"BinaryFileOutputMeta.CheckResult.BinaryFieldMissing"),
+ transformMeta);
+ remarks.add(cr);
+ } else {
+ cr =
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_OK,
+ BaseMessages.getString(PKG,
"BinaryFileOutputMeta.CheckResult.BinaryFieldOK"),
+ transformMeta);
+ remarks.add(cr);
+ }
+
+ if (Utils.isEmpty(filenameField)) {
+ cr =
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ BaseMessages.getString(PKG,
"BinaryFileOutputMeta.CheckResult.FilenameFieldMissing"),
+ transformMeta);
+ remarks.add(cr);
+ } else {
+ cr =
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_OK,
+ BaseMessages.getString(PKG,
"BinaryFileOutputMeta.CheckResult.FilenameFieldOK"),
+ transformMeta);
+ remarks.add(cr);
+ }
+
+ if (input.length > 0) {
+ cr =
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_OK,
+ BaseMessages.getString(
+ PKG,
"BinaryFileOutputMeta.CheckResult.ReceivingInfoFromOtherTransforms"),
+ transformMeta);
+ remarks.add(cr);
+ } else {
+ cr =
+ new CheckResult(
+ ICheckResult.TYPE_RESULT_ERROR,
+ BaseMessages.getString(PKG,
"BinaryFileOutputMeta.CheckResult.NoInputReceived"),
+ transformMeta);
+ remarks.add(cr);
+ }
+ }
+
+ @Override
+ public boolean supportsErrorHandling() {
+ return true;
+ }
+}
diff --git
a/plugins/transforms/binaryfileoutput/src/main/resources/binaryfileoutput.svg
b/plugins/transforms/binaryfileoutput/src/main/resources/binaryfileoutput.svg
new file mode 100644
index 0000000000..15bfc66077
--- /dev/null
+++
b/plugins/transforms/binaryfileoutput/src/main/resources/binaryfileoutput.svg
@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="42px" height="42px" viewBox="0
0 42 42">
+ <!-- Binary File Output: document with binary-style dashes and export
arrow -->
+ <polygon fill="#0E3A5A" points="9.148,5.794 9.148,8.877 3.923,3.652
2.579,4.996 7.805,10.221 4.659,10.221 4.659,12.122
+ 11.049,12.121 11.049,5.794 "/>
+ <path fill="#C9E8FB"
d="M11.468,33.541h17.694l-0.058-20.152h-4.257V9.131h-6.251l-7.128,7.128V33.541z"/>
+ <polygon fill="#0E3A5A" points="30.867,35.24 30.805,13.398 29.095,11.689
26.547,11.689 26.547,9.141 24.837,7.431 20.295,7.431
+ 18.596,9.131 24.847,9.131 24.847,13.388 29.104,13.388
29.162,33.541 11.468,33.541 11.468,16.258 9.768,17.959 9.768,35.24 "/>
+ <!-- binary-looking content bars -->
+ <rect x="14.365" y="17.552" fill="#0E3A5A" width="11.902" height="0.801"/>
+ <rect x="14.365" y="20.935" fill="#0E3A5A" width="7.5" height="0.801"/>
+ <rect x="14.365" y="24.318" fill="#0E3A5A" width="11.902" height="0.801"/>
+ <rect x="14.365" y="27.701" fill="#0E3A5A" width="5.2" height="0.801"/>
+ <!-- small binary indicator (1/0 style) -->
+ <circle fill="#5CC0C4" cx="27.2" cy="28.5" r="1.2"/>
+</svg>
diff --git
a/plugins/transforms/binaryfileoutput/src/main/resources/org/apache/hop/pipeline/transforms/binaryfileoutput/messages/messages_en_US.properties
b/plugins/transforms/binaryfileoutput/src/main/resources/org/apache/hop/pipeline/transforms/binaryfileoutput/messages/messages_en_US.properties
new file mode 100644
index 0000000000..89a505e058
--- /dev/null
+++
b/plugins/transforms/binaryfileoutput/src/main/resources/org/apache/hop/pipeline/transforms/binaryfileoutput/messages/messages_en_US.properties
@@ -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.
+#
+
+BinaryFileOutput.Name=Binary File Output
+BinaryFileOutput.Description=Write a binary field to a file. The filename is
taken from an input field so each row can write a different file.
+BinaryFileOutputMeta.keyword=binary,file,output,blob,bytes,write
+
+BinaryFileOutput.Error.BinaryFieldMissing=Binary field is missing\!
+BinaryFileOutput.Error.FilenameFieldMissing=Filename field is missing\!
+BinaryFileOutput.Error.FilenameEmpty=Filename is empty for this row\!
+BinaryFileOutput.Error.BinaryNull=Binary content is null for this row\!
+BinaryFileOutput.Error.FileExists=File already exists and overwrite is
disabled\: {0}
+BinaryFileOutput.Error.ParentFolderNotExists=Parent folder does not exist and
create parent folder is disabled\: {0}
+BinaryFileOutput.Error.WriteFailed=Failed to write binary content to file
[{0}]\: {1}
+BinaryFileOutput.Exception.CouldnotFindField=Couldn''t find field ''{0}'' in
row\!
+BinaryFileOutput.ErrorInTransformRunning=Because of an error, this transform
can''t continue\: {0}
+BinaryFileOutput.LineNumber=linenr {0}
+BinaryFileOutput.Log.FileWritten=Wrote {0} bytes to [{1}]
+BinaryFileOutput.Log.FileAddedResult=File was written by a Binary File Output
transform
+BinaryFileOutput.Log.FilenameAddResult=Filename [{0}] was added to result
filenames.
+BinaryFileOutput.Log.ParentFolderCreated=Parent folder created\: {0}
+
+BinaryFileOutputDialog.Shell.Title=Binary File Output
+BinaryFileOutputDialog.TransformName.Label=Transform name
+BinaryFileOutputDialog.BinaryField.Label=Binary field
+BinaryFileOutputDialog.BinaryField.Tooltip=Field containing the binary content
to write to the file
+BinaryFileOutputDialog.FilenameField.Label=Filename field
+BinaryFileOutputDialog.FilenameField.Tooltip=Field containing the target
filename (local path or VFS URI)
+BinaryFileOutputDialog.CreateParentFolder.Label=Create parent folder
+BinaryFileOutputDialog.CreateParentFolder.Tooltip=Create the parent folder of
the target file if it does not exist
+BinaryFileOutputDialog.Overwrite.Label=Overwrite existing file
+BinaryFileOutputDialog.Overwrite.Tooltip=Overwrite the target file when it
already exists
+BinaryFileOutputDialog.AddResult.Label=Add filename to result
+BinaryFileOutputDialog.AddResult.Tooltip=Add written filenames to the result
filenames
+BinaryFileOutputDialog.FailedToGetFields.DialogTitle=Get fields failed
+BinaryFileOutputDialog.FailedToGetFields.DialogMessage=Unable to get fields
from previous transforms because of an error
+
+BinaryFileOutputMeta.CheckResult.BinaryFieldMissing=Binary field name is
missing\!
+BinaryFileOutputMeta.CheckResult.BinaryFieldOK=Binary field name was specified
+BinaryFileOutputMeta.CheckResult.FilenameFieldMissing=Filename field name is
missing\!
+BinaryFileOutputMeta.CheckResult.FilenameFieldOK=Filename field name was
specified
+BinaryFileOutputMeta.CheckResult.NoInputReceived=No input received from other
transforms\!
+BinaryFileOutputMeta.CheckResult.ReceivingInfoFromOtherTransforms=Transform is
receiving info from other transforms.
+
+BinaryFileOutput.Injection.BinaryField=Binary field name
+BinaryFileOutput.Injection.FilenameField=Filename field name
+BinaryFileOutput.Injection.CreateParentFolder=Create parent folder (Y/N)
+BinaryFileOutput.Injection.OverwriteFile=Overwrite existing file (Y/N)
+BinaryFileOutput.Injection.AddResultFilenames=Add filenames to result (Y/N)
diff --git a/plugins/transforms/binaryfileoutput/src/main/resources/version.xml
b/plugins/transforms/binaryfileoutput/src/main/resources/version.xml
new file mode 100644
index 0000000000..36ab20e22e
--- /dev/null
+++ b/plugins/transforms/binaryfileoutput/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>
diff --git
a/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMetaTest.java
b/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMetaTest.java
new file mode 100644
index 0000000000..d29f5f3d97
--- /dev/null
+++
b/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputMetaTest.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.binaryfileoutput;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.transforms.loadsave.LoadSaveTester;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+class BinaryFileOutputMetaTest {
+ @RegisterExtension
+ static RestoreHopEngineEnvironmentExtension env = new
RestoreHopEngineEnvironmentExtension();
+
+ @Test
+ void testTransformMeta() throws HopException {
+ List<String> attributes =
+ Arrays.asList(
+ "binaryField",
+ "filenameField",
+ "createParentFolder",
+ "overwriteFile",
+ "addResultFilenames");
+
+ Map<String, String> getterMap = new HashMap<>();
+ getterMap.put("binaryField", "getBinaryField");
+ getterMap.put("filenameField", "getFilenameField");
+ getterMap.put("createParentFolder", "isCreateParentFolder");
+ getterMap.put("overwriteFile", "isOverwriteFile");
+ getterMap.put("addResultFilenames", "isAddResultFilenames");
+
+ Map<String, String> setterMap = new HashMap<>();
+ setterMap.put("binaryField", "setBinaryField");
+ setterMap.put("filenameField", "setFilenameField");
+ setterMap.put("createParentFolder", "setCreateParentFolder");
+ setterMap.put("overwriteFile", "setOverwriteFile");
+ setterMap.put("addResultFilenames", "setAddResultFilenames");
+
+ LoadSaveTester loadSaveTester =
+ new LoadSaveTester(BinaryFileOutputMeta.class, attributes, getterMap,
setterMap);
+ loadSaveTester.testSerialization();
+ }
+}
diff --git
a/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputTest.java
b/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputTest.java
new file mode 100644
index 0000000000..74afad6f22
--- /dev/null
+++
b/plugins/transforms/binaryfileoutput/src/test/java/org/apache/hop/pipeline/transforms/binaryfileoutput/BinaryFileOutputTest.java
@@ -0,0 +1,304 @@
+/*
+ * 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.binaryfileoutput;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Constructor;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.apache.hop.core.BlockingRowSet;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaBinary;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.Pipeline;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.PipelineTestingUtil;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
+
+class BinaryFileOutputTest {
+
+ private static final String BINARY_FIELD = "content";
+ private static final String FILENAME_FIELD = "filename";
+
+ @RegisterExtension
+ static RestoreHopEngineEnvironmentExtension env = new
RestoreHopEngineEnvironmentExtension();
+
+ private TransformMockHelper<BinaryFileOutputMeta, BinaryFileOutputData>
helper;
+
+ @BeforeAll
+ static void initHop() throws Exception {
+ HopEnvironment.init();
+ }
+
+ @BeforeEach
+ void setUp() {
+ helper =
+ new TransformMockHelper<>(
+ "BinaryFileOutputTest", BinaryFileOutputMeta.class,
BinaryFileOutputData.class);
+ when(helper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+ .thenReturn(helper.iLogChannel);
+
when(helper.logChannelFactory.create(any())).thenReturn(helper.iLogChannel);
+ when(helper.pipeline.isRunning()).thenReturn(true);
+ }
+
+ @AfterEach
+ void tearDown() {
+ helper.cleanUp();
+ }
+
+ private BinaryFileOutput createTransform(BinaryFileOutputMeta meta,
BinaryFileOutputData data)
+ throws Exception {
+ Constructor<BinaryFileOutput> kons =
+ BinaryFileOutput.class.getConstructor(
+ TransformMeta.class,
+ BinaryFileOutputMeta.class,
+ BinaryFileOutputData.class,
+ int.class,
+ PipelineMeta.class,
+ Pipeline.class);
+ return kons.newInstance(
+ helper.transformMeta, meta, data, 0, helper.pipelineMeta,
helper.pipeline);
+ }
+
+ private RowMeta rowMeta() {
+ RowMeta rm = new RowMeta();
+ rm.addValueMeta(new ValueMetaBinary(BINARY_FIELD));
+ rm.addValueMeta(new ValueMetaString(FILENAME_FIELD));
+ return rm;
+ }
+
+ private static String vfsPath(Path path) {
+ return path.toUri().toString();
+ }
+
+ private void wireInput(BinaryFileOutput transform, RowMeta rm, Object[]...
rows) {
+ BlockingRowSet input = new BlockingRowSet(Math.max(16, rows.length + 1));
+ for (Object[] row : rows) {
+ input.putRow(rm, row);
+ }
+ input.setDone();
+ transform.setInputRowSets(new
ArrayList<>(Collections.singletonList(input)));
+ }
+
+ private BinaryFileOutputMeta baseMeta() {
+ BinaryFileOutputMeta meta = new BinaryFileOutputMeta();
+ meta.setDefault();
+ meta.setBinaryField(BINARY_FIELD);
+ meta.setFilenameField(FILENAME_FIELD);
+ meta.setCreateParentFolder(false);
+ meta.setOverwriteFile(true);
+ meta.setAddResultFilenames(false);
+ return meta;
+ }
+
+ @Test
+ void writesRawBytesUnchanged(@TempDir Path tempDir) throws Exception {
+ // Include non-UTF8 / control bytes that text writers often corrupt
+ byte[] payload = new byte[] {0x00, 0x01, (byte) 0x98, (byte) 0xFF, 0x0A,
0x0D, 0x1A};
+ Path target = tempDir.resolve("out.bin");
+
+ BinaryFileOutputMeta meta = baseMeta();
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+ List<Object[]> out = PipelineTestingUtil.execute(transform, 1, false);
+ assertArrayEquals(payload, (byte[]) out.getFirst()[0]);
+ assertEquals(vfsPath(target), out.getFirst()[1]);
+
+ assertTrue(Files.isRegularFile(target));
+ assertArrayEquals(payload, Files.readAllBytes(target));
+ }
+
+ @Test
+ void writesEmptyBinaryFile(@TempDir Path tempDir) throws Exception {
+ byte[] payload = new byte[0];
+ Path target = tempDir.resolve("empty.bin");
+
+ BinaryFileOutputMeta meta = baseMeta();
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+ PipelineTestingUtil.execute(transform, 1, false);
+
+ assertTrue(Files.isRegularFile(target));
+ assertEquals(0, Files.size(target));
+ }
+
+ @Test
+ void createsParentFolderWhenConfigured(@TempDir Path tempDir) throws
Exception {
+ byte[] payload = new byte[] {0x01, 0x02, 0x03};
+ Path target = tempDir.resolve("nested/dir/out.bin");
+
+ BinaryFileOutputMeta meta = baseMeta();
+ meta.setCreateParentFolder(true);
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+ PipelineTestingUtil.execute(transform, 1, false);
+
+ assertTrue(Files.isRegularFile(target));
+ assertArrayEquals(payload, Files.readAllBytes(target));
+ }
+
+ @Test
+ void failsWhenParentMissingAndCreateDisabled(@TempDir Path tempDir) throws
Exception {
+ byte[] payload = new byte[] {0x01};
+ Path target = tempDir.resolve("missing/parent/out.bin");
+
+ BinaryFileOutputMeta meta = baseMeta();
+ meta.setCreateParentFolder(false);
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+ assertFalse(transform.processRow());
+ assertTrue(transform.getErrors() > 0);
+ assertFalse(Files.exists(target));
+ }
+
+ @Test
+ void overwritesWhenEnabled(@TempDir Path tempDir) throws Exception {
+ Path target = tempDir.resolve("out.bin");
+ Files.write(target, new byte[] {0x11, 0x22});
+ byte[] payload = new byte[] {(byte) 0xAA, (byte) 0xBB, (byte) 0xCC};
+
+ BinaryFileOutputMeta meta = baseMeta();
+ meta.setOverwriteFile(true);
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+ PipelineTestingUtil.execute(transform, 1, false);
+
+ assertArrayEquals(payload, Files.readAllBytes(target));
+ }
+
+ @Test
+ void failsWhenExistsAndOverwriteDisabled(@TempDir Path tempDir) throws
Exception {
+ Path target = tempDir.resolve("out.bin");
+ byte[] original = new byte[] {0x11, 0x22};
+ Files.write(target, original);
+ byte[] payload = new byte[] {(byte) 0xAA};
+
+ BinaryFileOutputMeta meta = baseMeta();
+ meta.setOverwriteFile(false);
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+ assertFalse(transform.processRow());
+ assertTrue(transform.getErrors() > 0);
+ assertArrayEquals(original, Files.readAllBytes(target));
+ }
+
+ @Test
+ void failsOnNullBinary(@TempDir Path tempDir) throws Exception {
+ Path target = tempDir.resolve("out.bin");
+
+ BinaryFileOutputMeta meta = baseMeta();
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {null, vfsPath(target)});
+
+ assertFalse(transform.processRow());
+ assertTrue(transform.getErrors() > 0);
+ assertFalse(Files.exists(target));
+ }
+
+ @Test
+ void failsOnEmptyFilename(@TempDir Path tempDir) throws Exception {
+ BinaryFileOutputMeta meta = baseMeta();
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {new byte[] {0x01}, ""});
+
+ assertFalse(transform.processRow());
+ assertTrue(transform.getErrors() > 0);
+ }
+
+ @Test
+ void throwsWhenBinaryFieldNotConfigured(@TempDir Path tempDir) throws
Exception {
+ Path target = tempDir.resolve("out.bin");
+ BinaryFileOutputMeta meta = baseMeta();
+ meta.setBinaryField(null);
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {new byte[] {0x01},
vfsPath(target)});
+
+ assertThrows(HopException.class, () ->
PipelineTestingUtil.execute(transform, 1, false));
+ }
+
+ @Test
+ void writesEachRowToItsOwnFile(@TempDir Path tempDir) throws Exception {
+ Path t1 = tempDir.resolve("a.bin");
+ Path t2 = tempDir.resolve("b.bin");
+ byte[] p1 = new byte[] {0x01, 0x02};
+ byte[] p2 = new byte[] {(byte) 0xFE, (byte) 0xFD, (byte) 0xFC};
+
+ BinaryFileOutputMeta meta = baseMeta();
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {p1, vfsPath(t1)}, new Object[] {p2,
vfsPath(t2)});
+
+ List<Object[]> out = PipelineTestingUtil.execute(transform, 2, false);
+ assertEquals(2, out.size());
+ assertArrayEquals(p1, Files.readAllBytes(t1));
+ assertArrayEquals(p2, Files.readAllBytes(t2));
+ }
+
+ @Test
+ void addResultFilenames_registersTarget(@TempDir Path tempDir) throws
Exception {
+ Path target = tempDir.resolve("result.bin");
+ byte[] payload = new byte[] {0x0A};
+
+ BinaryFileOutputMeta meta = baseMeta();
+ meta.setAddResultFilenames(true);
+ BinaryFileOutput transform = createTransform(meta, new
BinaryFileOutputData());
+ RowMeta rm = rowMeta();
+ wireInput(transform, rm, new Object[] {payload, vfsPath(target)});
+
+ PipelineTestingUtil.execute(transform, 1, false);
+
+ assertEquals(1, transform.getResultFiles().size());
+
assertTrue(transform.getResultFiles().containsKey(target.toUri().toString()));
+ }
+}
diff --git a/plugins/transforms/pom.xml b/plugins/transforms/pom.xml
index ec796c1588..531d2fde67 100644
--- a/plugins/transforms/pom.xml
+++ b/plugins/transforms/pom.xml
@@ -33,6 +33,7 @@
<module>addsequence</module>
<module>analyticquery</module>
<module>append</module>
+ <module>binaryfileoutput</module>
<module>blockingtransform</module>
<module>blockuntiltransformsfinish</module>
<module>calculator</module>