Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* 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.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Marks a method that is invoked by the metadata serializers when a property is encountered during
* deserialization that does not match any {@link HopMetadataProperty}-annotated field.
*
* <p>This is the counterpart of {@link HopMetadataProperty} and is mainly used to keep reading
* legacy or renamed keys: the annotated method receives an {@link UnknownProperty} describing the
* key and its raw textual value, and can translate that value into a current field. It is honored
* by both the XML and the JSON metadata serializers.
*
* <h2>Contract</h2>
*
* <ul>
* <li>The annotated method must be {@code public}. Discovery is based on {@link
* Class#getMethods()}, so private and protected methods are ignored.
* <li>The method must declare exactly one parameter of type {@link UnknownProperty}. Any other
* signature raises an {@link IllegalArgumentException}.
* <li>Several {@code @OnUnknownDeserializationProperty} methods may be declared; they are all
* invoked for each unknown property.
* <li>Because discovery goes through {@link Class#getMethods()}, a public override of an
* inherited annotated method replaces it: only the most-derived declaration is invoked, and
* the overridden parent method is not called.
* </ul>
*
* <p>Example - a legacy boolean property replaced by an enum is read into the new field:
*
* <pre>{@code
* @OnUnknownDeserializationProperty
* public void onUnknownDeserializationProperty(UnknownProperty property) {
* if ("legacy_flag".equals(property.getName())) {
* this.value = Boolean.parseBoolean(property.getValue());
* }
* }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface OnUnknownDeserializationProperty {}
Original file line number Diff line number Diff line change
@@ -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;

/** A property found during deserialization that does not map to any annotated field. */
public class UnknownProperty {
private final String name;
private final String value;

public UnknownProperty(String name, String value) {
this.name = name;
this.value = value;
}

/**
* @return The name of the unknown property as found in the serialized data.
*/
public String getName() {
return name;
}

/**
* @return The textual value of the unknown property as found in the serialized data.
*/
public String getValue() {
return value;
}

@Override
public String toString() {
return "UnknownProperty{name='" + name + "', value='" + value + "'}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
Expand All @@ -38,6 +39,7 @@
import org.apache.hop.metadata.api.IHopMetadataObjectFactory;
import org.apache.hop.metadata.api.IHopMetadataProvider;
import org.apache.hop.metadata.api.IHopMetadataSerializer;
import org.apache.hop.metadata.api.UnknownProperty;
import org.apache.hop.metadata.util.ReflectionUtil;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
Expand Down Expand Up @@ -93,6 +95,8 @@ private void loadProperties(Object object, com.fasterxml.jackson.core.JsonParser

// Load all the properties found in the JSON...
//
List<Consumer<UnknownProperty>> unknownPropertyCallbacks =
ReflectionUtil.onUnknownDeserializationPropertyCallbacks(object);
try {
while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
String key = jsonParser.getCurrentName();
Expand All @@ -101,6 +105,35 @@ private void loadProperties(Object object, com.fasterxml.jackson.core.JsonParser
// This is a recognized piece of data. We can load this...
//
loadProperty(object, jsonParser, key, field);
} else if (!unknownPropertyCallbacks.isEmpty()) {
// A key that does not map to any annotated field. Read its raw value and fire the
// callbacks so they can translate a legacy/renamed property into a current field.
//
// Advance to the value token. For an unknown structured value (object or array) we
// cannot meaningfully hand it to a scalar legacy callback, so consume the whole
// subtree with skipChildren() and pass null. Scalar values are passed through
// unchanged (a string that literally reads "null" stays "null"); only a real JSON
// null becomes a Java null.
//
JsonToken valueToken = jsonParser.nextToken();
String rawValue;
if (valueToken == JsonToken.START_OBJECT || valueToken == JsonToken.START_ARRAY) {
jsonParser.skipChildren();
rawValue = null;
} else {
rawValue = valueToken == JsonToken.VALUE_NULL ? null : jsonParser.getText();
}
UnknownProperty unknownProperty = new UnknownProperty(key, rawValue);
try {
unknownPropertyCallbacks.forEach(callback -> callback.accept(unknownProperty));
} catch (IllegalArgumentException e) {
throw new HopException(
"Error invoking @OnUnknownDeserializationProperty callback for key '"
+ key
+ "' on class "
+ objectClass.getName(),
e);
}
}
}
} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.util.Map;
import java.util.MissingResourceException;
import java.util.Set;
import java.util.function.Consumer;
import org.apache.commons.lang3.StringUtils;
import org.apache.hop.core.Const;
import org.apache.hop.core.encryption.Encr;
Expand All @@ -50,6 +51,7 @@
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.api.UnknownProperty;
import org.apache.hop.metadata.util.ReflectionUtil;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
Expand Down Expand Up @@ -799,6 +801,7 @@ public static <T> T deSerializeFromXml(
if (parentProperty != null) {
childKeysToIgnore.addAll(Set.of(parentProperty.childKeysToIgnore()));
}
Set<String> knownKeys = new HashSet<>();

if (object == null) {
object = createNewObject(parentObject, node, clazz);
Expand Down Expand Up @@ -842,6 +845,7 @@ public static <T> T deSerializeFromXml(
if (StringUtils.isEmpty(tag)) {
tag = field.getName();
}
knownKeys.add(tag);
Node tagNode;
if (property.inline()) {
tagNode = node;
Expand All @@ -851,6 +855,9 @@ public static <T> T deSerializeFromXml(

// Get the groupKey & groupNode
String groupKey = property.groupKey();
if (StringUtils.isNotEmpty(groupKey)) {
knownKeys.add(groupKey);
}
Node groupNode;
if (StringUtils.isEmpty(groupKey)) {
groupNode = node;
Expand Down Expand Up @@ -912,12 +919,16 @@ public static <T> T deSerializeFromXml(

// Get the groupKey & groupNode
String groupKey = property.groupKey();
if (StringUtils.isNotEmpty(groupKey)) {
knownKeys.add(groupKey);
}
Node groupNode;
if (StringUtils.isEmpty(groupKey)) {
groupNode = node;
} else {
groupNode = XmlHandler.getSubNode(node, groupKey);
}
knownKeys.add(tag);

// Find the single argument of the setter
//
Expand Down Expand Up @@ -954,6 +965,36 @@ public static <T> T deSerializeFromXml(
throw new HopXmlException("Error calling setter method on class " + clazz.getName(), e);
}

// Fire the @OnUnknownDeserializationProperty callbacks for any child element that does not map
// to an annotated field or group. This allows translating legacy/renamed keys into current
// fields.
//
List<Consumer<UnknownProperty>> unknownPropertyCallbacks =
ReflectionUtil.onUnknownDeserializationPropertyCallbacks(object);
if (!unknownPropertyCallbacks.isEmpty()) {
for (Node child = node.getFirstChild(); child != null; child = child.getNextSibling()) {
if (child.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
String childName = child.getNodeName();
if (knownKeys.contains(childName)) {
continue;
}
UnknownProperty unknownProperty =
new UnknownProperty(childName, XmlHandler.getNodeValue(child));
try {
unknownPropertyCallbacks.forEach(callback -> callback.accept(unknownProperty));
} catch (IllegalArgumentException e) {
throw new HopXmlException(
"Error invoking @OnUnknownDeserializationProperty callback for key '"
+ childName
+ "' on class "
+ clazz.getName(),
e);
}
}
}

if (object instanceof ILegacyXml legacyXml) {
try {
legacyXml.convertLegacyXml(node, metadataProvider);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
import org.apache.commons.lang3.StringUtils;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.OnUnknownDeserializationProperty;
import org.apache.hop.metadata.api.UnknownProperty;

public class ReflectionUtil {
private ReflectionUtil() {}
Expand Down Expand Up @@ -277,4 +282,73 @@ public static List<Method> findAllMethods(Class<?> clazz, String prefix) {

return new ArrayList<>(methodSet);
}

private static final Map<Class<?>, List<Method>> UNKNOWN_PROPERTY_METHODS =
new ConcurrentHashMap<>();

/**
* Returns the {@link Consumer}s that translate unknown deserialization properties, declared with
* {@link OnUnknownDeserializationProperty}, bound to the given instance.
*
* <p>Discovery is based on {@link Class#getMethods()}, so only {@code public} methods are
* considered (private/protected annotated methods are ignored) and a public override in a subtype
* replaces the inherited method (only the most-derived declaration is invoked). Multiple
* annotated methods are all returned, in reflection order.
*
* <p>Each annotated method must declare exactly one parameter of type {@link UnknownProperty};
* anything else raises an {@link IllegalArgumentException}.
*
* <p>The validated method list is cached per class; the returned consumers are fresh but cheap.
*
* @param instance The object on which the annotated methods are invoked.
* @return The list of instance-bound callbacks, possibly empty (never {@code null}).
*/
public static List<Consumer<UnknownProperty>> onUnknownDeserializationPropertyCallbacks(
Object instance) {
List<Method> methods =
UNKNOWN_PROPERTY_METHODS.computeIfAbsent(instance.getClass(), ReflectionUtil::collect);
List<Consumer<UnknownProperty>> callbacks = new ArrayList<>(methods.size());
for (Method method : methods) {
callbacks.add(toUnknownPropertyCallback(instance, method));
}
return List.copyOf(callbacks);
}

private static List<Method> collect(Class<?> type) {
List<Method> methods = new ArrayList<>();
for (Method method : type.getMethods()) {
if (!method.isAnnotationPresent(OnUnknownDeserializationProperty.class)) {
continue;
}
Class<?>[] parameterTypes = method.getParameterTypes();
if (parameterTypes.length != 1 || !UnknownProperty.class.equals(parameterTypes[0])) {
throw new IllegalArgumentException(
"@OnUnknownDeserializationProperty method "
+ type.getName()
+ "."
+ method.getName()
+ " must have exactly one parameter of type "
+ UnknownProperty.class.getName());
}
methods.add(method);
}
return List.copyOf(methods);
}

private static Consumer<UnknownProperty> toUnknownPropertyCallback(
Object instance, Method method) {
return unknownProperty -> {
try {
method.invoke(instance, unknownProperty);
} catch (Exception e) {
Throwable cause = e.getCause() != null ? e.getCause() : e;
throw new IllegalArgumentException(
"Unable to invoke @OnUnknownDeserializationProperty callback "
+ instance.getClass().getName()
+ "."
+ method.getName(),
cause);
}
};
}
}
Loading
Loading