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 8358e4806b Ingore missing plugins XML and keep existing state, fixes
#7778 (#7779)
8358e4806b is described below
commit 8358e4806ba93f18672863c02c6c6d76528e9f6d
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Wed Aug 5 13:31:51 2026 +0200
Ingore missing plugins XML and keep existing state, fixes #7778 (#7779)
---
.../apache/hop/metadata/api/IMissingPlugin.java | 48 ++++
.../metadata/serializer/xml/XmlMetadataUtil.java | 151 ++++++++++-
.../java/org/apache/hop/pipeline/PipelineMeta.java | 7 +
.../hop/pipeline/transform/TransformMeta.java | 50 ++--
.../hop/pipeline/transforms/missing/Missing.java | 19 +-
.../java/org/apache/hop/workflow/WorkflowMeta.java | 7 +
.../org/apache/hop/workflow/action/ActionMeta.java | 49 +++-
.../workflow/actions/missing/MissingAction.java | 21 +-
.../missing/MissingTransformSaveTest.java | 290 +++++++++++++++++++++
.../actions/missing/MissingActionSaveTest.java | 234 +++++++++++++++++
10 files changed, 838 insertions(+), 38 deletions(-)
diff --git a/core/src/main/java/org/apache/hop/metadata/api/IMissingPlugin.java
b/core/src/main/java/org/apache/hop/metadata/api/IMissingPlugin.java
new file mode 100644
index 0000000000..2273e2bb0e
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/metadata/api/IMissingPlugin.java
@@ -0,0 +1,48 @@
+/*
+ * 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.metadata.api;
+
+import java.util.List;
+
+/**
+ * Implemented by the placeholder objects which stand in for a plugin that
isn't installed: a
+ * transform in a pipeline or an action in a workflow.
+ *
+ * <p>Since the plugin isn't available there is no class to de-serialize its
settings into. To make
+ * sure that simply opening and saving the file doesn't destroy the
configuration of that plugin,
+ * the placeholder keeps the XML of the original object untouched and writes
it back out again on
+ * save.
+ */
+public interface IMissingPlugin {
+
+ /** The id of the plugin which couldn't be found. */
+ String getMissingPluginId();
+
+ /**
+ * The XML of the child elements of the object this placeholder replaces,
verbatim, in the order
+ * in which they were read.
+ */
+ List<String> getPreservedXml();
+
+ /**
+ * Remember the XML of the object this placeholder replaces so it can be
written back out on save.
+ *
+ * @param preservedXml the verbatim XML of the original child elements
+ */
+ void setPreservedXml(List<String> preservedXml);
+}
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 a2470a7d59..001d32cbe2 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
@@ -35,6 +35,7 @@ import org.apache.hop.core.encryption.Encr;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.exception.HopMissingPluginsException;
import org.apache.hop.core.exception.HopXmlException;
+import org.apache.hop.core.logging.LogChannel;
import org.apache.hop.core.xml.XmlHandler;
import org.apache.hop.metadata.api.EmptyStringEncoder;
import org.apache.hop.metadata.api.HopMetadataObject;
@@ -47,9 +48,11 @@ import org.apache.hop.metadata.api.IHopMetadata;
import org.apache.hop.metadata.api.IHopMetadataObjectFactory;
import org.apache.hop.metadata.api.IHopMetadataProvider;
import org.apache.hop.metadata.api.IIntCodeConverter;
+import org.apache.hop.metadata.api.IMissingPlugin;
import org.apache.hop.metadata.api.IStringEncoder;
import org.apache.hop.metadata.util.ReflectionUtil;
import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
public class XmlMetadataUtil {
@@ -80,22 +83,129 @@ public class XmlMetadataUtil {
HopMetadataObject metadataObject, Node node, String pluginId) {
String factoryName = metadataObject.objectFactory().getName();
String name = node != null ? XmlHandler.getTagValue(node, "name") : null;
+ Object placeholder = null;
try {
if (FQN_TRANSFORM_FACTORY.equals(factoryName)) {
Class<?> c =
Class.forName("org.apache.hop.pipeline.transforms.missing.Missing");
- return c.getDeclaredConstructor(String.class, String.class)
- .newInstance(Const.NVL(name, ""), pluginId);
- }
- if (FQN_ACTION_FACTORY.equals(factoryName)) {
+ placeholder =
+ c.getDeclaredConstructor(String.class, String.class)
+ .newInstance(Const.NVL(name, ""), pluginId);
+ } else if (FQN_ACTION_FACTORY.equals(factoryName)) {
Class<?> c =
Class.forName("org.apache.hop.workflow.actions.missing.MissingAction");
String actionName = (name == null || name.isEmpty()) ? null : name;
- return c.getDeclaredConstructor(String.class, String.class)
- .newInstance(actionName, pluginId);
+ placeholder =
+ c.getDeclaredConstructor(String.class,
String.class).newInstance(actionName, pluginId);
}
} catch (Exception ignored) {
return null;
}
- return null;
+ if (placeholder instanceof IMissingPlugin missingPlugin) {
+ preserveMissingPluginXml(missingPlugin, node);
+ }
+ return placeholder;
+ }
+
+ /**
+ * Remember the XML of the object a missing plugin placeholder stands in
for. Without this the
+ * settings of the missing plugin are lost the first time the file is saved.
+ *
+ * @param missingPlugin the placeholder to store the XML in
+ * @param node the XML node of the object the placeholder replaces
+ */
+ public static void preserveMissingPluginXml(IMissingPlugin missingPlugin,
Node node) {
+ if (node == null) {
+ return;
+ }
+ List<String> preservedXml = new ArrayList<>();
+ NodeList children = node.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ Node child = children.item(i);
+ if (child.getNodeType() != Node.ELEMENT_NODE) {
+ continue;
+ }
+ try {
+ preservedXml.add(XmlHandler.formatNode(child));
+ } catch (HopXmlException e) {
+ // Preserving is best effort: warn but keep loading the file.
+ //
+ LogChannel.GENERAL.logError(
+ "Unable to preserve the XML of missing plugin "
+ + missingPlugin.getMissingPluginId()
+ + ", the settings of element "
+ + child.getNodeName()
+ + " will be lost when this file is saved",
+ e);
+ }
+ }
+ missingPlugin.setPreservedXml(preservedXml);
+ }
+
+ /**
+ * Get the preserved XML of a missing plugin placeholder, leaving out the
elements which the
+ * object containing the placeholder has serialized itself.
+ *
+ * @param missingPlugin the placeholder holding the preserved XML
+ * @param serializedXml the XML which was serialized for the containing
object so far
+ * @return the XML of the missing plugin, ready to be appended to the
containing object's XML
+ * @throws HopException in case the XML serialized so far can't be parsed
+ */
+ public static String getPreservedMissingPluginXml(
+ IMissingPlugin missingPlugin, String serializedXml) throws HopException {
+ List<String> preservedXml = missingPlugin.getPreservedXml();
+ if (preservedXml == null || preservedXml.isEmpty()) {
+ return "";
+ }
+ Set<String> serializedTags = getTopLevelTags(serializedXml);
+ StringBuilder xml = new StringBuilder();
+ for (String elementXml : preservedXml) {
+ String tag = getRootTag(elementXml);
+ // Whatever the containing object serialized itself takes precedence: it
knows about renames,
+ // moves on the canvas and so on. Everything else belonged to the
missing plugin.
+ //
+ if (tag != null && !serializedTags.contains(tag)) {
+ xml.append(elementXml).append(Const.CR);
+ }
+ }
+ return xml.toString();
+ }
+
+ /** The names of the elements at the top level of an XML fragment. */
+ private static Set<String> getTopLevelTags(String xml) throws HopException {
+ Set<String> tags = new HashSet<>();
+ if (StringUtils.isBlank(xml)) {
+ return tags;
+ }
+ Node wrapper;
+ try {
+ wrapper = XmlHandler.wrapLoadXmlString(xml);
+ } catch (HopXmlException e) {
+ throw new HopException("Unable to parse the XML serialized so far: " +
xml, e);
+ }
+ NodeList children = wrapper.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ Node child = children.item(i);
+ if (child.getNodeType() == Node.ELEMENT_NODE) {
+ tags.add(child.getNodeName());
+ }
+ }
+ return tags;
+ }
+
+ /** The name of the root element of the given XML, without parsing it. */
+ private static String getRootTag(String xml) {
+ int start = xml.indexOf('<');
+ if (start < 0) {
+ return null;
+ }
+ int end = start + 1;
+ while (end < xml.length() && !Character.isWhitespace(xml.charAt(end))) {
+ char c = xml.charAt(end);
+ if (c == '>' || c == '/') {
+ break;
+ }
+ end++;
+ }
+ return end > start + 1 ? xml.substring(start + 1, end) : null;
}
/**
@@ -181,6 +291,7 @@ public class XmlMetadataUtil {
//
List<Field> fields =
ReflectionUtil.findAllFields(objectClass, new
MetadataPropertyKeyFunction(), false);
+ IMissingPlugin missingPlugin = null;
for (Field field : fields) {
// Is this field appropriate to be considered for serialization?
// We check it with the method below.
@@ -202,7 +313,15 @@ public class XmlMetadataUtil {
// Add the field value to the XML
//
- serializeFieldValueToXml(parentObject, field, isBoolean, property,
xml, tag, groupKey);
+ Object value =
+ serializeFieldValueToXml(parentObject, field, isBoolean, property,
xml, tag, groupKey);
+
+ // A placeholder for a plugin which isn't installed carries the XML of
the object it
+ // replaces. We write it back out below, once we know what was
serialized here.
+ //
+ if (value instanceof IMissingPlugin plugin) {
+ missingPlugin = plugin;
+ }
}
}
@@ -227,6 +346,14 @@ public class XmlMetadataUtil {
object, methodProperty, methodProperty.groupKey(),
methodProperty.key()));
}
+ // Write back the settings of a plugin which isn't installed. Everything
this object serialized
+ // itself wins, the rest of the original XML is written out untouched.
+ //
+ if (missingPlugin != null) {
+ int bodyStart = wrapper == null ? 0 :
XmlHandler.openTag(wrapper.tag()).length();
+ xml.append(getPreservedMissingPluginXml(missingPlugin,
xml.substring(bodyStart)));
+ }
+
if (wrapper != null) {
xml.append(XmlHandler.closeTag(wrapper.tag()));
}
@@ -259,7 +386,12 @@ public class XmlMetadataUtil {
return result;
}
- private static void serializeFieldValueToXml(
+ /**
+ * Serialize the value of the given field to XML.
+ *
+ * @return the value of the field which was serialized (or null)
+ */
+ private static Object serializeFieldValueToXml(
Object parentObject,
Field field,
boolean isBoolean,
@@ -285,6 +417,7 @@ public class XmlMetadataUtil {
xml.append(serializeObjectToXml(value, property, groupKey, tag));
}
}
+ return value;
}
private static Class<? extends IIntCodeConverter> getIntCodeConverter(
diff --git a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
index b53a7c7db2..f9f9f10c63 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/PipelineMeta.java
@@ -1641,6 +1641,7 @@ public class PipelineMeta extends AbstractMeta
* that need to be set. This is happening here.
*/
public void lookupReferencesAfterLoading() {
+ missingPipeline = null;
for (TransformMeta transformMeta : transforms) {
ITransformMeta iTransform = transformMeta.getTransform();
@@ -1650,6 +1651,12 @@ public class PipelineMeta extends AbstractMeta
// Also set the parent pipeline to which this transform belongs.
// This is rarely used, for example in getTableFields.getTableFields()
transformMeta.setParentPipelineMeta(this);
+
+ // Keep track of the transforms whose plugin isn't installed so we can
warn about them.
+ //
+ if (iTransform instanceof Missing missing) {
+ addMissingPipeline(missing);
+ }
}
syncTransformErrorHandlingWithHops();
}
diff --git
a/engine/src/main/java/org/apache/hop/pipeline/transform/TransformMeta.java
b/engine/src/main/java/org/apache/hop/pipeline/transform/TransformMeta.java
index 4ba0e2f6ae..735bc4d3ec 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/transform/TransformMeta.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/transform/TransformMeta.java
@@ -44,6 +44,8 @@ import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.IHasName;
import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IMissingPlugin;
+import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
import org.apache.hop.pipeline.PipelineMeta;
import org.apache.hop.pipeline.transform.stream.IStream;
import org.apache.hop.pipeline.transforms.missing.Missing;
@@ -186,34 +188,43 @@ public class TransformMeta
public String getXml() throws HopException {
- StringBuilder xml = new StringBuilder(200);
+ StringBuilder body = new StringBuilder(200);
- xml.append(" ").append(XmlHandler.openTag(XML_TAG)).append(Const.CR);
- xml.append(" ").append(XmlHandler.addTagValue("name", getName()));
- xml.append(" ").append(XmlHandler.addTagValue("type",
getTransformPluginId()));
- xml.append(" ").append(XmlHandler.addTagValue("description",
description));
- xml.append(" ").append(XmlHandler.addTagValue("distribute",
distributes));
- xml.append(" ")
+ body.append(" ").append(XmlHandler.addTagValue("name", getName()));
+ body.append(" ").append(XmlHandler.addTagValue("type",
getTransformPluginId()));
+ body.append(" ").append(XmlHandler.addTagValue("description",
description));
+ body.append(" ").append(XmlHandler.addTagValue("distribute",
distributes));
+ body.append(" ")
.append(
XmlHandler.addTagValue(
"custom_distribution", rowDistribution == null ? null :
rowDistribution.getCode()));
- xml.append(" ").append(XmlHandler.addTagValue("copies", copiesString));
+ body.append(" ").append(XmlHandler.addTagValue("copies", copiesString));
- xml.append(transformPartitioningMeta.getXml());
+ body.append(transformPartitioningMeta.getXml());
if (targetTransformPartitioningMeta != null) {
- xml.append(XmlHandler.openTag(CONST_TARGET_TRANSFORM_PARTITIONING))
+ body.append(XmlHandler.openTag(CONST_TARGET_TRANSFORM_PARTITIONING))
.append(targetTransformPartitioningMeta.getXml())
.append(XmlHandler.closeTag(CONST_TARGET_TRANSFORM_PARTITIONING));
}
- xml.append(transform.getXml());
+ body.append(transform.getXml());
+
+ body.append(AttributesUtil.getAttributesXml(attributesMap));
- xml.append(AttributesUtil.getAttributesXml(attributesMap));
+ body.append(" ").append(XmlHandler.openTag("GUI")).append(Const.CR);
+ body.append(" ").append(XmlHandler.addTagValue("xloc", location.x));
+ body.append(" ").append(XmlHandler.addTagValue("yloc", location.y));
+ body.append(" ").append(XmlHandler.closeTag("GUI")).append(Const.CR);
- xml.append(" ").append(XmlHandler.openTag("GUI")).append(Const.CR);
- xml.append(" ").append(XmlHandler.addTagValue("xloc", location.x));
- xml.append(" ").append(XmlHandler.addTagValue("yloc", location.y));
- xml.append(" ").append(XmlHandler.closeTag("GUI")).append(Const.CR);
+ // The settings of a transform whose plugin isn't installed are written
back out untouched.
+ //
+ if (transform instanceof IMissingPlugin missingPlugin) {
+ body.append(XmlMetadataUtil.getPreservedMissingPluginXml(missingPlugin,
body.toString()));
+ }
+
+ StringBuilder xml = new StringBuilder(body.length() + 100);
+ xml.append(" ").append(XmlHandler.openTag(XML_TAG)).append(Const.CR);
+ xml.append(body);
xml.append("
").append(XmlHandler.closeTag(XML_TAG)).append(Const.CR).append(Const.CR);
return xml.toString();
@@ -239,7 +250,12 @@ public class TransformMeta
registry.findPluginWithId(TransformPluginType.class,
transformPluginId, true);
if (transformPlugin == null) {
- setTransform(new Missing(name, transformPluginId));
+ // The plugin isn't installed: keep the XML of the transform so that
saving the pipeline
+ // doesn't throw away its configuration.
+ //
+ Missing missing = new Missing(name, transformPluginId);
+ XmlMetadataUtil.preserveMissingPluginXml(missing, transformNode);
+ setTransform(missing);
} else {
setTransform((ITransformMeta) registry.loadClass(transformPlugin));
}
diff --git
a/engine/src/main/java/org/apache/hop/pipeline/transforms/missing/Missing.java
b/engine/src/main/java/org/apache/hop/pipeline/transforms/missing/Missing.java
index 3f50fae925..aa219f0719 100644
---
a/engine/src/main/java/org/apache/hop/pipeline/transforms/missing/Missing.java
+++
b/engine/src/main/java/org/apache/hop/pipeline/transforms/missing/Missing.java
@@ -17,18 +17,35 @@
package org.apache.hop.pipeline.transforms.missing;
+import java.util.ArrayList;
+import java.util.List;
import lombok.Getter;
import lombok.Setter;
+import org.apache.hop.metadata.api.IMissingPlugin;
import org.apache.hop.pipeline.transform.BaseTransformMeta;
+/**
+ * Stands in for a transform whose plugin isn't installed. It keeps the XML of
the original
+ * transform untouched so that opening and saving the pipeline doesn't destroy
the configuration of
+ * that transform.
+ */
@Getter
@Setter
-public class Missing extends BaseTransformMeta<MissingTransform, MissingData> {
+public class Missing extends BaseTransformMeta<MissingTransform, MissingData>
+ implements IMissingPlugin {
private String transformName;
private String missingPluginId;
+ private List<String> preservedXml = new ArrayList<>();
public Missing(String transformName, String missingPluginId) {
this.transformName = transformName;
this.missingPluginId = missingPluginId;
}
+
+ @Override
+ public Object clone() {
+ Missing clone = (Missing) super.clone();
+ clone.preservedXml = new ArrayList<>(preservedXml);
+ return clone;
+ }
}
diff --git a/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
b/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
index 05f2fc36a8..e21f72ac4b 100644
--- a/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
+++ b/engine/src/main/java/org/apache/hop/workflow/WorkflowMeta.java
@@ -453,6 +453,7 @@ public class WorkflowMeta extends AbstractMeta
* that need to be set. This is happening here.
*/
public void lookupReferencesAfterLoading() {
+ missingActions = null;
for (ActionMeta actionMeta : workflowActions) {
IAction action = actionMeta.getAction();
@@ -460,6 +461,12 @@ public class WorkflowMeta extends AbstractMeta
// This is rarely used, for example in getTableFields.getTableFields()
//
action.setParentWorkflowMeta(this);
+
+ // Keep track of the actions whose plugin isn't installed so we can warn
about them.
+ //
+ if (action instanceof MissingAction missingAction) {
+ addMissingAction(missingAction);
+ }
}
}
diff --git
a/engine/src/main/java/org/apache/hop/workflow/action/ActionMeta.java
b/engine/src/main/java/org/apache/hop/workflow/action/ActionMeta.java
index 7cf847aea3..f3ad7d8553 100644
--- a/engine/src/main/java/org/apache/hop/workflow/action/ActionMeta.java
+++ b/engine/src/main/java/org/apache/hop/workflow/action/ActionMeta.java
@@ -24,6 +24,8 @@ import org.apache.hop.core.Const;
import org.apache.hop.core.IAttributes;
import org.apache.hop.core.attributes.AttributesUtil;
import org.apache.hop.core.changed.IChanged;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopRuntimeException;
import org.apache.hop.core.exception.HopXmlException;
import org.apache.hop.core.gui.IGuiPosition;
import org.apache.hop.core.gui.Point;
@@ -35,6 +37,8 @@ import org.apache.hop.core.xml.XmlHandler;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.IHasName;
import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IMissingPlugin;
+import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
import org.apache.hop.pipeline.transform.copy.CopyContext;
import org.apache.hop.workflow.WorkflowMeta;
import org.apache.hop.workflow.action.copy.DefaultActionCopyFactory;
@@ -89,19 +93,34 @@ public class ActionMeta
}
public String getXml() {
- StringBuilder xml = new StringBuilder(100);
+ StringBuilder body = new StringBuilder(100);
- xml.append(" ").append(XmlHandler.openTag(XML_TAG)).append(Const.CR);
action.setParentWorkflowMeta(
parentWorkflowMeta); // Attempt to set the WorkflowMeta for entries
that need it
- xml.append(action.getXml());
-
- xml.append(CONST_SPACE).append(XmlHandler.addTagValue("parallel",
launchingInParallel));
- xml.append(CONST_SPACE).append(XmlHandler.addTagValue("xloc", location.x));
- xml.append(CONST_SPACE).append(XmlHandler.addTagValue("yloc", location.y));
-
- xml.append(AttributesUtil.getAttributesXml(attributesMap,
XML_ATTRIBUTE_WORKFLOW_ACTION_COPY));
+ body.append(action.getXml());
+
+ body.append(CONST_SPACE).append(XmlHandler.addTagValue("parallel",
launchingInParallel));
+ body.append(CONST_SPACE).append(XmlHandler.addTagValue("xloc",
location.x));
+ body.append(CONST_SPACE).append(XmlHandler.addTagValue("yloc",
location.y));
+
+ body.append(AttributesUtil.getAttributesXml(attributesMap,
XML_ATTRIBUTE_WORKFLOW_ACTION_COPY));
+
+ // The settings of an action whose plugin isn't installed are written back
out untouched.
+ //
+ if (action instanceof IMissingPlugin missingPlugin) {
+ try {
+
body.append(XmlMetadataUtil.getPreservedMissingPluginXml(missingPlugin,
body.toString()));
+ } catch (HopException e) {
+ throw new HopRuntimeException(
+ "Unable to serialize the settings of missing plugin "
+ + missingPlugin.getMissingPluginId(),
+ e);
+ }
+ }
+ StringBuilder xml = new StringBuilder(body.length() + 100);
+ xml.append(" ").append(XmlHandler.openTag(XML_TAG)).append(Const.CR);
+ xml.append(body);
xml.append(" ").append(XmlHandler.closeTag(XML_TAG)).append(Const.CR);
return xml.toString();
}
@@ -113,8 +132,18 @@ public class ActionMeta
PluginRegistry registry = PluginRegistry.getInstance();
IPlugin actionPlugin = registry.findPluginWithId(ActionPluginType.class,
pluginId, true);
if (actionPlugin == null) {
+ // The plugin isn't installed: keep the XML of the action so that
saving the workflow
+ // doesn't throw away its configuration.
+ //
String name = XmlHandler.getTagValue(actionNode, "name");
- setAction(new MissingAction(name, pluginId));
+ MissingAction missingAction = new MissingAction(name, pluginId);
+ XmlMetadataUtil.preserveMissingPluginXml(missingAction, actionNode);
+ // Keep pointing at the plugin which is missing so the action can be
restored once it is
+ // installed.
+ //
+ missingAction.setPluginId(pluginId);
+ missingAction.setDescription(XmlHandler.getTagValue(actionNode,
"description"));
+ setAction(missingAction);
} else {
setAction(registry.loadClass(actionPlugin, IAction.class));
}
diff --git
a/engine/src/main/java/org/apache/hop/workflow/actions/missing/MissingAction.java
b/engine/src/main/java/org/apache/hop/workflow/actions/missing/MissingAction.java
index a39534216e..356f9b5ff0 100644
---
a/engine/src/main/java/org/apache/hop/workflow/actions/missing/MissingAction.java
+++
b/engine/src/main/java/org/apache/hop/workflow/actions/missing/MissingAction.java
@@ -17,23 +17,35 @@
package org.apache.hop.workflow.actions.missing;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
import org.apache.hop.core.Result;
import org.apache.hop.core.exception.HopWorkflowException;
import org.apache.hop.core.exception.HopXmlException;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.api.IMissingPlugin;
import org.apache.hop.workflow.action.ActionBase;
import org.apache.hop.workflow.action.IAction;
import org.w3c.dom.Node;
-public class MissingAction extends ActionBase implements IAction {
+/**
+ * Stands in for an action whose plugin isn't installed. It keeps the XML of
the original action
+ * untouched so that opening and saving the workflow doesn't destroy the
configuration of that
+ * action.
+ */
+public class MissingAction extends ActionBase implements IAction,
IMissingPlugin {
/** Action unique identifier" */
public static final String ID = "MISSING";
private final String missingPluginId;
+ @Getter @Setter private List<String> preservedXml = new ArrayList<>();
+
public MissingAction() {
this(null, null);
}
@@ -44,6 +56,13 @@ public class MissingAction extends ActionBase implements
IAction {
this.missingPluginId = missingPluginId;
}
+ @Override
+ public Object clone() {
+ MissingAction clone = (MissingAction) super.clone();
+ clone.setPreservedXml(new ArrayList<>(preservedXml));
+ return clone;
+ }
+
@Override
public Result execute(Result previousResult, int nr) throws
HopWorkflowException {
previousResult.setResult(false);
diff --git
a/engine/src/test/java/org/apache/hop/pipeline/transforms/missing/MissingTransformSaveTest.java
b/engine/src/test/java/org/apache/hop/pipeline/transforms/missing/MissingTransformSaveTest.java
new file mode 100644
index 0000000000..a656208a0c
--- /dev/null
+++
b/engine/src/test/java/org/apache/hop/pipeline/transforms/missing/MissingTransformSaveTest.java
@@ -0,0 +1,290 @@
+/*
+ * 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.missing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.hop.core.annotations.Transform;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transform.transforms.FakeMeta;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * A pipeline referencing a transform plugin which isn't installed must
survive a load/save round
+ * trip: the {@link Missing} placeholder keeps the XML of the original
transform untouched and
+ * writes it back out again, so that saving the file doesn't destroy the
configuration of that
+ * transform for anyone who does have the plugin installed.
+ */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class MissingTransformSaveTest {
+
+ private static final String MISSING_PLUGIN_ID =
"ThisTransformPluginIsNotInstalled";
+ private static final String TRANSFORM_NAME = "Transform with a missing
plugin";
+
+ private static final String PIPELINE_XML =
+ """
+ <pipeline>
+ <info>
+ <name>missing-plugin-pipeline</name>
+ </info>
+ <transform>
+ <name>Transform with a missing plugin</name>
+ <type>ThisTransformPluginIsNotInstalled</type>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <connection>my-precious-connection</connection>
+ <schema>my-precious-schema</schema>
+ <table>my-precious-table</table>
+ <commit_size>5000</commit_size>
+ <fields>
+ <field>
+ <column_name>id</column_name>
+ <stream_name>id</stream_name>
+ </field>
+ <field>
+ <column_name>name</column_name>
+ <stream_name>name</stream_name>
+ </field>
+ </fields>
+ <attributes/>
+ <GUI>
+ <xloc>240</xloc>
+ <yloc>128</yloc>
+ </GUI>
+ </transform>
+ </pipeline>
+ """;
+
+ private PipelineMeta loadPipeline(String xml) throws Exception {
+ IVariables variables = new Variables();
+ IHopMetadataProvider metadataProvider = new MemoryMetadataProvider();
+ return new PipelineMeta(
+ new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)),
+ metadataProvider,
+ variables);
+ }
+
+ /** A pipeline referencing an unknown transform plugin still loads, using a
placeholder. */
+ @Test
+ void unknownTransformPluginLoadsAsMissingPlaceholder() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+
+ assertEquals(1, pipelineMeta.nrTransforms());
+ TransformMeta transformMeta = pipelineMeta.getTransform(0);
+
+ assertEquals(TRANSFORM_NAME, transformMeta.getName());
+ assertTrue(transformMeta.isMissing());
+ Missing missing = assertInstanceOf(Missing.class,
transformMeta.getTransform());
+
+ assertEquals(MISSING_PLUGIN_ID, missing.getMissingPluginId());
+ assertEquals(MISSING_PLUGIN_ID, transformMeta.getTransformPluginId());
+ }
+
+ /** The XML of the transform we can't de-serialize is kept, element by
element. */
+ @Test
+ void theOriginalXmlOfTheTransformIsPreserved() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+
+ Missing missing = (Missing) pipelineMeta.getTransform(0).getTransform();
+
+ assertEquals(
+ List.of(
+ "<name>Transform with a missing plugin</name>",
+ "<type>ThisTransformPluginIsNotInstalled</type>",
+ "<distribute>Y</distribute>",
+ "<copies>1</copies>",
+ "<connection>my-precious-connection</connection>",
+ "<schema>my-precious-schema</schema>",
+ "<table>my-precious-table</table>",
+ "<commit_size>5000</commit_size>",
+ "<fields>\n"
+ + " <field>\n"
+ + " <column_name>id</column_name>\n"
+ + " <stream_name>id</stream_name>\n"
+ + " </field>\n"
+ + " <field>\n"
+ + " <column_name>name</column_name>\n"
+ + " <stream_name>name</stream_name>\n"
+ + " </field>\n"
+ + " </fields>",
+ "<attributes/>",
+ "<GUI>\n" + " <xloc>240</xloc>\n" + "
<yloc>128</yloc>\n" + " </GUI>"),
+ missing.getPreservedXml());
+ }
+
+ /** Saving the pipeline writes the settings of the missing plugin back out.
*/
+ @Test
+ void savingAPipelineWithAMissingTransformPluginKeepsItsConfiguration()
throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+
+ String savedXml = pipelineMeta.getXml(new Variables());
+
+ assertTrue(savedXml.contains("<name>" + TRANSFORM_NAME + "</name>"));
+ assertTrue(savedXml.contains("<type>" + MISSING_PLUGIN_ID + "</type>"));
+ assertTrue(savedXml.contains("<xloc>240</xloc>"));
+
+ // Everything the plugin itself owned is still there.
+
assertTrue(savedXml.contains("<connection>my-precious-connection</connection>"));
+ assertTrue(savedXml.contains("<schema>my-precious-schema</schema>"));
+ assertTrue(savedXml.contains("<table>my-precious-table</table>"));
+ assertTrue(savedXml.contains("<commit_size>5000</commit_size>"));
+ assertTrue(savedXml.contains("<column_name>id</column_name>"));
+ assertTrue(savedXml.contains("<stream_name>name</stream_name>"));
+ }
+
+ /**
+ * The elements which the pipeline serializes itself are not duplicated:
what {@link
+ * TransformMeta} writes wins, since that is where renames and moves on the
canvas end up.
+ */
+ @Test
+ void theTransformOwnedElementsAreNotDuplicated() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+ pipelineMeta.getTransform(0).setName("Renamed while the plugin was
missing");
+ pipelineMeta.getTransform(0).setLocation(500, 600);
+
+ String savedXml = transformElement(pipelineMeta.getXml(new Variables()));
+
+ assertEquals(1, countOccurrences(savedXml, "<name>"));
+ assertEquals(1, countOccurrences(savedXml, "<type>"));
+ assertEquals(1, countOccurrences(savedXml, "<GUI>"));
+ assertEquals(1, countOccurrences(savedXml, "<distribute>"));
+
+ assertTrue(savedXml.contains("<name>Renamed while the plugin was
missing</name>"));
+ assertTrue(savedXml.contains("<xloc>500</xloc>"));
+ assertTrue(savedXml.contains("<yloc>600</yloc>"));
+ // ...and the settings of the missing plugin came along.
+ assertTrue(savedXml.contains("<table>my-precious-table</table>"));
+ }
+
+ /** Repeated load/save round trips are stable: nothing is lost and nothing
is added. */
+ @Test
+ void theRoundTripIsStable() throws Exception {
+ String savedOnce = loadPipeline(PIPELINE_XML).getXml(new Variables());
+ String savedTwice = loadPipeline(savedOnce).getXml(new Variables());
+ String savedThrice = loadPipeline(savedTwice).getXml(new Variables());
+
+ assertEquals(savedOnce, savedTwice);
+ assertEquals(savedTwice, savedThrice);
+ assertTrue(savedThrice.contains("<table>my-precious-table</table>"));
+ }
+
+ /** Copy/paste of a transform with a missing plugin keeps its settings as
well. */
+ @Test
+ void theLegacyTransformXmlRoundTripKeepsTheConfiguration() throws Exception {
+ TransformMeta transformMeta = loadPipeline(PIPELINE_XML).getTransform(0);
+
+ String copied = transformMeta.getXml();
+ assertTrue(copied.contains("<table>my-precious-table</table>"));
+ assertEquals(1, countOccurrences(copied, "<name>"));
+
+ TransformMeta pasted = TransformMeta.fromXml(copied);
+ assertTrue(pasted.isMissing());
+ assertEquals(MISSING_PLUGIN_ID, pasted.getTransformPluginId());
+ assertTrue(pasted.getXml().contains("<table>my-precious-table</table>"));
+ }
+
+ /**
+ * The point of it all: a pipeline saved while the plugin was missing still
works once the plugin
+ * is installed again.
+ */
+ @Test
+ void theConfigurationIsRestoredOnceThePluginIsInstalled() throws Exception {
+ String xml =
+ """
+ <pipeline>
+ <info>
+ <name>missing-plugin-pipeline</name>
+ </info>
+ <transform>
+ <name>A fake transform</name>
+ <type>fake</type>
+ <distribute>Y</distribute>
+ <copies>1</copies>
+ <fake>this setting must survive</fake>
+ <attributes/>
+ <GUI>
+ <xloc>240</xloc>
+ <yloc>128</yloc>
+ </GUI>
+ </transform>
+ </pipeline>
+ """;
+
+ // The "fake" plugin isn't registered yet, so the pipeline loads with a
placeholder...
+ //
+ PipelineMeta withoutPlugin = loadPipeline(xml);
+ assertTrue(withoutPlugin.getTransform(0).isMissing());
+
+ // ...and it gets saved in that state.
+ //
+ String savedXml = withoutPlugin.getXml(new Variables());
+
+ // Now install the plugin and open the saved file again.
+ //
+ PluginRegistry.getInstance()
+ .registerPluginClass(FakeMeta.class.getName(),
TransformPluginType.class, Transform.class);
+
+ PipelineMeta withPlugin = loadPipeline(savedXml);
+ TransformMeta transformMeta = withPlugin.getTransform(0);
+
+ assertFalse(transformMeta.isMissing());
+ FakeMeta fakeMeta = assertInstanceOf(FakeMeta.class,
transformMeta.getTransform());
+ assertEquals("this setting must survive", fakeMeta.getFake());
+ }
+
+ /** The pipeline reports the missing plugins so the user can be warned about
them. */
+ @Test
+ void theMissingPluginIsReported() throws Exception {
+ PipelineMeta pipelineMeta = loadPipeline(PIPELINE_XML);
+
+ assertTrue(pipelineMeta.hasMissingPlugins());
+ assertEquals(1, pipelineMeta.getMissingPipeline().size());
+ assertEquals(MISSING_PLUGIN_ID,
pipelineMeta.getMissingPipeline().get(0).getMissingPluginId());
+ }
+
+ /** The {@code <transform>} element of a serialized pipeline, without the
surrounding pipeline. */
+ private static String transformElement(String pipelineXml) {
+ return pipelineXml.substring(
+ pipelineXml.indexOf("<transform>"),
pipelineXml.indexOf("</transform>"));
+ }
+
+ private static int countOccurrences(String xml, String tag) {
+ int count = 0;
+ int index = xml.indexOf(tag);
+ while (index >= 0) {
+ count++;
+ index = xml.indexOf(tag, index + tag.length());
+ }
+ return count;
+ }
+}
diff --git
a/engine/src/test/java/org/apache/hop/workflow/actions/missing/MissingActionSaveTest.java
b/engine/src/test/java/org/apache/hop/workflow/actions/missing/MissingActionSaveTest.java
new file mode 100644
index 0000000000..a8f36e09d5
--- /dev/null
+++
b/engine/src/test/java/org/apache/hop/workflow/actions/missing/MissingActionSaveTest.java
@@ -0,0 +1,234 @@
+/*
+ * 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.workflow.actions.missing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionMeta;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * A workflow referencing an action plugin which isn't installed must survive
a load/save round
+ * trip: the {@link MissingAction} placeholder keeps the XML of the original
action untouched and
+ * writes it back out again, so that saving the file doesn't destroy the
configuration of that
+ * action for anyone who does have the plugin installed.
+ *
+ * <p>This is the workflow counterpart of {@code MissingTransformSaveTest}.
+ */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class MissingActionSaveTest {
+
+ private static final String MISSING_PLUGIN_ID =
"ThisActionPluginIsNotInstalled";
+ private static final String ACTION_NAME = "Action with a missing plugin";
+
+ private static final String WORKFLOW_XML =
+ """
+ <workflow>
+ <name>missing-plugin-workflow</name>
+ <actions>
+ <action>
+ <name>Action with a missing plugin</name>
+ <type>ThisActionPluginIsNotInstalled</type>
+ <description>Send the nightly report</description>
+ <server>my-precious-server</server>
+ <port>2525</port>
+ <destination>[email protected]</destination>
+ <subject>Nightly report</subject>
+ <attachments>
+ <attachment>report.pdf</attachment>
+ </attachments>
+ <parallel>N</parallel>
+ <xloc>240</xloc>
+ <yloc>128</yloc>
+ </action>
+ </actions>
+ </workflow>
+ """;
+
+ private WorkflowMeta loadWorkflow(String xml) throws Exception {
+ IVariables variables = new Variables();
+ IHopMetadataProvider metadataProvider = new MemoryMetadataProvider();
+ return new WorkflowMeta(
+ new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)),
+ metadataProvider,
+ variables);
+ }
+
+ /** A workflow referencing an unknown action plugin still loads, using a
placeholder. */
+ @Test
+ void unknownActionPluginLoadsAsMissingPlaceholder() throws Exception {
+ WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+
+ assertEquals(1, workflowMeta.nrActions());
+ ActionMeta actionMeta = workflowMeta.getAction(0);
+
+ assertEquals(ACTION_NAME, actionMeta.getName());
+ assertTrue(actionMeta.isMissing());
+ MissingAction missing = assertInstanceOf(MissingAction.class,
actionMeta.getAction());
+
+ // The placeholder keeps pointing at the plugin which is missing, so the
action can be restored
+ // once that plugin is installed.
+ assertEquals(MISSING_PLUGIN_ID, missing.getMissingPluginId());
+ assertEquals(MISSING_PLUGIN_ID, missing.getPluginId());
+ }
+
+ /** The XML of the action we can't de-serialize is kept, element by element.
*/
+ @Test
+ void theOriginalXmlOfTheActionIsPreserved() throws Exception {
+ WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+
+ MissingAction missing = (MissingAction)
workflowMeta.getAction(0).getAction();
+
+ assertEquals(
+ List.of(
+ "<name>Action with a missing plugin</name>",
+ "<type>ThisActionPluginIsNotInstalled</type>",
+ "<description>Send the nightly report</description>",
+ "<server>my-precious-server</server>",
+ "<port>2525</port>",
+ "<destination>[email protected]</destination>",
+ "<subject>Nightly report</subject>",
+ "<attachments>\n"
+ + " <attachment>report.pdf</attachment>\n"
+ + " </attachments>",
+ "<parallel>N</parallel>",
+ "<xloc>240</xloc>",
+ "<yloc>128</yloc>"),
+ missing.getPreservedXml());
+ }
+
+ /** Saving the workflow writes the settings of the missing plugin back out.
*/
+ @Test
+ void savingAWorkflowWithAMissingActionPluginKeepsItsConfiguration() throws
Exception {
+ WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+
+ String savedXml = workflowMeta.getXml(new Variables());
+
+ assertTrue(savedXml.contains("<name>" + ACTION_NAME + "</name>"));
+ assertTrue(savedXml.contains("<type>" + MISSING_PLUGIN_ID + "</type>"));
+ assertTrue(savedXml.contains("<description>Send the nightly
report</description>"));
+ assertTrue(savedXml.contains("<xloc>240</xloc>"));
+
+ // Everything the plugin itself owned is still there.
+ assertTrue(savedXml.contains("<server>my-precious-server</server>"));
+ assertTrue(savedXml.contains("<port>2525</port>"));
+
assertTrue(savedXml.contains("<destination>[email protected]</destination>"));
+ assertTrue(savedXml.contains("<subject>Nightly report</subject>"));
+ assertTrue(savedXml.contains("<attachment>report.pdf</attachment>"));
+ }
+
+ /**
+ * The elements which the workflow serializes itself are not duplicated:
what {@link ActionMeta}
+ * writes wins, since that is where renames and moves on the canvas end up.
+ */
+ @Test
+ void theActionOwnedElementsAreNotDuplicated() throws Exception {
+ WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+ workflowMeta.getAction(0).setName("Renamed while the plugin was missing");
+ workflowMeta.getAction(0).setLocation(500, 600);
+
+ String savedXml = actionElement(workflowMeta.getXml(new Variables()));
+
+ assertEquals(1, countOccurrences(savedXml, "<name>"));
+ assertEquals(1, countOccurrences(savedXml, "<type>"));
+ assertEquals(1, countOccurrences(savedXml, "<xloc>"));
+ assertEquals(1, countOccurrences(savedXml, "<parallel>"));
+
+ assertTrue(savedXml.contains("<name>Renamed while the plugin was
missing</name>"));
+ assertTrue(savedXml.contains("<xloc>500</xloc>"));
+ assertTrue(savedXml.contains("<yloc>600</yloc>"));
+ // ...and the settings of the missing plugin came along.
+ assertTrue(savedXml.contains("<server>my-precious-server</server>"));
+ }
+
+ /** Repeated load/save round trips are stable: nothing is lost and nothing
is added. */
+ @Test
+ void theRoundTripIsStable() throws Exception {
+ String savedOnce = loadWorkflow(WORKFLOW_XML).getXml(new Variables());
+ String savedTwice = loadWorkflow(savedOnce).getXml(new Variables());
+ String savedThrice = loadWorkflow(savedTwice).getXml(new Variables());
+
+ assertEquals(stripDates(savedOnce), stripDates(savedTwice));
+ assertEquals(stripDates(savedTwice), stripDates(savedThrice));
+ assertTrue(savedThrice.contains("<server>my-precious-server</server>"));
+ }
+
+ /** Copy/paste of an action with a missing plugin keeps its settings as
well. */
+ @Test
+ void theLegacyActionXmlRoundTripKeepsTheConfiguration() throws Exception {
+ ActionMeta actionMeta = loadWorkflow(WORKFLOW_XML).getAction(0);
+
+ String copied = actionMeta.getXml();
+ assertTrue(copied.contains("<server>my-precious-server</server>"));
+ assertEquals(1, countOccurrences(copied, "<name>"));
+ assertEquals(1, countOccurrences(copied, "<type>"));
+
+ ActionMeta pasted =
+ new ActionMeta(
+ org.apache.hop.core.xml.XmlHandler.getSubNode(
+ org.apache.hop.core.xml.XmlHandler.loadXmlString(copied),
ActionMeta.XML_TAG),
+ new MemoryMetadataProvider(),
+ new Variables());
+
+ assertTrue(pasted.isMissing());
+ MissingAction missing = (MissingAction) pasted.getAction();
+ assertEquals(MISSING_PLUGIN_ID, missing.getMissingPluginId());
+ assertEquals(MISSING_PLUGIN_ID, missing.getPluginId());
+
assertTrue(pasted.getXml().contains("<server>my-precious-server</server>"));
+ }
+
+ /** The workflow reports the missing plugins so the user can be warned about
them. */
+ @Test
+ void theMissingPluginIsReported() throws Exception {
+ WorkflowMeta workflowMeta = loadWorkflow(WORKFLOW_XML);
+
+ assertTrue(workflowMeta.hasMissingPlugins());
+ }
+
+ /** The {@code <action>} element of a serialized workflow, without the
surrounding workflow. */
+ private static String actionElement(String workflowXml) {
+ return workflowXml.substring(workflowXml.indexOf("<action>"),
workflowXml.indexOf("</action>"));
+ }
+
+ /** The created/modified dates change on every save, they are not what these
tests are about. */
+ private static String stripDates(String xml) {
+ return
xml.replaceAll("<(created|modified)_date>[^<]*</(created|modified)_date>", "");
+ }
+
+ private static int countOccurrences(String xml, String tag) {
+ int count = 0;
+ int index = xml.indexOf(tag);
+ while (index >= 0) {
+ count++;
+ index = xml.indexOf(tag, index + tag.length());
+ }
+ return count;
+ }
+}