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 286b0f6d41 Issue #8035 : Add Data set output transform (#8118)
286b0f6d41 is described below

commit 286b0f6d410676671211b5948a27b06fe0083f72
Author: Matt Casters <[email protected]>
AuthorDate: Thu Aug 27 14:08:20 2026 +0200

    Issue #8035 : Add Data set output transform (#8118)
    
    Add a Data set output transform so pipelines can write rows into a
    unit-test data set (metadata + CSV) from Hop GUI and hop-run, without
    hard-coding the data set file path.
    
    The dialog is built from @GuiWidgetElement annotations with a grouped
    layout. Rows are streamed through DataSetCsvWriter, which is also used
    by Write rows to data set. Recreate builds the data set from incoming
    fields; validate checks an existing set before overwrite.
---
 .../images/transforms/icons/datasetoutput.svg      |  66 +++++++
 docs/hop-user-manual/modules/ROOT/nav.adoc         |   1 +
 .../ROOT/pages/metadata-types/data-set.adoc        |   2 +-
 .../ROOT/pages/pipeline/pipeline-unit-testing.adoc |   3 +
 .../modules/ROOT/pages/pipeline/transforms.adoc    |   1 +
 .../pages/pipeline/transforms/datasetoutput.adoc   |  64 +++++++
 .../transforms/0108-data-set-output-generate.hpl   | 123 +++++++++++++
 .../transforms/0108-data-set-output-verify.hpl     |  85 +++++++++
 .../datasets/golden-0108-data-set-output.csv       |   4 +
 .../transforms/main-0108-data-set-output.hwf       | 115 +++++++++++++
 .../metadata/dataset/0108-data-set-output.json     |  23 +++
 .../dataset/golden-0108-data-set-output.json       |  23 +++
 .../0108-data-set-output-verify UNIT.json          |  33 ++++
 .../main/java/org/apache/hop/testing/DataSet.java  |  69 ++++++++
 .../org/apache/hop/testing/DataSetCsvUtil.java     |  49 +-----
 .../org/apache/hop/testing/DataSetCsvWriter.java   | 116 +++++++++++++
 .../transforms/datasetoutput/DataSetOutput.java    | 190 +++++++++++++++++++++
 .../datasetoutput/DataSetOutputData.java           |  40 +++++
 .../datasetoutput/DataSetOutputDialog.java         | 120 +++++++++++++
 .../datasetoutput/DataSetOutputMeta.java           | 177 +++++++++++++++++++
 .../testing/xp/WriteToDataSetExtensionPoint.java   |  25 +--
 .../messages/messages_en_US.properties             |  40 +++++
 .../apache/hop/testing/DataSetCsvWriterTest.java   | 134 +++++++++++++++
 .../org/apache/hop/testing/DataSetRowMetaTest.java | 111 ++++++++++++
 .../datasetoutput/DataSetOutputMetaTest.java       |  51 ++++++
 .../test/resources/data-set-output-transform.xml   |  40 +++++
 26 files changed, 1646 insertions(+), 59 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/datasetoutput.svg
 
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/datasetoutput.svg
new file mode 100644
index 0000000000..b88913ebd5
--- /dev/null
+++ 
b/docs/hop-user-manual/modules/ROOT/assets/images/transforms/icons/datasetoutput.svg
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg version="1.1"
+     xmlns="http://www.w3.org/2000/svg";
+     x="0px" y="0px" width="24px" height="24px" viewBox="-1 -1 24 24" 
enable-background="new -1 -1 24 24"
+     xml:space="preserve">
+<rect id="rect1408_54_" x="2" y="12" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+      width="3" height="3"/>
+    <rect id="rect1408_53_" x="7" y="12" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_52_" x="12" y="12" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_51_" x="17" y="12" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_50_" x="2" y="17" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_49_" x="7" y="17" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_48_" x="12" y="17" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_47_" x="17" y="17" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_46_" x="12" y="2" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_45_" x="17" y="2" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_44_" x="7" y="7" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_43_" x="12" y="7" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <rect id="rect1408_42_" x="17" y="7" fill="#5DC2C6" stroke="#5DC2C6" 
stroke-linecap="round" stroke-linejoin="round"
+          width="3" height="3"/>
+    <polygon id="polygon6_3_" display="none" fill="#0e3a5a" points="22,18.982 
22,18.02 18.98,18.02 18.98,15 18.02,15 18.02,18.02
+       15,18.02 15,18.982 18.02,18.982 18.02,22 18.98,22 18.98,18.982 "/>
+    <polygon id="polygon6_2_" display="none" fill="#0e3a5a" points="22,18.982 
22,18.02 18.98,18.02 18.98,15 18.02,15 18.02,18.02
+       15,18.02 15,18.982 18.02,18.982 18.02,22 18.98,22 18.98,18.982 "/>
+    <polygon id="input_1_" fill="#0e3a5a" points="1.719,5.43 1.719,7 7,6.997 
7,1.771 5.429,1.771 5.429,4.318 1.11,0 0,1.111
+       4.319,5.43 "/>
+    <rect id="rect1408_41_" x="2" y="12" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_40_" x="7" y="12" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_39_" x="12" y="12" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_38_" x="17" y="12" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_37_" x="2" y="17" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_36_" x="7" y="17" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_35_" x="12" y="17" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_34_" x="17" y="17" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_33_" x="2" y="2" transform="matrix(-0.7072 0.707 -0.707 
-0.7072 8.4493 3.5007)" display="none"
+          fill="none" stroke="#F9C940" stroke-linecap="round" 
stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_32_" x="12" y="2" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_31_" x="17" y="2" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_30_" x="7" y="7" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_29_" x="12" y="7" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+    <rect id="rect1408_20_" x="17" y="7" display="none" fill="#F9C940" 
stroke="#F9C940" stroke-linecap="round"
+          stroke-linejoin="round" width="3" height="3"/>
+</svg>
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc 
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index 7978adfc85..7e3762a580 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -129,6 +129,7 @@ under the License.
 *** xref:pipeline/transforms/databaselookup.adoc[Database Lookup]
 *** xref:pipeline/transforms/datagrid.adoc[Data Grid]
 *** xref:pipeline/transforms/datasetinput.adoc[Data set input]
+*** xref:pipeline/transforms/datasetoutput.adoc[Data set output]
 *** xref:pipeline/transforms/dbimpactinput.adoc[Database impact input]
 *** xref:pipeline/transforms/ddl.adoc[DDL Generator]
 *** xref:pipeline/transforms/validator.adoc[Data Validator]
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/data-set.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/data-set.adoc
index 0f8d233afb..602d166976 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/data-set.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/data-set.adoc
@@ -28,7 +28,7 @@ After processing the result of the pipeline is compared to a 
data set that has b
 
 == Related Plugins
 
-None/All
+xref:pipeline/transforms/datasetinput.adoc[Data set input], 
xref:pipeline/transforms/datasetoutput.adoc[Data set output]
 
 == Options
 
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/pipeline-unit-testing.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/pipeline-unit-testing.adoc
index 44c7b23f53..4dc86cd1e0 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/pipeline-unit-testing.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/pipeline-unit-testing.adoc
@@ -88,6 +88,9 @@ image::pipeline-unit-testing-data-sets.png[Pipeline Unit 
Testing - Data Sets,wid
 
 TIP: creating data sets is also possible from the 'New' context menu or 
metadata perspective.
 
+You can also generate or regenerate a data set from a pipeline with the 
xref:pipeline/transforms/datasetoutput.adoc[Data set output] transform.
+That is useful in automated or headless (`hop-run`) runs, where the canvas 
*Write rows to data set* action is not available.
+
 === Create and add data sets
 
 Consider the following basic pipeline below.
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 9b61466564..15f47959a2 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms.adoc
@@ -80,6 +80,7 @@ Those are explained once in 
xref:pipeline/formatting-values.adoc[Formatting numb
 * xref:pipeline/transforms/databaselookup.adoc[Database Lookup]
 * xref:pipeline/transforms/datagrid.adoc[Data Grid]
 * xref:pipeline/transforms/datasetinput.adoc[Data set input]
+* xref:pipeline/transforms/datasetoutput.adoc[Data set output]
 * xref:pipeline/transforms/data-stream-input.adoc[Data stream input]
 * xref:pipeline/transforms/data-stream-output.adoc[Data stream output]
 * xref:pipeline/transforms/dbimpactinput.adoc[Database impact input]
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/datasetoutput.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/datasetoutput.adoc
new file mode 100644
index 0000000000..d4ff1c143e
--- /dev/null
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/datasetoutput.adoc
@@ -0,0 +1,64 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+  http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+:documentationPath: /pipeline/transforms/
+:language: en_US
+:description: The Data set output transform writes rows to a data set in the 
metadata
+
+= image:transforms/icons/datasetoutput.svg[Data set output transform Icon, 
role="image-doc-icon"] Data set output
+
+[%noheader,cols="3a,1a", role="table-no-borders" ]
+|===
+|
+== Description
+
+The Data set output transform writes incoming rows to a 
xref:metadata-types/data-set.adoc[data set].
+Use it to generate or regenerate unit-test input and golden data sets from a 
pipeline, including headless `hop-run` executions, without hard-coding the CSV 
path in a Text file output transform.
+
+Rows are passed through unchanged so you can keep using the stream after the 
write.
+
+|
+== 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:cross.svg[Not Supported, 24]
+!Beam Spark! image:cross.svg[Not Supported, 24]
+!Beam Flink! image:cross.svg[Not Supported, 24]
+!Beam Dataflow! image:cross.svg[Not Supported, 24]
+!===
+|===
+
+== Options
+
+[width="90%", options="header"]
+|===
+|Option|Description
+|Transform name|The name of this transform
+|Data set name|The name of the data set to write to. Select an existing data 
set or type a new name when (re)creating.
+|Folder name|Optional folder for the CSV file. Leave empty to use 
`'${HOP_DATASETS_FOLDER}'`.
+|CSV filename|Base filename of the data set CSV file. Leave empty to use the 
data set name with a `.csv` extension when (re)creating, or the filename stored 
on an existing data set.
+|(Re)create data set|When enabled, the transform creates or overwrites the 
data set metadata from the incoming row metadata, then writes the CSV 
(including a header-only file when the stream is empty).
+|Validate against existing data set|When (re)create is disabled, load the 
existing data set and fail if the incoming field count, names (same order, 
case-insensitive) or types do not match. Extra input fields are ignored when 
validation is off; missing data set fields are written empty.
+|===
+
+== Notes
+
+* The CSV is overwritten on every run.
+* Multiple copies of this transform are not supported.
+* (Re)create takes precedence over validate.
+* The GUI context actions *Create data set* and *Write rows to data set* 
remain available for interactive use on the pipeline canvas.
diff --git a/integration-tests/transforms/0108-data-set-output-generate.hpl 
b/integration-tests/transforms/0108-data-set-output-generate.hpl
new file mode 100644
index 0000000000..c96a515710
--- /dev/null
+++ b/integration-tests/transforms/0108-data-set-output-generate.hpl
@@ -0,0 +1,123 @@
+<?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>
+    <pipeline_version/>
+    <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>0</pipeline_status>
+    <parameters/>
+    <name>0108-data-set-output-generate</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description/>
+    <extended_description/>
+    <created_user>-</created_user>
+    <modified_user>-</modified_user>
+    <created_date>2026/04/26 12:00:00.000</created_date>
+    <modified_date>2026/04/26 12:00:00.000</modified_date>
+  </info>
+  <transform>
+    <type>DataGrid</type>
+    <name>test data</name>
+    <fields>
+      <field>
+        <currency/>
+        <decimal/>
+        <group/>
+        <name>id</name>
+        <type>Integer</type>
+        <format/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <set_empty_string>N</set_empty_string>
+      </field>
+      <field>
+        <currency/>
+        <decimal/>
+        <group/>
+        <name>name</name>
+        <type>String</type>
+        <format/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <set_empty_string>N</set_empty_string>
+      </field>
+    </fields>
+    <data>
+      <line>
+        <item>1</item>
+        <item>Alice</item>
+      </line>
+      <line>
+        <item>2</item>
+        <item>Bob</item>
+      </line>
+      <line>
+        <item>3</item>
+        <item>Carol</item>
+      </line>
+    </data>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>144</xloc>
+      <yloc>96</yloc>
+    </GUI>
+    <description/>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <transform>
+    <type>DataSetOutput</type>
+    <name>write data set</name>
+    <dataSetName>0108-data-set-output</dataSetName>
+    <folderName/>
+    <csvFilename>0108-data-set-output.csv</csvFilename>
+    <recreateDataSet>N</recreateDataSet>
+    <validateDataSet>Y</validateDataSet>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>368</xloc>
+      <yloc>96</yloc>
+    </GUI>
+    <description/>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <order>
+    <hop>
+      <from>test data</from>
+      <to>write data set</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <notepads/>
+  <attributes/>
+  <transform_error_handling/>
+</pipeline>
diff --git a/integration-tests/transforms/0108-data-set-output-verify.hpl 
b/integration-tests/transforms/0108-data-set-output-verify.hpl
new file mode 100644
index 0000000000..de58f2ee51
--- /dev/null
+++ b/integration-tests/transforms/0108-data-set-output-verify.hpl
@@ -0,0 +1,85 @@
+<?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>0108-data-set-output-verify</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description/>
+    <extended_description/>
+    <pipeline_version/>
+    <pipeline_type>Normal</pipeline_type>
+    <parameters>
+    </parameters>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2026/04/26 12:00:00.000</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2026/04/26 12:00:00.000</modified_date>
+  </info>
+  <notepads>
+  </notepads>
+  <order>
+    <hop>
+      <from>read data set</from>
+      <to>Validate</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <transform>
+    <name>read data set</name>
+    <type>DataSetInput</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <dataSetName>0108-data-set-output</dataSetName>
+    <attributes/>
+    <GUI>
+      <xloc>160</xloc>
+      <yloc>96</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>368</xloc>
+      <yloc>96</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git 
a/integration-tests/transforms/datasets/golden-0108-data-set-output.csv 
b/integration-tests/transforms/datasets/golden-0108-data-set-output.csv
new file mode 100644
index 0000000000..578dd686ed
--- /dev/null
+++ b/integration-tests/transforms/datasets/golden-0108-data-set-output.csv
@@ -0,0 +1,4 @@
+id,name
+1,Alice
+2,Bob
+3,Carol
diff --git a/integration-tests/transforms/main-0108-data-set-output.hwf 
b/integration-tests/transforms/main-0108-data-set-output.hwf
new file mode 100644
index 0000000000..a196643e25
--- /dev/null
+++ b/integration-tests/transforms/main-0108-data-set-output.hwf
@@ -0,0 +1,115 @@
+<?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-0108-data-set-output</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description/>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/04/26 12:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/04/26 12: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>80</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>0108-data-set-output-generate</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <filename>${PROJECT_HOME}/0108-data-set-output-generate.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>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <set_append_logfile>N</set_append_logfile>
+      <wait_until_finished>Y</wait_until_finished>
+      <create_parent_folder>N</create_parent_folder>
+      <run_configuration>local</run_configuration>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <parallel>N</parallel>
+      <xloc>288</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>Run 0108 tests</name>
+      <description/>
+      <type>RunPipelineTests</type>
+      <attributes/>
+      <test_names>
+        <test_name>
+          <name>0108-data-set-output-verify UNIT</name>
+        </test_name>
+      </test_names>
+      <parallel>N</parallel>
+      <xloc>544</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>0108-data-set-output-generate</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>0108-data-set-output-generate</from>
+      <to>Run 0108 tests</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git 
a/integration-tests/transforms/metadata/dataset/0108-data-set-output.json 
b/integration-tests/transforms/metadata/dataset/0108-data-set-output.json
new file mode 100644
index 0000000000..0df7b60e90
--- /dev/null
+++ b/integration-tests/transforms/metadata/dataset/0108-data-set-output.json
@@ -0,0 +1,23 @@
+{
+  "base_filename": "0108-data-set-output.csv",
+  "name": "0108-data-set-output",
+  "description": "",
+  "dataset_fields": [
+    {
+      "field_comment": "",
+      "field_length": -1,
+      "field_type": 5,
+      "field_precision": 0,
+      "field_name": "id",
+      "field_format": "0"
+    },
+    {
+      "field_comment": "",
+      "field_length": -1,
+      "field_type": 2,
+      "field_precision": -1,
+      "field_name": "name",
+      "field_format": ""
+    }
+  ]
+}
diff --git 
a/integration-tests/transforms/metadata/dataset/golden-0108-data-set-output.json
 
b/integration-tests/transforms/metadata/dataset/golden-0108-data-set-output.json
new file mode 100644
index 0000000000..f5b8147982
--- /dev/null
+++ 
b/integration-tests/transforms/metadata/dataset/golden-0108-data-set-output.json
@@ -0,0 +1,23 @@
+{
+  "base_filename": "golden-0108-data-set-output.csv",
+  "name": "golden-0108-data-set-output",
+  "description": "",
+  "dataset_fields": [
+    {
+      "field_comment": "",
+      "field_length": -1,
+      "field_type": 5,
+      "field_precision": 0,
+      "field_name": "id",
+      "field_format": "0"
+    },
+    {
+      "field_comment": "",
+      "field_length": -1,
+      "field_type": 2,
+      "field_precision": -1,
+      "field_name": "name",
+      "field_format": ""
+    }
+  ]
+}
diff --git 
a/integration-tests/transforms/metadata/unit-test/0108-data-set-output-verify 
UNIT.json 
b/integration-tests/transforms/metadata/unit-test/0108-data-set-output-verify 
UNIT.json
new file mode 100644
index 0000000000..36c11a372e
--- /dev/null
+++ 
b/integration-tests/transforms/metadata/unit-test/0108-data-set-output-verify 
UNIT.json     
@@ -0,0 +1,33 @@
+{
+  "database_replacements": [],
+  "autoOpening": true,
+  "description": "",
+  "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": "name",
+          "data_set_field": "name"
+        }
+      ],
+      "field_order": [
+        "id",
+        "name"
+      ],
+      "data_set_name": "golden-0108-data-set-output",
+      "transform_name": "Validate"
+    }
+  ],
+  "input_data_sets": [],
+  "name": "0108-data-set-output-verify UNIT",
+  "trans_test_tweaks": [],
+  "pipeline_filename": "./0108-data-set-output-verify.hpl"
+}
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
index 6f87743c56..b3742010c1 100644
--- a/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
+++ b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
@@ -98,6 +98,75 @@ public class DataSet extends HopMetadataBase implements 
Cloneable, IHopMetadata
     return rowMeta;
   }
 
+  /**
+   * Build data set fields from Hop row metadata. Used when (re)creating a 
data set from a pipeline
+   * stream.
+   */
+  public static List<DataSetField> createFieldsFromRowMeta(IRowMeta rowMeta) {
+    List<DataSetField> setFields = new ArrayList<>();
+    for (int i = 0; i < rowMeta.size(); i++) {
+      IValueMeta valueMeta = rowMeta.getValueMeta(i);
+      setFields.add(
+          new DataSetField(
+              valueMeta.getName(),
+              valueMeta.getType(),
+              valueMeta.getLength(),
+              valueMeta.getPrecision(),
+              valueMeta.getComments(),
+              valueMeta.getFormatMask()));
+    }
+    return setFields;
+  }
+
+  /**
+   * Compare incoming row metadata with this data set. Field count, names 
(case-insensitive, same
+   * order) and types must match because the CSV layout is positional.
+   */
+  public void validateRowMeta(IRowMeta inputRowMeta) throws HopException {
+    IRowMeta setRowMeta = getSetRowMeta();
+    List<String> errors = new ArrayList<>();
+    if (inputRowMeta.size() != setRowMeta.size()) {
+      errors.add(
+          "field count: input has "
+              + inputRowMeta.size()
+              + ", data set '"
+              + Const.NVL(getName(), "")
+              + "' has "
+              + setRowMeta.size());
+    }
+    int n = Math.min(inputRowMeta.size(), setRowMeta.size());
+    for (int i = 0; i < n; i++) {
+      IValueMeta inputMeta = inputRowMeta.getValueMeta(i);
+      IValueMeta setMeta = setRowMeta.getValueMeta(i);
+      if (!inputMeta.getName().equalsIgnoreCase(setMeta.getName())) {
+        errors.add(
+            "field "
+                + (i + 1)
+                + ": name '"
+                + inputMeta.getName()
+                + "' vs '"
+                + setMeta.getName()
+                + "'");
+      }
+      if (inputMeta.getType() != setMeta.getType()) {
+        errors.add(
+            "field '"
+                + inputMeta.getName()
+                + "': type "
+                + inputMeta.getTypeDesc()
+                + " vs "
+                + setMeta.getTypeDesc());
+      }
+    }
+    if (!errors.isEmpty()) {
+      throw new HopException(
+          "Input row metadata does not match data set '"
+              + Const.NVL(getName(), "")
+              + "': "
+              + String.join("; ", errors));
+    }
+  }
+
   public DataSetField findFieldWithName(String fieldName) {
     for (DataSetField field : fields) {
       if (field.getFieldName().equalsIgnoreCase(fieldName)) {
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSetCsvUtil.java 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSetCsvUtil.java
index 07cf152302..76f414ef53 100644
--- 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSetCsvUtil.java
+++ 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSetCsvUtil.java
@@ -18,17 +18,12 @@
 package org.apache.hop.testing;
 
 import java.io.BufferedInputStream;
-import java.io.BufferedWriter;
-import java.io.IOException;
 import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
 import java.io.Reader;
 import java.util.ArrayList;
 import java.util.List;
 import org.apache.commons.csv.CSVFormat;
 import org.apache.commons.csv.CSVParser;
-import org.apache.commons.csv.CSVPrinter;
 import org.apache.commons.csv.CSVRecord;
 import org.apache.commons.csv.QuoteMode;
 import org.apache.commons.lang3.StringUtils;
@@ -223,49 +218,9 @@ public class DataSetCsvUtil {
   public static final void writeDataSetData(
       IVariables variables, DataSet dataSet, IRowMeta rowMeta, List<Object[]> 
rows)
       throws HopException {
-
-    String dataSetFilename = dataSet.getActualDataSetFilename(variables);
-
-    IRowMeta setRowMeta = rowMeta.clone(); // just making sure
-    setValueFormats(setRowMeta);
-
-    OutputStream outputStream = null;
-    BufferedWriter writer = null;
-    CSVPrinter csvPrinter = null;
-    try {
-
-      FileObject file = HopVfs.getFileObject(dataSetFilename);
-      outputStream = HopVfs.getOutputStream(file, false);
-      writer = new BufferedWriter(new OutputStreamWriter(outputStream));
-      CSVFormat csvFormat = getCsvFormat(rowMeta);
-      csvPrinter = new CSVPrinter(writer, csvFormat);
-
+    try (DataSetCsvWriter writer = new DataSetCsvWriter(variables, dataSet, 
rowMeta)) {
       for (Object[] row : rows) {
-        List<String> strings = new ArrayList<>();
-        for (int i = 0; i < setRowMeta.size(); i++) {
-          IValueMeta valueMeta = setRowMeta.getValueMeta(i);
-          String string = valueMeta.getString(row[i]);
-          strings.add(string);
-        }
-        csvPrinter.printRecord(strings);
-      }
-      csvPrinter.flush();
-
-    } catch (Exception e) {
-      throw new HopException("Unable to write data set to file '" + 
dataSetFilename + "'", e);
-    } finally {
-      try {
-        if (csvPrinter != null) {
-          csvPrinter.close();
-        }
-        if (writer != null) {
-          writer.close();
-        }
-        if (outputStream != null) {
-          outputStream.close();
-        }
-      } catch (IOException e) {
-        throw new HopException("Error closing file " + dataSetFilename + " : 
", e);
+        writer.writeRow(row);
       }
     }
   }
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSetCsvWriter.java
 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSetCsvWriter.java
new file mode 100644
index 0000000000..7e95177da8
--- /dev/null
+++ 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSetCsvWriter.java
@@ -0,0 +1,116 @@
+/*
+ * 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.testing;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.csv.CSVPrinter;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+
+/**
+ * Streams rows to a data set CSV file. The header is written when the writer 
is opened so an empty
+ * input still produces a valid (header-only) data set file.
+ */
+public class DataSetCsvWriter implements AutoCloseable {
+
+  private final IRowMeta setRowMeta;
+  private final String dataSetFilename;
+  private OutputStream outputStream;
+  private BufferedWriter writer;
+  private CSVPrinter csvPrinter;
+  private boolean closed;
+
+  public DataSetCsvWriter(IVariables variables, DataSet dataSet, IRowMeta 
rowMeta)
+      throws HopException {
+    this.dataSetFilename = dataSet.getActualDataSetFilename(variables);
+    this.setRowMeta = rowMeta.clone();
+    DataSetCsvUtil.setValueFormats(this.setRowMeta);
+
+    try {
+      FileObject file = HopVfs.getFileObject(dataSetFilename);
+      FileObject parent = file.getParent();
+      if (parent != null && !parent.exists()) {
+        parent.createFolder();
+      }
+      outputStream = HopVfs.getOutputStream(file, false);
+      writer = new BufferedWriter(new OutputStreamWriter(outputStream));
+      csvPrinter = new CSVPrinter(writer, 
DataSetCsvUtil.getCsvFormat(this.setRowMeta));
+    } catch (Exception e) {
+      closeQuietly();
+      throw new HopException("Unable to open data set file '" + 
dataSetFilename + "'", e);
+    }
+  }
+
+  public void writeRow(Object[] row) throws HopException {
+    if (closed) {
+      throw new HopException("Data set CSV writer is already closed: " + 
dataSetFilename);
+    }
+    try {
+      List<String> strings = new ArrayList<>(setRowMeta.size());
+      for (int i = 0; i < setRowMeta.size(); i++) {
+        IValueMeta valueMeta = setRowMeta.getValueMeta(i);
+        strings.add(valueMeta.getString(row[i]));
+      }
+      csvPrinter.printRecord(strings);
+    } catch (Exception e) {
+      throw new HopException("Unable to write a row to data set file '" + 
dataSetFilename + "'", e);
+    }
+  }
+
+  @Override
+  public void close() throws HopException {
+    if (closed) {
+      return;
+    }
+    closed = true;
+    try {
+      if (csvPrinter != null) {
+        csvPrinter.flush();
+        csvPrinter.close();
+        csvPrinter = null;
+      }
+      if (writer != null) {
+        writer.close();
+        writer = null;
+      }
+      if (outputStream != null) {
+        outputStream.close();
+        outputStream = null;
+      }
+    } catch (IOException e) {
+      throw new HopException("Error closing data set file '" + dataSetFilename 
+ "'", e);
+    }
+  }
+
+  private void closeQuietly() {
+    try {
+      close();
+    } catch (HopException e) {
+      // Best effort while handling an open failure
+    }
+  }
+}
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutput.java
 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutput.java
new file mode 100644
index 0000000000..354c3d7cf8
--- /dev/null
+++ 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutput.java
@@ -0,0 +1,190 @@
+/*
+ * 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.testing.transforms.datasetoutput;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowDataUtil;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataSerializer;
+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;
+import org.apache.hop.testing.DataSet;
+import org.apache.hop.testing.DataSetCsvWriter;
+
+public class DataSetOutput extends BaseTransform<DataSetOutputMeta, 
DataSetOutputData> {
+  private static final Class<?> PKG = DataSetOutputMeta.class;
+
+  public DataSetOutput(
+      TransformMeta transformMeta,
+      DataSetOutputMeta meta,
+      DataSetOutputData data,
+      int copyNr,
+      PipelineMeta pipelineMeta,
+      Pipeline pipeline) {
+    super(transformMeta, meta, data, copyNr, pipelineMeta, pipeline);
+  }
+
+  @Override
+  public boolean init() {
+    if (getTransformMeta().getCopies(this) > 1) {
+      logError(BaseMessages.getString(PKG, "DataSetOutput.Log.OnlyOneCopy"));
+      return false;
+    }
+    if (StringUtils.isEmpty(meta.getDataSetName())) {
+      logError(BaseMessages.getString(PKG, 
"DataSetOutput.Log.DataSetNameMissing"));
+      return false;
+    }
+    data.realDataSetName = resolve(meta.getDataSetName());
+    if (StringUtils.isEmpty(data.realDataSetName)) {
+      logError(BaseMessages.getString(PKG, 
"DataSetOutput.Log.DataSetNameMissing"));
+      return false;
+    }
+    data.realFolderName = Const.NVL(resolve(Const.NVL(meta.getFolderName(), 
"")), "");
+    data.realCsvFilename = Const.NVL(resolve(Const.NVL(meta.getCsvFilename(), 
"")), "");
+    return super.init();
+  }
+
+  @Override
+  public boolean processRow() throws HopException {
+    Object[] row = getRow();
+
+    if (first) {
+      first = false;
+      prepareDataSet(getInputRowMeta());
+    }
+
+    if (row == null) {
+      closeWriter();
+      setOutputDone();
+      return false;
+    }
+
+    writeMappedRow(row);
+    putRow(getInputRowMeta(), row);
+    return true;
+  }
+
+  private void prepareDataSet(IRowMeta inputRowMeta) throws HopException {
+    IRowMeta rowMeta = inputRowMeta;
+    if (rowMeta == null) {
+      rowMeta = getPipelineMeta().getPrevTransformFields(this, 
getTransformMeta());
+    }
+    if (rowMeta == null || rowMeta.isEmpty()) {
+      throw new HopException(BaseMessages.getString(PKG, 
"DataSetOutput.Log.NoInputRowMeta"));
+    }
+
+    IHopMetadataSerializer<DataSet> serializer = 
metadataProvider.getSerializer(DataSet.class);
+
+    if (meta.isRecreateDataSet()) {
+      data.dataSet = serializer.load(data.realDataSetName);
+      if (data.dataSet == null) {
+        data.dataSet = new DataSet();
+        data.dataSet.setName(data.realDataSetName);
+      }
+      if (StringUtils.isNotEmpty(data.realFolderName)) {
+        data.dataSet.setFolderName(data.realFolderName);
+      }
+      if (StringUtils.isNotEmpty(data.realCsvFilename)) {
+        data.dataSet.setBaseFilename(data.realCsvFilename);
+      } else if (StringUtils.isEmpty(data.dataSet.getBaseFilename())) {
+        data.dataSet.setBaseFilename(data.realDataSetName + ".csv");
+      }
+      data.dataSet.setFields(DataSet.createFieldsFromRowMeta(rowMeta));
+      serializer.save(data.dataSet);
+      data.setRowMeta = data.dataSet.getSetRowMeta();
+      data.fieldIndexes = identityIndexes(rowMeta.size());
+    } else {
+      DataSet existing = serializer.load(data.realDataSetName);
+      if (existing == null) {
+        throw new HopException(
+            BaseMessages.getString(PKG, "DataSetOutput.Log.DataSetNotFound", 
data.realDataSetName));
+      }
+      if (meta.isValidateDataSet()) {
+        existing.validateRowMeta(rowMeta);
+      }
+      String folder =
+          StringUtils.isNotEmpty(data.realFolderName)
+              ? data.realFolderName
+              : existing.getFolderName();
+      String filename =
+          StringUtils.isNotEmpty(data.realCsvFilename)
+              ? data.realCsvFilename
+              : existing.getBaseFilename();
+      data.dataSet =
+          new DataSet(
+              existing.getName(),
+              existing.getDescription(),
+              folder,
+              filename,
+              existing.getFields());
+      data.setRowMeta = existing.getSetRowMeta();
+      data.fieldIndexes = mapInputToDataSet(rowMeta, data.setRowMeta);
+    }
+
+    data.writer = new DataSetCsvWriter(this, data.dataSet, data.setRowMeta);
+  }
+
+  private int[] identityIndexes(int size) {
+    int[] indexes = new int[size];
+    for (int i = 0; i < size; i++) {
+      indexes[i] = i;
+    }
+    return indexes;
+  }
+
+  private int[] mapInputToDataSet(IRowMeta inputRowMeta, IRowMeta setRowMeta) {
+    int[] indexes = new int[setRowMeta.size()];
+    for (int i = 0; i < setRowMeta.size(); i++) {
+      indexes[i] = 
inputRowMeta.indexOfValue(setRowMeta.getValueMeta(i).getName());
+    }
+    return indexes;
+  }
+
+  private void writeMappedRow(Object[] row) throws HopException {
+    Object[] setRow = RowDataUtil.allocateRowData(data.setRowMeta.size());
+    for (int i = 0; i < data.fieldIndexes.length; i++) {
+      int index = data.fieldIndexes[i];
+      if (index >= 0 && index < row.length) {
+        setRow[i] = row[index];
+      }
+    }
+    data.writer.writeRow(setRow);
+  }
+
+  private void closeWriter() throws HopException {
+    if (data.writer != null) {
+      data.writer.close();
+      data.writer = null;
+    }
+  }
+
+  @Override
+  public void dispose() {
+    try {
+      closeWriter();
+    } catch (HopException e) {
+      logError("Error closing data set CSV file", e);
+    }
+    super.dispose();
+  }
+}
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputData.java
 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputData.java
new file mode 100644
index 0000000000..409f5620f2
--- /dev/null
+++ 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputData.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.testing.transforms.datasetoutput;
+
+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.testing.DataSet;
+import org.apache.hop.testing.DataSetCsvWriter;
+
+@SuppressWarnings("java:S1104")
+public class DataSetOutputData extends BaseTransformData implements 
ITransformData {
+
+  public String realDataSetName;
+  public String realFolderName;
+  public String realCsvFilename;
+  public DataSet dataSet;
+  public IRowMeta setRowMeta;
+  public int[] fieldIndexes;
+  public DataSetCsvWriter writer;
+
+  public DataSetOutputData() {
+    // Do nothing
+  }
+}
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputDialog.java
 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputDialog.java
new file mode 100644
index 0000000000..d0de0e3c0c
--- /dev/null
+++ 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputDialog.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.testing.transforms.datasetoutput;
+
+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.dialog.BaseDialog;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.IGuiPluginCompositeWidgetsListener;
+import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
+
+public class DataSetOutputDialog extends BaseTransformDialog {
+  private static final Class<?> PKG = DataSetOutputMeta.class;
+
+  private final DataSetOutputMeta input;
+  private GuiCompositeWidgets widgets;
+
+  public DataSetOutputDialog(
+      Shell parent,
+      IVariables variables,
+      DataSetOutputMeta transformMeta,
+      PipelineMeta pipelineMeta) {
+    super(parent, variables, transformMeta, pipelineMeta);
+    input = transformMeta;
+  }
+
+  @Override
+  public String open() {
+    createShell(BaseMessages.getString(PKG, 
"DataSetOutputDialog.Shell.Title"));
+
+    buildButtonBar().ok(e -> ok()).cancel(e -> cancel()).build();
+
+    changed = input.hasChanged();
+
+    widgets =
+        GuiCompositeWidgets.addScrolledComposite(
+            shell,
+            variables,
+            wTransformName,
+            wOk,
+            DataSetOutputMeta.GUI_PLUGIN_ELEMENT_PARENT_ID,
+            input);
+    widgets.setCompositeWidgetsListener(
+        new IGuiPluginCompositeWidgetsListener() {
+          @Override
+          public void widgetsCreated(GuiCompositeWidgets compositeWidgets) {
+            // No extra layout after creation
+          }
+
+          @Override
+          public void widgetsPopulated(GuiCompositeWidgets compositeWidgets) {
+            updateValidateEnabled();
+          }
+
+          @Override
+          public void widgetModified(
+              GuiCompositeWidgets compositeWidgets, Control changedWidget, 
String widgetId) {
+            input.setChanged();
+            if (DataSetOutputMeta.WIDGET_RECREATE.equals(widgetId)) {
+              updateValidateEnabled();
+            }
+          }
+
+          @Override
+          public void persistContents(GuiCompositeWidgets compositeWidgets) {
+            // Contents are persisted when the dialog is closed with OK
+          }
+        });
+    updateValidateEnabled();
+
+    focusTransformName();
+    BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
+    return transformName;
+  }
+
+  private void updateValidateEnabled() {
+    Control recreate = 
widgets.getWidgetsMap().get(DataSetOutputMeta.WIDGET_RECREATE);
+    Control validate = 
widgets.getWidgetsMap().get(DataSetOutputMeta.WIDGET_VALIDATE);
+    if (recreate instanceof Button recreateButton && validate instanceof 
Button validateButton) {
+      validateButton.setEnabled(!recreateButton.getSelection());
+    }
+  }
+
+  private void cancel() {
+    transformName = null;
+    input.setChanged(changed);
+    dispose();
+  }
+
+  private void ok() {
+    if (Utils.isEmpty(wTransformName.getText())) {
+      return;
+    }
+
+    widgets.getWidgetsContents(input, 
DataSetOutputMeta.GUI_PLUGIN_ELEMENT_PARENT_ID);
+    transformName = wTransformName.getText();
+    input.setChanged();
+    dispose();
+  }
+}
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputMeta.java
 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputMeta.java
new file mode 100644
index 0000000000..f286660a47
--- /dev/null
+++ 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputMeta.java
@@ -0,0 +1,177 @@
+/*
+ * 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.testing.transforms.datasetoutput;
+
+import static org.apache.hop.core.ICheckResult.TYPE_RESULT_ERROR;
+import static org.apache.hop.core.ICheckResult.TYPE_RESULT_OK;
+
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.CheckResult;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.core.row.IRowMeta;
+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.HopMetadataPropertyType;
+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;
+import org.apache.hop.testing.DataSet;
+
+@Getter
+@Setter
+@Transform(
+    id = "DataSetOutput",
+    description = "Write rows to a data set defined in the metadata",
+    name = "Data set output",
+    image = "write-to-dataset.svg",
+    categoryDescription = 
"i18n:org.apache.hop.pipeline.transform:BaseTransform.Category.Output",
+    keywords = "i18n::DataSetOutputMeta.keyword",
+    documentationUrl = "/pipeline/transforms/datasetoutput.html")
+@GuiPlugin
+public class DataSetOutputMeta extends BaseTransformMeta<DataSetOutput, 
DataSetOutputData> {
+  private static final Class<?> PKG = DataSetOutputMeta.class;
+  public static final String GUI_PLUGIN_ELEMENT_PARENT_ID = 
"DATA_SET_OUTPUT_DIALOG_OPTIONS";
+  public static final String WIDGET_DATA_SET_NAME = "dataSetName";
+  public static final String WIDGET_FOLDER_NAME = "folderName";
+  public static final String WIDGET_CSV_FILENAME = "csvFilename";
+  public static final String WIDGET_RECREATE = "recreateDataSet";
+  public static final String WIDGET_VALIDATE = "validateDataSet";
+
+  @GuiWidgetElement(
+      id = WIDGET_DATA_SET_NAME,
+      order = "0100",
+      type = GuiElementType.METADATA,
+      metadata = DataSet.class,
+      label = "i18n::DataSetOutputMeta.DataSetName.Label",
+      toolTip = "i18n::DataSetOutputMeta.DataSetName.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "Data Set")
+  @HopMetadataProperty(hopMetadataPropertyType = 
HopMetadataPropertyType.PIPELINE_DATA_SET)
+  private String dataSetName;
+
+  @GuiWidgetElement(
+      id = WIDGET_FOLDER_NAME,
+      order = "0200",
+      type = GuiElementType.FOLDER,
+      label = "i18n::DataSetOutputMeta.FolderName.Label",
+      toolTip = "i18n::DataSetOutputMeta.FolderName.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "Data Set")
+  @HopMetadataProperty
+  private String folderName;
+
+  @GuiWidgetElement(
+      id = WIDGET_CSV_FILENAME,
+      order = "0300",
+      type = GuiElementType.TEXT,
+      label = "i18n::DataSetOutputMeta.CsvFilename.Label",
+      toolTip = "i18n::DataSetOutputMeta.CsvFilename.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "Data Set")
+  @HopMetadataProperty
+  private String csvFilename;
+
+  @GuiWidgetElement(
+      id = WIDGET_RECREATE,
+      order = "0400",
+      type = GuiElementType.CHECKBOX,
+      label = "i18n::DataSetOutputMeta.Recreate.Label",
+      toolTip = "i18n::DataSetOutputMeta.Recreate.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "Data Set")
+  @HopMetadataProperty
+  private boolean recreateDataSet;
+
+  @GuiWidgetElement(
+      id = WIDGET_VALIDATE,
+      order = "0500",
+      type = GuiElementType.CHECKBOX,
+      label = "i18n::DataSetOutputMeta.Validate.Label",
+      toolTip = "i18n::DataSetOutputMeta.Validate.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "Data Set")
+  @HopMetadataProperty
+  private boolean validateDataSet;
+
+  public DataSetOutputMeta() {
+    super();
+    this.recreateDataSet = true;
+  }
+
+  @Override
+  public void setDefault() {
+    recreateDataSet = true;
+    validateDataSet = false;
+  }
+
+  @Override
+  public void check(
+      List<ICheckResult> remarks,
+      PipelineMeta pipelineMeta,
+      TransformMeta transformMeta,
+      IRowMeta prev,
+      String[] input,
+      String[] output,
+      IRowMeta info,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider) {
+    if (StringUtils.isEmpty(dataSetName)) {
+      remarks.add(
+          new CheckResult(
+              TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, 
"DataSetOutputMeta.CheckResult.DataSetNameMissing"),
+              transformMeta));
+    } else {
+      remarks.add(
+          new CheckResult(
+              TYPE_RESULT_OK,
+              BaseMessages.getString(PKG, 
"DataSetOutputMeta.CheckResult.DataSetNameOK"),
+              transformMeta));
+    }
+
+    if (input.length > 0) {
+      remarks.add(
+          new CheckResult(
+              TYPE_RESULT_OK,
+              BaseMessages.getString(
+                  PKG, 
"DataSetOutputMeta.CheckResult.ReceivingInfoFromOtherTransforms"),
+              transformMeta));
+    } else {
+      remarks.add(
+          new CheckResult(
+              TYPE_RESULT_ERROR,
+              BaseMessages.getString(PKG, 
"DataSetOutputMeta.CheckResult.NoInputReceived"),
+              transformMeta));
+    }
+  }
+}
diff --git 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/xp/WriteToDataSetExtensionPoint.java
 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/xp/WriteToDataSetExtensionPoint.java
index d4743e8a2a..7b7e67fa9b 100644
--- 
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/xp/WriteToDataSetExtensionPoint.java
+++ 
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/xp/WriteToDataSetExtensionPoint.java
@@ -17,7 +17,6 @@
 
 package org.apache.hop.testing.xp;
 
-import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -37,7 +36,7 @@ import org.apache.hop.pipeline.engine.IPipelineEngine;
 import org.apache.hop.pipeline.transform.RowAdapter;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.testing.DataSet;
-import org.apache.hop.testing.DataSetCsvUtil;
+import org.apache.hop.testing.DataSetCsvWriter;
 import org.apache.hop.testing.util.DataSetConst;
 
 @ExtensionPoint(
@@ -122,8 +121,14 @@ public class WriteToDataSetExtensionPoint
     final IRowMeta setRowMeta = dataSet.getSetRowMeta();
 
     IEngineComponent component = 
pipeline.findComponent(transformMeta.getName(), 0);
+    if (component == null) {
+      throw new HopException(
+          "Could not find pipeline component '"
+              + transformMeta.getName()
+              + "' to write to a data set");
+    }
 
-    final List<Object[]> transformsForDbRows = new ArrayList<>();
+    final DataSetCsvWriter writer = new DataSetCsvWriter(pipeline, dataSet, 
setRowMeta);
 
     component.addRowListener(
         new RowAdapter() {
@@ -133,16 +138,14 @@ public class WriteToDataSetExtensionPoint
             for (SourceToTargetMapping mapping : mappings) {
               transformForDbRow[mapping.getTargetPosition()] = 
row[mapping.getSourcePosition()];
             }
-            transformsForDbRows.add(transformForDbRow);
+            try {
+              writer.writeRow(transformForDbRow);
+            } catch (HopException e) {
+              throw new HopTransformException(e);
+            }
           }
         });
 
-    // At the end of the pipeline, write it...
-    //
-    pipeline.addExecutionFinishedListener(
-        engine ->
-            // Write it
-            //
-            DataSetCsvUtil.writeDataSetData(pipeline, dataSet, setRowMeta, 
transformsForDbRows));
+    pipeline.addExecutionFinishedListener(engine -> writer.close());
   }
 }
diff --git 
a/plugins/misc/testing/src/main/resources/org/apache/hop/testing/transforms/datasetoutput/messages/messages_en_US.properties
 
b/plugins/misc/testing/src/main/resources/org/apache/hop/testing/transforms/datasetoutput/messages/messages_en_US.properties
new file mode 100644
index 0000000000..61a73efc92
--- /dev/null
+++ 
b/plugins/misc/testing/src/main/resources/org/apache/hop/testing/transforms/datasetoutput/messages/messages_en_US.properties
@@ -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.
+#
+#
+
+DataSetOutputMeta.keyword=dataset,unittest,golden,fixture,testing,output
+DataSetOutputMeta.CheckResult.DataSetNameMissing=A data set name is not 
specified
+DataSetOutputMeta.CheckResult.DataSetNameOK=A data set name is specified
+DataSetOutputMeta.CheckResult.ReceivingInfoFromOtherTransforms=Transform is 
receiving input from other transforms
+DataSetOutputMeta.CheckResult.NoInputReceived=No input received from other 
transforms
+
+DataSetOutputDialog.Shell.Title=Data set output
+DataSetOutputMeta.DataSetName.Label=Data set name
+DataSetOutputMeta.DataSetName.Tooltip=The name of the data set to write to. 
You can select an existing data set or type a new name when (re)creating.
+DataSetOutputMeta.FolderName.Label=Folder name
+DataSetOutputMeta.FolderName.Tooltip=Optional folder for the CSV file. Leave 
empty to use '${HOP_DATASETS_FOLDER}'.
+DataSetOutputMeta.CsvFilename.Label=CSV filename
+DataSetOutputMeta.CsvFilename.Tooltip=Base filename of the data set CSV file 
(for example customers.csv). Leave empty to use the data set name with a .csv 
extension.
+DataSetOutputMeta.Recreate.Label=(Re)create data set
+DataSetOutputMeta.Recreate.Tooltip=Create or overwrite the data set metadata 
using the incoming row metadata, then write the CSV file.
+DataSetOutputMeta.Validate.Label=Validate against existing data set
+DataSetOutputMeta.Validate.Tooltip=When not (re)creating, load the existing 
data set and fail if the incoming field names, order or types do not match.
+
+DataSetOutput.Log.OnlyOneCopy=The Data set output transform does not support 
multiple copies
+DataSetOutput.Log.DataSetNameMissing=Please specify the name of the data set 
to write to
+DataSetOutput.Log.NoInputRowMeta=No input row metadata is available for Data 
set output
+DataSetOutput.Log.DataSetNotFound=Data set ''{0}'' could not be found in the 
metadata
diff --git 
a/plugins/misc/testing/src/test/java/org/apache/hop/testing/DataSetCsvWriterTest.java
 
b/plugins/misc/testing/src/test/java/org/apache/hop/testing/DataSetCsvWriterTest.java
new file mode 100644
index 0000000000..da5431180b
--- /dev/null
+++ 
b/plugins/misc/testing/src/test/java/org/apache/hop/testing/DataSetCsvWriterTest.java
@@ -0,0 +1,134 @@
+/*
+ * 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.testing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class DataSetCsvWriterTest {
+
+  @TempDir Path tempDir;
+
+  @BeforeAll
+  static void initHop() throws HopException {
+    HopEnvironment.init();
+  }
+
+  @Test
+  void writeAndReadRoundTrip() throws Exception {
+    Variables variables = new Variables();
+    variables.setVariable(DataSet.VARIABLE_HOP_DATASETS_FOLDER, 
tempDir.toString());
+
+    DataSet dataSet = new DataSet();
+    dataSet.setName("roundtrip");
+    dataSet.setBaseFilename("roundtrip.csv");
+    dataSet.setFields(
+        List.of(
+            new DataSetField("id", IValueMeta.TYPE_INTEGER, -1, 0, "", "0"),
+            new DataSetField("name", IValueMeta.TYPE_STRING, -1, -1, "", "")));
+
+    IRowMeta rowMeta = dataSet.getSetRowMeta();
+    try (DataSetCsvWriter writer = new DataSetCsvWriter(variables, dataSet, 
rowMeta)) {
+      writer.writeRow(new Object[] {1L, "Alice"});
+      writer.writeRow(new Object[] {2L, "Bob"});
+    }
+
+    List<Object[]> rows = DataSetCsvUtil.getAllRows(variables, dataSet);
+    assertEquals(2, rows.size());
+    assertEquals(1L, rows.get(0)[0]);
+    assertEquals("Alice", rows.get(0)[1]);
+    assertEquals(2L, rows.get(1)[0]);
+    assertEquals("Bob", rows.get(1)[1]);
+  }
+
+  @Test
+  void emptyStreamWritesHeaderOnly() throws Exception {
+    Variables variables = new Variables();
+    variables.setVariable(DataSet.VARIABLE_HOP_DATASETS_FOLDER, 
tempDir.toString());
+
+    DataSet dataSet = new DataSet();
+    dataSet.setName("empty");
+    dataSet.setBaseFilename("empty.csv");
+    dataSet.setFields(List.of(new DataSetField("id", IValueMeta.TYPE_INTEGER, 
-1, 0, "", "0")));
+
+    IRowMeta rowMeta = dataSet.getSetRowMeta();
+    try (DataSetCsvWriter writer = new DataSetCsvWriter(variables, dataSet, 
rowMeta)) {
+      // no rows
+    }
+
+    Path csv = tempDir.resolve("empty.csv");
+    assertTrue(Files.exists(csv));
+    String content = Files.readString(csv);
+    assertTrue(content.contains("id"));
+    assertEquals(0, DataSetCsvUtil.getAllRows(variables, dataSet).size());
+  }
+
+  @Test
+  void writeDataSetDataUsesStreamingWriter() throws Exception {
+    Variables variables = new Variables();
+    variables.setVariable(DataSet.VARIABLE_HOP_DATASETS_FOLDER, 
tempDir.toString());
+
+    DataSet dataSet = new DataSet();
+    dataSet.setName("batch");
+    dataSet.setBaseFilename("batch.csv");
+    dataSet.setFields(List.of(new DataSetField("name", IValueMeta.TYPE_STRING, 
-1, -1, "", "")));
+
+    IRowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("name"));
+    DataSetCsvUtil.writeDataSetData(
+        variables, dataSet, rowMeta, List.of(new Object[] {"one"}, new 
Object[] {"two"}));
+
+    List<Object[]> rows = DataSetCsvUtil.getAllRows(variables, dataSet);
+    assertEquals(2, rows.size());
+    assertEquals("one", rows.get(0)[0]);
+    assertEquals("two", rows.get(1)[0]);
+  }
+
+  @Test
+  void createsMissingParentFolder() throws Exception {
+    Variables variables = new Variables();
+    Path nested = tempDir.resolve("nested").resolve("sets");
+    variables.setVariable(DataSet.VARIABLE_HOP_DATASETS_FOLDER, 
nested.toString());
+
+    DataSet dataSet = new DataSet();
+    dataSet.setName("nested");
+    dataSet.setBaseFilename("nested.csv");
+    dataSet.setFields(List.of(new DataSetField("id", IValueMeta.TYPE_INTEGER, 
-1, 0, "", "0")));
+
+    try (DataSetCsvWriter writer =
+        new DataSetCsvWriter(variables, dataSet, dataSet.getSetRowMeta())) {
+      writer.writeRow(new Object[] {7L});
+    }
+
+    assertTrue(Files.exists(nested.resolve("nested.csv")));
+  }
+}
diff --git 
a/plugins/misc/testing/src/test/java/org/apache/hop/testing/DataSetRowMetaTest.java
 
b/plugins/misc/testing/src/test/java/org/apache/hop/testing/DataSetRowMetaTest.java
new file mode 100644
index 0000000000..a9522d9ecf
--- /dev/null
+++ 
b/plugins/misc/testing/src/test/java/org/apache/hop/testing/DataSetRowMetaTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.testing;
+
+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.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class DataSetRowMetaTest {
+
+  @BeforeAll
+  static void initHop() throws HopException {
+    HopEnvironment.init();
+  }
+
+  @Test
+  void createFieldsFromRowMetaCopiesNameTypeLengthPrecisionFormat() {
+    IRowMeta rowMeta = new RowMeta();
+    ValueMetaString name = new ValueMetaString("name", 50, -1);
+    name.setComments("customer name");
+    name.setConversionMask(null);
+    rowMeta.addValueMeta(name);
+    ValueMetaInteger id = new ValueMetaInteger("id", 9, 0);
+    id.setConversionMask("0");
+    rowMeta.addValueMeta(id);
+
+    List<DataSetField> fields = DataSet.createFieldsFromRowMeta(rowMeta);
+
+    assertEquals(2, fields.size());
+    assertEquals("name", fields.get(0).getFieldName());
+    assertEquals(name.getType(), fields.get(0).getType());
+    assertEquals(50, fields.get(0).getLength());
+    assertEquals("customer name", fields.get(0).getComment());
+    assertEquals("id", fields.get(1).getFieldName());
+    assertEquals(id.getType(), fields.get(1).getType());
+    assertEquals("0", fields.get(1).getFormat());
+  }
+
+  @Test
+  void validateRowMetaAcceptsMatchingLayout() throws HopException {
+    DataSet dataSet = sampleDataSet();
+    IRowMeta input = new RowMeta();
+    input.addValueMeta(new ValueMetaInteger("id"));
+    input.addValueMeta(new ValueMetaString("name"));
+    dataSet.validateRowMeta(input);
+  }
+
+  @Test
+  void validateRowMetaAcceptsCaseInsensitiveNames() throws HopException {
+    DataSet dataSet = sampleDataSet();
+    IRowMeta input = new RowMeta();
+    input.addValueMeta(new ValueMetaInteger("ID"));
+    input.addValueMeta(new ValueMetaString("NAME"));
+    dataSet.validateRowMeta(input);
+  }
+
+  @Test
+  void validateRowMetaRejectsTypeMismatch() {
+    DataSet dataSet = sampleDataSet();
+    IRowMeta input = new RowMeta();
+    input.addValueMeta(new ValueMetaString("id"));
+    input.addValueMeta(new ValueMetaString("name"));
+    HopException exception = assertThrows(HopException.class, () -> 
dataSet.validateRowMeta(input));
+    assertTrue(exception.getMessage().contains("type"));
+  }
+
+  @Test
+  void validateRowMetaRejectsNameAndCountMismatch() {
+    DataSet dataSet = sampleDataSet();
+    IRowMeta input = new RowMeta();
+    input.addValueMeta(new ValueMetaInteger("id"));
+    HopException exception = assertThrows(HopException.class, () -> 
dataSet.validateRowMeta(input));
+    assertTrue(exception.getMessage().contains("field count"));
+  }
+
+  private DataSet sampleDataSet() {
+    DataSet dataSet = new DataSet();
+    dataSet.setName("customers");
+    dataSet.setFields(
+        List.of(
+            new DataSetField("id", IValueMeta.TYPE_INTEGER, 9, 0, "", "0"),
+            new DataSetField("name", IValueMeta.TYPE_STRING, 50, -1, "", "")));
+    return dataSet;
+  }
+}
diff --git 
a/plugins/misc/testing/src/test/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputMetaTest.java
 
b/plugins/misc/testing/src/test/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputMetaTest.java
new file mode 100644
index 0000000000..013d8b0ee3
--- /dev/null
+++ 
b/plugins/misc/testing/src/test/java/org/apache/hop/testing/transforms/datasetoutput/DataSetOutputMetaTest.java
@@ -0,0 +1,51 @@
+/*
+ * 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.testing.transforms.datasetoutput;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.pipeline.transform.TransformSerializationTestUtil;
+import org.junit.jupiter.api.Test;
+
+class DataSetOutputMetaTest {
+
+  @Test
+  void testSerialization() throws Exception {
+    DataSetOutputMeta meta =
+        TransformSerializationTestUtil.testSerialization(
+            "/data-set-output-transform.xml", DataSetOutputMeta.class);
+
+    assertEquals("golden-customers", meta.getDataSetName());
+    assertEquals("${HOP_DATASETS_FOLDER}", meta.getFolderName());
+    assertEquals("golden-customers.csv", meta.getCsvFilename());
+    assertTrue(meta.isRecreateDataSet());
+    assertFalse(meta.isValidateDataSet());
+  }
+
+  @Test
+  void testSetDefault() {
+    DataSetOutputMeta meta = new DataSetOutputMeta();
+    meta.setRecreateDataSet(false);
+    meta.setValidateDataSet(true);
+    meta.setDefault();
+    assertTrue(meta.isRecreateDataSet());
+    assertFalse(meta.isValidateDataSet());
+  }
+}
diff --git 
a/plugins/misc/testing/src/test/resources/data-set-output-transform.xml 
b/plugins/misc/testing/src/test/resources/data-set-output-transform.xml
new file mode 100644
index 0000000000..b64648d3f6
--- /dev/null
+++ b/plugins/misc/testing/src/test/resources/data-set-output-transform.xml
@@ -0,0 +1,40 @@
+<?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.
+  ~
+  -->
+<transform>
+    <name>Data set output</name>
+    <type>DataSetOutput</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+        <method>none</method>
+        <schema_name/>
+    </partitioning>
+    <dataSetName>golden-customers</dataSetName>
+    <folderName>${HOP_DATASETS_FOLDER}</folderName>
+    <csvFilename>golden-customers.csv</csvFilename>
+    <recreateDataSet>Y</recreateDataSet>
+    <validateDataSet>N</validateDataSet>
+    <attributes/>
+    <GUI>
+        <xloc>240</xloc>
+        <yloc>96</yloc>
+    </GUI>
+</transform>

Reply via email to