This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new fdfd718729 [core] Narrow format table partition listing by name
pattern (#9025)
fdfd718729 is described below
commit fdfd71872923aee6ea8a67d9c014bc568140b33d
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Tue Aug 4 18:51:53 2026 +0800
[core] Narrow format table partition listing by name pattern (#9025)
---
.../format/CatalogFormatTablePartitionManager.java | 3 +-
.../paimon/table/format/PartitionNamePatterns.java | 249 +++++++++++++++++++++
.../CatalogFormatTablePartitionManagerTest.java | 110 +++++++++
.../table/format/PartitionNamePatternsTest.java | 220 ++++++++++++++++++
4 files changed, 580 insertions(+), 2 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
index ed266db2a5..4d1009f196 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
@@ -25,7 +25,6 @@ import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.utils.FunctionWithException;
-import org.apache.paimon.utils.PartitionPathUtils;
import org.apache.paimon.utils.StringUtils;
import javax.annotation.Nullable;
@@ -73,7 +72,7 @@ class CatalogFormatTablePartitionManager implements
FormatTablePartitionManager
prefix,
partitionKeys,
identifier.getFullName());
- String pattern =
PartitionPathUtils.buildPartitionNamePrefixPattern(partitionKeys, ordered);
+ String pattern = PartitionNamePatterns.build(partitionKeys, ordered,
filter);
return execute(
catalog -> {
if (filter != null) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/PartitionNamePatterns.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/PartitionNamePatterns.java
new file mode 100644
index 0000000000..7a9eee3549
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/PartitionNamePatterns.java
@@ -0,0 +1,249 @@
+/*
+ * 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.paimon.table.format;
+
+import org.apache.paimon.casting.CastExecutor;
+import org.apache.paimon.casting.CastExecutors;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.predicate.Between;
+import org.apache.paimon.predicate.Equal;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.GreaterOrEqual;
+import org.apache.paimon.predicate.GreaterThan;
+import org.apache.paimon.predicate.LeafFunction;
+import org.apache.paimon.predicate.LeafPredicate;
+import org.apache.paimon.predicate.LessOrEqual;
+import org.apache.paimon.predicate.LessThan;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.predicate.StartsWith;
+import org.apache.paimon.predicate.SubstringTransform;
+import org.apache.paimon.predicate.Transform;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeFamily;
+import org.apache.paimon.types.VarCharType;
+import org.apache.paimon.utils.PartitionPathUtils;
+
+import javax.annotation.Nullable;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+
+/**
+ * Builds the {@code partitionNamePattern} sent to the catalog when a format
table lists its
+ * partitions.
+ *
+ * <p>Without a pattern the client drains the whole registry one page at a
time, which costs one
+ * round trip per page no matter how few partitions the query needs. A pattern
lets the catalog
+ * filter by name prefix and can reduce the number of pages returned.
+ *
+ * <p>{@link PartitionPathUtils#buildPartitionNamePrefixPattern} already
covers the case where
+ * leading partition keys are pinned by equality. This class extends the
pattern by one more key
+ * using predicates that constrain only a <em>prefix of that key's value</em>.
+ *
+ * <h2>Why a too-wide pattern is safe</h2>
+ *
+ * <p>The pattern only narrows the candidate set fetched from the catalog.
Every candidate is still
+ * checked against the full partition predicate before its files are listed
(see {@code
+ * CatalogSplitEnumerator}). So a pattern that matches extra partitions merely
wastes a little
+ * listing; a pattern that misses a matching partition would silently drop
data. Every rule below is
+ * therefore chosen so that <b>every partition satisfying the predicate
matches the pattern</b>.
+ */
+final class PartitionNamePatterns {
+
+ private PartitionNamePatterns() {}
+
+ /**
+ * Returns the pattern for a listing constrained by {@code equalityPrefix}
and {@code filter},
+ * or null when no useful prefix can be derived.
+ */
+ @Nullable
+ static String build(
+ List<String> partitionKeys,
+ LinkedHashMap<String, String> equalityPrefix,
+ @Nullable Predicate filter) {
+ String base =
+
PartitionPathUtils.buildPartitionNamePrefixPattern(partitionKeys,
equalityPrefix);
+ if (filter == null || equalityPrefix.size() >= partitionKeys.size()) {
+ // Either there is nothing left to narrow, or equality already
pinned every key.
+ return base;
+ }
+ if (base == null && !equalityPrefix.isEmpty()) {
+ return null;
+ }
+
+ String nextKey = partitionKeys.get(equalityPrefix.size());
+ String valuePrefix = leadingValuePrefix(filter, nextKey);
+ if (valuePrefix == null || valuePrefix.isEmpty()) {
+ return base;
+ }
+
+ LinkedHashMap<String, String> extended = new
LinkedHashMap<>(equalityPrefix);
+ extended.put(nextKey, valuePrefix);
+ // Escaping is per character, so escaping a value prefix yields a
prefix of the escaped
+ // value. generatePartitionPath appends a trailing separator that a
partial value must not
+ // carry.
+ String path = PartitionPathUtils.generatePartitionPath(extended);
+ String escaped = path.substring(0, path.length() - 1);
+ if (escaped.indexOf('%') >= 0) {
+ // A percent-escaped character would read as a LIKE wildcard.
Widening is safe but
+ // pointless, so fall back rather than send a pattern the server
cannot use as an index
+ // prefix.
+ return base;
+ }
+ return escaped + '%';
+ }
+
+ /**
+ * Derives a prefix of {@code key}'s value that every partition satisfying
{@code filter} must
+ * start with, or null if the predicates do not constrain the value's
prefix.
+ */
+ @Nullable
+ private static String leadingValuePrefix(Predicate filter, String key) {
+ String substringPrefix = null;
+ String startsWithPrefix = null;
+ String lower = null;
+ String upper = null;
+
+ for (Predicate conjunct : PredicateBuilder.splitAnd(filter)) {
+ if (!(conjunct instanceof LeafPredicate)) {
+ continue;
+ }
+ LeafPredicate leaf = (LeafPredicate) conjunct;
+ LeafFunction function = leaf.function();
+ List<Object> literals = leaf.literals();
+ if (literals.isEmpty() || literals.get(0) == null) {
+ continue;
+ }
+
+ if (function instanceof Equal) {
+ String prefix = substringPrefixOf(leaf, key);
+ if (prefix != null) {
+ substringPrefix = prefix;
+ }
+ }
+
+ FieldRef ref = leaf.fieldRefOptional().orElse(null);
+ if (ref == null || !key.equals(ref.name()) ||
!isStringType(ref.type())) {
+ // Prefix reasoning below compares values lexicographically.
That only matches the
+ // predicate's own ordering for string types: on a numeric
key, `k >= 9 AND k <= 99`
+ // holds for 10, whose name does not start with the common
prefix "9".
+ continue;
+ }
+ String literal = literalToString(ref.type(), literals.get(0));
+ if (literal == null) {
+ continue;
+ }
+ if (function instanceof StartsWith) {
+ startsWithPrefix = literal;
+ } else if (function instanceof Between) {
+ // `k >= lo AND k <= hi` reaches us already folded into one
Between leaf, which is
+ // the shape real query plans carry; the split form below is
the fallback.
+ if (literals.size() == 2 && literals.get(1) != null) {
+ lower = literal;
+ upper = literalToString(ref.type(), literals.get(1));
+ }
+ } else if (function instanceof GreaterOrEqual || function
instanceof GreaterThan) {
+ lower = literal;
+ } else if (function instanceof LessOrEqual || function instanceof
LessThan) {
+ upper = literal;
+ }
+ }
+
+ if (substringPrefix != null) {
+ return substringPrefix;
+ }
+ if (startsWithPrefix != null) {
+ return startsWithPrefix;
+ }
+ if (lower != null && upper != null) {
+ // For lexicographic order, lo <= v <= hi implies v starts with
the common prefix of lo
+ // and hi: if v differed from them at some position inside that
prefix, it would fall
+ // outside the range on that very position.
+ return commonPrefix(lower, upper);
+ }
+ return null;
+ }
+
+ /** Returns the literal of {@code substr(key, 1, n) = 'literal'}, or null
if it is not that. */
+ @Nullable
+ private static String substringPrefixOf(LeafPredicate leaf, String key) {
+ Transform transform = leaf.transform();
+ if (!(transform instanceof SubstringTransform)) {
+ return null;
+ }
+ List<Object> inputs = transform.inputs();
+ if (inputs.size() != 3 || !(inputs.get(0) instanceof FieldRef)) {
+ return null;
+ }
+ FieldRef ref = (FieldRef) inputs.get(0);
+ if (!key.equals(ref.name()) || !isStringType(ref.type())) {
+ return null;
+ }
+ // Only a substring anchored at the first character bounds the value's
prefix.
+ if (!isOne(inputs.get(1))) {
+ return null;
+ }
+ Object literal = leaf.literals().get(0);
+ return literal instanceof BinaryString ? literal.toString() : null;
+ }
+
+ private static boolean isOne(Object begin) {
+ if (begin instanceof Number) {
+ return ((Number) begin).longValue() == 1L;
+ }
+ try {
+ return begin != null && Long.parseLong(begin.toString()) == 1L;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+
+ private static boolean isStringType(DataType type) {
+ return
type.getTypeRoot().getFamilies().contains(DataTypeFamily.CHARACTER_STRING);
+ }
+
+ @Nullable
+ private static String literalToString(DataType type, Object literal) {
+ @SuppressWarnings("unchecked")
+ CastExecutor<Object, BinaryString> executor =
+ (CastExecutor<Object, BinaryString>)
+ CastExecutors.resolve(type, VarCharType.STRING_TYPE);
+ if (executor == null) {
+ return null;
+ }
+ BinaryString value = executor.cast(literal);
+ return value == null ? null : value.toString();
+ }
+
+ private static String commonPrefix(String a, String b) {
+ int max = Math.min(a.length(), b.length());
+ int i = 0;
+ while (i < max) {
+ int codePoint = a.codePointAt(i);
+ if (codePoint != b.codePointAt(i)
+ || (Character.isSurrogate(a.charAt(i))
+ && Character.charCount(codePoint) == 1)) {
+ break;
+ }
+ i += Character.charCount(codePoint);
+ }
+ return a.substring(0, i);
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
index 7d246bf4f5..dfcc422be3 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
@@ -287,6 +287,72 @@ class CatalogFormatTablePartitionManagerTest {
assertThat(partitions).containsExactly(matching);
}
+ @Test
+ void testRangeOnLeadingKeyIsPushedDownAsValuePattern() throws Exception {
+ Catalog catalog = mock(Catalog.class);
+ when(catalog.listPartitionsByFilterPaged(any(), any(), any(), any(),
any()))
+ .thenReturn(new PagedList<>(Collections.emptyList(), null));
+
+ partitionManager(catalog)
+ .listPartitions(Collections.emptyMap(), yearBetween("2019",
"2021"));
+
+ verify(catalog)
+ .listPartitionsByFilterPaged(
+ eq(IDENTIFIER), any(), eq(REQUEST_SIZE), isNull(),
eq("year=20%"));
+ }
+
+ @Test
+ void testValuePatternExtendsAnEqualityPrefixByOneKey() throws Exception {
+ Catalog catalog = mock(Catalog.class);
+ when(catalog.listPartitionsByFilterPaged(any(), any(), any(), any(),
any()))
+ .thenReturn(new PagedList<>(Collections.emptyList(), null));
+
+ PredicateBuilder builder = partitionPredicates();
+ Predicate monthRange =
+ PredicateBuilder.and(
+ builder.greaterOrEqual(1,
BinaryString.fromString("01")),
+ builder.lessOrEqual(1, BinaryString.fromString("09")));
+
+ partitionManager(catalog)
+ .listPartitions(Collections.singletonMap("year", "2025"),
monthRange);
+
+ verify(catalog)
+ .listPartitionsByFilterPaged(
+ eq(IDENTIFIER),
+ any(),
+ eq(REQUEST_SIZE),
+ isNull(),
+ eq("year=2025/month=0%"));
+ }
+
+ /**
+ * The pattern only narrows what the catalog returns; the scan still tests
every candidate. So a
+ * pattern that matches extra partitions is harmless, while one that
misses a matching partition
+ * silently drops data. This pins the direction that matters against a
catalog that really
+ * applies the LIKE pattern.
+ */
+ @Test
+ void testValuePatternNeverDropsAMatchingPartition() throws Exception {
+ List<Partition> universe = new ArrayList<>();
+ for (int year = 2018; year <= 2021; year++) {
+ for (int month = 1; month <= 12; month++) {
+ universe.add(partition(String.valueOf(year),
String.format("%02d", month)));
+ }
+ }
+ Catalog catalog = catalogHonouringPattern(universe);
+
+ Predicate filter = yearBetween("2019", "2021");
+ List<Partition> returned =
+
partitionManager(catalog).listPartitions(Collections.emptyMap(), filter);
+
+ List<Partition> shouldSurvive =
+ universe.stream()
+ .filter(p -> p.spec().get("year").compareTo("2019") >=
0)
+ .filter(p -> p.spec().get("year").compareTo("2021") <=
0)
+ .collect(Collectors.toList());
+ assertThat(returned).containsAll(shouldSurvive);
+ }
+
@Test
void testNonLeadingPrefixIsRejected() {
Catalog catalog = mock(Catalog.class);
@@ -506,6 +572,50 @@ class CatalogFormatTablePartitionManagerTest {
return
batches.stream().flatMap(List::stream).collect(Collectors.toList());
}
+ private static PredicateBuilder partitionPredicates() {
+ return new PredicateBuilder(
+ RowType.of(
+ new DataType[] {DataTypes.STRING(),
DataTypes.STRING()},
+ new String[] {"year", "month"}));
+ }
+
+ private static Predicate yearBetween(String lower, String upper) {
+ PredicateBuilder builder = partitionPredicates();
+ return PredicateBuilder.and(
+ builder.greaterOrEqual(0, BinaryString.fromString(lower)),
+ builder.lessOrEqual(0, BinaryString.fromString(upper)));
+ }
+
+ /** A catalog that really applies the LIKE pattern, so an over-narrow
pattern shows up. */
+ private static Catalog catalogHonouringPattern(List<Partition> universe)
throws Exception {
+ Catalog catalog = mock(Catalog.class);
+ when(catalog.listPartitionsByFilterPaged(any(), any(), any(), any(),
any()))
+ .thenAnswer(
+ invocation -> {
+ String pattern = invocation.getArgument(4);
+ java.util.regex.Pattern like =
+ pattern == null
+ ? null
+ : java.util.regex.Pattern.compile(
+
java.util.regex.Pattern.quote(pattern)
+ .replace("%",
"\\E.*\\Q")
+ .replace("_",
"\\E.\\Q"));
+ List<Partition> matched = new ArrayList<>();
+ for (Partition partition : universe) {
+ String name =
+ "year="
+ + partition.spec().get("year")
+ + "/month="
+ +
partition.spec().get("month");
+ if (like == null ||
like.matcher(name).matches()) {
+ matched.add(partition);
+ }
+ }
+ return new PagedList<>(matched, null);
+ });
+ return catalog;
+ }
+
private static Partition partition(String year, String month) {
return new Partition(spec(year, month), 0, 0, 0, 0, -1, false);
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/PartitionNamePatternsTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/PartitionNamePatternsTest.java
new file mode 100644
index 0000000000..d11d91b4a2
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/PartitionNamePatternsTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.paimon.table.format;
+
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.predicate.Equal;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.LeafPredicate;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.predicate.SubstringTransform;
+import org.apache.paimon.rest.RESTApi;
+import org.apache.paimon.rest.requests.ListPartitionsByFilterRequest;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link PartitionNamePatterns}. */
+class PartitionNamePatternsTest {
+
+ private static final List<String> ONE_KEY =
Collections.singletonList("dt");
+ private static final List<String> TWO_KEYS = Arrays.asList("dt", "hour");
+
+ private static PredicateBuilder stringBuilder() {
+ return new PredicateBuilder(
+ RowType.of(
+ new org.apache.paimon.types.DataType[] {
+ DataTypes.STRING(), DataTypes.STRING()
+ },
+ new String[] {"dt", "hour"}));
+ }
+
+ private static PredicateBuilder intBuilder() {
+ return new PredicateBuilder(
+ RowType.of(
+ new org.apache.paimon.types.DataType[]
{DataTypes.INT()},
+ new String[] {"dt"}));
+ }
+
+ private static BinaryString s(String v) {
+ return BinaryString.fromString(v);
+ }
+
+ private static LinkedHashMap<String, String> noPrefix() {
+ return new LinkedHashMap<>();
+ }
+
+ @Test
+ void noFilterKeepsTheExistingEqualityBehaviour() {
+ LinkedHashMap<String, String> prefix = new LinkedHashMap<>();
+ prefix.put("dt", "20190702");
+ assertThat(PartitionNamePatterns.build(TWO_KEYS, prefix,
null)).isEqualTo("dt=20190702/%");
+ assertThat(PartitionNamePatterns.build(ONE_KEY, prefix,
null)).isEqualTo("dt=20190702");
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(),
null)).isNull();
+ }
+
+ @Test
+ void rangeOnLeadingKeyNarrowsToTheCommonPrefix() {
+ PredicateBuilder b = stringBuilder();
+ Predicate filter =
+ PredicateBuilder.and(
+ b.greaterOrEqual(0, s("2019070100")), b.lessOrEqual(0,
s("2019070123")));
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(), filter))
+ .isEqualTo("dt=20190701%");
+ }
+
+ @Test
+ void supplementaryRangeDoesNotProduceMalformedPatternAcrossJson() throws
Exception {
+ PredicateBuilder b = stringBuilder();
+ Predicate filter = b.between(0, s("😀-a"), s("🙏-z"));
+
+ String pattern = PartitionNamePatterns.build(ONE_KEY, noPrefix(),
filter);
+ ListPartitionsByFilterRequest request =
+ new ListPartitionsByFilterRequest("filter-json", pattern,
1000, null);
+ ListPartitionsByFilterRequest roundTripped =
+ RESTApi.fromJson(RESTApi.toJson(request),
ListPartitionsByFilterRequest.class);
+
+ assertThat(roundTripped.getPartitionNamePattern()).isNull();
+ }
+
+ @Test
+ void strictBoundsNarrowTheSameWay() {
+ PredicateBuilder b = stringBuilder();
+ Predicate filter =
+ PredicateBuilder.and(
+ b.greaterThan(0, s("2019070100")), b.lessThan(0,
s("2019070123")));
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(), filter))
+ .isEqualTo("dt=20190701%");
+ }
+
+ @Test
+ void rangeUsesAnyNonEmptyCommonPrefix() {
+ PredicateBuilder b = stringBuilder();
+ Predicate filter =
+ PredicateBuilder.and(
+ b.greaterOrEqual(0, s("2019010100")), b.lessOrEqual(0,
s("2019123123")));
+ // The year is shared, which is still a useful prefix.
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(),
filter)).isEqualTo("dt=2019%");
+
+ Predicate spanning =
+ PredicateBuilder.and(
+ b.greaterOrEqual(0, s("2019010100")), b.lessOrEqual(0,
s("2020123123")));
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(),
spanning)).isEqualTo("dt=20%");
+ }
+
+ @Test
+ void oneSidedRangeGivesNoPrefix() {
+ PredicateBuilder b = stringBuilder();
+ assertThat(
+ PartitionNamePatterns.build(
+ ONE_KEY, noPrefix(), b.greaterOrEqual(0,
s("2019070100"))))
+ .isNull();
+ }
+
+ @Test
+ void betweenWithNullBoundaryFallsBack() {
+ PredicateBuilder b = stringBuilder();
+
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(),
b.between(0, null, s("z"))))
+ .isNull();
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(),
b.between(0, s("a"), null)))
+ .isNull();
+ }
+
+ /**
+ * The counterexample that forces the string-only guard: on an INT key,
{@code dt >= 9 AND dt <=
+ * 99} holds for 10, whose partition name {@code dt=10} does not start
with the common prefix
+ * "9". Deriving a pattern there would silently drop a matching partition.
+ */
+ @Test
+ void numericKeyNeverGetsAPrefix() {
+ PredicateBuilder b = intBuilder();
+ Predicate filter = PredicateBuilder.and(b.greaterOrEqual(0, 9),
b.lessOrEqual(0, 99));
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(),
filter)).isNull();
+ }
+
+ @Test
+ void substringAnchoredAtOneNarrowsToItsLiteral() {
+ Predicate filter = substringEqual(1, 6, "201907");
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(), filter))
+ .isEqualTo("dt=201907%");
+ }
+
+ @Test
+ void substringNotAnchoredAtOneGivesNoPrefix() {
+ // substr(dt, 3, 4) says nothing about the first two characters.
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(),
substringEqual(3, 4, "1907")))
+ .isNull();
+ }
+
+ @Test
+ void valuePrefixExtendsAnEqualityPrefixByExactlyOneKey() {
+ LinkedHashMap<String, String> prefix = new LinkedHashMap<>();
+ prefix.put("dt", "20190702");
+ PredicateBuilder b = stringBuilder();
+ Predicate filter =
+ PredicateBuilder.and(b.greaterOrEqual(1, s("06")),
b.lessOrEqual(1, s("09")));
+ assertThat(PartitionNamePatterns.build(TWO_KEYS, prefix, filter))
+ .isEqualTo("dt=20190702/hour=0%");
+ }
+
+ @Test
+ void unrepresentableEqualityPrefixIsNotExtended() {
+ LinkedHashMap<String, String> prefix = new LinkedHashMap<>();
+ prefix.put("dt", "");
+ PredicateBuilder b = stringBuilder();
+ Predicate filter =
+ PredicateBuilder.and(b.greaterOrEqual(1, s("06")),
b.lessOrEqual(1, s("09")));
+
+ assertThat(PartitionNamePatterns.build(TWO_KEYS, prefix,
filter)).isNull();
+ }
+
+ @Test
+ void predicateOnALaterKeyDoesNotLeakIntoTheLeadingPosition() {
+ // Constraining `hour` says nothing about `dt`, so the pattern must
stay unconstrained.
+ PredicateBuilder b = stringBuilder();
+ Predicate filter =
+ PredicateBuilder.and(b.greaterOrEqual(1, s("06")),
b.lessOrEqual(1, s("09")));
+ assertThat(PartitionNamePatterns.build(TWO_KEYS, noPrefix(),
filter)).isNull();
+ }
+
+ @Test
+ void orPredicatesAreIgnored() {
+ PredicateBuilder b = stringBuilder();
+ Predicate filter =
+ PredicateBuilder.or(b.equal(0, s("2019070212")), b.equal(0,
s("2019070213")));
+ // splitAnd yields the OR itself, which is not a leaf: no prefix, and
no wrong narrowing.
+ assertThat(PartitionNamePatterns.build(ONE_KEY, noPrefix(),
filter)).isNull();
+ }
+
+ private static Predicate substringEqual(int begin, int length, String
literal) {
+ FieldRef ref = new FieldRef(0, "dt", DataTypes.STRING());
+ SubstringTransform transform = new
SubstringTransform(Arrays.asList(ref, begin, length));
+ return LeafPredicate.of(transform, Equal.INSTANCE,
Collections.singletonList(s(literal)));
+ }
+}