From dd8953a1f9ada669795dccfe1e17f7e630963d54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=94=80?= Date: Mon, 8 Jun 2026 12:05:18 +0800 Subject: [PATCH 1/3] =?UTF-8?q?[Feature]=20=E5=85=81=E8=AE=B8=E6=89=A9?= =?UTF-8?q?=E5=B1=95=20ReflectLoader#loadField=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AD=97=E6=AE=B5=E5=8F=96=E5=80=BC?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ExtendFieldHandler (@FunctionalInterface) 接口,允许用户在 ReflectLoader 中注册自定义的字段取值逻辑,支持对非标准容器(如 Flink Row、JDBC ResultSet、 自定义 MapLike/CollectionLike 等)进行属性访问。 改动: - 新增 ExtendFieldHandler (FunctionalInterface) 到 function 包 - ReflectLoader 添加 addExtendFieldHandler + fieldHandlers 处理链 - loadField 在最前面插入扩展分支,handler 返回 null 则继续下一个 - Express4Runner 透传 addExtendFieldHandler 不影响现有行为,不注入 FieldHandler 时与原来完全一致。 Closes #415 --- .../alibaba/qlexpress4/Express4Runner.java | 13 ++- .../qlexpress4/runtime/ReflectLoader.java | 30 ++++- .../runtime/function/ExtendFieldHandler.java | 40 +++++++ .../qlexpress4/ExtendFieldHandlerTest.java | 109 ++++++++++++++++++ 4 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java create mode 100644 src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java diff --git a/src/main/java/com/alibaba/qlexpress4/Express4Runner.java b/src/main/java/com/alibaba/qlexpress4/Express4Runner.java index c00115086..02189bb00 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,17 @@ public boolean addCompileTimeFunction(String name, CompileTimeFunction compileTi public void addExtendFunction(ExtensionFunction extensionFunction) { this.reflectLoader.addExtendFunction(extensionFunction); } - + + /** + * 添加自定义字段取值处理器,用于处理非标准容器对象(如 Flink Row、JDBC ResultSet 等)的字段取值。 + * 底层对应 {@link ReflectLoader#addExtendFieldHandler}。 + * + * @param fieldHandler 字段取值处理器 + */ + public void addExtendFieldHandler(ExtendFieldHandler fieldHandler) { + this.reflectLoader.addExtendFieldHandler(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 d32012bd4..94945f45d 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,13 @@ public class ReflectLoader { */ private final List extensionFunctions = new CopyOnWriteArrayList<>(Arrays.asList(FilterExtensionFunction.INSTANCE, MapExtensionFunction.INSTANCE)); - + + /** + * 用户注册的自定义字段取值处理器(如 Flink Row、JDBC ResultSet 等非标准容器的字段访问)。 + * 按注册顺序依次调用。返回 null 视为不匹配,降级到后续处理器或原有 Java 反射逻辑。 + */ + private final List fieldHandlers = new CopyOnWriteArrayList<>(); + public ReflectLoader(QLSecurityStrategy securityStrategy, boolean allowPrivateAccess) { this.securityStrategy = securityStrategy; this.allowPrivateAccess = allowPrivateAccess; @@ -57,7 +64,18 @@ public ReflectLoader(QLSecurityStrategy securityStrategy, boolean allowPrivateAc public void addExtendFunction(ExtensionFunction extensionFunction) { extensionFunctions.add(extensionFunction); } - + + /** + * 注册自定义字段取值处理器,用于在 QL 表达式的 field 取值阶段处理非标准容器对象 + * (如 Flink Row、JDBC ResultSet、自定义 MapLike/CollectionLike)的属性访问。 + * 处理器按注册顺序依次匹配,直到某个处理器返回非 null 的 Value。 + * + * @param fieldHandler 字段取值处理器 + */ + public void addExtendFieldHandler(ExtendFieldHandler fieldHandler) { + fieldHandlers.add(fieldHandler); + } + public Constructor loadConstructor(Class cls, Class[] paramTypes) { if (securityStrategy instanceof StrategyIsolation) { return null; @@ -81,6 +99,14 @@ public Constructor loadConstructor(Class cls, Class[] paramTypes) { } public Value loadField(Object bean, String fieldName, boolean skipSecurity, ErrorReporter errorReporter) { + // 优先走用户注册的自定义字段取值处理器(如 Flink Row、JDBC ResultSet 等非标准容器) + for (ExtendFieldHandler handler : fieldHandlers) { + Value extended = handler.load(bean, fieldName); + if (extended != null) { + return extended; + } + } + if (bean.getClass().isArray() && BasicUtil.LENGTH.equals(fieldName)) { return new DataValue(((Object[])bean).length); } 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 000000000..ec9ece403 --- /dev/null +++ b/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java @@ -0,0 +1,40 @@ +package com.alibaba.qlexpress4.runtime.function; + +import com.alibaba.qlexpress4.runtime.Value; + +/** + * 自定义字段取值处理器。 + * 用于扩展 {@link com.alibaba.qlexpress4.runtime.ReflectLoader#loadField} 的行为, + * 支持对非标准容器(如 Flink Row、JDBC ResultSet、自定义 MapLike/CollectionLike 等)进行属性取值。 + * 用户通过 {@link com.alibaba.qlexpress4.Express4Runner#addExtendFieldHandler} 注入到运行时。 + * + *

使用示例 —— 支持 Flink Row: + *

{@code
+ * runner.addExtendFieldHandler((bean, fieldName) -> {
+ *     if (bean instanceof org.apache.flink.types.Row) {
+ *         Row row = (Row) bean;
+ *         return new DataValue(row.getField(fieldName));
+ *     }
+ *     return null; // 返回 null 表示当前处理器无法处理,继续走下一个
+ * });
+ * }
+ * + *

处理器按注册顺序依次调用:若某个处理器返回 null,日志层面意为"不匹配",继续尝试下一个。 + * 第一个返回非 null {@code Value} 的处理器将消费本次取值请求,后续处理器不再执行。 + * 如果所有处理器均返回 null,则回退到 {@code ReflectLoader} 原有的 Java 反射取值逻辑。 + * + * @author ayasaz + * @since QLExpress4 + */ +@FunctionalInterface +public interface ExtendFieldHandler { + + /** + * 根据字段名从 bean 中取值。 + * + * @param bean 当前对象(可能为任意类型,包括非标准容器) + * @param fieldName 字段名 + * @return 取值结果,或 null 表示当前处理器不匹配该 bean 类型(交由下一个处理器或默认反射路径继续处理) + */ + Value load(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 000000000..b730de855 --- /dev/null +++ b/src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java @@ -0,0 +1,109 @@ +package com.alibaba.qlexpress4; + +import com.alibaba.qlexpress4.runtime.Value; +import com.alibaba.qlexpress4.runtime.data.DataValue; +import org.junit.Assert; +import org.junit.Test; + +/** + * ExtendFieldHandler 单元测试。 + * 验证自定义字段取值处理器链:匹配返回、不匹配穿透到默认逻辑、多处理器链式调用。 + * + * @author ayasaz + */ +public class ExtendFieldHandlerTest { + + /** + * 模拟一个非标准 MapLike 容器(如 Flink Row、Spark Row 的简化模型)。 + * 字段通过 String[] + Object[] 存储,只能通过 getValue(name) 取值,无法通过 Java 反射 getter 直接访问。 + */ + 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; + } + } + + @Test + public void testCustomFieldHandlerMatches() { + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + runner.addExtendFieldHandler((bean, fieldName) -> { + if (bean instanceof RowLike) { + Object value = ((RowLike) bean).getValue(fieldName); + return value == null ? null : new DataValue(value); + } + return null; + }); + + 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((bean, fieldName) -> { + if (bean instanceof RowLike) { + Object value = ((RowLike) bean).getValue(fieldName); + return value == null ? null : new DataValue(value); + } + return null; + }); + + // 非 RowLike 的普通 Java 对象仍应走默认反射路径拿到属性。 + // String 有 hashCode() 的 getter,可以作为普通 Java bean 验证。 + String hello = "hello"; + Value result = runner.loadField(hello, "bytes"); + Assert.assertNotNull(result); + Assert.assertTrue(result.get() instanceof byte[]); + } + + @Test + public void testFieldHandlerReturnsNullFallsThrough() { + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + // 始终返回 null —— 应回退到原有的默认逻辑 + runner.addExtendFieldHandler((bean, fieldName) -> null); + + // 默认 loadField 对 Map 有硬编码支持(返回 MapItemValue) + java.util.Map map = new java.util.HashMap<>(); + map.put("key", "value"); + Value result = runner.loadField(map, "key"); + Assert.assertNotNull(result); + Assert.assertEquals("value", result.get()); + } + + @Test + public void testMultipleHandlersChained() { + Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); + + // Handler 1: 不匹配 RowLike(始终返回 null) + runner.addExtendFieldHandler((bean, fieldName) -> null); + + // Handler 2: 匹配 RowLike + runner.addExtendFieldHandler((bean, fieldName) -> { + if (bean instanceof RowLike) { + return new DataValue(((RowLike) bean).getValue(fieldName)); + } + return null; + }); + + RowLike row = new RowLike(new String[] { "city" }, new Object[] { "杭州" }); + Assert.assertEquals("杭州", runner.loadField(row, "city").get()); + } +} From 2cab1c133870a3650258546a6cf689d3e6aae67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=94=80?= Date: Mon, 15 Jun 2026 11:41:14 +0800 Subject: [PATCH 2/3] [Feature] address review: bind ExtendFieldHandler to a class & use English doc Per review feedback on #443: - Bind ExtendFieldHandler to a receiver Class (mirroring addExtendFunction(String, Class, QLFunctionalVarargs)) so registrations cannot conflict and dispatch is by isAssignableFrom. - Handler now returns a raw Object instead of the low-level Value; ReflectLoader wraps it into DataValue, so users no longer touch internal runtime structures. - Rewrite all comments/Javadoc in English. - Update tests accordingly (4 cases, incl. super-type binding). Co-Authored-By: Claude Opus 4.8 --- .../alibaba/qlexpress4/Express4Runner.java | 12 ++-- .../qlexpress4/runtime/ReflectLoader.java | 54 ++++++++++++------ .../runtime/function/ExtendFieldHandler.java | 44 +++++++-------- .../qlexpress4/ExtendFieldHandlerTest.java | 55 +++++++------------ 4 files changed, 86 insertions(+), 79 deletions(-) diff --git a/src/main/java/com/alibaba/qlexpress4/Express4Runner.java b/src/main/java/com/alibaba/qlexpress4/Express4Runner.java index 02189bb00..f2e803dfb 100644 --- a/src/main/java/com/alibaba/qlexpress4/Express4Runner.java +++ b/src/main/java/com/alibaba/qlexpress4/Express4Runner.java @@ -519,13 +519,15 @@ public void addExtendFunction(ExtensionFunction extensionFunction) { } /** - * 添加自定义字段取值处理器,用于处理非标准容器对象(如 Flink Row、JDBC ResultSet 等)的字段取值。 - * 底层对应 {@link ReflectLoader#addExtendFieldHandler}。 + * 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}. * - * @param fieldHandler 字段取值处理器 + * @param bindingClass the receiver type the handler is bound to + * @param fieldHandler the field-access handler */ - public void addExtendFieldHandler(ExtendFieldHandler fieldHandler) { - this.reflectLoader.addExtendFieldHandler(fieldHandler); + public void addExtendFieldHandler(Class bindingClass, ExtendFieldHandler fieldHandler) { + this.reflectLoader.addExtendFieldHandler(bindingClass, fieldHandler); } /** diff --git a/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java b/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java index 94945f45d..f28e13728 100644 --- a/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java +++ b/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java @@ -51,10 +51,11 @@ public class ReflectLoader { new CopyOnWriteArrayList<>(Arrays.asList(FilterExtensionFunction.INSTANCE, MapExtensionFunction.INSTANCE)); /** - * 用户注册的自定义字段取值处理器(如 Flink Row、JDBC ResultSet 等非标准容器的字段访问)。 - * 按注册顺序依次调用。返回 null 视为不匹配,降级到后续处理器或原有 Java 反射逻辑。 + * Custom field-access handlers registered by the user, keyed by their binding class + * (e.g. Flink Row, JDBC ResultSet or other non-standard containers). A handler is + * invoked only when the bean is assignable to its binding class. */ - private final List fieldHandlers = new CopyOnWriteArrayList<>(); + private final Map, ExtendFieldHandler> fieldHandlers = new ConcurrentHashMap<>(); public ReflectLoader(QLSecurityStrategy securityStrategy, boolean allowPrivateAccess) { this.securityStrategy = securityStrategy; @@ -66,14 +67,16 @@ public void addExtendFunction(ExtensionFunction extensionFunction) { } /** - * 注册自定义字段取值处理器,用于在 QL 表达式的 field 取值阶段处理非标准容器对象 - * (如 Flink Row、JDBC ResultSet、自定义 MapLike/CollectionLike)的属性访问。 - * 处理器按注册顺序依次匹配,直到某个处理器返回非 null 的 Value。 + * 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. + * The handler is invoked only when the bean is assignable to {@code bindingClass}. * - * @param fieldHandler 字段取值处理器 + * @param bindingClass the receiver type the handler is bound to + * @param fieldHandler the field-access handler */ - public void addExtendFieldHandler(ExtendFieldHandler fieldHandler) { - fieldHandlers.add(fieldHandler); + public void addExtendFieldHandler(Class bindingClass, ExtendFieldHandler fieldHandler) { + fieldHandlers.put(bindingClass, fieldHandler); } public Constructor loadConstructor(Class cls, Class[] paramTypes) { @@ -99,12 +102,10 @@ public Constructor loadConstructor(Class cls, Class[] paramTypes) { } public Value loadField(Object bean, String fieldName, boolean skipSecurity, ErrorReporter errorReporter) { - // 优先走用户注册的自定义字段取值处理器(如 Flink Row、JDBC ResultSet 等非标准容器) - for (ExtendFieldHandler handler : fieldHandlers) { - Value extended = handler.load(bean, fieldName); - if (extended != null) { - return extended; - } + // 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)) { @@ -130,7 +131,28 @@ else if (bean instanceof MetaClass) { return loadJavaField(bean.getClass(), bean, fieldName, skipSecurity, errorReporter); } } - + + /** + * Dispatch the field access to a user-registered handler whose binding class is assignable + * from the bean's class. Returns {@code null} when no handler matches or the matched handler + * yields {@code null}, so that the caller falls back to the default reflection logic. + */ + private Value loadExtendField(Object bean, String fieldName) { + if (fieldHandlers.isEmpty()) { + return null; + } + Class beanClass = bean.getClass(); + for (Map.Entry, ExtendFieldHandler> entry : fieldHandlers.entrySet()) { + if (entry.getKey().isAssignableFrom(beanClass)) { + Object value = entry.getValue().getField(bean, fieldName); + if (value != null) { + return new DataValue(value); + } + } + } + return null; + } + public IMethod loadMethod(Object bean, String methodName, Class[] argTypes) { boolean isStaticMethod = bean instanceof MetaClass; Class clz = isStaticMethod ? ((MetaClass)bean).getClz() : bean.getClass(); diff --git a/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java b/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java index ec9ece403..0d6c3c91b 100644 --- a/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java +++ b/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java @@ -1,27 +1,27 @@ package com.alibaba.qlexpress4.runtime.function; -import com.alibaba.qlexpress4.runtime.Value; - /** - * 自定义字段取值处理器。 - * 用于扩展 {@link com.alibaba.qlexpress4.runtime.ReflectLoader#loadField} 的行为, - * 支持对非标准容器(如 Flink Row、JDBC ResultSet、自定义 MapLike/CollectionLike 等)进行属性取值。 - * 用户通过 {@link com.alibaba.qlexpress4.Express4Runner#addExtendFieldHandler} 注入到运行时。 + * 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 (handlers cannot conflict with each other) and frees the + * caller from dealing with low-level runtime structures: just return the raw field value. * - *

使用示例 —— 支持 Flink Row: + *

Example —— supporting Flink Row: *

{@code
- * runner.addExtendFieldHandler((bean, fieldName) -> {
- *     if (bean instanceof org.apache.flink.types.Row) {
- *         Row row = (Row) bean;
- *         return new DataValue(row.getField(fieldName));
- *     }
- *     return null; // 返回 null 表示当前处理器无法处理,继续走下一个
- * });
+ * runner.addExtendFieldHandler(org.apache.flink.types.Row.class,
+ *     (bean, fieldName) -> ((Row) bean).getField(fieldName));
  * }
* - *

处理器按注册顺序依次调用:若某个处理器返回 null,日志层面意为"不匹配",继续尝试下一个。 - * 第一个返回非 null {@code Value} 的处理器将消费本次取值请求,后续处理器不再执行。 - * 如果所有处理器均返回 null,则回退到 {@code ReflectLoader} 原有的 Java 反射取值逻辑。 + *

If the handler returns {@code null}, QLExpress falls back to the default Java reflection + * field-access logic of {@code ReflectLoader}. * * @author ayasaz * @since QLExpress4 @@ -30,11 +30,11 @@ public interface ExtendFieldHandler { /** - * 根据字段名从 bean 中取值。 + * Resolve the value of {@code fieldName} from the given bean. * - * @param bean 当前对象(可能为任意类型,包括非标准容器) - * @param fieldName 字段名 - * @return 取值结果,或 null 表示当前处理器不匹配该 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, or {@code null} to fall back to the default reflection logic */ - Value load(Object bean, String fieldName); + 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 index b730de855..7c2b4fa79 100644 --- a/src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java +++ b/src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java @@ -1,21 +1,23 @@ package com.alibaba.qlexpress4; import com.alibaba.qlexpress4.runtime.Value; -import com.alibaba.qlexpress4.runtime.data.DataValue; import org.junit.Assert; import org.junit.Test; /** - * ExtendFieldHandler 单元测试。 - * 验证自定义字段取值处理器链:匹配返回、不匹配穿透到默认逻辑、多处理器链式调用。 + * 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 handler returning + * {@code null} falls back to the default logic, and binding to a super type works for subtypes. * * @author ayasaz */ public class ExtendFieldHandlerTest { /** - * 模拟一个非标准 MapLike 容器(如 Flink Row、Spark Row 的简化模型)。 - * 字段通过 String[] + Object[] 存储,只能通过 getValue(name) 取值,无法通过 Java 反射 getter 直接访问。 + * 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; @@ -40,13 +42,7 @@ Object getValue(String fieldName) { public void testCustomFieldHandlerMatches() { Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); - runner.addExtendFieldHandler((bean, fieldName) -> { - if (bean instanceof RowLike) { - Object value = ((RowLike) bean).getValue(fieldName); - return value == null ? null : new DataValue(value); - } - return null; - }); + 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"); @@ -57,16 +53,10 @@ public void testCustomFieldHandlerMatches() { public void testCustomFieldHandlerNotMatches() { Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); - runner.addExtendFieldHandler((bean, fieldName) -> { - if (bean instanceof RowLike) { - Object value = ((RowLike) bean).getValue(fieldName); - return value == null ? null : new DataValue(value); - } - return null; - }); + runner.addExtendFieldHandler(RowLike.class, (bean, fieldName) -> ((RowLike) bean).getValue(fieldName)); - // 非 RowLike 的普通 Java 对象仍应走默认反射路径拿到属性。 - // String 有 hashCode() 的 getter,可以作为普通 Java bean 验证。 + // 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); @@ -77,10 +67,10 @@ public void testCustomFieldHandlerNotMatches() { public void testFieldHandlerReturnsNullFallsThrough() { Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); - // 始终返回 null —— 应回退到原有的默认逻辑 - runner.addExtendFieldHandler((bean, fieldName) -> null); + // always returns null -> should fall back to the default logic + runner.addExtendFieldHandler(java.util.Map.class, (bean, fieldName) -> null); - // 默认 loadField 对 Map 有硬编码支持(返回 MapItemValue) + // the default loadField has built-in support for Map (returns MapItemValue) java.util.Map map = new java.util.HashMap<>(); map.put("key", "value"); Value result = runner.loadField(map, "key"); @@ -89,21 +79,14 @@ public void testFieldHandlerReturnsNullFallsThrough() { } @Test - public void testMultipleHandlersChained() { + public void testHandlerBoundToSuperTypeMatchesSubType() { Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); - // Handler 1: 不匹配 RowLike(始终返回 null) - runner.addExtendFieldHandler((bean, fieldName) -> null); - - // Handler 2: 匹配 RowLike - runner.addExtendFieldHandler((bean, fieldName) -> { - if (bean instanceof RowLike) { - return new DataValue(((RowLike) bean).getValue(fieldName)); - } - return null; - }); + // 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[] { "杭州" }); + RowLike row = new RowLike(new String[] { "city" }, new Object[] { "杭州" }) { + }; Assert.assertEquals("杭州", runner.loadField(row, "city").get()); } } From b37cf2c9571c08117037c18131b9cb0a405deefc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=94=80?= Date: Mon, 6 Jul 2026 16:50:43 +0800 Subject: [PATCH 3/3] [Feature] address review round 2: list-based handlers, authoritative null semantics & docs Per review feedback on #443 (commit 2cab1c1): - Store ExtendFieldHandler registrations in a CopyOnWriteArrayList (mirroring extensionFunctions) instead of a ConcurrentHashMap, matching the read-heavy / write-rare access pattern and keeping registration order deterministic. - A matched handler (bean assignable to its binding class) is now authoritative: its return value including null is taken as the field value, so a field that is present but null no longer falls through to reflection and gets reported as missing. The default logic only applies when no binding class matches. - Document the feature in README-source.adoc / README-EN-source.adoc and the generated README.adoc / README-EN.adoc. - Update ExtendFieldHandlerTest accordingly (null-value authority, handler wins over reflection) and add a doc example tag. Co-Authored-By: Claude Opus 4.8 --- README-EN-source.adoc | 18 ++++++ README-EN.adoc | 31 ++++++++- README-source.adoc | 18 ++++++ README.adoc | 31 ++++++++- .../alibaba/qlexpress4/Express4Runner.java | 4 ++ .../qlexpress4/runtime/ReflectLoader.java | 56 +++++++++++----- .../runtime/function/ExtendFieldHandler.java | 20 ++++-- .../qlexpress4/ExtendFieldHandlerTest.java | 64 ++++++++++++++++--- 8 files changed, 209 insertions(+), 33 deletions(-) diff --git a/README-EN-source.adoc b/README-EN-source.adoc index e7f309503..f44a23c26 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 442311017..d3eed0113 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 85cc80a8e..647d6b309 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 a1dfda593..077ffe2cf 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 f2e803dfb..3088b037b 100644 --- a/src/main/java/com/alibaba/qlexpress4/Express4Runner.java +++ b/src/main/java/com/alibaba/qlexpress4/Express4Runner.java @@ -522,6 +522,10 @@ public void addExtendFunction(ExtensionFunction 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 diff --git a/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java b/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java index f28e13728..e8d44170f 100644 --- a/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java +++ b/src/main/java/com/alibaba/qlexpress4/runtime/ReflectLoader.java @@ -51,11 +51,12 @@ public class ReflectLoader { new CopyOnWriteArrayList<>(Arrays.asList(FilterExtensionFunction.INSTANCE, MapExtensionFunction.INSTANCE)); /** - * Custom field-access handlers registered by the user, keyed by their binding class - * (e.g. Flink Row, JDBC ResultSet or other non-standard containers). A handler is - * invoked only when the bean is assignable to its binding class. + * 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 Map, ExtendFieldHandler> fieldHandlers = new ConcurrentHashMap<>(); + private final List fieldHandlers = new CopyOnWriteArrayList<>(); public ReflectLoader(QLSecurityStrategy securityStrategy, boolean allowPrivateAccess) { this.securityStrategy = securityStrategy; @@ -70,13 +71,17 @@ public void addExtendFunction(ExtensionFunction 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. - * The handler is invoked only when the bean is assignable to {@code bindingClass}. + *

+ * 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.put(bindingClass, fieldHandler); + fieldHandlers.add(new ExtendFieldHandlerHolder(bindingClass, fieldHandler)); } public Constructor loadConstructor(Class cls, Class[] paramTypes) { @@ -133,21 +138,20 @@ else if (bean instanceof MetaClass) { } /** - * Dispatch the field access to a user-registered handler whose binding class is assignable - * from the bean's class. Returns {@code null} when no handler matches or the matched handler - * yields {@code null}, so that the caller falls back to the default reflection logic. + * 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 (Map.Entry, ExtendFieldHandler> entry : fieldHandlers.entrySet()) { - if (entry.getKey().isAssignableFrom(beanClass)) { - Object value = entry.getValue().getField(bean, fieldName); - if (value != null) { - return new DataValue(value); - } + for (ExtendFieldHandlerHolder holder : fieldHandlers) { + if (holder.getBindingClass().isAssignableFrom(beanClass)) { + return new DataValue(holder.getHandler().getField(bean, fieldName)); } } return null; @@ -415,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 index 0d6c3c91b..b61cf602c 100644 --- a/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java +++ b/src/main/java/com/alibaba/qlexpress4/runtime/function/ExtendFieldHandler.java @@ -11,8 +11,19 @@ * 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 (handlers cannot conflict with each other) and frees the - * caller from dealing with low-level runtime structures: just return the raw field value. + * 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: + *

    + *
  • the bean type is not bound to any handler → the default reflection logic applies;
  • + *
  • the bean type is bound but the field value is {@code null} → {@code null} is returned.
  • + *
+ * 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
@@ -20,9 +31,6 @@
  *     (bean, fieldName) -> ((Row) bean).getField(fieldName));
  * }
* - *

If the handler returns {@code null}, QLExpress falls back to the default Java reflection - * field-access logic of {@code ReflectLoader}. - * * @author ayasaz * @since QLExpress4 */ @@ -34,7 +42,7 @@ public interface ExtendFieldHandler { * * @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, or {@code null} to fall back to the default reflection logic + * @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 index 7c2b4fa79..906603c68 100644 --- a/src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java +++ b/src/test/java/com/alibaba/qlexpress4/ExtendFieldHandlerTest.java @@ -1,5 +1,8 @@ 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; @@ -7,8 +10,9 @@ /** * 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 handler returning - * {@code null} falls back to the default logic, and binding to a super type works for subtypes. + * 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 */ @@ -38,6 +42,16 @@ Object getValue(String fieldName) { } } + /** + * 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); @@ -64,18 +78,30 @@ public void testCustomFieldHandlerNotMatches() { } @Test - public void testFieldHandlerReturnsNullFallsThrough() { + public void testMatchedHandlerNullValueIsAuthoritative() { Express4Runner runner = new Express4Runner(InitOptions.DEFAULT_OPTIONS); - // always returns null -> should fall back to the default logic - runner.addExtendFieldHandler(java.util.Map.class, (bean, fieldName) -> null); + runner.addExtendFieldHandler(RowLike.class, (bean, fieldName) -> ((RowLike) bean).getValue(fieldName)); - // the default loadField has built-in support for Map (returns MapItemValue) - java.util.Map map = new java.util.HashMap<>(); - map.put("key", "value"); - Value result = runner.loadField(map, "key"); + // 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.assertEquals("value", result.get()); + 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 @@ -89,4 +115,22 @@ public void testHandlerBoundToSuperTypeMatchesSubType() { }; 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[] + } }