From 2a04fccf808aa5f2e3a8451bb7e7512f1d22b479 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Thu, 16 Jul 2026 12:30:02 +0200 Subject: [PATCH] Support nullable optionals and generic collections Jackson property type inference only recognized List as a CEL list-like type and treated Optional as an object-like value. Runtime adaptation had the same List-only limitation and returned Optional values as ordinary objects. Generalize runtime list adaptation to Collection, unwrap Optional values to either their contained CEL value or null, and teach the Jackson 2 and Jackson 3 type descriptors to infer Collection as list and Optional as T. Add regression coverage for Collection, Set, Optional.of, Optional.empty, and nested Optional values. --- .../common/types/ref/TypeAdapterSupport.java | 11 +++-- .../types/jackson/JacksonTypeDescription.java | 23 +++++++-- .../jackson/Jackson2TypeDescriptionTest.java | 47 ++++++++++++++++++- .../jackson/types/CollectionsObject.java | 15 +++++- .../jackson3/JacksonTypeDescription.java | 23 +++++++-- .../jackson3/Jackson3TypeDescriptionTest.java | 47 ++++++++++++++++++- .../jackson3/types/CollectionsObject.java | 15 +++++- 7 files changed, 164 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/org/projectnessie/cel/common/types/ref/TypeAdapterSupport.java b/core/src/main/java/org/projectnessie/cel/common/types/ref/TypeAdapterSupport.java index a9e951b3..15110953 100644 --- a/core/src/main/java/org/projectnessie/cel/common/types/ref/TypeAdapterSupport.java +++ b/core/src/main/java/org/projectnessie/cel/common/types/ref/TypeAdapterSupport.java @@ -45,10 +45,11 @@ import java.time.ZonedDateTime; import java.util.Arrays; import java.util.Calendar; +import java.util.Collection; import java.util.Date; import java.util.IdentityHashMap; -import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.BiFunction; import org.projectnessie.cel.common.ULong; import org.projectnessie.cel.common.types.DoubleT; @@ -135,8 +136,12 @@ public static Val maybeNativeToValue(TypeAdapter a, Object value) { if (value instanceof Object[]) { return newGenericArrayList(a, (Object[]) value); } - if (value instanceof List) { - return newGenericArrayList(a, ((List) value).toArray()); + if (value instanceof Collection) { + return newGenericArrayList(a, ((Collection) value).toArray()); + } + if (value instanceof Optional) { + Optional optional = (Optional) value; + return optional.map(a::nativeToValue).orElse(NullT.NullValue); } if (value instanceof Map) { return newMaybeWrappedMap(a, (Map) value); diff --git a/jackson/src/main/java/org/projectnessie/cel/types/jackson/JacksonTypeDescription.java b/jackson/src/main/java/org/projectnessie/cel/types/jackson/JacksonTypeDescription.java index dc2e24a7..6bf814c2 100644 --- a/jackson/src/main/java/org/projectnessie/cel/types/jackson/JacksonTypeDescription.java +++ b/jackson/src/main/java/org/projectnessie/cel/types/jackson/JacksonTypeDescription.java @@ -24,10 +24,11 @@ import com.google.protobuf.Timestamp; import java.time.Instant; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.HashMap; import java.util.Iterator; -import java.util.List; import java.util.Map; +import java.util.Optional; import org.projectnessie.cel.checker.Decls; import org.projectnessie.cel.common.ULong; import org.projectnessie.cel.common.types.TypeT; @@ -103,15 +104,17 @@ com.google.api.expr.v1alpha1.Type findTypeForJacksonType(JavaType type, TypeQuer || Instant.class.isAssignableFrom(rawClass) || ZonedDateTime.class.isAssignableFrom(rawClass)) { return Checked.checkedTimestamp; + } else if (Optional.class.isAssignableFrom(rawClass)) { + return findTypeForJacksonType(elementType(type), typeQuery); } else if (Map.class.isAssignableFrom(rawClass)) { com.google.api.expr.v1alpha1.Type keyType = findTypeForJacksonType(type.getKeyType(), typeQuery); com.google.api.expr.v1alpha1.Type valueType = - findTypeForJacksonType(type.getContentType(), typeQuery); + findTypeForJacksonType(elementType(type), typeQuery); return Decls.newMapType(keyType, valueType); - } else if (List.class.isAssignableFrom(rawClass)) { + } else if (Collection.class.isAssignableFrom(rawClass)) { com.google.api.expr.v1alpha1.Type valueType = - findTypeForJacksonType(type.getContentType(), typeQuery); + findTypeForJacksonType(elementType(type), typeQuery); return Decls.newListType(valueType); } else if (type.isEnumType()) { return typeQuery.getType(type); @@ -124,6 +127,18 @@ com.google.api.expr.v1alpha1.Type findTypeForJacksonType(JavaType type, TypeQuer } } + private JavaType elementType(JavaType type) { + JavaType elementType = type.getContentType(); + if (elementType == null && type.containedTypeCount() > 0) { + elementType = type.containedType(0); + } + if (elementType == null) { + throw new UnsupportedOperationException( + String.format("Unsupported Java Type '%s' without element type", type)); + } + return elementType; + } + boolean hasProperty(String property) { return fieldTypes.containsKey(property); } diff --git a/jackson/src/test/java/org/projectnessie/cel/types/jackson/Jackson2TypeDescriptionTest.java b/jackson/src/test/java/org/projectnessie/cel/types/jackson/Jackson2TypeDescriptionTest.java index 07dafac2..3cf8a92c 100644 --- a/jackson/src/test/java/org/projectnessie/cel/types/jackson/Jackson2TypeDescriptionTest.java +++ b/jackson/src/test/java/org/projectnessie/cel/types/jackson/Jackson2TypeDescriptionTest.java @@ -37,8 +37,10 @@ import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; -import java.util.List; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.projectnessie.cel.common.ULong; import org.projectnessie.cel.common.types.Err; @@ -218,6 +220,27 @@ void types() { checkListType(reg, "bytesList", ByteString.class, Checked.checkedBytes); checkListType(reg, "floatList", Float.class, Checked.checkedDouble); checkListType(reg, "doubleList", Double.class, Checked.checkedDouble); + checkListType(reg, "stringCollection", String.class, Checked.checkedString); + checkListType(reg, "stringSet", String.class, Checked.checkedString); + + // verify optional fields use their contained type + + checkFieldType(reg, "optionalString", Checked.checkedString); + checkFieldType(reg, "emptyOptionalString", Checked.checkedString); + checkFieldType( + reg, + "optionalInnerType", + com.google.api.expr.v1alpha1.Type.newBuilder() + .setMessageType(InnerType.class.getName()) + .build()); + } + + private void checkFieldType( + JacksonRegistry reg, String prop, com.google.api.expr.v1alpha1.Type type) { + JacksonFieldType ft = + (JacksonFieldType) reg.findFieldType(CollectionsObject.class.getName(), prop); + assertThat(ft).isNotNull(); + assertThat(ft.type).isEqualTo(type); } private void checkListType( @@ -362,6 +385,8 @@ void collectionsObjectTypeTest() throws Exception { ByteString.copyFrom(new byte[] {(byte) 3})); collectionsObject.floatList = asList(1f, 2f, 3f); collectionsObject.doubleList = asList(1d, 2d, 3d); + collectionsObject.stringCollection = asList("collection-a", "collection-b"); + collectionsObject.stringSet = new LinkedHashSet<>(asList("set-a", "set-b")); // populate inner/nested type list/map @@ -374,6 +399,7 @@ void collectionsObjectTypeTest() throws Exception { inner2.intProp = 3; inner2.wrappedIntProp = 4; collectionsObject.innerTypes = asList(inner1, inner2); + collectionsObject.optionalInnerType = Optional.of(inner2); // populate enum-related fields @@ -381,6 +407,8 @@ void collectionsObjectTypeTest() throws Exception { collectionsObject.anEnumList = asList(AnEnum.ENUM_VALUE_2, AnEnum.ENUM_VALUE_3); collectionsObject.anEnumStringMap = singletonMap(AnEnum.ENUM_VALUE_2, "a"); collectionsObject.stringAnEnumMap = singletonMap("a", AnEnum.ENUM_VALUE_2); + collectionsObject.optionalString = Optional.of("optional-a"); + collectionsObject.emptyOptionalString = Optional.empty(); // prepare registry @@ -401,7 +429,7 @@ void collectionsObjectTypeTest() throws Exception { Object fieldObj = CollectionsObject.class.getDeclaredField(field).get(collectionsObject); if (fieldObj instanceof Map) { assertThat(fieldVal).isInstanceOf(MapT.class); - } else if (fieldObj instanceof List) { + } else if (fieldObj instanceof Collection) { assertThat(fieldVal).isInstanceOf(ListT.class); } @@ -434,6 +462,11 @@ void collectionsObjectTypeTest() throws Exception { l -> l.get(intOf(2))) .containsExactly(intOf(3), False, True, True, True, uintOf(1), uintOf(2), uintOf(3)); + listVal = (ListT) obj.get(stringOf("stringSet")); + assertThat(listVal) + .extracting(ListT::size, l -> l.get(intOf(0)), l -> l.get(intOf(1))) + .containsExactly(intOf(2), stringOf("set-a"), stringOf("set-b")); + mapVal = (MapT) obj.get(stringOf("stringInnerMap")); assertThat(mapVal) .extracting(MapT::size, m -> m.contains(stringOf("42")), m -> m.contains(stringOf("a"))) @@ -454,6 +487,11 @@ void collectionsObjectTypeTest() throws Exception { .extracting(o -> o.get(stringOf("intProp")), o -> o.get(stringOf("wrappedIntProp"))) .containsExactly(intOf(3), intOf(4)); + i = (ObjectT) obj.get(stringOf("optionalInnerType")); + assertThat(i) + .extracting(o -> o.get(stringOf("intProp")), o -> o.get(stringOf("wrappedIntProp"))) + .containsExactly(intOf(3), intOf(4)); + // verify enums Val x = obj.get(stringOf("anEnum")); @@ -471,5 +509,10 @@ void collectionsObjectTypeTest() throws Exception { assertThat(mapVal) .extracting(l -> l.get(stringOf("a"))) .isEqualTo(intOf(AnEnum.ENUM_VALUE_2.ordinal())); + + assertThat(obj.isSet(stringOf("optionalString"))).isSameAs(True); + assertThat(obj.get(stringOf("optionalString"))).isEqualTo(stringOf("optional-a")); + assertThat(obj.isSet(stringOf("emptyOptionalString"))).isSameAs(True); + assertThat(obj.get(stringOf("emptyOptionalString"))).isSameAs(NullT.NullValue); } } diff --git a/jackson/src/test/java/org/projectnessie/cel/types/jackson/types/CollectionsObject.java b/jackson/src/test/java/org/projectnessie/cel/types/jackson/types/CollectionsObject.java index 702c87ba..2b416ab5 100644 --- a/jackson/src/test/java/org/projectnessie/cel/types/jackson/types/CollectionsObject.java +++ b/jackson/src/test/java/org/projectnessie/cel/types/jackson/types/CollectionsObject.java @@ -21,8 +21,11 @@ import com.google.protobuf.Duration; import com.google.protobuf.Timestamp; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import org.projectnessie.cel.common.ULong; public class CollectionsObject { @@ -50,14 +53,19 @@ public class CollectionsObject { public List bytesList; public List floatList; public List doubleList; + public Collection stringCollection; + public Set stringSet; public Map stringInnerMap; public List innerTypes; + public Optional optionalInnerType; public AnEnum anEnum; public List anEnumList; public Map anEnumStringMap; public Map stringAnEnumMap; + public Optional optionalString; + public Optional emptyOptionalString; public static final List ALL_PROPERTIES = asList( @@ -84,10 +92,15 @@ public class CollectionsObject { "bytesList", "floatList", "doubleList", + "stringCollection", + "stringSet", "stringInnerMap", "innerTypes", + "optionalInnerType", "anEnum", "anEnumList", "anEnumStringMap", - "stringAnEnumMap"); + "stringAnEnumMap", + "optionalString", + "emptyOptionalString"); } diff --git a/jackson3/src/main/java/org/projectnessie/cel/types/jackson3/JacksonTypeDescription.java b/jackson3/src/main/java/org/projectnessie/cel/types/jackson3/JacksonTypeDescription.java index b7f213fd..02cca3f4 100644 --- a/jackson3/src/main/java/org/projectnessie/cel/types/jackson3/JacksonTypeDescription.java +++ b/jackson3/src/main/java/org/projectnessie/cel/types/jackson3/JacksonTypeDescription.java @@ -20,10 +20,11 @@ import com.google.protobuf.Timestamp; import java.time.Instant; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.HashMap; import java.util.Iterator; -import java.util.List; import java.util.Map; +import java.util.Optional; import org.projectnessie.cel.checker.Decls; import org.projectnessie.cel.common.ULong; import org.projectnessie.cel.common.types.TypeT; @@ -103,15 +104,17 @@ com.google.api.expr.v1alpha1.Type findTypeForJacksonType(JavaType type, TypeQuer || Instant.class.isAssignableFrom(rawClass) || ZonedDateTime.class.isAssignableFrom(rawClass)) { return Checked.checkedTimestamp; + } else if (Optional.class.isAssignableFrom(rawClass)) { + return findTypeForJacksonType(elementType(type), typeQuery); } else if (Map.class.isAssignableFrom(rawClass)) { com.google.api.expr.v1alpha1.Type keyType = findTypeForJacksonType(type.getKeyType(), typeQuery); com.google.api.expr.v1alpha1.Type valueType = - findTypeForJacksonType(type.getContentType(), typeQuery); + findTypeForJacksonType(elementType(type), typeQuery); return Decls.newMapType(keyType, valueType); - } else if (List.class.isAssignableFrom(rawClass)) { + } else if (Collection.class.isAssignableFrom(rawClass)) { com.google.api.expr.v1alpha1.Type valueType = - findTypeForJacksonType(type.getContentType(), typeQuery); + findTypeForJacksonType(elementType(type), typeQuery); return Decls.newListType(valueType); } else if (type.isEnumType()) { return typeQuery.getType(type); @@ -124,6 +127,18 @@ com.google.api.expr.v1alpha1.Type findTypeForJacksonType(JavaType type, TypeQuer } } + private JavaType elementType(JavaType type) { + JavaType elementType = type.getContentType(); + if (elementType == null && type.containedTypeCount() > 0) { + elementType = type.containedType(0); + } + if (elementType == null) { + throw new UnsupportedOperationException( + String.format("Unsupported Java Type '%s' without element type", type)); + } + return elementType; + } + boolean hasProperty(String property) { return fieldTypes.containsKey(property); } diff --git a/jackson3/src/test/java/org/projectnessie/cel/types/jackson3/Jackson3TypeDescriptionTest.java b/jackson3/src/test/java/org/projectnessie/cel/types/jackson3/Jackson3TypeDescriptionTest.java index 6ee91bff..23057396 100644 --- a/jackson3/src/test/java/org/projectnessie/cel/types/jackson3/Jackson3TypeDescriptionTest.java +++ b/jackson3/src/test/java/org/projectnessie/cel/types/jackson3/Jackson3TypeDescriptionTest.java @@ -36,8 +36,10 @@ import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; -import java.util.List; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.Test; import org.projectnessie.cel.common.ULong; import org.projectnessie.cel.common.types.Err; @@ -206,6 +208,27 @@ void types() { checkListType(reg, "bytesList", ByteString.class, Checked.checkedBytes); checkListType(reg, "floatList", Float.class, Checked.checkedDouble); checkListType(reg, "doubleList", Double.class, Checked.checkedDouble); + checkListType(reg, "stringCollection", String.class, Checked.checkedString); + checkListType(reg, "stringSet", String.class, Checked.checkedString); + + // verify optional fields use their contained type + + checkFieldType(reg, "optionalString", Checked.checkedString); + checkFieldType(reg, "emptyOptionalString", Checked.checkedString); + checkFieldType( + reg, + "optionalInnerType", + com.google.api.expr.v1alpha1.Type.newBuilder() + .setMessageType(InnerType.class.getName()) + .build()); + } + + private void checkFieldType( + Jackson3Registry reg, String prop, com.google.api.expr.v1alpha1.Type type) { + JacksonFieldType ft = + (JacksonFieldType) reg.findFieldType(CollectionsObject.class.getName(), prop); + assertThat(ft).isNotNull(); + assertThat(ft.type).isEqualTo(type); } private void checkListType( @@ -350,6 +373,8 @@ void collectionsObjectTypeTest() throws Exception { ByteString.copyFrom(new byte[] {(byte) 3})); collectionsObject.floatList = asList(1f, 2f, 3f); collectionsObject.doubleList = asList(1d, 2d, 3d); + collectionsObject.stringCollection = asList("collection-a", "collection-b"); + collectionsObject.stringSet = new LinkedHashSet<>(asList("set-a", "set-b")); // populate inner/nested type list/map @@ -362,6 +387,7 @@ void collectionsObjectTypeTest() throws Exception { inner2.intProp = 3; inner2.wrappedIntProp = 4; collectionsObject.innerTypes = asList(inner1, inner2); + collectionsObject.optionalInnerType = Optional.of(inner2); // populate enum-related fields @@ -369,6 +395,8 @@ void collectionsObjectTypeTest() throws Exception { collectionsObject.anEnumList = asList(AnEnum.ENUM_VALUE_2, AnEnum.ENUM_VALUE_3); collectionsObject.anEnumStringMap = singletonMap(AnEnum.ENUM_VALUE_2, "a"); collectionsObject.stringAnEnumMap = singletonMap("a", AnEnum.ENUM_VALUE_2); + collectionsObject.optionalString = Optional.of("optional-a"); + collectionsObject.emptyOptionalString = Optional.empty(); // prepare registry @@ -389,7 +417,7 @@ void collectionsObjectTypeTest() throws Exception { Object fieldObj = CollectionsObject.class.getDeclaredField(field).get(collectionsObject); if (fieldObj instanceof Map) { assertThat(fieldVal).isInstanceOf(MapT.class); - } else if (fieldObj instanceof List) { + } else if (fieldObj instanceof Collection) { assertThat(fieldVal).isInstanceOf(ListT.class); } @@ -422,6 +450,11 @@ void collectionsObjectTypeTest() throws Exception { l -> l.get(intOf(2))) .containsExactly(intOf(3), False, True, True, True, uintOf(1), uintOf(2), uintOf(3)); + listVal = (ListT) obj.get(stringOf("stringSet")); + assertThat(listVal) + .extracting(ListT::size, l -> l.get(intOf(0)), l -> l.get(intOf(1))) + .containsExactly(intOf(2), stringOf("set-a"), stringOf("set-b")); + mapVal = (MapT) obj.get(stringOf("stringInnerMap")); assertThat(mapVal) .extracting(MapT::size, m -> m.contains(stringOf("42")), m -> m.contains(stringOf("a"))) @@ -442,6 +475,11 @@ void collectionsObjectTypeTest() throws Exception { .extracting(o -> o.get(stringOf("intProp")), o -> o.get(stringOf("wrappedIntProp"))) .containsExactly(intOf(3), intOf(4)); + i = (ObjectT) obj.get(stringOf("optionalInnerType")); + assertThat(i) + .extracting(o -> o.get(stringOf("intProp")), o -> o.get(stringOf("wrappedIntProp"))) + .containsExactly(intOf(3), intOf(4)); + // verify enums Val x = obj.get(stringOf("anEnum")); @@ -459,5 +497,10 @@ void collectionsObjectTypeTest() throws Exception { assertThat(mapVal) .extracting(l -> l.get(stringOf("a"))) .isEqualTo(intOf(AnEnum.ENUM_VALUE_2.ordinal())); + + assertThat(obj.isSet(stringOf("optionalString"))).isSameAs(True); + assertThat(obj.get(stringOf("optionalString"))).isEqualTo(stringOf("optional-a")); + assertThat(obj.isSet(stringOf("emptyOptionalString"))).isSameAs(True); + assertThat(obj.get(stringOf("emptyOptionalString"))).isSameAs(NullT.NullValue); } } diff --git a/jackson3/src/test/java/org/projectnessie/cel/types/jackson3/types/CollectionsObject.java b/jackson3/src/test/java/org/projectnessie/cel/types/jackson3/types/CollectionsObject.java index 9b0cca97..2b38d805 100644 --- a/jackson3/src/test/java/org/projectnessie/cel/types/jackson3/types/CollectionsObject.java +++ b/jackson3/src/test/java/org/projectnessie/cel/types/jackson3/types/CollectionsObject.java @@ -21,8 +21,11 @@ import com.google.protobuf.Duration; import com.google.protobuf.Timestamp; import java.time.ZonedDateTime; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import org.projectnessie.cel.common.ULong; public class CollectionsObject { @@ -50,14 +53,19 @@ public class CollectionsObject { public List bytesList; public List floatList; public List doubleList; + public Collection stringCollection; + public Set stringSet; public Map stringInnerMap; public List innerTypes; + public Optional optionalInnerType; public AnEnum anEnum; public List anEnumList; public Map anEnumStringMap; public Map stringAnEnumMap; + public Optional optionalString; + public Optional emptyOptionalString; public static final List ALL_PROPERTIES = asList( @@ -84,10 +92,15 @@ public class CollectionsObject { "bytesList", "floatList", "doubleList", + "stringCollection", + "stringSet", "stringInnerMap", "innerTypes", + "optionalInnerType", "anEnum", "anEnumList", "anEnumStringMap", - "stringAnEnumMap"); + "stringAnEnumMap", + "optionalString", + "emptyOptionalString"); }