diff --git a/api/src/main/java/org/apache/iceberg/expressions/BoundExtract.java b/api/src/main/java/org/apache/iceberg/expressions/BoundExtract.java new file mode 100644 index 000000000000..3767e5665079 --- /dev/null +++ b/api/src/main/java/org/apache/iceberg/expressions/BoundExtract.java @@ -0,0 +1,75 @@ +/* + * 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.iceberg.expressions; + +import org.apache.iceberg.StructLike; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.types.Type; + +public class BoundExtract implements BoundTerm { + private final BoundReference ref; + private final String path; + private final String fullFieldName; + private final Type type; + + BoundExtract(BoundReference ref, String path, Type type) { + this.ref = ref; + this.path = path; + this.fullFieldName = Joiner.on(".").join(PathUtil.parse(path)); + this.type = type; + } + + @Override + public BoundReference ref() { + return ref; + } + + public String path() { + return path; + } + + String fullFieldName() { + return fullFieldName; + } + + @Override + public Type type() { + return type; + } + + @Override + public boolean isEquivalentTo(BoundTerm other) { + if (other instanceof BoundExtract) { + BoundExtract that = (BoundExtract) other; + return ref.isEquivalentTo(that.ref) && path.equals(that.path) && type.equals(that.type); + } + + return false; + } + + @Override + public T eval(StructLike struct) { + throw new UnsupportedOperationException("Cannot evaluate " + this); + } + + @Override + public String toString() { + return "extract(" + ref + ", path=" + path + ", type=" + type + ")"; + } +} diff --git a/api/src/main/java/org/apache/iceberg/expressions/BoundReference.java b/api/src/main/java/org/apache/iceberg/expressions/BoundReference.java index 0ff73632b1d6..0295fe518fc3 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/BoundReference.java +++ b/api/src/main/java/org/apache/iceberg/expressions/BoundReference.java @@ -55,6 +55,11 @@ public Type type() { return field.type(); } + @Override + public boolean producesNull() { + return field.isOptional(); + } + @Override public String name() { return name; diff --git a/api/src/main/java/org/apache/iceberg/expressions/BoundTerm.java b/api/src/main/java/org/apache/iceberg/expressions/BoundTerm.java index 5ded3c903d19..5eb6769c4605 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/BoundTerm.java +++ b/api/src/main/java/org/apache/iceberg/expressions/BoundTerm.java @@ -31,6 +31,11 @@ public interface BoundTerm extends Bound, Term { /** Returns the type produced by this expression. */ Type type(); + /** Returns whether values produced by this expression may be null. */ + default boolean producesNull() { + return true; + } + /** Returns a {@link Comparator} for values produced by this term. */ default Comparator comparator() { return Comparators.forType(type().asPrimitiveType()); diff --git a/api/src/main/java/org/apache/iceberg/expressions/BoundTransform.java b/api/src/main/java/org/apache/iceberg/expressions/BoundTransform.java index 22271aaed9d5..9aa22d3b985f 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/BoundTransform.java +++ b/api/src/main/java/org/apache/iceberg/expressions/BoundTransform.java @@ -54,6 +54,13 @@ public Transform transform() { return transform; } + @Override + public boolean producesNull() { + // transforms must produce null for null input values + // transforms may produce null for non-null inputs when not order-preserving + return ref.producesNull() || !transform.preservesOrder(); + } + @Override public Type type() { return transform.getResultType(ref.type()); diff --git a/api/src/main/java/org/apache/iceberg/expressions/Expressions.java b/api/src/main/java/org/apache/iceberg/expressions/Expressions.java index 7020b259b1b5..22626866725b 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/Expressions.java +++ b/api/src/main/java/org/apache/iceberg/expressions/Expressions.java @@ -102,6 +102,10 @@ public static UnboundTerm truncate(String name, int width) { return new UnboundTransform<>(ref(name), Transforms.truncate(width)); } + public static UnboundTerm extract(String name, String path, String type) { + return new UnboundExtract<>(ref(name), path, type); + } + public static UnboundPredicate isNull(String name) { return new UnboundPredicate<>(Expression.Operation.IS_NULL, ref(name)); } diff --git a/api/src/main/java/org/apache/iceberg/expressions/PathUtil.java b/api/src/main/java/org/apache/iceberg/expressions/PathUtil.java new file mode 100644 index 000000000000..ad7b5cd72684 --- /dev/null +++ b/api/src/main/java/org/apache/iceberg/expressions/PathUtil.java @@ -0,0 +1,64 @@ +/* + * 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.iceberg.expressions; + +import java.util.List; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; + +class PathUtil { + private PathUtil() {} + + private static final String RFC9535_NAME_FIRST = + "[A-Za-z_\\x{0080}-\\x{D7FF}\\x{E000}-\\x{10FFFF}]"; + private static final String RFC9535_NAME_CHARS = + "[0-9A-Za-z_\\x{0080}-\\x{D7FF}\\x{E000}-\\x{10FFFF}]*"; + private static final Predicate RFC9535_MEMBER_NAME_SHORTHAND = + Pattern.compile(RFC9535_NAME_FIRST + RFC9535_NAME_CHARS).asMatchPredicate(); + + private static final Splitter DOT = Splitter.on("."); + private static final String ROOT = "$"; + + static List parse(String path) { + Preconditions.checkArgument(path != null, "Invalid path: null"); + Preconditions.checkArgument( + !path.contains("[") && !path.contains("]"), "Unsupported path, contains bracket: %s", path); + Preconditions.checkArgument( + !path.contains("*"), "Unsupported path, contains wildcard: %s", path); + Preconditions.checkArgument( + !path.contains(".."), "Unsupported path, contains recursive descent: %s", path); + + List parts = DOT.splitToList(path); + Preconditions.checkArgument( + ROOT.equals(parts.get(0)), "Invalid path, does not start with %s: %s", ROOT, path); + + List names = parts.subList(1, parts.size()); + for (String name : names) { + Preconditions.checkArgument( + RFC9535_MEMBER_NAME_SHORTHAND.test(name), + "Invalid path: %s (%s has invalid characters)", + path, + name); + } + + return names; + } +} diff --git a/api/src/main/java/org/apache/iceberg/expressions/UnboundExtract.java b/api/src/main/java/org/apache/iceberg/expressions/UnboundExtract.java new file mode 100644 index 000000000000..1f29650c7411 --- /dev/null +++ b/api/src/main/java/org/apache/iceberg/expressions/UnboundExtract.java @@ -0,0 +1,67 @@ +/* + * 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.iceberg.expressions; + +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; + +public class UnboundExtract implements UnboundTerm { + private final NamedReference ref; + private final String path; + private final Type.PrimitiveType type; + + public UnboundExtract(NamedReference ref, String path, String type) { + this.ref = ref; + this.path = path; + this.type = Types.fromPrimitiveString(type); + // verify that the path is well-formed + PathUtil.parse(path); + } + + @Override + public BoundTerm bind(Types.StructType struct, boolean caseSensitive) { + BoundReference boundRef = ref.bind(struct, caseSensitive); + ValidationException.check( + Types.VariantType.get().equals(boundRef.type()), + "Cannot bind extract, not a variant: %s", + boundRef.name()); + ValidationException.check( + !type.equals(Types.UnknownType.get()), "Invalid type to extract: unknown"); + return new BoundExtract<>(boundRef, path, type); + } + + @Override + public NamedReference ref() { + return ref; + } + + public String path() { + return path; + } + + public Type type() { + return type; + } + + @Override + public String toString() { + return "extract(" + ref + ", path=" + path + ", type=" + type + ")"; + } +} diff --git a/api/src/main/java/org/apache/iceberg/expressions/UnboundPredicate.java b/api/src/main/java/org/apache/iceberg/expressions/UnboundPredicate.java index 04513086e042..4736ca4a8668 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/UnboundPredicate.java +++ b/api/src/main/java/org/apache/iceberg/expressions/UnboundPredicate.java @@ -27,6 +27,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; import org.apache.iceberg.types.Types.StructType; import org.apache.iceberg.util.CharSequenceSet; @@ -124,13 +125,17 @@ public Expression bind(StructType struct, boolean caseSensitive) { private Expression bindUnaryOperation(BoundTerm boundTerm) { switch (op()) { case IS_NULL: - if (boundTerm.ref().field().isRequired()) { + if (!boundTerm.producesNull()) { return Expressions.alwaysFalse(); + } else if (boundTerm.type().equals(Types.UnknownType.get())) { + return Expressions.alwaysTrue(); } return new BoundUnaryPredicate<>(Operation.IS_NULL, boundTerm); case NOT_NULL: - if (boundTerm.ref().field().isRequired()) { + if (!boundTerm.producesNull()) { return Expressions.alwaysTrue(); + } else if (boundTerm.type().equals(Types.UnknownType.get())) { + return Expressions.alwaysFalse(); } return new BoundUnaryPredicate<>(Operation.NOT_NULL, boundTerm); case IS_NAN: @@ -155,6 +160,14 @@ private boolean floatingType(Type.TypeID typeID) { } private Expression bindLiteralOperation(BoundTerm boundTerm) { + if (op() == Operation.STARTS_WITH || op() == Operation.NOT_STARTS_WITH) { + ValidationException.check( + boundTerm.type().equals(Types.StringType.get()), + "Term for STARTS_WITH or NOT_STARTS_WITH must produce a string: %s: %s", + boundTerm, + boundTerm.type()); + } + Literal lit = literal().to(boundTerm.type()); if (lit == null) { diff --git a/api/src/test/java/org/apache/iceberg/expressions/TestExpressionBinding.java b/api/src/test/java/org/apache/iceberg/expressions/TestExpressionBinding.java index 8dccc4e6a5d6..40919cb4cbb0 100644 --- a/api/src/test/java/org/apache/iceberg/expressions/TestExpressionBinding.java +++ b/api/src/test/java/org/apache/iceberg/expressions/TestExpressionBinding.java @@ -23,12 +23,17 @@ import static org.apache.iceberg.expressions.Expressions.and; import static org.apache.iceberg.expressions.Expressions.bucket; import static org.apache.iceberg.expressions.Expressions.equal; +import static org.apache.iceberg.expressions.Expressions.extract; import static org.apache.iceberg.expressions.Expressions.greaterThan; +import static org.apache.iceberg.expressions.Expressions.isNull; import static org.apache.iceberg.expressions.Expressions.lessThan; import static org.apache.iceberg.expressions.Expressions.not; +import static org.apache.iceberg.expressions.Expressions.notNull; import static org.apache.iceberg.expressions.Expressions.notStartsWith; import static org.apache.iceberg.expressions.Expressions.or; import static org.apache.iceberg.expressions.Expressions.startsWith; +import static org.apache.iceberg.expressions.Expressions.truncate; +import static org.apache.iceberg.types.Types.NestedField.optional; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -38,6 +43,8 @@ import org.apache.iceberg.types.Types; import org.apache.iceberg.types.Types.StructType; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; public class TestExpressionBinding { private static final StructType STRUCT = @@ -45,7 +52,10 @@ public class TestExpressionBinding { required(0, "x", Types.IntegerType.get()), required(1, "y", Types.IntegerType.get()), required(2, "z", Types.IntegerType.get()), - required(3, "data", Types.StringType.get())); + required(3, "data", Types.StringType.get()), + required(4, "var", Types.VariantType.get()), + optional(5, "nullable", Types.IntegerType.get()), + optional(6, "always_null", Types.UnknownType.get())); @Test public void testMissingReference() { @@ -212,4 +222,202 @@ public void testTransformExpressionBinding() { .as("Should use a bucket[16] transform") .hasToString("bucket[16]"); } + + @Test + public void testIsNullWithUnknown() { + Expression bound = Binder.bind(STRUCT, isNull("always_null")); + TestHelpers.assertAllReferencesBound("IsNull", bound); + assertThat(bound).isEqualTo(Expressions.alwaysTrue()); + } + + @Test + public void testNotNullWithUnknown() { + Expression bound = Binder.bind(STRUCT, notNull("always_null")); + TestHelpers.assertAllReferencesBound("NotNull", bound); + assertThat(bound).isEqualTo(Expressions.alwaysFalse()); + } + + @Test + public void testIsNullWithNullable() { + Expression bound = Binder.bind(STRUCT, isNull("nullable")); + TestHelpers.assertAllReferencesBound("IsNull", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.op()).isEqualTo(Expression.Operation.IS_NULL); + } + + @Test + public void testIsNullWithRequired() { + Expression bound = Binder.bind(STRUCT, isNull("x")); + TestHelpers.assertAllReferencesBound("IsNull", bound); + assertThat(bound).isEqualTo(Expressions.alwaysFalse()); + } + + @Test + public void testNotNullWithNullable() { + Expression bound = Binder.bind(STRUCT, notNull("nullable")); + TestHelpers.assertAllReferencesBound("NotNull", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.op()).isEqualTo(Expression.Operation.NOT_NULL); + } + + @Test + public void testNotNullWithRequired() { + Expression bound = Binder.bind(STRUCT, notNull("x")); + TestHelpers.assertAllReferencesBound("NotNull", bound); + assertThat(bound).isEqualTo(Expressions.alwaysTrue()); + } + + @Test + public void testIsNullWithNullableTransformedOrderPreserving() { + Expression bound = Binder.bind(STRUCT, isNull(truncate("nullable", 10))); + TestHelpers.assertAllReferencesBound("IsNull", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.op()).isEqualTo(Expression.Operation.IS_NULL); + } + + @Test + public void testIsNullWithRequiredTransformedOrderPreserving() { + Expression bound = Binder.bind(STRUCT, isNull(truncate("x", 10))); + TestHelpers.assertAllReferencesBound("IsNull", bound); + assertThat(bound).isEqualTo(Expressions.alwaysFalse()); + } + + @Test + public void testNotNullWithNullableTransformedOrderPreserving() { + Expression bound = Binder.bind(STRUCT, notNull(truncate("nullable", 10))); + TestHelpers.assertAllReferencesBound("NotNull", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.op()).isEqualTo(Expression.Operation.NOT_NULL); + } + + @Test + public void testNotNullWithRequiredTransformedOrderPreserving() { + Expression bound = Binder.bind(STRUCT, notNull(truncate("x", 10))); + TestHelpers.assertAllReferencesBound("NotNull", bound); + assertThat(bound).isEqualTo(Expressions.alwaysTrue()); + } + + @Test + public void testIsNullWithRequiredTransformedNonOrderPreserving() { + Expression bound = Binder.bind(STRUCT, isNull(bucket("x", 10))); + TestHelpers.assertAllReferencesBound("IsNull", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.op()).isEqualTo(Expression.Operation.IS_NULL); + } + + @Test + public void testNotNullWithRequiredTransformedNonOrderPreserving() { + Expression bound = Binder.bind(STRUCT, notNull(bucket("x", 10))); + TestHelpers.assertAllReferencesBound("NotNull", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.op()).isEqualTo(Expression.Operation.NOT_NULL); + } + + @Test + public void testIsNullWithRequiredVariant() { + Expression bound = Binder.bind(STRUCT, isNull(extract("var", "$.event_id", "long"))); + TestHelpers.assertAllReferencesBound("IsNull", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.op()).isEqualTo(Expression.Operation.IS_NULL); + } + + @Test + public void testNotNullWithRequiredVariant() { + Expression bound = Binder.bind(STRUCT, notNull(extract("var", "$.event_id", "long"))); + TestHelpers.assertAllReferencesBound("NotNull", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.op()).isEqualTo(Expression.Operation.NOT_NULL); + } + + @Test + public void testExtractExpressionBinding() { + Expression bound = Binder.bind(STRUCT, lessThan(extract("var", "$.event_id", "long"), 100)); + TestHelpers.assertAllReferencesBound("BoundExtract", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.op()).isEqualTo(Expression.Operation.LT); + assertThat(pred.asLiteralPredicate().literal().value()).isEqualTo(100L); // cast to long + assertThat(pred.term()).as("Should use a BoundExtract").isInstanceOf(BoundExtract.class); + BoundExtract boundExtract = (BoundExtract) pred.term(); + assertThat(boundExtract.ref().fieldId()).isEqualTo(4); + assertThat(boundExtract.fullFieldName()).isEqualTo("event_id"); + assertThat(boundExtract.type()).isEqualTo(Types.LongType.get()); + } + + @Test + public void testExtractExpressionNonVariant() { + assertThatThrownBy(() -> Binder.bind(STRUCT, lessThan(extract("x", "$.event_id", "long"), 100))) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot bind extract, not a variant: x"); + } + + private static final String[] VALID_PATHS = + new String[] { + "$", // root path + "$.event_id", + "$.event.id" + }; + + @ParameterizedTest + @FieldSource("VALID_PATHS") + public void testExtractExpressionBindingPaths(String path) { + Expression bound = Binder.bind(STRUCT, lessThan(extract("var", path, "long"), 100)); + TestHelpers.assertAllReferencesBound("BoundExtract", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.term()).as("Should use a BoundExtract").isInstanceOf(BoundExtract.class); + } + + private static final String[] UNSUPPORTED_PATHS = + new String[] { + null, + "", + "event_id", // missing root + "$['event_id']", // uses bracket notation + "$..event_id", // uses recursive descent + "$.events[0].event_id", // uses position accessor + "$.events.*" // uses wildcard + }; + + @ParameterizedTest + @FieldSource("UNSUPPORTED_PATHS") + public void testExtractBindingWithInvalidPath(String path) { + assertThatThrownBy(() -> Binder.bind(STRUCT, lessThan(extract("var", path, "long"), 100))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testExtractUnknown() { + assertThatThrownBy(() -> Binder.bind(STRUCT, isNull(extract("var", "$.field", "unknown")))) + .isInstanceOf(ValidationException.class) + .hasMessage("Invalid type to extract: unknown"); + } + + private static final String[] VALID_TYPES = + new String[] { + "boolean", + "int", + "long", + "float", + "double", + "decimal(9,2)", + "date", + "time", + "timestamp", + "timestamptz", + "timestamp_ns", + "timestamptz_ns", + "string", + "uuid", + "fixed[4]", + "binary", + }; + + @ParameterizedTest + @FieldSource("VALID_TYPES") + public void testExtractBindingWithTypes(String typeName) { + Expression bound = Binder.bind(STRUCT, notNull(extract("var", "$.field", typeName))); + TestHelpers.assertAllReferencesBound("BoundExtract", bound); + BoundPredicate pred = TestHelpers.assertAndUnwrap(bound); + assertThat(pred.term()).as("Should use a BoundExtract").isInstanceOf(BoundExtract.class); + assertThat(pred.term().type()).isEqualTo(Types.fromPrimitiveString(typeName)); + } } diff --git a/api/src/test/java/org/apache/iceberg/expressions/TestPathParsing.java b/api/src/test/java/org/apache/iceberg/expressions/TestPathParsing.java new file mode 100644 index 000000000000..13fa2ef7184f --- /dev/null +++ b/api/src/test/java/org/apache/iceberg/expressions/TestPathParsing.java @@ -0,0 +1,71 @@ +/* + * 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.iceberg.expressions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; + +public class TestPathParsing { + + @Test + public void testSimplePath() { + assertThat(PathUtil.parse("$.event.id")).isEqualTo(List.of("event", "id")); + } + + private static final String[] VALID_PATHS = + new String[] { + "$", // root path + "$.event_id", + "$.event.id", + "$.\u2603", // snowman + "$.\uD834\uDD1E", // surrogate pair, U+1D11E + }; + + @ParameterizedTest + @FieldSource("VALID_PATHS") + public void testExtractExpressionBindingPaths(String path) { + assertThatCode(() -> PathUtil.parse(path)).doesNotThrowAnyException(); + } + + private static final String[] INVALID_PATHS = + new String[] { + null, + "", + "event_id", // missing root + "$['event_id']", // uses bracket notation + "$..event_id", // uses recursive descent + "$.events[0].event_id", // uses position accessor + "$.events.*", // uses wildcard + "$.0invalid", // starts with a digit + "$._\uD834", // dangling high surrogate + "$._\uDC34", // low surrogate without high surrogate + }; + + @ParameterizedTest + @FieldSource("INVALID_PATHS") + public void testExtractBindingWithInvalidPath(String path) { + assertThatThrownBy(() -> PathUtil.parse(path)).isInstanceOf(IllegalArgumentException.class); + } +}