You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by ha...@apache.org on 2026/04/09 09:24:07 UTC

(hop) branch main updated: fallback to getXml and loadXml if they still exist, fixes #6972 (#6976)

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 8e7e22a6bb fallback to getXml and loadXml if they still exist, fixes #6972 (#6976)
8e7e22a6bb is described below

commit 8e7e22a6bba690833d97664f109c6d6318e56882
Author: Hans Van Akelyen <ha...@gmail.com>
AuthorDate: Thu Apr 9 11:24:01 2026 +0200

    fallback to getXml and loadXml if they still exist, fixes #6972 (#6976)
---
 .../hop/metadata/serializer/xml/ILegacyXml.java    | 13 ++++
 .../metadata/serializer/xml/XmlMetadataUtil.java   | 74 ++++++++++++++++++-
 .../serializer/xml/XmlMetadataUtilTest.java        |  7 ++
 .../hop/pipeline/transform/BaseTransformMeta.java  | 57 ++++++++++++++-
 .../hop/pipeline/transform/LegacyOnlyTestMeta.java | 84 ++++++++++++++++++++++
 .../transform/LegacyTransformXmlBridgeTest.java    | 50 +++++++++++++
 6 files changed, 282 insertions(+), 3 deletions(-)

diff --git a/core/src/main/java/org/apache/hop/metadata/serializer/xml/ILegacyXml.java b/core/src/main/java/org/apache/hop/metadata/serializer/xml/ILegacyXml.java
index a96c4df17a..8ada630a8a 100644
--- a/core/src/main/java/org/apache/hop/metadata/serializer/xml/ILegacyXml.java
+++ b/core/src/main/java/org/apache/hop/metadata/serializer/xml/ILegacyXml.java
@@ -19,6 +19,7 @@
 package org.apache.hop.metadata.serializer.xml;
 
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.w3c.dom.Node;
 
 public interface ILegacyXml {
@@ -30,4 +31,16 @@ public interface ILegacyXml {
    * @throws HopException In case there's an unexpected error in the old XML format.
    */
   void convertLegacyXml(Node node) throws HopException;
+
+  /**
+   * Same as {@link #convertLegacyXml(Node)} with access to the metadata provider used while loading
+   * pipeline/workflow XML. Default delegates to {@link #convertLegacyXml(Node)}.
+   *
+   * @param node The node containing the object properties (often the full transform/action node)
+   * @param metadataProvider May be null when not available
+   */
+  default void convertLegacyXml(Node node, IHopMetadataProvider metadataProvider)
+      throws HopException {
+    convertLegacyXml(node);
+  }
 }
diff --git a/core/src/main/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtil.java b/core/src/main/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtil.java
index 12c8a2694c..ab867ec744 100644
--- a/core/src/main/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtil.java
+++ b/core/src/main/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtil.java
@@ -58,6 +58,16 @@ public class XmlMetadataUtil {
   private static final String FQN_ACTION_FACTORY =
       "org.apache.hop.workflow.action.IAction$ActionFactory";
 
+  private static final String BASE_TRANSFORM_META_CLASS =
+      "org.apache.hop.pipeline.transform.BaseTransformMeta";
+
+  /**
+   * Prevents infinite recursion when {@code getXml()} on a transform delegates back to {@link
+   * #serializeObjectToXml(Object)}.
+   */
+  private static final ThreadLocal<Integer> LEGACY_TRANSFORM_GET_XML_DEPTH =
+      ThreadLocal.withInitial(() -> 0);
+
   private XmlMetadataUtil() {
     // Hides the public constructor
   }
@@ -88,6 +98,41 @@ public class XmlMetadataUtil {
     return null;
   }
 
+  /**
+   * @return true if the class (or a superclass) declares at least one {@link HopMetadataProperty}
+   *     on a field or on a {@code get*()} method, using the same rules as XML serialization.
+   */
+  public static boolean hasHopMetadataSerializableProperties(Class<?> clazz) {
+    Set<String> serializeOnly = Set.of();
+    Set<String> childKeysToIgnore = Set.of();
+    List<Field> fields =
+        ReflectionUtil.findAllFields(clazz, new MetadataPropertyKeyFunction(), false);
+    for (Field field : fields) {
+      if (getValidFieldAnnotation(field, serializeOnly, childKeysToIgnore) != null) {
+        return true;
+      }
+    }
+    for (Method getter : ReflectionUtil.findAllMethods(clazz, "get")) {
+      if (getter.getAnnotation(HopMetadataProperty.class) != null) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static boolean isAssignableFromBaseTransformMeta(Class<?> clazz) {
+    try {
+      ClassLoader cl = clazz.getClassLoader();
+      if (cl == null) {
+        cl = XmlMetadataUtil.class.getClassLoader();
+      }
+      Class<?> base = Class.forName(BASE_TRANSFORM_META_CLASS, false, cl);
+      return base.isAssignableFrom(clazz);
+    } catch (ClassNotFoundException | LinkageError e) {
+      return false;
+    }
+  }
+
   /**
    * This method looks at the fields in the class of the provided parentObject. It then sees which
    * fields have annotation HopMetadataProperty and proceeds to serialize the values of those fields
@@ -186,7 +231,32 @@ public class XmlMetadataUtil {
       xml.append(XmlHandler.closeTag(wrapper.tag()));
     }
 
-    return xml.toString();
+    String result = xml.toString();
+    if (StringUtils.isNotBlank(result)) {
+      return result;
+    }
+    if (!hasHopMetadataSerializableProperties(objectClass)
+        && isAssignableFromBaseTransformMeta(objectClass)
+        && LEGACY_TRANSFORM_GET_XML_DEPTH.get() == 0) {
+      try {
+        LEGACY_TRANSFORM_GET_XML_DEPTH.set(1);
+        Method m = parentObject.getClass().getMethod("getXml");
+        m.trySetAccessible();
+        if (m.getParameterCount() == 0
+            && String.class.equals(m.getReturnType())
+            && !BASE_TRANSFORM_META_CLASS.equals(m.getDeclaringClass().getName())) {
+          Object out = m.invoke(parentObject);
+          if (out instanceof String legacy && StringUtils.isNotEmpty(legacy)) {
+            return legacy;
+          }
+        }
+      } catch (ReflectiveOperationException e) {
+        throw new HopException("Unable to invoke legacy getXml() on " + objectClass.getName(), e);
+      } finally {
+        LEGACY_TRANSFORM_GET_XML_DEPTH.set(0);
+      }
+    }
+    return result;
   }
 
   private static void serializeFieldValueToXml(
@@ -740,7 +810,7 @@ public class XmlMetadataUtil {
 
     if (object instanceof ILegacyXml legacyXml) {
       try {
-        legacyXml.convertLegacyXml(node);
+        legacyXml.convertLegacyXml(node, metadataProvider);
       } catch (HopException e) {
         throw new HopXmlException("Error de-serializing legacy XML", e);
       }
diff --git a/core/src/test/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtilTest.java b/core/src/test/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtilTest.java
index 796c681f21..48d5d25613 100644
--- a/core/src/test/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtilTest.java
+++ b/core/src/test/java/org/apache/hop/metadata/serializer/xml/XmlMetadataUtilTest.java
@@ -18,6 +18,7 @@
 package org.apache.hop.metadata.serializer.xml;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -214,6 +215,12 @@ class XmlMetadataUtilTest {
     assertEquals(withCopy.getHops().size(), listRef.getHops().size());
   }
 
+  @Test
+  void hasHopMetadataSerializablePropertiesReflectsAnnotatedFields() {
+    assertTrue(XmlMetadataUtil.hasHopMetadataSerializableProperties(MetaData.class));
+    assertFalse(XmlMetadataUtil.hasHopMetadataSerializableProperties(Object.class));
+  }
+
   @Test
   void testListReferenceSerializationWithEmptyReference() throws Exception {
     String xml =
diff --git a/engine/src/main/java/org/apache/hop/pipeline/transform/BaseTransformMeta.java b/engine/src/main/java/org/apache/hop/pipeline/transform/BaseTransformMeta.java
index 2e60fb5bdd..1facaafe89 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/transform/BaseTransformMeta.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/transform/BaseTransformMeta.java
@@ -18,12 +18,16 @@
 package org.apache.hop.pipeline.transform;
 
 import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
 import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.IHopAttribute;
@@ -68,6 +72,14 @@ import org.w3c.dom.Node;
 public class BaseTransformMeta<Main extends ITransform, Data extends ITransformData>
     implements ITransformMeta, Cloneable {
 
+  /**
+   * Prevents infinite recursion when {@link #loadXml(Node, IHopMetadataProvider)} calls {@code
+   * super.loadXml}, which runs {@link XmlMetadataUtil#deSerializeFromXml} and reaches {@link
+   * #convertLegacyXml(Node, IHopMetadataProvider)} again for the same instance.
+   */
+  private static final Set<Object> LEGACY_LOAD_XML_REENTRANT_GUARD =
+      Collections.newSetFromMap(new IdentityHashMap<>());
+
   public static final ILoggingObject loggingObject =
       new SimpleLoggingObject("Transform metadata", LoggingObjectType.TRANSFORM_META, null);
 
@@ -897,8 +909,51 @@ public class BaseTransformMeta<Main extends ITransform, Data extends ITransformD
     return null;
   }
 
+  /**
+   * When a transform has no {@link org.apache.hop.metadata.api.HopMetadataProperty} fields,
+   * pipeline XML loaded through {@link org.apache.hop.metadata.serializer.xml.XmlMetadataUtil}
+   * never calls a custom {@code loadXml} unless we bridge here. External plugins that override
+   * {@link #loadXml(Node, IHopMetadataProvider)} are handled by this path.
+   *
+   * <p>The default {@link #loadXml(Node, IHopMetadataProvider)} only re-enters {@link
+   * XmlMetadataUtil#deSerializeFromXml}; calling it from here would recurse with {@link
+   * #convertLegacyXml(Node, IHopMetadataProvider)} (see {@link #declaresOwnLoadXml()}).
+   */
+  @Override
+  public void convertLegacyXml(Node node, IHopMetadataProvider metadataProvider)
+      throws HopException {
+    if (XmlMetadataUtil.hasHopMetadataSerializableProperties(getClass())) {
+      convertLegacyXml(node);
+      return;
+    }
+    if (declaresOwnLoadXml()) {
+      if (!LEGACY_LOAD_XML_REENTRANT_GUARD.add(this)) {
+        return;
+      }
+      try {
+        loadXml(node, metadataProvider);
+      } finally {
+        LEGACY_LOAD_XML_REENTRANT_GUARD.remove(this);
+      }
+    }
+  }
+
+  /**
+   * @return true if this class overrides {@link #loadXml(Node, IHopMetadataProvider)}; the base
+   *     implementation must not be invoked from {@link #convertLegacyXml(Node,
+   *     IHopMetadataProvider)} because it only runs annotation deserialization again.
+   */
+  private boolean declaresOwnLoadXml() {
+    try {
+      Method m = getClass().getMethod("loadXml", Node.class, IHopMetadataProvider.class);
+      return !BaseTransformMeta.class.equals(m.getDeclaringClass());
+    } catch (NoSuchMethodException e) {
+      return false;
+    }
+  }
+
   @Override
   public void convertLegacyXml(Node node) throws HopException {
-    // Nothing by default
+    // Migrated transforms may override to read leftover legacy tags after annotation loading.
   }
 }
diff --git a/engine/src/test/java/org/apache/hop/pipeline/transform/LegacyOnlyTestMeta.java b/engine/src/test/java/org/apache/hop/pipeline/transform/LegacyOnlyTestMeta.java
new file mode 100644
index 0000000000..a6849c7e7a
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/pipeline/transform/LegacyOnlyTestMeta.java
@@ -0,0 +1,84 @@
+/*
+ * 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.transform;
+
+import java.util.List;
+import org.apache.hop.core.CheckResult;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.exception.HopXmlException;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transforms.dummy.Dummy;
+import org.apache.hop.pipeline.transforms.dummy.DummyData;
+import org.w3c.dom.Node;
+
+/**
+ * Public test double mimicking an external plugin: no {@code @HopMetadataProperty}, only {@code
+ * getXml}/{@code loadXml}. Must be top-level public so {@link
+ * org.apache.hop.metadata.serializer.xml.XmlMetadataUtil} can reflectively invoke {@code getXml}.
+ */
+public class LegacyOnlyTestMeta extends BaseTransformMeta<Dummy, DummyData> {
+  String secret = "";
+
+  public String getSecret() {
+    return secret;
+  }
+
+  @Override
+  public String getXml() throws HopException {
+    return XmlHandler.addTagValue("secret", secret);
+  }
+
+  @Override
+  public void loadXml(Node transformNode, IHopMetadataProvider metadataProvider)
+      throws HopXmlException {
+    secret = XmlHandler.getTagValue(transformNode, "secret");
+  }
+
+  @Override
+  public void setDefault() {}
+
+  @Override
+  public void getFields(
+      IRowMeta inputRowMeta,
+      String name,
+      IRowMeta[] info,
+      TransformMeta nextTransform,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider)
+      throws HopTransformException {}
+
+  @Override
+  public void check(
+      List<ICheckResult> remarks,
+      PipelineMeta pipelineMeta,
+      TransformMeta transformMeta,
+      IRowMeta prev,
+      String[] input,
+      String[] output,
+      IRowMeta info,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider) {
+    remarks.add(new CheckResult(ICheckResult.TYPE_RESULT_OK, "ok", transformMeta));
+  }
+}
diff --git a/engine/src/test/java/org/apache/hop/pipeline/transform/LegacyTransformXmlBridgeTest.java b/engine/src/test/java/org/apache/hop/pipeline/transform/LegacyTransformXmlBridgeTest.java
new file mode 100644
index 0000000000..a540c19973
--- /dev/null
+++ b/engine/src/test/java/org/apache/hop/pipeline/transform/LegacyTransformXmlBridgeTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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.transform;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Node;
+
+class LegacyTransformXmlBridgeTest {
+
+  @Test
+  void serializeUsesOverriddenGetXmlWhenNoMetadataProperties() throws HopException {
+    LegacyOnlyTestMeta meta = new LegacyOnlyTestMeta();
+    meta.secret = "external-plugin";
+    String xml = XmlMetadataUtil.serializeObjectToXml(meta);
+    assertTrue(xml.contains("external-plugin"));
+  }
+
+  @Test
+  void deserializeInvokesLoadXmlForLegacyOnlyTransform() throws HopException {
+    String fragment =
+        "<transform><name>x</name><type>Dummy</type><secret>from-xml</secret></transform>";
+    Node transformNode = XmlHandler.getSubNode(XmlHandler.loadXmlString(fragment), "transform");
+    LegacyOnlyTestMeta meta = new LegacyOnlyTestMeta();
+    XmlMetadataUtil.deSerializeFromXml(
+        transformNode, LegacyOnlyTestMeta.class, meta, new MemoryMetadataProvider());
+    assertEquals("from-xml", meta.getSecret());
+  }
+}