diff --git a/README-EN-source.adoc b/README-EN-source.adoc index e7f30950..f44a23c2 100644 --- a/README-EN-source.adoc +++ b/README-EN-source.adoc @@ -456,6 +456,24 @@ The following example code adds a `hello()` extension function to the String cla include::./src/test/java/com/alibaba/qlexpress4/Express4RunnerTest.java[tag=extensionFunction] ---- +=== Extend Field Handler + +By default, `obj.field` can only read fields or getters of standard Java Beans. For non-standard containers such as Flink Row, JDBC ResultSet or user-defined MapLike/CollectionLike structures, field access is closed, and users usually have to convert and copy the data before they can access it in scripts. + +With `addExtendFieldHandler(Class, ExtendFieldHandler)` you can register a custom field-access handler for a given type, so that these non-standard containers can also be accessed with the regular `obj.field` syntax: + +[source,java,indent=0] +---- +include::./src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java[tag=extendFieldHandler] +---- + +Once a bean is an instance of the binding class (`bindingClass`), the handler becomes the *authoritative* source for that type's fields: whatever it returns is taken as the field value, and returning `null` means the field value itself is `null` rather than falling back to the default Java reflection logic. The default logic only applies when no handler's binding class matches the current bean. Therefore the two cases of `obj.field` are distinguished as follows: + +* the bean type is not bound to any handler → the default reflection logic applies; +* the bean type is bound but the field value is `null` → `null` is returned. + +If a container should signal "field does not exist" rather than return `null`, throw an exception from within the handler. + === Java Class Object, Field, and Method Aliases QLExpress supports defining one or more aliases for objects, fields, or methods through the `QLAlias` annotation, making it convenient for non-technical personnel to use expressions to define rules. diff --git a/README-EN.adoc b/README-EN.adoc index 44231101..d3eed011 100644 --- a/README-EN.adoc +++ b/README-EN.adoc @@ -788,9 +788,38 @@ The following example code adds a `hello()` extension function to the String cla params -> ((Number)params[0]).intValue() + ((Number)params[1]).intValue()); QLResult resultAdd = express4Runner.execute("1.add(2)", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS); assertEquals(3, resultAdd.getResult()); - + ---- +=== Extend Field Handler + +By default, `obj.field` can only read fields or getters of standard Java Beans. For non-standard containers such as Flink Row, JDBC ResultSet or user-defined MapLike/CollectionLike structures, field access is closed, and users usually have to convert and copy the data before they can access it in scripts. + +With `addExtendFieldHandler(Class, ExtendFieldHandler)` you can register a custom field-access handler for a given type, so that these non-standard containers can also be accessed with the regular `obj.field` syntax: + +[source,java,indent=0] +---- + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + // RowLike is a non-standard container whose fields can only be read via getValue(name); + // register a handler so it can be accessed with the regular obj.field syntax in scripts. + runner.addExtendFieldHandler(RowLike.class, (bean, fieldName) -> ((RowLike) bean).getValue(fieldName)); + + RowLike row = new RowLike(new String[] { "name", "age" }, new Object[] { "张三", 30 }); + Map context = new HashMap<>(); + context.put("row", row); + + Object name = runner.execute("row.name", context, QLOptions.DEFAULT_OPTIONS).getResult(); + Assert.assertEquals("张三", name); +---- + +Once a bean is an instance of the binding class (`bindingClass`), the handler becomes the *authoritative* source for that type's fields: whatever it returns is taken as the field value, and returning `null` means the field value itself is `null` rather than falling back to the default Java reflection logic. The default logic only applies when no handler's binding class matches the current bean. Therefore the two cases of `obj.field` are distinguished as follows: + +* the bean type is not bound to any handler → the default reflection logic applies; +* the bean type is bound but the field value is `null` → `null` is returned. + +If a container should signal "field does not exist" rather than return `null`, throw an exception from within the handler. + === Java Class Object, Field, and Method Aliases QLExpress supports defining one or more aliases for objects, fields, or methods through the `QLAlias` annotation, making it convenient for non-technical personnel to use expressions to define rules. diff --git a/README-source.adoc b/README-source.adoc index 85cc80a8..647d6b30 100644 --- a/README-source.adoc +++ b/README-source.adoc @@ -457,6 +457,24 @@ include::./src/test/java/com/alibaba/qlexpress4/Express4RunnerTest.java[tag=scri include::./src/test/java/com/alibaba/qlexpress4/Express4RunnerTest.java[tag=extensionFunction] ---- +=== 扩展字段取值 + +默认情况下,`obj.field` 只能读取标准 Java Bean 的字段或 getter。对于 Flink Row、JDBC ResultSet 或者自定义的 MapLike/CollectionLike 等非标准容器,字段取值逻辑是封闭的,用户往往需要先做一次转换拷贝才能在脚本中访问。 + +通过 `addExtendFieldHandler(Class, ExtendFieldHandler)` 可以给某个类型注册一个自定义的字段取值处理器,让这些非标准容器也能直接用 `obj.field` 语法访问: + +[source,java,indent=0] +---- +include::./src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java[tag=extendFieldHandler] +---- + +一旦 bean 是绑定类(`bindingClass`)的实例,该处理器就是这个类型字段取值的**权威**来源:它返回什么就是什么,返回 `null` 表示字段的值就是 `null`,而不会回退到默认的 Java 反射逻辑。只有当没有任何处理器的绑定类匹配当前 bean 时,才走默认取值逻辑。因此 `obj.field` 的两种情况可以这样区分: + +* bean 的类型没有绑定任何处理器 → 走默认反射逻辑; +* bean 的类型绑定了处理器但字段值为 `null` → 返回 `null`。 + +如果某个容器希望对「字段不存在」报错而不是返回 `null`,可以在处理器内部主动抛出异常。 + === Java类的对象,字段和方法别名 QLExpress 支持通过 `QLAlias` 注解给对象,字段或者方法定义一个或多个别名,方便非技术人员使用表达式定义规则。 diff --git a/README.adoc b/README.adoc index a1dfda59..077ffe2c 100644 --- a/README.adoc +++ b/README.adoc @@ -789,9 +789,38 @@ QLExpress 使用 ANTLR4 作为解析引擎,ANTLR4 在运行时会构建 DFA ( params -> ((Number)params[0]).intValue() + ((Number)params[1]).intValue()); QLResult resultAdd = express4Runner.execute("1.add(2)", Collections.emptyMap(), QLOptions.DEFAULT_OPTIONS); assertEquals(3, resultAdd.getResult()); - + ---- +=== 扩展字段取值 + +默认情况下,`obj.field` 只能读取标准 Java Bean 的字段或 getter。对于 Flink Row、JDBC ResultSet 或者自定义的 MapLike/CollectionLike 等非标准容器,字段取值逻辑是封闭的,用户往往需要先做一次转换拷贝才能在脚本中访问。 + +通过 `addExtendFieldHandler(Class, ExtendFieldHandler)` 可以给某个类型注册一个自定义的字段取值处理器,让这些非标准容器也能直接用 `obj.field` 语法访问: + +[source,java,indent=0] +---- + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + // RowLike is a non-standard container whose fields can only be read via getValue(name); + // register a handler so it can be accessed with the regular obj.field syntax in scripts. + runner.addExtendFieldHandler(RowLike.class, (bean, fieldName) -> ((RowLike) bean).getValue(fieldName)); + + RowLike row = new RowLike(new String[] { "name", "age" }, new Object[] { "张三", 30 }); + Map context = new HashMap<>(); + context.put("row", row); + + Object name = runner.execute("row.name", context, QLOptions.DEFAULT_OPTIONS).getResult(); + Assert.assertEquals("张三", name); +---- + +一旦 bean 是绑定类(`bindingClass`)的实例,该处理器就是这个类型字段取值的**权威**来源:它返回什么就是什么,返回 `null` 表示字段的值就是 `null`,而不会回退到默认的 Java 反射逻辑。只有当没有任何处理器的绑定类匹配当前 bean 时,才走默认取值逻辑。因此 `obj.field` 的两种情况可以这样区分: + +* bean 的类型没有绑定任何处理器 → 走默认反射逻辑; +* bean 的类型绑定了处理器但字段值为 `null` → 返回 `null`。 + +如果某个容器希望对「字段不存在」报错而不是返回 `null`,可以在处理器内部主动抛出异常。 + === Java类的对象,字段和方法别名 QLExpress 支持通过 `QLAlias` 注解给对象,字段或者方法定义一个或多个别名,方便非技术人员使用表达式定义规则。 diff --git a/src/main/java/com/alibaba/qlexpress4/Express4Runner.java b/src/main/java/com/alibaba/qlexpress4/Express4Runner.java index c0011508..3088b037 100644 --- a/src/main/java/com/alibaba/qlexpress4/Express4Runner.java +++ b/src/main/java/com/alibaba/qlexpress4/Express4Runner.java @@ -37,6 +37,7 @@ import com.alibaba.qlexpress4.runtime.context.ObjectFieldExpressContext; import com.alibaba.qlexpress4.runtime.context.QLAliasContext; import com.alibaba.qlexpress4.runtime.function.CustomFunction; +import com.alibaba.qlexpress4.runtime.function.ExtendFieldHandler; import com.alibaba.qlexpress4.runtime.function.ExtensionFunction; import com.alibaba.qlexpress4.runtime.function.QMethodFunction; import com.alibaba.qlexpress4.runtime.instruction.QLInstruction; @@ -516,7 +517,23 @@ public boolean addCompileTimeFunction(String name, CompileTimeFunction compileTi public void addExtendFunction(ExtensionFunction extensionFunction) { this.reflectLoader.addExtendFunction(extensionFunction); } - + + /** + * Register a custom field-access handler bound to {@code bindingClass}, used to access + * fields of non-standard containers (e.g. Flink Row, JDBC ResultSet) with the regular + * {@code obj.fieldName} syntax. Delegates to {@link ReflectLoader#addExtendFieldHandler}. + *

+ * Once a bean is assignable to {@code bindingClass} the handler is authoritative for its + * fields: its return value (including {@code null}) is taken as the field value rather than + * falling back to Java reflection. + * + * @param bindingClass the receiver type the handler is bound to + * @param fieldHandler the field-access handler + */ + public void addExtendFieldHandler(Class bindingClass, ExtendFieldHandler fieldHandler) { + this.reflectLoader.addExtendFieldHandler(bindingClass, fieldHandler); + } + /** * add an extension function with variable arguments. * @param name the name of the extension function diff --git a/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java b/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java index d32012bd..e8d44170 100644 --- a/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java +++ b/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java @@ -8,6 +8,7 @@ import com.alibaba.qlexpress4.runtime.data.DataValue; import com.alibaba.qlexpress4.runtime.data.FieldValue; import com.alibaba.qlexpress4.runtime.data.MapItemValue; +import com.alibaba.qlexpress4.runtime.function.ExtendFieldHandler; import com.alibaba.qlexpress4.runtime.function.ExtensionFunction; import com.alibaba.qlexpress4.runtime.function.FilterExtensionFunction; import com.alibaba.qlexpress4.runtime.function.MapExtensionFunction; @@ -48,7 +49,15 @@ public class ReflectLoader { */ private final List extensionFunctions = new CopyOnWriteArrayList<>(Arrays.asList(FilterExtensionFunction.INSTANCE, MapExtensionFunction.INSTANCE)); - + + /** + * Custom field-access handlers registered by the user. Each entry binds a handler to a + * receiver type (e.g. Flink Row, JDBC ResultSet or other non-standard containers). + * The list is iterated in insertion order; the first handler whose binding class is + * assignable from the bean's class is considered authoritative. + */ + private final List fieldHandlers = new CopyOnWriteArrayList<>(); + public ReflectLoader(QLSecurityStrategy securityStrategy, boolean allowPrivateAccess) { this.securityStrategy = securityStrategy; this.allowPrivateAccess = allowPrivateAccess; @@ -57,7 +66,24 @@ public ReflectLoader(QLSecurityStrategy securityStrategy, boolean allowPrivateAc public void addExtendFunction(ExtensionFunction extensionFunction) { extensionFunctions.add(extensionFunction); } - + + /** + * Register a custom field-access handler bound to {@code bindingClass}, used to access + * fields of non-standard containers (e.g. Flink Row, JDBC ResultSet or user-defined + * MapLike/CollectionLike) during the field-access stage of a QL expression. + *

+ * Once the bean is assignable to {@code bindingClass}, the handler becomes the authoritative + * source for that bean's fields: whatever it returns (including {@code null}) is taken as the + * field value. Handlers are consulted in registration order, so an earlier registration for an + * assignable type wins. + * + * @param bindingClass the receiver type the handler is bound to + * @param fieldHandler the field-access handler + */ + public void addExtendFieldHandler(Class bindingClass, ExtendFieldHandler fieldHandler) { + fieldHandlers.add(new ExtendFieldHandlerHolder(bindingClass, fieldHandler)); + } + public Constructor loadConstructor(Class cls, Class[] paramTypes) { if (securityStrategy instanceof StrategyIsolation) { return null; @@ -81,6 +107,12 @@ public Constructor loadConstructor(Class cls, Class[] paramTypes) { } public Value loadField(Object bean, String fieldName, boolean skipSecurity, ErrorReporter errorReporter) { + // first try the user-registered custom field handlers (e.g. Flink Row, JDBC ResultSet) + Value extended = loadExtendField(bean, fieldName); + if (extended != null) { + return extended; + } + if (bean.getClass().isArray() && BasicUtil.LENGTH.equals(fieldName)) { return new DataValue(((Object[])bean).length); } @@ -104,7 +136,27 @@ else if (bean instanceof MetaClass) { return loadJavaField(bean.getClass(), bean, fieldName, skipSecurity, errorReporter); } } - + + /** + * Dispatch the field access to the first user-registered handler whose binding class is + * assignable from the bean's class. Such a handler is authoritative for the bean type, so its + * result is wrapped and returned even when it is {@code null} (meaning the field value itself + * is {@code null}). Returns {@code null} only when no handler's binding class matches, so that + * the caller falls back to the default field-access logic. + */ + private Value loadExtendField(Object bean, String fieldName) { + if (fieldHandlers.isEmpty()) { + return null; + } + Class beanClass = bean.getClass(); + for (ExtendFieldHandlerHolder holder : fieldHandlers) { + if (holder.getBindingClass().isAssignableFrom(beanClass)) { + return new DataValue(holder.getHandler().getField(bean, fieldName)); + } + } + return null; + } + public IMethod loadMethod(Object bean, String methodName, Class[] argTypes) { boolean isStaticMethod = bean instanceof MetaClass; Class clz = isStaticMethod ? ((MetaClass)bean).getClz() : bean.getClass(); @@ -367,6 +419,28 @@ else if (ex instanceof InvocationTargetException) { } } + /** + * Binds an {@link ExtendFieldHandler} to the receiver type it handles. + */ + private static class ExtendFieldHandlerHolder { + private final Class bindingClass; + + private final ExtendFieldHandler handler; + + private ExtendFieldHandlerHolder(Class bindingClass, ExtendFieldHandler handler) { + this.bindingClass = bindingClass; + this.handler = handler; + } + + public Class getBindingClass() { + return bindingClass; + } + + public ExtendFieldHandler getHandler() { + return handler; + } + } + private static class FieldReflectCache { private final BiFunction> getterSupplier; diff --git a/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java b/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java new file mode 100644 index 00000000..b61cf602 --- /dev/null +++ b/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java @@ -0,0 +1,48 @@ +package com.alibaba.qlexpress4.runtime.function; + +/** + * Custom field-access handler bound to a specific receiver type. + *

+ * It extends the behaviour of {@link com.alibaba.qlexpress4.runtime.ReflectLoader#loadField} + * so that non-standard containers (such as Flink Row, JDBC ResultSet or user-defined + * MapLike/CollectionLike structures) can be accessed with the regular {@code obj.fieldName} + * syntax in QL expressions. + *

+ * A handler is registered against a binding class via + * {@link com.alibaba.qlexpress4.Express4Runner#addExtendFieldHandler(Class, ExtendFieldHandler)} + * and is only invoked when the bean is assignable to that binding class. Binding to a class + * keeps each registration isolated and frees the caller from dealing with low-level runtime + * structures: just return the raw field value. + *

+ * Once the bean matches the binding class the handler is authoritative for that bean's + * fields: whatever it returns is taken as the field value, so returning {@code null} means the + * field value itself is {@code null} (it does not fall back to Java reflection). This is + * how the two cases below are distinguished: + *

+ * If a bound container should signal "field does not exist" rather than yield {@code null}, throw + * an exception from the handler. + * + *

Example —— supporting Flink Row: + *

{@code
+ * runner.addExtendFieldHandler(org.apache.flink.types.Row.class,
+ *     (bean, fieldName) -> ((Row) bean).getField(fieldName));
+ * }
+ * + * @author ayasaz + * @since QLExpress4 + */ +@FunctionalInterface +public interface ExtendFieldHandler { + + /** + * Resolve the value of {@code fieldName} from the given bean. + * + * @param bean the receiver object, guaranteed to be assignable to the binding class + * @param fieldName the field name being accessed + * @return the raw field value; {@code null} means the field value itself is {@code null} + */ + Object getField(Object bean, String fieldName); +} diff --git a/src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java b/src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java new file mode 100644 index 00000000..906603c6 --- /dev/null +++ b/src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java @@ -0,0 +1,136 @@ +package com.alibaba.qlexpress4; + +import java.util.HashMap; +import java.util.Map; + +import com.alibaba.qlexpress4.runtime.Value; +import org.junit.Assert; +import org.junit.Test; + +/** + * Unit tests for {@link com.alibaba.qlexpress4.runtime.function.ExtendFieldHandler}. + * They verify class-bound custom field access: a matched handler resolves the value, + * a non-matching bean falls through to the default reflection logic, a matched handler is + * authoritative (so a {@code null} return means the field value itself is {@code null} and it wins + * over reflection), and binding to a super type works for subtypes. + * + * @author ayasaz + */ +public class ExtendFieldHandlerTest { + + /** + * A non-standard MapLike container (a simplified model of Flink Row / Spark Row). + * Fields are stored as String[] + Object[] and can only be read through getValue(name); + * they are not reachable through ordinary Java reflection getters. + */ + static class RowLike { + private final String[] fields; + private final Object[] values; + + RowLike(String[] fields, Object[] values) { + this.fields = fields; + this.values = values; + } + + Object getValue(String fieldName) { + for (int i = 0; i < fields.length; i++) { + if (fields[i].equals(fieldName)) { + return values[i]; + } + } + return null; + } + } + + /** + * An ordinary Java bean with a public getter that is reachable through reflection. + * Used to prove that a matched handler is authoritative and wins over the reflection path. + */ + public static class PojoWithGetter { + public String getStatus() { + return "REFLECTED"; + } + } + + @Test + public void testCustomFieldHandlerMatches() { + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + runner.addExtendFieldHandler(RowLike.class, (bean, fieldName) -> ((RowLike) bean).getValue(fieldName)); + + RowLike row = new RowLike(new String[] { "name", "age" }, new Object[] { "张三", 30 }); + Value result = runner.loadField(row, "name"); + Assert.assertEquals("张三", result.get()); + } + + @Test + public void testCustomFieldHandlerNotMatches() { + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + runner.addExtendFieldHandler(RowLike.class, (bean, fieldName) -> ((RowLike) bean).getValue(fieldName)); + + // a plain Java object that is not a RowLike should still go through the default reflection path. + // String has a getter for bytes, so it works as an ordinary Java bean here. + String hello = "hello"; + Value result = runner.loadField(hello, "bytes"); + Assert.assertNotNull(result); + Assert.assertTrue(result.get() instanceof byte[]); + } + + @Test + public void testMatchedHandlerNullValueIsAuthoritative() { + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + runner.addExtendFieldHandler(RowLike.class, (bean, fieldName) -> ((RowLike) bean).getValue(fieldName)); + + // the field exists in the container but its value is null: the matched handler is + // authoritative, so we must get a non-null Value wrapping null - NOT a fall-through that + // would end up reporting the field as missing. + RowLike row = new RowLike(new String[] { "score" }, new Object[] { null }); + Value result = runner.loadField(row, "score"); + Assert.assertNotNull(result); + Assert.assertNull(result.get()); + } + + @Test + public void testMatchedHandlerWinsOverReflection() { + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + // the bean has a reflective getter for "status", but a matched handler is authoritative + // and its value must win over reflection. + runner.addExtendFieldHandler(PojoWithGetter.class, (bean, fieldName) -> "HANDLER"); + + Value result = runner.loadField(new PojoWithGetter(), "status"); + Assert.assertEquals("HANDLER", result.get()); + } + + @Test + public void testHandlerBoundToSuperTypeMatchesSubType() { + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + // bind to the super type; a subclass instance should still be dispatched to this handler + runner.addExtendFieldHandler(RowLike.class, (bean, fieldName) -> ((RowLike) bean).getValue(fieldName)); + + RowLike row = new RowLike(new String[] { "city" }, new Object[] { "杭州" }) { + }; + Assert.assertEquals("杭州", runner.loadField(row, "city").get()); + } + + @Test + public void extendFieldHandlerDocExample() { + // tag::extendFieldHandler[] + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + // RowLike is a non-standard container whose fields can only be read via getValue(name); + // register a handler so it can be accessed with the regular obj.field syntax in scripts. + runner.addExtendFieldHandler(RowLike.class, (bean, fieldName) -> ((RowLike) bean).getValue(fieldName)); + + RowLike row = new RowLike(new String[] { "name", "age" }, new Object[] { "张三", 30 }); + Map context = new HashMap<>(); + context.put("row", row); + + Object name = runner.execute("row.name", context, QLOptions.DEFAULT_OPTIONS).getResult(); + Assert.assertEquals("张三", name); + // end::extendFieldHandler[] + } +}