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 4f9a865091 keep namespace declarations in the Add XML transform, fixes 
#3320 (#8258)
4f9a865091 is described below

commit 4f9a8650915f76726a9a8511ce1b28e1825195bc
Author: Bart Maertens <[email protected]>
AuthorDate: Fri Sep 4 16:44:41 2026 +0200

    keep namespace declarations in the Add XML transform, fixes #3320 (#8258)
---
 .../ROOT/pages/pipeline/transforms/addxml.adoc     |  19 +
 integration-tests/xml/0020-add-xml-namespace.hpl   | 594 +++++++++++++++++++++
 .../xml/files/expected/0020-default-namespace.xml  |   2 +
 .../xml/files/expected/0020-prefixed-namespace.xml |   2 +
 .../files/expected/0020-secondary-declaration.xml  |   2 +
 .../xml/main-0020-add-xml-namespace.hwf            | 248 +++++++++
 .../hop/pipeline/transforms/xml/addxml/AddXml.java |  94 +++-
 .../pipeline/transforms/xml/addxml/AddXmlData.java |  13 +
 .../xml/addxml/messages/messages_en_US.properties  |   1 +
 .../transforms/xml/addxml/AddXmlNamespaceTest.java | 262 +++++++++
 10 files changed, 1234 insertions(+), 3 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/addxml.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/addxml.adoc
index 27b1c66374..6aab9f258a 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/addxml.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/addxml.adoc
@@ -56,6 +56,25 @@ This XML is added to the row in the form of a String field.
 This is often used to limit the size of the target XML.
 |===
 
+=== Namespaces
+
+Namespaces are declared like any other value: add a field in the Fields tab, 
mark it as an
+attribute and give it the element name of the declaration. Because the value 
comes from the row,
+different rows can use different namespace URIs.
+
+* To put the root element in a default namespace, use the element name 
`xmlns`. A root element
+`catalog` with a field `xmlns` holding `https://hop.apache.org` generates
+`<catalog xmlns="https://hop.apache.org";>`. Child elements without a prefix 
are in that namespace
+too.
+* To bind the root element to a prefix, name the root XML element 
`prefix:element` and declare the
+matching `xmlns:prefix` field. A root element `cat:catalog` with a field 
`xmlns:cat` generates
+`<cat:catalog xmlns:cat="https://hop.apache.org";>`. Unprefixed child elements 
are then *not* in
+that namespace; give them the prefix in their element name if they should be.
+* Any further declarations, e.g. `xmlns:xsi`, can be added as attribute fields 
in the same way.
+
+A prefixed root XML element without a matching `xmlns:prefix` field is an 
error: XML cannot use a
+prefix that is not bound to a namespace URI.
+
 === Fields Tab
 
 The Fields tab is where you configure the output fields and their formats.
diff --git a/integration-tests/xml/0020-add-xml-namespace.hpl 
b/integration-tests/xml/0020-add-xml-namespace.hpl
new file mode 100644
index 0000000000..f0b4ee1895
--- /dev/null
+++ b/integration-tests/xml/0020-add-xml-namespace.hpl
@@ -0,0 +1,594 @@
+<?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>0020-add-xml-namespace</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description>Integration test for apache/hop#3320: the Add XML transform 
must honour a namespace on the root node, both as a default namespace and as a 
prefixed one.</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/09/03 10:00:00.000</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2026/09/03 10:00:00.000</modified_date>
+  </info>
+  <notepads>
+  </notepads>
+  <order>
+    <hop>
+      <from>rows</from>
+      <to>add xml default namespace</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>rows</from>
+      <to>add xml prefixed namespace</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>rows</from>
+      <to>add xml secondary declaration</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>add xml secondary declaration</from>
+      <to>write secondary declaration</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>add xml default namespace</from>
+      <to>write default namespace</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>add xml prefixed namespace</from>
+      <to>write prefixed namespace</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <transform>
+    <name>rows</name>
+    <type>DataGrid</type>
+    <description>Two static rows, so the generated XML is byte-for-byte 
predictable.</description>
+    <distribute>N</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <data>
+      <line>
+        <item>1</item>
+        <item>alpha</item>
+        <item>https://hop.apache.org/one</item>
+        <item>http://www.w3.org/2001/XMLSchema-instance</item>
+        <item>https://hop.apache.org/one one.xsd</item>
+      </line>
+      <line>
+        <item>2</item>
+        <item>beta</item>
+        <item>https://hop.apache.org/two</item>
+        <item>http://www.w3.org/2001/XMLSchema-instance</item>
+        <item>https://hop.apache.org/two two.xsd</item>
+      </line>
+    </data>
+    <fields>
+      <field>
+        <length>-1</length>
+        <precision>-1</precision>
+        <currency/>
+        <set_empty_string>N</set_empty_string>
+        <name>id</name>
+        <format/>
+        <group/>
+        <decimal/>
+        <type>String</type>
+      </field>
+      <field>
+        <length>-1</length>
+        <precision>-1</precision>
+        <currency/>
+        <set_empty_string>N</set_empty_string>
+        <name>name</name>
+        <format/>
+        <group/>
+        <decimal/>
+        <type>String</type>
+      </field>
+      <field>
+        <length>-1</length>
+        <precision>-1</precision>
+        <currency/>
+        <set_empty_string>N</set_empty_string>
+        <name>ns</name>
+        <format/>
+        <group/>
+        <decimal/>
+        <type>String</type>
+      </field>
+      <field>
+        <length>-1</length>
+        <precision>-1</precision>
+        <currency/>
+        <set_empty_string>N</set_empty_string>
+        <name>xsins</name>
+        <format/>
+        <group/>
+        <decimal/>
+        <type>String</type>
+      </field>
+      <field>
+        <length>-1</length>
+        <precision>-1</precision>
+        <currency/>
+        <set_empty_string>N</set_empty_string>
+        <name>schemaloc</name>
+        <format/>
+        <group/>
+        <decimal/>
+        <type>String</type>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>96</xloc>
+      <yloc>112</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>add xml default namespace</name>
+    <type>AddXML</type>
+    <description>Root node in a default namespace: expected &lt;Row 
xmlns="http://www.apache.org/hop/it/addxml"&gt;.</description>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <encoding>UTF-8</encoding>
+    <valueName>xmlDefault</valueName>
+    <xml_repeat_element>Row</xml_repeat_element>
+    <file>
+      <omitXMLheader>Y</omitXMLheader>
+      <omitNullValues>N</omitNullValues>
+    </file>
+    <fields>
+      <field>
+        <name>ns</name>
+        <element>xmlns</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>Y</attribute>
+        <attributeParentName/>
+      </field>
+      <field>
+        <name>ns</name>
+        <element>not_xmlns</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>Y</attribute>
+        <attributeParentName/>
+      </field>
+      <field>
+        <name>id</name>
+        <element>id</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>N</attribute>
+        <attributeParentName/>
+      </field>
+      <field>
+        <name>name</name>
+        <element>name</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>N</attribute>
+        <attributeParentName/>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>288</xloc>
+      <yloc>48</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>add xml prefixed namespace</name>
+    <type>AddXML</type>
+    <description>Root node bound to a prefix: expected &lt;it:Row 
xmlns:it="http://www.apache.org/hop/it/addxml"&gt;.</description>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <encoding>UTF-8</encoding>
+    <valueName>xmlPrefixed</valueName>
+    <xml_repeat_element>it:Row</xml_repeat_element>
+    <file>
+      <omitXMLheader>Y</omitXMLheader>
+      <omitNullValues>N</omitNullValues>
+    </file>
+    <fields>
+      <field>
+        <name>ns</name>
+        <element>xmlns:it</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>Y</attribute>
+        <attributeParentName/>
+      </field>
+      <field>
+        <name>id</name>
+        <element>id</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>N</attribute>
+        <attributeParentName/>
+      </field>
+      <field>
+        <name>name</name>
+        <element>name</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>N</attribute>
+        <attributeParentName/>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>288</xloc>
+      <yloc>176</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>write default namespace</name>
+    <type>TextFileOutput</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <separator/>
+    <enclosure/>
+    <enclosure_forced>N</enclosure_forced>
+    <enclosure_fix_disabled>Y</enclosure_fix_disabled>
+    <header>N</header>
+    <footer>N</footer>
+    <format>UNIX</format>
+    <compression>None</compression>
+    <encoding>UTF-8</encoding>
+    <endedLine/>
+    <fileNameInField>N</fileNameInField>
+    <fileNameField/>
+    <create_parent_folder>Y</create_parent_folder>
+    <file>
+      
<name>${java.io.tmpdir}/hop-xml-integration-output/0020-default-namespace</name>
+      <servlet_output>N</servlet_output>
+      <do_not_open_new_file_init>N</do_not_open_new_file_init>
+      <extention>xml</extention>
+      <append>N</append>
+      <split>N</split>
+      <haspartno>N</haspartno>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <add_to_result_filenames>N</add_to_result_filenames>
+      <pad>N</pad>
+      <fast_dump>N</fast_dump>
+      <splitevery/>
+    </file>
+    <fields>
+      <field>
+        <name>xmlDefault</name>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <trim_type>none</trim_type>
+        <length>-1</length>
+        <precision>-1</precision>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>496</xloc>
+      <yloc>48</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>write prefixed namespace</name>
+    <type>TextFileOutput</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <separator/>
+    <enclosure/>
+    <enclosure_forced>N</enclosure_forced>
+    <enclosure_fix_disabled>Y</enclosure_fix_disabled>
+    <header>N</header>
+    <footer>N</footer>
+    <format>UNIX</format>
+    <compression>None</compression>
+    <encoding>UTF-8</encoding>
+    <endedLine/>
+    <fileNameInField>N</fileNameInField>
+    <fileNameField/>
+    <create_parent_folder>Y</create_parent_folder>
+    <file>
+      
<name>${java.io.tmpdir}/hop-xml-integration-output/0020-prefixed-namespace</name>
+      <servlet_output>N</servlet_output>
+      <do_not_open_new_file_init>N</do_not_open_new_file_init>
+      <extention>xml</extention>
+      <append>N</append>
+      <split>N</split>
+      <haspartno>N</haspartno>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <add_to_result_filenames>N</add_to_result_filenames>
+      <pad>N</pad>
+      <fast_dump>N</fast_dump>
+      <splitevery/>
+    </file>
+    <fields>
+      <field>
+        <name>xmlPrefixed</name>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <trim_type>none</trim_type>
+        <length>-1</length>
+        <precision>-1</precision>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>496</xloc>
+      <yloc>176</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>add xml secondary declaration</name>
+    <type>AddXML</type>
+    <description>Default namespace alongside a secondary xmlns:xsi declaration 
and an xsi:schemaLocation attribute that uses it. The secondary declaration is 
not the root's own, so it is written as an attribute rather than folded into 
the document element.</description>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <encoding>UTF-8</encoding>
+    <valueName>xmlSecondary</valueName>
+    <xml_repeat_element>Row</xml_repeat_element>
+    <file>
+      <omitXMLheader>Y</omitXMLheader>
+      <omitNullValues>N</omitNullValues>
+    </file>
+    <fields>
+      <field>
+        <name>ns</name>
+        <element>xmlns</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>Y</attribute>
+        <attributeParentName/>
+      </field>
+      <field>
+        <name>xsins</name>
+        <element>xmlns:xsi</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>Y</attribute>
+        <attributeParentName/>
+      </field>
+      <field>
+        <name>schemaloc</name>
+        <element>xsi:schemaLocation</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>Y</attribute>
+        <attributeParentName/>
+      </field>
+      <field>
+        <name>id</name>
+        <element>id</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>N</attribute>
+        <attributeParentName/>
+      </field>
+      <field>
+        <name>name</name>
+        <element>name</element>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <length>-1</length>
+        <precision>-1</precision>
+        <attribute>N</attribute>
+        <attributeParentName/>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>288</xloc>
+      <yloc>304</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>write secondary declaration</name>
+    <type>TextFileOutput</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <separator/>
+    <enclosure/>
+    <enclosure_forced>N</enclosure_forced>
+    <enclosure_fix_disabled>Y</enclosure_fix_disabled>
+    <header>N</header>
+    <footer>N</footer>
+    <format>UNIX</format>
+    <compression>None</compression>
+    <encoding>UTF-8</encoding>
+    <endedLine/>
+    <fileNameInField>N</fileNameInField>
+    <fileNameField/>
+    <create_parent_folder>Y</create_parent_folder>
+    <file>
+      
<name>${java.io.tmpdir}/hop-xml-integration-output/0020-secondary-declaration</name>
+      <servlet_output>N</servlet_output>
+      <do_not_open_new_file_init>N</do_not_open_new_file_init>
+      <extention>xml</extention>
+      <append>N</append>
+      <split>N</split>
+      <haspartno>N</haspartno>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <SpecifyFormat>N</SpecifyFormat>
+      <date_time_format/>
+      <add_to_result_filenames>N</add_to_result_filenames>
+      <pad>N</pad>
+      <fast_dump>N</fast_dump>
+      <splitevery/>
+    </file>
+    <fields>
+      <field>
+        <name>xmlSecondary</name>
+        <type>String</type>
+        <format/>
+        <currency/>
+        <decimal/>
+        <group/>
+        <nullif/>
+        <trim_type>none</trim_type>
+        <length>-1</length>
+        <precision>-1</precision>
+      </field>
+    </fields>
+    <attributes/>
+    <GUI>
+      <xloc>496</xloc>
+      <yloc>304</yloc>
+    </GUI>
+  </transform>
+  <transform_error_handling>
+  </transform_error_handling>
+  <attributes/>
+</pipeline>
diff --git a/integration-tests/xml/files/expected/0020-default-namespace.xml 
b/integration-tests/xml/files/expected/0020-default-namespace.xml
new file mode 100644
index 0000000000..846b18bef9
--- /dev/null
+++ b/integration-tests/xml/files/expected/0020-default-namespace.xml
@@ -0,0 +1,2 @@
+<Row xmlns="https://hop.apache.org/one"; 
not_xmlns="https://hop.apache.org/one";><id>1</id><name>alpha</name></Row>
+<Row xmlns="https://hop.apache.org/two"; 
not_xmlns="https://hop.apache.org/two";><id>2</id><name>beta</name></Row>
diff --git a/integration-tests/xml/files/expected/0020-prefixed-namespace.xml 
b/integration-tests/xml/files/expected/0020-prefixed-namespace.xml
new file mode 100644
index 0000000000..9299f2f5a8
--- /dev/null
+++ b/integration-tests/xml/files/expected/0020-prefixed-namespace.xml
@@ -0,0 +1,2 @@
+<it:Row 
xmlns:it="https://hop.apache.org/one";><id>1</id><name>alpha</name></it:Row>
+<it:Row 
xmlns:it="https://hop.apache.org/two";><id>2</id><name>beta</name></it:Row>
diff --git 
a/integration-tests/xml/files/expected/0020-secondary-declaration.xml 
b/integration-tests/xml/files/expected/0020-secondary-declaration.xml
new file mode 100644
index 0000000000..05dd6e502f
--- /dev/null
+++ b/integration-tests/xml/files/expected/0020-secondary-declaration.xml
@@ -0,0 +1,2 @@
+<Row xmlns="https://hop.apache.org/one"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="https://hop.apache.org/one 
one.xsd"><id>1</id><name>alpha</name></Row>
+<Row xmlns="https://hop.apache.org/two"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="https://hop.apache.org/two 
two.xsd"><id>2</id><name>beta</name></Row>
diff --git a/integration-tests/xml/main-0020-add-xml-namespace.hwf 
b/integration-tests/xml/main-0020-add-xml-namespace.hwf
new file mode 100644
index 0000000000..20d2002443
--- /dev/null
+++ b/integration-tests/xml/main-0020-add-xml-namespace.hwf
@@ -0,0 +1,248 @@
+<?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-0020-add-xml-namespace</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Integration test for apache/hop#3320: Add XML must not drop the 
namespace of the root node.</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/09/03 10:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/09/03 10:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>START</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <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>64</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>clean output</name>
+      <description/>
+      <type>DELETE_FOLDERS</type>
+      <attributes/>
+      <arg_from_previous>N</arg_from_previous>
+      <fields>
+        <field>
+          <name>${java.io.tmpdir}/hop-xml-integration-output</name>
+        </field>
+      </fields>
+      <limit_folders>10</limit_folders>
+      <success_condition>success_if_no_errors</success_condition>
+      <parallel>N</parallel>
+      <xloc>192</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>create output</name>
+      <description/>
+      <type>CREATE_FOLDER</type>
+      <attributes/>
+      <foldername>${java.io.tmpdir}/hop-xml-integration-output</foldername>
+      <fail_of_folder_exists>N</fail_of_folder_exists>
+      <parallel>N</parallel>
+      <xloc>320</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>0020-add-xml-namespace.hpl</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <clear_files>N</clear_files>
+      <clear_rows>N</clear_rows>
+      <create_parent_folder>N</create_parent_folder>
+      <exec_per_row>N</exec_per_row>
+      <filename>${PROJECT_HOME}/0020-add-xml-namespace.hpl</filename>
+      <loglevel>Basic</loglevel>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <params_from_previous>N</params_from_previous>
+      <run_configuration>local</run_configuration>
+      <set_append_logfile>N</set_append_logfile>
+      <set_logfile>N</set_logfile>
+      <wait_until_finished>Y</wait_until_finished>
+      <parallel>N</parallel>
+      <xloc>448</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>verify default namespace</name>
+      <description/>
+      <type>FILE_COMPARE</type>
+      <attributes/>
+      
<filename1>${java.io.tmpdir}/hop-xml-integration-output/0020-default-namespace.xml</filename1>
+      
<filename2>${PROJECT_HOME}/files/expected/0020-default-namespace.xml</filename2>
+      <add_filename_result>N</add_filename_result>
+      <parallel>N</parallel>
+      <xloc>624</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>verify prefixed namespace</name>
+      <description/>
+      <type>FILE_COMPARE</type>
+      <attributes/>
+      
<filename1>${java.io.tmpdir}/hop-xml-integration-output/0020-prefixed-namespace.xml</filename1>
+      
<filename2>${PROJECT_HOME}/files/expected/0020-prefixed-namespace.xml</filename2>
+      <add_filename_result>N</add_filename_result>
+      <parallel>N</parallel>
+      <xloc>800</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>verify secondary declaration</name>
+      <description/>
+      <type>FILE_COMPARE</type>
+      <attributes/>
+      
<filename1>${java.io.tmpdir}/hop-xml-integration-output/0020-secondary-declaration.xml</filename1>
+      
<filename2>${PROJECT_HOME}/files/expected/0020-secondary-declaration.xml</filename2>
+      <add_filename_result>N</add_filename_result>
+      <parallel>N</parallel>
+      <xloc>976</xloc>
+      <yloc>64</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>secondary declaration mismatch</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>Add XML mishandled a secondary namespace declaration: generated 
XML does not match 
${PROJECT_HOME}/files/expected/0020-secondary-declaration.xml (see 
apache/hop#3320).</message>
+      <parallel>N</parallel>
+      <xloc>976</xloc>
+      <yloc>192</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>default namespace mismatch</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>Add XML dropped the default namespace: generated XML does not 
match ${PROJECT_HOME}/files/expected/0020-default-namespace.xml (see 
apache/hop#3320).</message>
+      <parallel>N</parallel>
+      <xloc>624</xloc>
+      <yloc>192</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>prefixed namespace mismatch</name>
+      <description/>
+      <type>ABORT</type>
+      <attributes/>
+      <message>Add XML dropped the prefixed namespace: generated XML does not 
match ${PROJECT_HOME}/files/expected/0020-prefixed-namespace.xml (see 
apache/hop#3320).</message>
+      <parallel>N</parallel>
+      <xloc>800</xloc>
+      <yloc>192</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>START</from>
+      <to>clean output</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+    <hop>
+      <from>clean output</from>
+      <to>create output</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>create output</from>
+      <to>0020-add-xml-namespace.hpl</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>0020-add-xml-namespace.hpl</from>
+      <to>verify default namespace</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>verify default namespace</from>
+      <to>default namespace mismatch</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>verify default namespace</from>
+      <to>verify prefixed namespace</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>verify prefixed namespace</from>
+      <to>prefixed namespace mismatch</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>verify prefixed namespace</from>
+      <to>verify secondary declaration</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+    <hop>
+      <from>verify secondary declaration</from>
+      <to>secondary declaration mismatch</to>
+      <enabled>Y</enabled>
+      <evaluation>N</evaluation>
+      <unconditional>N</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git 
a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXml.java
 
b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXml.java
index 36e850d0ea..b0434dfd15 100644
--- 
a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXml.java
+++ 
b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXml.java
@@ -19,6 +19,7 @@ package org.apache.hop.pipeline.transforms.xml.addxml;
 
 import java.io.StringWriter;
 import java.nio.charset.StandardCharsets;
+import javax.xml.XMLConstants;
 import javax.xml.parsers.ParserConfigurationException;
 import javax.xml.transform.OutputKeys;
 import javax.xml.transform.Transformer;
@@ -48,6 +49,11 @@ import org.w3c.dom.NodeList;
 public class AddXml extends BaseTransform<AddXmlMeta, AddXmlData> {
   private static final Class<?> PKG = AddXml.class;
 
+  /** Attribute name declaring a default namespace, and the prefix of a 
namespace declaration. */
+  private static final String XMLNS = "xmlns";
+
+  private static final String XMLNS_PREFIX = XMLNS + ":";
+
   private DOMImplementation domImplentation;
   private Transformer serializer;
 
@@ -76,9 +82,20 @@ public class AddXml extends BaseTransform<AddXmlMeta, 
AddXmlData> {
       processRowFirstCall();
     }
 
-    Document xmlDoc = getDomImplentation().createDocument(null, 
meta.getRootNode(), null);
+    // A default namespace can only be established when the document is 
created, so the value of
+    // the declaring field has to be read before the output fields are walked.
+    String documentNamespace = getDocumentNamespace(r);
+
+    Document xmlDoc =
+        getDomImplentation().createDocument(documentNamespace, 
meta.getRootNode(), null);
     Element root = xmlDoc.getDocumentElement();
     for (int i = 0; i < meta.getOutputFields().size(); i++) {
+      if (i == data.namespaceFieldIndex) {
+        // Already applied to the document element; emitting it again would 
duplicate the
+        // declaration.
+        continue;
+      }
+
       XmlField outputField = meta.getOutputFields().get(i);
       String fieldName = outputField.getFieldName();
 
@@ -112,12 +129,18 @@ public class AddXml extends BaseTransform<AddXmlMeta, 
AddXmlData> {
             }
           }
 
-          node.setAttribute(element, value);
+          if (XMLNS.equals(element) || element.startsWith(XMLNS_PREFIX)) {
+            // A namespace declaration only survives serialization when it is 
set as an attribute
+            // in the xmlns namespace; as a plain attribute the serializer 
discards its value.
+            node.setAttributeNS(XMLConstants.XMLNS_ATTRIBUTE_NS_URI, element, 
value);
+          } else {
+            node.setAttribute(element, value);
+          }
 
         } else {
           /* encode as subnode */
           if (!element.equals(meta.getRootNode())) {
-            Element e = xmlDoc.createElement(element);
+            Element e = createElement(xmlDoc, documentNamespace, element);
             Node n = xmlDoc.createTextNode(value);
             e.appendChild(n);
             root.appendChild(e);
@@ -145,6 +168,39 @@ public class AddXml extends BaseTransform<AddXmlMeta, 
AddXmlData> {
     return true;
   }
 
+  /**
+   * Finds the output field declaring the namespace of the root node. An 
unprefixed root node is
+   * declared by a field named {@code xmlns}, a prefixed one by {@code 
xmlns:prefix}.
+   */
+  private void resolveNamespaceField() throws HopException {
+    data.namespaceFieldIndex = -1;
+
+    String rootNode = Const.NVL(meta.getRootNode(), "");
+    int colon = rootNode.indexOf(':');
+    String declaration = colon > 0 ? XMLNS_PREFIX + rootNode.substring(0, 
colon) : XMLNS;
+    data.rootNamespaceIsDefault = colon <= 0;
+
+    for (int i = 0; i < meta.getOutputFields().size(); i++) {
+      XmlField field = meta.getOutputFields().get(i);
+      String element = field.getElementName();
+      if (Utils.isEmpty(element)) {
+        element = field.getFieldName();
+      }
+      if (declaration.equals(element)) {
+        data.namespaceFieldIndex = i;
+        return;
+      }
+    }
+
+    if (colon > 0) {
+      // The DOM refuses a prefixed root node without a namespace URI. Say 
which field is missing
+      // rather than failing with a bare DOMException.
+      throw new HopException(
+          BaseMessages.getString(
+              PKG, "AddXML.Exception.MissingNamespaceDeclaration", rootNode, 
declaration));
+    }
+  }
+
   private void processRowFirstCall() throws HopException {
     data.outputRowMeta = getInputRowMeta().clone();
     meta.getFields(data.outputRowMeta, getTransformName(), null, null, this, 
metadataProvider);
@@ -160,6 +216,38 @@ public class AddXml extends BaseTransform<AddXmlMeta, 
AddXmlData> {
             BaseMessages.getString(PKG, "AddXML.Exception.FieldNotFound", 
fieldsName));
       }
     }
+
+    resolveNamespaceField();
+  }
+
+  /**
+   * Reads the namespace URI for the root node from the current row. The 
declaring field is the one
+   * whose element name matches the root node's prefix: {@code xmlns} for an 
unprefixed root node,
+   * {@code xmlns:prefix} otherwise.
+   *
+   * @return the namespace URI, or null when the root node has no namespace
+   */
+  private String getDocumentNamespace(Object[] r) throws HopValueException {
+    if (data.namespaceFieldIndex < 0) {
+      return null;
+    }
+    XmlField field = meta.getOutputFields().get(data.namespaceFieldIndex);
+    int index = data.fieldIndexes[data.namespaceFieldIndex];
+    String namespace = formatField(getInputRowMeta().getValueMeta(index), 
r[index], field);
+    return Utils.isEmpty(namespace) ? null : namespace;
+  }
+
+  /**
+   * Creates a child element. When the root node declares a default namespace, 
unprefixed children
+   * are created in it so that the DOM matches what is serialized. Under a 
prefixed root node an
+   * unprefixed child is genuinely in no namespace, and names that already 
carry a prefix are
+   * created as-is: the declaration for that prefix is the user's own.
+   */
+  private Element createElement(Document doc, String documentNamespace, String 
name) {
+    if (documentNamespace == null || !data.rootNamespaceIsDefault || 
name.indexOf(':') >= 0) {
+      return doc.createElement(name);
+    }
+    return doc.createElementNS(documentNamespace, name);
   }
 
   private String formatField(IValueMeta valueMeta, Object valueData, XmlField 
field)
diff --git 
a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXmlData.java
 
b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXmlData.java
index 96c3afd8ed..11b38a7c78 100644
--- 
a/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXmlData.java
+++ 
b/plugins/transforms/xml/src/main/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXmlData.java
@@ -56,6 +56,19 @@ public class AddXmlData extends BaseTransformData implements 
ITransformData {
 
   public int[] fieldIndexes;
 
+  /**
+   * Index, in the output fields, of the field that declares the namespace of 
the root node, or -1
+   * when the root node has no namespace. Its value is read per row and 
applied when the document is
+   * created, which is the only point at which a default namespace can be set.
+   */
+  public int namespaceFieldIndex = -1;
+
+  /**
+   * True when the root node's namespace is declared as a default namespace 
({@code xmlns}) rather
+   * than a prefixed one. Only a default namespace is inherited by unprefixed 
child elements.
+   */
+  public boolean rootNamespaceIsDefault;
+
   /** */
   public AddXmlData() {
     super();
diff --git 
a/plugins/transforms/xml/src/main/resources/org/apache/hop/pipeline/transforms/xml/addxml/messages/messages_en_US.properties
 
b/plugins/transforms/xml/src/main/resources/org/apache/hop/pipeline/transforms/xml/addxml/messages/messages_en_US.properties
index 4bb59c6510..22f1bc2992 100644
--- 
a/plugins/transforms/xml/src/main/resources/org/apache/hop/pipeline/transforms/xml/addxml/messages/messages_en_US.properties
+++ 
b/plugins/transforms/xml/src/main/resources/org/apache/hop/pipeline/transforms/xml/addxml/messages/messages_en_US.properties
@@ -19,6 +19,7 @@
 AddXML.category=Transform
 AddXML.description=Encode several fields into an XML fragment
 AddXML.Exception.FieldNotFound=The specified field ''{0}'' could not be found 
in the input.
+AddXML.Exception.MissingNamespaceDeclaration=Root XML element ''{0}'' uses a 
namespace prefix, but no field declares that namespace. Add an attribute field 
with element name ''{1}'' holding the namespace URI.
 AddXML.name=Add XML
 AddXMLDialog.Attribute.Column=Attribute
 AddXMLDialog.AttributeParentName.Column=Attribute parent name
diff --git 
a/plugins/transforms/xml/src/test/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXmlNamespaceTest.java
 
b/plugins/transforms/xml/src/test/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXmlNamespaceTest.java
new file mode 100644
index 0000000000..d6e09c28a0
--- /dev/null
+++ 
b/plugins/transforms/xml/src/test/java/org/apache/hop/pipeline/transforms/xml/addxml/AddXmlNamespaceTest.java
@@ -0,0 +1,262 @@
+/*
+ * 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.xml.addxml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.RowMetaAndData;
+import org.apache.hop.core.exception.HopException;
+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.apache.hop.core.xml.XmlParserFactoryProducer;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transforms.xml.PipelineTestFactory;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+/**
+ * Runtime tests for the namespace handling of the Add XML transform 
(apache/hop#3320). Namespaces
+ * are declared through regular output fields, so each test runs an actual 
pipeline and asserts on
+ * the generated XML.
+ */
+class AddXmlNamespaceTest {
+
+  private static final String XSI = 
"http://www.w3.org/2001/XMLSchema-instance";;
+  private static final String TRANSFORM = "addXml";
+  private static final String XML_FIELD = "xml";
+
+  @BeforeAll
+  static void setup() throws Exception {
+    HopEnvironment.init();
+  }
+
+  @Test
+  void defaultNamespaceIsTakenFromTheDeclaringField() throws Exception {
+    AddXmlMeta meta = meta("catalog", field("ns", "xmlns", true), field("id", 
"id", false));
+
+    List<RowMetaAndData> out = run(meta, row("ns", "https://hop.apache.org";, 
"id", "1"));
+    Element root = firstRoot(out);
+
+    assertEquals("catalog", root.getTagName());
+    assertEquals("https://hop.apache.org";, root.getNamespaceURI());
+    // the field is consumed as the declaration, so it is written once and not 
also as an attribute
+    assertEquals(1, occurrences(xml(out, 0), "xmlns="));
+  }
+
+  @Test
+  void unprefixedChildrenInheritTheDefaultNamespace() throws Exception {
+    AddXmlMeta meta = meta("catalog", field("ns", "xmlns", true), field("id", 
"id", false));
+
+    Element root = firstRoot(run(meta, row("ns", "https://hop.apache.org";, 
"id", "1")));
+    Element child = (Element) root.getElementsByTagName("id").item(0);
+
+    assertNotNull(child);
+    assertEquals("https://hop.apache.org";, child.getNamespaceURI());
+    assertEquals("1", child.getTextContent());
+  }
+
+  @Test
+  void namespaceCanDifferPerRow() throws Exception {
+    AddXmlMeta meta = meta("catalog", field("ns", "xmlns", true), field("id", 
"id", false));
+
+    List<RowMetaAndData> out =
+        run(
+            meta,
+            row("ns", "https://hop.apache.org/one";, "id", "1"),
+            row("ns", "https://hop.apache.org/two";, "id", "2"));
+
+    assertEquals(2, out.size());
+    assertEquals("https://hop.apache.org/one";, root(out, 0).getNamespaceURI());
+    assertEquals("https://hop.apache.org/two";, root(out, 1).getNamespaceURI());
+  }
+
+  @Test
+  void prefixedRootNodeBindsItsPrefix() throws Exception {
+    AddXmlMeta meta = meta("it:catalog", field("ns", "xmlns:it", true), 
field("id", "id", false));
+
+    Element root = firstRoot(run(meta, row("ns", "https://hop.apache.org";, 
"id", "1")));
+
+    assertEquals("it:catalog", root.getTagName());
+    assertEquals("https://hop.apache.org";, root.getNamespaceURI());
+    assertEquals("it", root.getPrefix());
+  }
+
+  @Test
+  void unprefixedChildrenAreNotInTheNamespaceOfAPrefixedRootNode() throws 
Exception {
+    AddXmlMeta meta = meta("it:catalog", field("ns", "xmlns:it", true), 
field("id", "id", false));
+
+    Element root = firstRoot(run(meta, row("ns", "https://hop.apache.org";, 
"id", "1")));
+    Element child = (Element) root.getElementsByTagName("id").item(0);
+
+    // A prefixed root node declares no default namespace, so an unprefixed 
child is in none.
+    assertNotNull(child);
+    assertNull(child.getNamespaceURI());
+  }
+
+  @Test
+  void prefixedRootNodeWithoutADeclaringFieldFails() {
+    AddXmlMeta meta = meta("it:catalog", field("id", "id", false));
+
+    assertThrows(HopException.class, () -> run(meta, row("id", "1")));
+  }
+
+  @Test
+  void secondaryDeclarationAndAPrefixedAttributeAreKept() throws Exception {
+    AddXmlMeta meta =
+        meta(
+            "catalog",
+            field("ns", "xmlns", true),
+            field("xsins", "xmlns:xsi", true),
+            field("loc", "xsi:schemaLocation", true));
+
+    Element root =
+        firstRoot(
+            run(
+                meta,
+                row(
+                    "ns", "https://hop.apache.org";,
+                    "xsins", XSI,
+                    "loc", "https://hop.apache.org catalog.xsd")));
+
+    assertEquals("https://hop.apache.org";, root.getNamespaceURI());
+    // the prefixed attribute has to resolve through the xsi declaration, not 
the default namespace
+    assertEquals("https://hop.apache.org catalog.xsd", 
root.getAttributeNS(XSI, "schemaLocation"));
+  }
+
+  @Test
+  void withoutADeclaringFieldTheXmlHasNoNamespace() throws Exception {
+    AddXmlMeta meta = meta("catalog", field("id", "id", false));
+
+    Element root = firstRoot(run(meta, row("id", "1")));
+
+    assertEquals("catalog", root.getTagName());
+    assertNull(root.getNamespaceURI());
+  }
+
+  @Test
+  void anEmptyNamespaceValueLeavesTheXmlWithoutANamespace() throws Exception {
+    AddXmlMeta meta = meta("catalog", field("ns", "xmlns", true), field("id", 
"id", false));
+
+    Element root = firstRoot(run(meta, row("ns", "", "id", "1")));
+
+    assertNull(root.getNamespaceURI());
+  }
+
+  @Test
+  void aRegularAttributeIsStillWrittenAsAPlainAttribute() throws Exception {
+    AddXmlMeta meta =
+        meta("catalog", field("ns", "xmlns", true), field("other", 
"not_xmlns", true));
+
+    Element root =
+        firstRoot(run(meta, row("ns", "https://hop.apache.org";, "other", 
"https://example.com";)));
+
+    assertEquals("https://hop.apache.org";, root.getNamespaceURI());
+    assertEquals("https://example.com";, root.getAttribute("not_xmlns"));
+  }
+
+  // 
---------------------------------------------------------------------------
+  // helpers
+  // 
---------------------------------------------------------------------------
+
+  private static XmlField field(String fieldName, String elementName, boolean 
attribute) {
+    XmlField field = new XmlField();
+    field.setFieldName(fieldName);
+    field.setElementName(elementName);
+    field.setType(IValueMeta.TYPE_STRING);
+    field.setLength(-1);
+    field.setPrecision(-1);
+    field.setAttribute(attribute);
+    return field;
+  }
+
+  private static AddXmlMeta meta(String rootNode, XmlField... fields) {
+    AddXmlMeta meta = new AddXmlMeta();
+    meta.setRootNode(rootNode);
+    meta.setValueName(XML_FIELD);
+    meta.getOmitDetails().setOmittingXmlHeader(true);
+    meta.setOutputFields(new ArrayList<>(List.of(fields)));
+    return meta;
+  }
+
+  /** Builds one input row from alternating field name / value pairs. */
+  private static RowMetaAndData row(String... nameValuePairs) {
+    RowMeta rowMeta = new RowMeta();
+    Object[] data = new Object[nameValuePairs.length / 2];
+    for (int i = 0; i < nameValuePairs.length; i += 2) {
+      rowMeta.addValueMeta(new ValueMetaString(nameValuePairs[i]));
+      data[i / 2] = nameValuePairs[i + 1];
+    }
+    return new RowMetaAndData(rowMeta, data);
+  }
+
+  private static List<RowMetaAndData> run(AddXmlMeta meta, RowMetaAndData... 
rows)
+      throws HopException {
+    PipelineMeta pipelineMeta =
+        PipelineTestFactory.generateTestTransformation(new Variables(), meta, 
TRANSFORM);
+    return PipelineTestFactory.executeTestTransformation(
+        pipelineMeta,
+        PipelineTestFactory.INJECTOR_TRANSFORMNAME,
+        TRANSFORM,
+        PipelineTestFactory.DUMMY_TRANSFORMNAME,
+        List.of(rows));
+  }
+
+  private static int occurrences(String haystack, String needle) {
+    int count = 0;
+    for (int i = haystack.indexOf(needle); i >= 0; i = 
haystack.indexOf(needle, i + 1)) {
+      count++;
+    }
+    return count;
+  }
+
+  private static String xml(List<RowMetaAndData> rows, int index) throws 
HopException {
+    String xml = rows.get(index).getString(XML_FIELD, null);
+    assertNotNull(xml, "no XML was added to the row");
+    return xml;
+  }
+
+  private static Element firstRoot(List<RowMetaAndData> rows) throws Exception 
{
+    assertTrue(!rows.isEmpty(), "the transform produced no rows");
+    return root(rows, 0);
+  }
+
+  /** Parses the generated XML of one output row, namespace aware, and returns 
its root element. */
+  private static Element root(List<RowMetaAndData> rows, int index) throws 
Exception {
+    DocumentBuilderFactory factory = 
XmlParserFactoryProducer.createSecureDocBuilderFactory();
+    factory.setNamespaceAware(true);
+    Document document =
+        factory
+            .newDocumentBuilder()
+            .parse(new ByteArrayInputStream(xml(rows, 
index).getBytes(StandardCharsets.UTF_8)));
+    return document.getDocumentElement();
+  }
+}

Reply via email to