From 1770f4dfd3d6207e203842e2a7f358f71c9a4532 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 20 Feb 2026 15:38:20 +0100 Subject: [PATCH 1/9] Support secondary index name hint --- .../java/com/aerospike/dsl/IndexContext.java | 25 ++++ .../LogicalParsedExpressionTests.java | 141 ++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/src/main/java/com/aerospike/dsl/IndexContext.java b/src/main/java/com/aerospike/dsl/IndexContext.java index d655963..5c483c8 100644 --- a/src/main/java/com/aerospike/dsl/IndexContext.java +++ b/src/main/java/com/aerospike/dsl/IndexContext.java @@ -4,6 +4,7 @@ import lombok.Getter; import java.util.Collection; +import java.util.List; /** * This class stores namespace and indexes required to build secondary index Filter @@ -22,4 +23,28 @@ public class IndexContext { * with bins in DSL String */ private Collection indexes; + + + /** + * Create index context specifying the index to be used + * + * @param namespace Namespace to be used for creating {@link com.aerospike.dsl.client.query.Filter}. + * Is matched with namespace of indexes + * @param indexes Collection of {@link Index} objects to be used for creating + * {@link com.aerospike.dsl.client.query.Filter}. Bin name and + * index type are matched with bins in DSL String + * @param indexToUse The name of an index to use for creating + * {@link com.aerospike.dsl.client.query.Filter}. If the index with the specified name + * is not found (or {@code indexToUse} is {@code null}), the resulting index is chosen + * the usual way (cardinality-based or alphabetically) + * @return A new instance of {@code IndexContext} + */ + public static IndexContext of(String namespace, Collection indexes, String indexToUse) { + List matchingIndexes = indexes.stream().filter(idx -> areNamesEqual(idx, indexToUse)).toList(); + return new IndexContext(namespace, matchingIndexes.isEmpty() ? indexes : matchingIndexes); + } + + private static boolean areNamesEqual(Index idx, String indexToUse) { + return idx != null && idx.getName() != null && idx.getName().equals(indexToUse); + } } diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java index bdd6585..5fa1c8d 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java @@ -172,6 +172,129 @@ void binLogical_AND_AND_all_indexes_partial_data() { filter, exp, IndexContext.of(TestUtils.NAMESPACE, indexes)); } + @Test + void binLogical_AND_AND_explicitly_given_index() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin1").bin("intBin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin3").bin("intBin3") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); // The index has been chosen manually + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin2"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and $.intBin3 > 100"), filter, exp, + // Manually selecting the index by its name + IndexContext.of(TestUtils.NAMESPACE, indexes, "idx_bin1")); + } + + @Test + void binLogical_AND_AND_explicitly_given_index_unavailable() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + // Fallback to the default automatic choosing of the index + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and $.intBin3 > 100"), filter, exp, + // There is no index with the name "intBin10" + IndexContext.of(TestUtils.NAMESPACE, indexes, "intBin10")); + } + + @Test + void binLogical_AND_AND_explicitly_given_index_null() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin1").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin3").bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + // Null falls back to automatic selection (highest cardinality) + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and $.intBin3 > 100"), filter, exp, + IndexContext.of(TestUtils.NAMESPACE, indexes, null)); + } + + @Test + void binLogical_AND_AND_explicitly_given_index_empty_string() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin1").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin3").bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + // Empty string falls back to automatic selection (highest cardinality) + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and $.intBin3 > 100"), filter, exp, + IndexContext.of(TestUtils.NAMESPACE, indexes, "")); + } + + @Test + void binLogical_AND_AND_explicitly_given_index_overrides_alphabetical() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin1").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(100).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(100).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin3").bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(100).build() + ); + // Without hint intBin1 would be chosen alphabetically (same cardinality). With hint, intBin2 is selected. + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and $.intBin3 > 100"), filter, exp, + IndexContext.of(TestUtils.NAMESPACE, indexes, "idx_bin2")); + } + + @Test + void binLogical_AND_AND_with_OR_subexpression_explicitly_given_index() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin1").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin3").bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin4").bin("intBin4").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + // Without hint, intBin2 is chosen (highest cardinality). Bins in the OR are not eligible for SI filter. + // With hint, intBin1 is selected despite lower cardinality. + Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin2"), Exp.val(100)), + Exp.or( + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin4"), Exp.val(100)) + ) + ); + parseDslExpressionAndCompare( + ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and ($.intBin3 > 100 or $.intBin4 > 100)"), + filter, exp, + IndexContext.of(TestUtils.NAMESPACE, indexes, "idx_bin1")); + } + + @Test + void binLogical_single_bin_explicitly_given_index() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin1").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100"), filter, null, + IndexContext.of(TestUtils.NAMESPACE, indexes, "idx_bin1")); + } + @Test void binLogical_AND_AND_two_indexes() { List indexes = List.of( @@ -258,6 +381,24 @@ void binLogical_OR_OR_all_indexes_same_cardinality() { IndexContext.of(TestUtils.NAMESPACE, indexes)); } + @Test + void binLogical_OR_OR_provided_index_no_filter_created() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin1").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin3").bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + // SI filter is never produced for a top-level OR expression, even when an explicit index is requested + Filter filter = null; + Exp exp = Exp.or( + Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin2"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 or $.intBin2 > 100 or $.intBin3 > 100"), filter, exp, + IndexContext.of(TestUtils.NAMESPACE, indexes, "idx_bin1")); + } + @Test void binLogical_prioritizedAND_OR_indexed() { List indexes = List.of( From dd09ab128d217bef5744c2004c408fa3636251f0 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 20 Feb 2026 15:48:11 +0100 Subject: [PATCH 2/9] Update src/main/java/com/aerospike/dsl/IndexContext.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../java/com/aerospike/dsl/IndexContext.java | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/aerospike/dsl/IndexContext.java b/src/main/java/com/aerospike/dsl/IndexContext.java index 5c483c8..73d8337 100644 --- a/src/main/java/com/aerospike/dsl/IndexContext.java +++ b/src/main/java/com/aerospike/dsl/IndexContext.java @@ -40,11 +40,28 @@ public class IndexContext { * @return A new instance of {@code IndexContext} */ public static IndexContext of(String namespace, Collection indexes, String indexToUse) { - List matchingIndexes = indexes.stream().filter(idx -> areNamesEqual(idx, indexToUse)).toList(); + List matchingIndexes = indexes.stream() + .filter(idx -> indexMatches(idx, namespace, indexToUse)) + .toList(); return new IndexContext(namespace, matchingIndexes.isEmpty() ? indexes : matchingIndexes); } - private static boolean areNamesEqual(Index idx, String indexToUse) { - return idx != null && idx.getName() != null && idx.getName().equals(indexToUse); + private static boolean indexMatches(Index idx, String namespace, String indexToUse) { + if (idx == null || indexToUse == null) { + return false; + } + + String indexName = idx.getName(); + if (indexName == null || !indexName.equals(indexToUse)) { + return false; + } + + // If no namespace is specified, match by name only (preserves existing behavior for null namespace). + if (namespace == null) { + return true; + } + + String indexNamespace = idx.getNamespace(); + return namespace.equals(indexNamespace); } } From 3edb2deb578b5fc259708e7c942198ab34da10a1 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Fri, 20 Feb 2026 16:04:14 +0100 Subject: [PATCH 3/9] Add tests --- .../LogicalParsedExpressionTests.java | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java index 5fa1c8d..c3f6907 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java @@ -210,6 +210,33 @@ void binLogical_AND_AND_explicitly_given_index_unavailable() { IndexContext.of(TestUtils.NAMESPACE, indexes, "intBin10")); } + @Test + void binLogical_AND_AND_explicitly_given_index_namespace_mismatch() { + List indexes = List.of( + Index.builder().namespace("other_namespace").name("idx_bin1").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + // idx_bin1 name matches but belongs to a different namespace, so falls back to automatic selection + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.gt(Exp.intBin("intBin1"), Exp.val(100)); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100"), filter, exp, + IndexContext.of(TestUtils.NAMESPACE, indexes, "idx_bin1")); + } + + @Test + void binLogical_AND_AND_explicitly_given_index_null_namespace_in_index() { + List indexes = List.of( + // idx_bin1 has no namespace set + Index.builder().name("idx_bin1").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + // idx_bin1 name matches but has no namespace, so falls back to automatic selection + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.gt(Exp.intBin("intBin1"), Exp.val(100)); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100"), filter, exp, + IndexContext.of(TestUtils.NAMESPACE, indexes, "idx_bin1")); + } + @Test void binLogical_AND_AND_explicitly_given_index_null() { List indexes = List.of( @@ -445,7 +472,7 @@ void binLogical_AND_prioritizedOR_indexed_same_cardinality() { Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(1).build() ); - // Cardinality is the same, is it correct that intBin3 is chosen because it is the only one filtered? + // Cardinality is the same, intBin3 is chosen because it is the only one filtered Filter filter = Filter.range("intBin3", 101, Long.MAX_VALUE); Exp exp = Exp.or( Exp.gt(Exp.intBin("intBin2"), Exp.val(100)), From 34da072534f0b45a226003d9ea5ad142d0181fad Mon Sep 17 00:00:00 2001 From: Andrey G Date: Tue, 24 Feb 2026 22:44:48 +0100 Subject: [PATCH 4/9] Make namespace, bin, indexType, and binValuesRatio mandatory fields --- docs/api-reference.md | 2 +- docs/guides/06-using-secondary-indexes.md | 10 +- src/main/java/com/aerospike/dsl/Index.java | 43 ++++- .../java/com/aerospike/dsl/IndexContext.java | 52 +++--- .../com/aerospike/dsl/impl/DSLParserImpl.java | 4 +- .../com/aerospike/dsl/IndexContextTests.java | 57 +++++++ .../java/com/aerospike/dsl/IndexTests.java | 156 ++++++++++++++++++ .../LogicalParsedExpressionTests.java | 21 +-- 8 files changed, 296 insertions(+), 49 deletions(-) create mode 100644 src/test/java/com/aerospike/dsl/IndexContextTests.java create mode 100644 src/test/java/com/aerospike/dsl/IndexTests.java diff --git a/docs/api-reference.md b/docs/api-reference.md index b7340b4..bf364ed 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -48,7 +48,7 @@ This class holds the final, concrete outputs of the parsing and substitution pro A container for the information required for automatic secondary index optimization. * **`static IndexContext of(String namespace, Collection indexes)`**: Creates a context. - * `namespace`: The namespace the query will be run against. + * `namespace`: The namespace the query will be run against. Must not be null or blank. * `indexes`: A collection of `Index` objects representing the available secondary indexes for that namespace. ## Example API Flow diff --git a/docs/guides/06-using-secondary-indexes.md b/docs/guides/06-using-secondary-indexes.md index bf2acf5..895c4d2 100644 --- a/docs/guides/06-using-secondary-indexes.md +++ b/docs/guides/06-using-secondary-indexes.md @@ -39,17 +39,17 @@ import com.aerospike.dsl.Index; import com.aerospike.dsl.IndexContext; import java.util.List; -// Describe the available secondary index +// Describe the available secondary index (namespace, bin, indexType, binValuesRatio are required) Index cityIndex = Index.builder() - .name("idx_users_city") .namespace("test") .bin("city") .indexType(IndexType.STRING) - .binValuesRatio(1) // Cardinality can be retrieved from the Aerospike DB or set manually + .binValuesRatio(1) // Cardinality from Aerospike sindex-stat or set manually + .name("idx_users_city") .build(); -// Create index context -IndexContext indexContext = IndexContext.of("namespace", List.of(cityIndex)); +// Create index context (namespace must not be null or blank) +IndexContext indexContext = IndexContext.of("test", List.of(cityIndex)); ``` ### 2. Parse the Expression using IndexContext diff --git a/src/main/java/com/aerospike/dsl/Index.java b/src/main/java/com/aerospike/dsl/Index.java index 6c68f84..b7eb459 100644 --- a/src/main/java/com/aerospike/dsl/Index.java +++ b/src/main/java/com/aerospike/dsl/Index.java @@ -9,6 +9,9 @@ /** * This class represents a secondary index created in the cluster. + *

+ * Mandatory fields: {@code namespace}, {@code bin}, {@code indexType}, {@code binValuesRatio}. + * These are validated on build and must not be null/blank (for strings) or negative (for binValuesRatio). */ @Builder @EqualsAndHashCode @@ -35,13 +38,47 @@ public class Index { * Cardinality of the index calculated using "sindex-stat" command and looking at the ratio of entries * to unique bin values for the given secondary index on the node (entries_per_bval) */ - private int binValuesRatio; + private final int binValuesRatio; /** * {@link IndexCollectionType} of the index */ - private IndexCollectionType indexCollectionType; + private final IndexCollectionType indexCollectionType; /** * Array of {@link CTX} representing context of the index */ - private CTX[] ctx; + private final CTX[] ctx; + + public Index(String namespace, String bin, String name, IndexType indexType, int binValuesRatio, + IndexCollectionType indexCollectionType, CTX[] ctx) { + validateMandatory(namespace, bin, indexType, binValuesRatio); + this.namespace = namespace; + this.bin = bin; + this.name = name; + this.indexType = indexType; + this.binValuesRatio = binValuesRatio; + this.indexCollectionType = indexCollectionType; + this.ctx = ctx; + } + + private static void validateMandatory(String namespace, String bin, IndexType indexType, int binValuesRatio) { + requireNonBlank(namespace, "namespace"); + requireNonBlank(bin, "bin"); + requireNonNull(indexType, "indexType"); + if (binValuesRatio < 0) { + throw new IllegalArgumentException("binValuesRatio must not be negative"); + } + } + + private static void requireNonBlank(String value, String fieldName) { + requireNonNull(value, fieldName); + if (value.isBlank()) { + throw new IllegalArgumentException(fieldName + " must not be blank"); + } + } + + private static void requireNonNull(Object value, String fieldName) { + if (value == null) { + throw new IllegalArgumentException(fieldName + " must not be null"); + } + } } diff --git a/src/main/java/com/aerospike/dsl/IndexContext.java b/src/main/java/com/aerospike/dsl/IndexContext.java index 73d8337..9d514e5 100644 --- a/src/main/java/com/aerospike/dsl/IndexContext.java +++ b/src/main/java/com/aerospike/dsl/IndexContext.java @@ -1,6 +1,5 @@ package com.aerospike.dsl; -import lombok.AllArgsConstructor; import lombok.Getter; import java.util.Collection; @@ -9,43 +8,64 @@ /** * This class stores namespace and indexes required to build secondary index Filter */ -@AllArgsConstructor(staticName = "of") @Getter public class IndexContext { /** * Namespace to be used for creating secondary index Filter. Is matched with namespace of indexes */ - private String namespace; + private final String namespace; /** * Collection of {@link Index} objects to be used for creating secondary index Filter. * Namespace of indexes is matched with the given {@link #namespace}, bin name and index type are matched * with bins in DSL String */ - private Collection indexes; + private final Collection indexes; + private IndexContext(String namespace, Collection indexes) { + this.namespace = namespace; + this.indexes = indexes; + } + + /** + * Create index context with namespace and indexes. + * + * @param namespace Namespace to be used for creating {@link com.aerospike.dsl.client.query.Filter}. + * Must not be null or blank + * @param indexes Collection of {@link Index} objects to be used for creating Filter + * @return A new instance of {@code IndexContext} + */ + public static IndexContext of(String namespace, Collection indexes) { + validateNamespace(namespace); + return new IndexContext(namespace, indexes); + } /** * Create index context specifying the index to be used * * @param namespace Namespace to be used for creating {@link com.aerospike.dsl.client.query.Filter}. - * Is matched with namespace of indexes - * @param indexes Collection of {@link Index} objects to be used for creating - * {@link com.aerospike.dsl.client.query.Filter}. Bin name and - * index type are matched with bins in DSL String - * @param indexToUse The name of an index to use for creating - * {@link com.aerospike.dsl.client.query.Filter}. If the index with the specified name - * is not found (or {@code indexToUse} is {@code null}), the resulting index is chosen - * the usual way (cardinality-based or alphabetically) + * Must not be null or blank + * @param indexes Collection of {@link Index} objects to be used for creating Filter + * @param indexToUse The name of an index to use. If not found or null, index is chosen by cardinality or alphabetically * @return A new instance of {@code IndexContext} */ public static IndexContext of(String namespace, Collection indexes, String indexToUse) { + validateNamespace(namespace); List matchingIndexes = indexes.stream() .filter(idx -> indexMatches(idx, namespace, indexToUse)) .toList(); return new IndexContext(namespace, matchingIndexes.isEmpty() ? indexes : matchingIndexes); } + private static void validateNamespace(String namespace) { + if (namespace == null) { + throw new IllegalArgumentException("namespace must not be null"); + } + if (namespace.isBlank()) { + throw new IllegalArgumentException("namespace must not be blank"); + } + } + private static boolean indexMatches(Index idx, String namespace, String indexToUse) { if (idx == null || indexToUse == null) { return false; @@ -56,12 +76,6 @@ private static boolean indexMatches(Index idx, String namespace, String indexToU return false; } - // If no namespace is specified, match by name only (preserves existing behavior for null namespace). - if (namespace == null) { - return true; - } - - String indexNamespace = idx.getNamespace(); - return namespace.equals(indexNamespace); + return namespace.equals(idx.getNamespace()); } } diff --git a/src/main/java/com/aerospike/dsl/impl/DSLParserImpl.java b/src/main/java/com/aerospike/dsl/impl/DSLParserImpl.java index d7ef10a..3f2c7f7 100644 --- a/src/main/java/com/aerospike/dsl/impl/DSLParserImpl.java +++ b/src/main/java/com/aerospike/dsl/impl/DSLParserImpl.java @@ -73,9 +73,7 @@ private ParsedExpression getParsedExpression(ParseTree parseTree, PlaceholderVal .orElse(Collections.emptyList()); Map> indexesMap = indexes.stream() - // Filtering the indexes with the given namespace - .filter(idx -> idx.getNamespace() != null && idx.getNamespace().equals(namespace)) - // Group the indexes by bin name + .filter(idx -> namespace != null && namespace.equals(idx.getNamespace())) .collect(Collectors.groupingBy(Index::getBin)); AbstractPart resultingPart = new ExpressionConditionVisitor().visit(parseTree); diff --git a/src/test/java/com/aerospike/dsl/IndexContextTests.java b/src/test/java/com/aerospike/dsl/IndexContextTests.java new file mode 100644 index 0000000..c8f874b --- /dev/null +++ b/src/test/java/com/aerospike/dsl/IndexContextTests.java @@ -0,0 +1,57 @@ +package com.aerospike.dsl; + +import com.aerospike.dsl.client.query.IndexType; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class IndexContextTests { + + private static final String NAMESPACE = "test"; + private static final Index VALID_INDEX = Index.builder() + .namespace(NAMESPACE) + .bin("bin1") + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build(); + + @Test + void of_rejects_null_namespace() { + assertThatThrownBy(() -> IndexContext.of(null, List.of(VALID_INDEX))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be null"); + } + + @Test + void of_rejects_blank_namespace() { + assertThatThrownBy(() -> IndexContext.of(" ", List.of(VALID_INDEX))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be blank"); + } + + @Test + void of_accepts_valid_namespace() { + IndexContext ctx = IndexContext.of(NAMESPACE, Collections.emptyList()); + + assertThat(ctx.getNamespace()).isEqualTo(NAMESPACE); + assertThat(ctx.getIndexes()).isEmpty(); + } + + @Test + void of_3arg_rejects_null_namespace() { + assertThatThrownBy(() -> IndexContext.of(null, List.of(VALID_INDEX), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be null"); + } + + @Test + void of_3arg_rejects_blank_namespace() { + assertThatThrownBy(() -> IndexContext.of("", List.of(VALID_INDEX), "idx1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be blank"); + } +} diff --git a/src/test/java/com/aerospike/dsl/IndexTests.java b/src/test/java/com/aerospike/dsl/IndexTests.java new file mode 100644 index 0000000..25985aa --- /dev/null +++ b/src/test/java/com/aerospike/dsl/IndexTests.java @@ -0,0 +1,156 @@ +package com.aerospike.dsl; + +import com.aerospike.dsl.client.query.IndexType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class IndexTests { + + private static final String NAMESPACE = "test"; + private static final String BIN = "bin1"; + + @Test + void build_rejects_null_namespace() { + assertThatThrownBy(() -> Index.builder() + .namespace(null) + .bin(BIN) + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be null"); + } + + @Test + void build_rejects_blank_namespace() { + assertThatThrownBy(() -> Index.builder() + .namespace(" ") + .bin(BIN) + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be blank"); + } + + @Test + void build_rejects_null_bin() { + assertThatThrownBy(() -> Index.builder() + .namespace(NAMESPACE) + .bin(null) + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("bin must not be null"); + } + + @Test + void build_rejects_blank_bin() { + assertThatThrownBy(() -> Index.builder() + .namespace(NAMESPACE) + .bin("") + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("bin must not be blank"); + } + + @Test + void build_rejects_null_indexType() { + assertThatThrownBy(() -> Index.builder() + .namespace(NAMESPACE) + .bin(BIN) + .indexType(null) + .binValuesRatio(0) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("indexType must not be null"); + } + + @Test + void build_rejects_negative_binValuesRatio() { + assertThatThrownBy(() -> Index.builder() + .namespace(NAMESPACE) + .bin(BIN) + .indexType(IndexType.NUMERIC) + .binValuesRatio(-1) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("binValuesRatio must not be negative"); + } + + @Test + void build_fails_when_namespace_omitted() { + assertThatThrownBy(() -> Index.builder() + .bin(BIN) + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be null"); + } + + @Test + void build_fails_when_bin_omitted() { + assertThatThrownBy(() -> Index.builder() + .namespace(NAMESPACE) + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("bin must not be null"); + } + + @Test + void build_fails_when_indexType_omitted() { + assertThatThrownBy(() -> Index.builder() + .namespace(NAMESPACE) + .bin(BIN) + .binValuesRatio(0) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("indexType must not be null"); + } + + @Test + void build_succeeds_when_binValuesRatio_omitted() { + Index index = Index.builder() + .namespace(NAMESPACE) + .bin(BIN) + .indexType(IndexType.NUMERIC) + .build(); + + assertThat(index.getBinValuesRatio()).isZero(); + } + + @Test + void build_accepts_zero_binValuesRatio() { + Index index = Index.builder() + .namespace(NAMESPACE) + .bin(BIN) + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build(); + + assertThat(index.getBinValuesRatio()).isZero(); + } + + @Test + void build_succeeds_with_all_mandatory() { + Index index = Index.builder() + .namespace(NAMESPACE) + .bin(BIN) + .indexType(IndexType.STRING) + .binValuesRatio(1) + .build(); + + assertThat(index.getNamespace()).isEqualTo(NAMESPACE); + assertThat(index.getBin()).isEqualTo(BIN); + assertThat(index.getIndexType()).isEqualTo(IndexType.STRING); + assertThat(index.getBinValuesRatio()).isOne(); + } +} diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java index c3f6907..8b8b953 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java @@ -156,13 +156,12 @@ void binLogical_AND_AND_all_indexes_no_cardinality() { @Test void binLogical_AND_AND_all_indexes_partial_data() { List indexes = List.of( - Index.builder().bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").binValuesRatio(1).build(), + Index.builder().namespace("other_ns").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace("other_ns").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.STRING).binValuesRatio(0).build(), - // The only matching index with full data Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build() ); - // The only matching index with full data is for intBin3 + // intBin1/intBin2 in other namespace filtered out; intBin3 STRING wrong type; only intBin3 NUMERIC matches Filter filter = Filter.range("intBin3", 101, Long.MAX_VALUE); Exp exp = Exp.and( Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), @@ -223,20 +222,6 @@ void binLogical_AND_AND_explicitly_given_index_namespace_mismatch() { IndexContext.of(TestUtils.NAMESPACE, indexes, "idx_bin1")); } - @Test - void binLogical_AND_AND_explicitly_given_index_null_namespace_in_index() { - List indexes = List.of( - // idx_bin1 has no namespace set - Index.builder().name("idx_bin1").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), - Index.builder().namespace(TestUtils.NAMESPACE).name("idx_bin2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build() - ); - // idx_bin1 name matches but has no namespace, so falls back to automatic selection - Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); - Exp exp = Exp.gt(Exp.intBin("intBin1"), Exp.val(100)); - parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100"), filter, exp, - IndexContext.of(TestUtils.NAMESPACE, indexes, "idx_bin1")); - } - @Test void binLogical_AND_AND_explicitly_given_index_null() { List indexes = List.of( From 8c827d81a9f2d8366103bfda41842236743a8ff9 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Tue, 24 Feb 2026 22:57:09 +0100 Subject: [PATCH 5/9] Update src/main/java/com/aerospike/dsl/IndexContext.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/java/com/aerospike/dsl/IndexContext.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/aerospike/dsl/IndexContext.java b/src/main/java/com/aerospike/dsl/IndexContext.java index 9d514e5..dcb3ce4 100644 --- a/src/main/java/com/aerospike/dsl/IndexContext.java +++ b/src/main/java/com/aerospike/dsl/IndexContext.java @@ -46,7 +46,7 @@ public static IndexContext of(String namespace, Collection indexes) { * @param namespace Namespace to be used for creating {@link com.aerospike.dsl.client.query.Filter}. * Must not be null or blank * @param indexes Collection of {@link Index} objects to be used for creating Filter - * @param indexToUse The name of an index to use. If not found or null, index is chosen by cardinality or alphabetically + * @param indexToUse The name of an index to use. If not found, null, or empty, index is chosen by cardinality or alphabetically * @return A new instance of {@code IndexContext} */ public static IndexContext of(String namespace, Collection indexes, String indexToUse) { From fef2324c8bae9e49ad317757eb0ea8599ba719c9 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Tue, 24 Feb 2026 23:00:15 +0100 Subject: [PATCH 6/9] Update src/main/java/com/aerospike/dsl/Index.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/java/com/aerospike/dsl/Index.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/aerospike/dsl/Index.java b/src/main/java/com/aerospike/dsl/Index.java index b7eb459..06c57d7 100644 --- a/src/main/java/com/aerospike/dsl/Index.java +++ b/src/main/java/com/aerospike/dsl/Index.java @@ -10,8 +10,9 @@ /** * This class represents a secondary index created in the cluster. *

- * Mandatory fields: {@code namespace}, {@code bin}, {@code indexType}, {@code binValuesRatio}. - * These are validated on build and must not be null/blank (for strings) or negative (for binValuesRatio). + * Mandatory fields: {@code namespace}, {@code bin}, {@code indexType}. + * These are validated on build and must not be null/blank (for strings). {@code binValuesRatio} is optional, + * defaults to {@code 0} when omitted, and is validated to ensure it is not negative. */ @Builder @EqualsAndHashCode From 1e80db2202ad2a9c8255108f3dedb63cd6b78b21 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Tue, 24 Feb 2026 23:37:10 +0100 Subject: [PATCH 7/9] Make binValuesRatio fully mandatory --- README.md | 1 + docs/api-reference.md | 16 +++++++ docs/guides/06-using-secondary-indexes.md | 32 ++++++++++++++ src/main/java/com/aerospike/dsl/Index.java | 13 +++--- .../java/com/aerospike/dsl/IndexTests.java | 10 ++--- .../dsl/filter/ListExpressionsTests.java | 8 ++-- .../LogicalParsedExpressionTests.java | 44 +++++++++---------- 7 files changed, 87 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 23baa27..1c78be4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ DSLParser parser = new DSLParserImpl(); // Providing list of existing secondary indexes // At most only one index will be chosen based on its namespace and binValuesRatio (cardinality) // If cardinality of multiple indexes is the same, the index is chosen alphabetically +// binValuesRatio is mandatory for each Index and must be non-negative List indexes = List.of( Index.builder().namespace("namespace").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), Index.builder().namespace("namespace2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build() diff --git a/docs/api-reference.md b/docs/api-reference.md index bf364ed..0ef4323 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -50,6 +50,22 @@ A container for the information required for automatic secondary index optimizat * **`static IndexContext of(String namespace, Collection indexes)`**: Creates a context. * `namespace`: The namespace the query will be run against. Must not be null or blank. * `indexes`: A collection of `Index` objects representing the available secondary indexes for that namespace. +* **`static IndexContext of(String namespace, Collection indexes, String indexToUse)`**: +* Creates a context with an explicit index hint. + * `namespace`: The namespace the query will be run against. Must not be null or blank. + * `indexes`: A collection of `Index` objects representing the available secondary indexes for that namespace. + * `indexToUse`: The name of the index to use for the secondary index filter. + * If not found or `null`, the index is chosen automatically by cardinality (higher `binValuesRatio` preferred), + * then alphabetically by bin name. + +### `com.aerospike.dsl.Index` + +Represents an available secondary index for optimization. + +* **Mandatory fields**: `namespace`, `bin`, `indexType`, `binValuesRatio`. +* **`binValuesRatio` validation**: + * Must be explicitly provided. + * Must be non-negative (`>= 0`). ## Example API Flow diff --git a/docs/guides/06-using-secondary-indexes.md b/docs/guides/06-using-secondary-indexes.md index 895c4d2..3e00104 100644 --- a/docs/guides/06-using-secondary-indexes.md +++ b/docs/guides/06-using-secondary-indexes.md @@ -25,6 +25,38 @@ The parser then does the following: > **Note:** Only one secondary index can be used per query. The index will be chosen based on cardinality (preferring indexes with a higher `binValuesRatio`), otherwise alphabetically. +## Choosing a Specific Index (Index Hint) + +When multiple indexes could satisfy your query, the parser selects one automatically. +If you need to force a specific index — for example, for query planning, testing, or when you know +one index performs better for your data, you can use the three-parameter `IndexContext.of` overload +and pass the index name as the third argument: + +```java +Index cityIndex = Index.builder() + .namespace("test") // is mandatory + .bin("city") // is mandatory + .indexType(IndexType.STRING) // is mandatory + .binValuesRatio(1) // is mandatory and must be non-negative + .name("idx_users_city") + .build(); + +Index ageIndex = Index.builder() + .namespace("test") + .bin("age") + .indexType(IndexType.NUMERIC) + .binValuesRatio(10) + .name("idx_users_age") + .build(); + +// Force use of the city index even though age has higher cardinality +IndexContext indexContext = IndexContext.of("test", List.of(cityIndex, ageIndex), "idx_users_city"); +``` + +If the named index is not found in the collection or does not match the namespace, +the parser falls back to automatic selection (cardinality, then alphabetically). +Passing `null` or an empty string as the third parameter also triggers automatic selection. + ## Usage Example Let's assume you have a secondary index named `idx_users_city` on the `city` bin in the `users` set. diff --git a/src/main/java/com/aerospike/dsl/Index.java b/src/main/java/com/aerospike/dsl/Index.java index 06c57d7..1308983 100644 --- a/src/main/java/com/aerospike/dsl/Index.java +++ b/src/main/java/com/aerospike/dsl/Index.java @@ -10,9 +10,9 @@ /** * This class represents a secondary index created in the cluster. *

- * Mandatory fields: {@code namespace}, {@code bin}, {@code indexType}. - * These are validated on build and must not be null/blank (for strings). {@code binValuesRatio} is optional, - * defaults to {@code 0} when omitted, and is validated to ensure it is not negative. + * Mandatory fields: {@code namespace}, {@code bin}, {@code indexType}, {@code binValuesRatio}. + * These are validated on build and must not be null/blank (for strings). + * {@code binValuesRatio} must be provided and is validated to ensure it is not negative. */ @Builder @EqualsAndHashCode @@ -39,7 +39,7 @@ public class Index { * Cardinality of the index calculated using "sindex-stat" command and looking at the ratio of entries * to unique bin values for the given secondary index on the node (entries_per_bval) */ - private final int binValuesRatio; + private final Integer binValuesRatio; /** * {@link IndexCollectionType} of the index */ @@ -49,7 +49,7 @@ public class Index { */ private final CTX[] ctx; - public Index(String namespace, String bin, String name, IndexType indexType, int binValuesRatio, + public Index(String namespace, String bin, String name, IndexType indexType, Integer binValuesRatio, IndexCollectionType indexCollectionType, CTX[] ctx) { validateMandatory(namespace, bin, indexType, binValuesRatio); this.namespace = namespace; @@ -61,10 +61,11 @@ public Index(String namespace, String bin, String name, IndexType indexType, int this.ctx = ctx; } - private static void validateMandatory(String namespace, String bin, IndexType indexType, int binValuesRatio) { + private static void validateMandatory(String namespace, String bin, IndexType indexType, Integer binValuesRatio) { requireNonBlank(namespace, "namespace"); requireNonBlank(bin, "bin"); requireNonNull(indexType, "indexType"); + requireNonNull(binValuesRatio, "binValuesRatio"); if (binValuesRatio < 0) { throw new IllegalArgumentException("binValuesRatio must not be negative"); } diff --git a/src/test/java/com/aerospike/dsl/IndexTests.java b/src/test/java/com/aerospike/dsl/IndexTests.java index 25985aa..3b4cbc3 100644 --- a/src/test/java/com/aerospike/dsl/IndexTests.java +++ b/src/test/java/com/aerospike/dsl/IndexTests.java @@ -117,14 +117,14 @@ void build_fails_when_indexType_omitted() { } @Test - void build_succeeds_when_binValuesRatio_omitted() { - Index index = Index.builder() + void build_fails_when_binValuesRatio_omitted() { + assertThatThrownBy(() -> Index.builder() .namespace(NAMESPACE) .bin(BIN) .indexType(IndexType.NUMERIC) - .build(); - - assertThat(index.getBinValuesRatio()).isZero(); + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("binValuesRatio must not be null"); } @Test diff --git a/src/test/java/com/aerospike/dsl/filter/ListExpressionsTests.java b/src/test/java/com/aerospike/dsl/filter/ListExpressionsTests.java index 24d50de..e833813 100644 --- a/src/test/java/com/aerospike/dsl/filter/ListExpressionsTests.java +++ b/src/test/java/com/aerospike/dsl/filter/ListExpressionsTests.java @@ -18,13 +18,13 @@ class ListExpressionsTests { String NAMESPACE = "test1"; List INDEXES = List.of( - Index.builder().namespace(NAMESPACE).bin("listBin1").indexType(IndexType.NUMERIC).build(), + Index.builder().namespace(NAMESPACE).bin("listBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), Index.builder().namespace(NAMESPACE).bin("listBin1").indexType(IndexType.STRING) - .indexCollectionType(LIST).build(), + .binValuesRatio(0).indexCollectionType(LIST).build(), Index.builder().namespace(NAMESPACE).bin("listBin1").indexType(IndexType.STRING) - .indexCollectionType(LIST).ctx(new CTX[]{CTX.listIndex(5)}).build(), + .binValuesRatio(0).indexCollectionType(LIST).ctx(new CTX[]{CTX.listIndex(5)}).build(), Index.builder().namespace(NAMESPACE).bin("listBin1").indexType(IndexType.STRING) - .indexCollectionType(LIST).ctx(new CTX[]{CTX.listValue(Value.get(5))}).build() + .binValuesRatio(0).indexCollectionType(LIST).ctx(new CTX[]{CTX.listValue(Value.get(5))}).build() ); IndexContext INDEX_FILTER_INPUT = IndexContext.of(NAMESPACE, INDEXES); diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java index 8b8b953..ea58c25 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java @@ -40,8 +40,8 @@ void binLogical_AND_all_indexes() { @Test void binLogical_AND_all_indexes_no_cardinality() { List indexes = List.of( - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).build() + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(0).build() ); // Filter is chosen alphabetically because no cardinality is given Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); @@ -139,9 +139,9 @@ void binLogical_AND_AND_all_indexes_same_cardinality() { @Test void binLogical_AND_AND_all_indexes_no_cardinality() { List indexes = List.of( - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).build() + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build() ); // Filter is chosen alphabetically because no cardinality is given Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); @@ -472,9 +472,9 @@ void binLogical_AND_prioritizedOR_indexed_same_cardinality() { @Test void binLogical_AND_prioritizedOR_indexed_no_cardinality() { List indexes = List.of( - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).build() + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build() ); // Cardinality is the same, is it correct that intBin3 is chosen because it is the only one filtered? Filter filter = Filter.range("intBin3", 101, Long.MAX_VALUE); @@ -813,12 +813,12 @@ void binLogical_prioritizedOR_prioritizedAND_AND_indexed_withFilterPerCardinalit @Test void binLogical_OR2_OR1_AND2_AND_AND1_indexed_no_cardinality() { List indexes = List.of( - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin4").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin5").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin6").indexType(IndexType.NUMERIC).build() + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin4").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin5").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin6").indexType(IndexType.NUMERIC).binValuesRatio(0).build() ); Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); Exp exp = Exp.and( @@ -844,14 +844,14 @@ void binLogical_OR2_OR1_AND2_AND_AND1_indexed_no_cardinality() { @Test void binLogical_OR2_OR1_AND2_AND_AND2_OR1_AND2_indexed_no_cardinality() { List indexes = List.of( - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin4").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin5").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin6").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin7").indexType(IndexType.NUMERIC).build(), - Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin8").indexType(IndexType.NUMERIC).build() + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin4").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin5").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin6").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin7").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin8").indexType(IndexType.NUMERIC).binValuesRatio(0).build() ); // No Filter can be built as all expression parts participate in OR-combined queries Filter filter = null; From 0ff1f3eb726e9a1b5d8139f96cce9a1b5bfa1826 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Wed, 25 Feb 2026 13:55:10 +0100 Subject: [PATCH 8/9] Support bin name hint --- docs/api-reference.md | 19 ++- docs/guides/06-using-secondary-indexes.md | 49 ++++++- .../java/com/aerospike/dsl/IndexContext.java | 40 +++++- .../com/aerospike/dsl/IndexContextTests.java | 72 +++++++++++ .../LogicalParsedExpressionTests.java | 121 ++++++++++++++++++ 5 files changed, 289 insertions(+), 12 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 0ef4323..c0e12a5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -50,13 +50,22 @@ A container for the information required for automatic secondary index optimizat * **`static IndexContext of(String namespace, Collection indexes)`**: Creates a context. * `namespace`: The namespace the query will be run against. Must not be null or blank. * `indexes`: A collection of `Index` objects representing the available secondary indexes for that namespace. -* **`static IndexContext of(String namespace, Collection indexes, String indexToUse)`**: -* Creates a context with an explicit index hint. +* **`static IndexContext of(String namespace, Collection indexes, String indexToUse)`**: + Creates a context with an explicit index name hint. * `namespace`: The namespace the query will be run against. Must not be null or blank. * `indexes`: A collection of `Index` objects representing the available secondary indexes for that namespace. - * `indexToUse`: The name of the index to use for the secondary index filter. - * If not found or `null`, the index is chosen automatically by cardinality (higher `binValuesRatio` preferred), - * then alphabetically by bin name. + * `indexToUse`: The name of the index to use for the secondary index filter. + If not found, `null`, or empty, the index is chosen automatically by cardinality + (higher `binValuesRatio` preferred), then alphabetically by bin name. +* **`static IndexContext withBinHint(String namespace, Collection indexes, String binToUse)`**: + Creates a context with an explicit bin name hint. Use this when you want to direct the parser to + an index on a specific bin without knowing the index name. + * `namespace`: The namespace the query will be run against. Must not be null or blank. + * `indexes`: A collection of `Index` objects representing the available secondary indexes for that namespace. + * `binToUse`: The name of the bin whose index should be used. If exactly one index in the collection + matches the given bin name and namespace, that index is used. If the bin matches multiple indexes, + no index matches, the value is `null`, or the value is blank, the parser falls back to fully automatic + selection (cardinality, then alphabetically). ### `com.aerospike.dsl.Index` diff --git a/docs/guides/06-using-secondary-indexes.md b/docs/guides/06-using-secondary-indexes.md index 3e00104..c1d7dc1 100644 --- a/docs/guides/06-using-secondary-indexes.md +++ b/docs/guides/06-using-secondary-indexes.md @@ -27,10 +27,13 @@ The parser then does the following: ## Choosing a Specific Index (Index Hint) -When multiple indexes could satisfy your query, the parser selects one automatically. -If you need to force a specific index — for example, for query planning, testing, or when you know -one index performs better for your data, you can use the three-parameter `IndexContext.of` overload -and pass the index name as the third argument: +When multiple indexes could satisfy your query, the parser selects one automatically. +You can override this by providing an explicit hint in two ways: + +### Hint by Index Name + +If you know the exact name of the index you want to use, pass it as the third argument to +the three-parameter `IndexContext.of` overload: ```java Index cityIndex = Index.builder() @@ -53,10 +56,44 @@ Index ageIndex = Index.builder() IndexContext indexContext = IndexContext.of("test", List.of(cityIndex, ageIndex), "idx_users_city"); ``` -If the named index is not found in the collection or does not match the namespace, -the parser falls back to automatic selection (cardinality, then alphabetically). +If the named index is not found in the collection or does not match the namespace, +the parser falls back to automatic selection (cardinality, then alphabetically). Passing `null` or an empty string as the third parameter also triggers automatic selection. +### Hint by Bin Name + +If you know which bin should be used for the secondary index filter but not the specific index name, +use `IndexContext.withBinHint`: + +```java +Index cityIndex = Index.builder() + .namespace("test") + .bin("city") + .indexType(IndexType.STRING) + .binValuesRatio(1) + .name("idx_users_city") + .build(); + +Index ageIndex = Index.builder() + .namespace("test") + .bin("age") + .indexType(IndexType.NUMERIC) + .binValuesRatio(10) + .name("idx_users_age") + .build(); + +// Force use of an index on the "city" bin even though age has higher cardinality +IndexContext indexContext = IndexContext.withBinHint("test", List.of(cityIndex, ageIndex), "city"); +``` + +The bin hint behaves as follows: + +- If exactly one index in the indexes collection matches the given bin name and namespace, that index is used. +- If the bin name matches multiple indexes (for example, indexes of different types on the same bin), + or if there is no match, the hint is ignored and the parser falls back to fully automatic selection + (by cardinality, then alphabetically). +- Passing `null` or a blank string bin name hint also triggers fallback to automatic selection. + ## Usage Example Let's assume you have a secondary index named `idx_users_city` on the `city` bin in the `users` set. diff --git a/src/main/java/com/aerospike/dsl/IndexContext.java b/src/main/java/com/aerospike/dsl/IndexContext.java index dcb3ce4..52f1ab4 100644 --- a/src/main/java/com/aerospike/dsl/IndexContext.java +++ b/src/main/java/com/aerospike/dsl/IndexContext.java @@ -46,7 +46,8 @@ public static IndexContext of(String namespace, Collection indexes) { * @param namespace Namespace to be used for creating {@link com.aerospike.dsl.client.query.Filter}. * Must not be null or blank * @param indexes Collection of {@link Index} objects to be used for creating Filter - * @param indexToUse The name of an index to use. If not found, null, or empty, index is chosen by cardinality or alphabetically + * @param indexToUse The name of an index to use. If not found, null, or empty, index is chosen + * by cardinality or alphabetically * @return A new instance of {@code IndexContext} */ public static IndexContext of(String namespace, Collection indexes, String indexToUse) { @@ -57,6 +58,30 @@ public static IndexContext of(String namespace, Collection indexes, Strin return new IndexContext(namespace, matchingIndexes.isEmpty() ? indexes : matchingIndexes); } + /** + * Create index context with a bin name hint specifying which bin's index to use. + * If exactly one index in the collection matches the given bin name and namespace, + * that index is used. Otherwise, all indexes are kept and selection falls back to + * the automatic cardinality / alphabetical strategy. + * + * @param namespace Namespace to be used for creating {@link com.aerospike.dsl.client.query.Filter}. + * Must not be null or blank + * @param indexes Collection of {@link Index} objects to be used for creating Filter + * @param binToUse The name of the bin whose index should be used. If not found, null, + * blank, or matches multiple indexes, index is chosen automatically + * @return A new instance of {@code IndexContext} + */ + public static IndexContext withBinHint(String namespace, Collection indexes, String binToUse) { + validateNamespace(namespace); + if (binToUse == null || binToUse.isBlank()) { + return new IndexContext(namespace, indexes); + } + List matchingIndexes = indexes.stream() + .filter(idx -> binMatches(idx, namespace, binToUse)) + .toList(); + return new IndexContext(namespace, matchingIndexes.size() == 1 ? matchingIndexes : indexes); + } + private static void validateNamespace(String namespace) { if (namespace == null) { throw new IllegalArgumentException("namespace must not be null"); @@ -66,6 +91,19 @@ private static void validateNamespace(String namespace) { } } + private static boolean binMatches(Index idx, String namespace, String binToUse) { + if (idx == null || binToUse == null) { + return false; + } + + String binName = idx.getBin(); + if (binName == null || !binName.equals(binToUse)) { + return false; + } + + return namespace.equals(idx.getNamespace()); + } + private static boolean indexMatches(Index idx, String namespace, String indexToUse) { if (idx == null || indexToUse == null) { return false; diff --git a/src/test/java/com/aerospike/dsl/IndexContextTests.java b/src/test/java/com/aerospike/dsl/IndexContextTests.java index c8f874b..70418c5 100644 --- a/src/test/java/com/aerospike/dsl/IndexContextTests.java +++ b/src/test/java/com/aerospike/dsl/IndexContextTests.java @@ -3,6 +3,7 @@ import com.aerospike.dsl.client.query.IndexType; import org.junit.jupiter.api.Test; +import java.util.Collection; import java.util.Collections; import java.util.List; @@ -54,4 +55,75 @@ void of_3arg_rejects_blank_namespace() { .isInstanceOf(IllegalArgumentException.class) .hasMessage("namespace must not be blank"); } + + @Test + void withBinHint_rejects_null_namespace() { + assertThatThrownBy(() -> IndexContext.withBinHint(null, List.of(VALID_INDEX), "bin1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be null"); + } + + @Test + void withBinHint_rejects_blank_namespace() { + assertThatThrownBy(() -> IndexContext.withBinHint("", List.of(VALID_INDEX), "bin1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("namespace must not be blank"); + } + + @Test + void withBinHint_null_bin_returns_all_indexes() { + Collection indexes = List.of(VALID_INDEX); + IndexContext ctx = IndexContext.withBinHint(NAMESPACE, indexes, null); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } + + @Test + void withBinHint_empty_bin_returns_all_indexes() { + Collection indexes = List.of(VALID_INDEX); + IndexContext ctx = IndexContext.withBinHint(NAMESPACE, indexes, ""); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } + + @Test + void withBinHint_single_match_returns_that_index() { + Index other = Index.builder().namespace(NAMESPACE).bin("bin2") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(); + Collection indexes = List.of(VALID_INDEX, other); + + IndexContext ctx = IndexContext.withBinHint(NAMESPACE, indexes, "bin1"); + + assertThat(ctx.getIndexes()).containsExactly(VALID_INDEX); + } + + @Test + void withBinHint_multiple_matches_returns_all_indexes() { + Index second = Index.builder().namespace(NAMESPACE).bin("bin1") + .indexType(IndexType.STRING).binValuesRatio(5).build(); + Collection indexes = List.of(VALID_INDEX, second); + + IndexContext ctx = IndexContext.withBinHint(NAMESPACE, indexes, "bin1"); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } + + @Test + void withBinHint_no_match_returns_all_indexes() { + Collection indexes = List.of(VALID_INDEX); + IndexContext ctx = IndexContext.withBinHint(NAMESPACE, indexes, "nonExistentBin"); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } + + @Test + void withBinHint_namespace_mismatch_returns_all_indexes() { + Index wrongNs = Index.builder().namespace("other_ns").bin("bin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(); + Collection indexes = List.of(wrongNs); + + IndexContext ctx = IndexContext.withBinHint(NAMESPACE, indexes, "bin1"); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } } diff --git a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java index ea58c25..a84b5d4 100644 --- a/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java +++ b/src/test/java/com/aerospike/dsl/parsedExpression/LogicalParsedExpressionTests.java @@ -884,6 +884,127 @@ void binLogical_OR2_OR1_AND2_AND_AND2_OR1_AND2_indexed_no_cardinality() { IndexContext.of(TestUtils.NAMESPACE, indexes)); } + @Test + void binLogical_AND_AND_bin_hint_selects_index_overrides_cardinality() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + // Without hint intBin2 would be chosen (highest cardinality). With bin hint, intBin1 is selected. + Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin2"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and $.intBin3 > 100"), filter, exp, + IndexContext.withBinHint(TestUtils.NAMESPACE, indexes, "intBin1")); + } + + @Test + void binLogical_AND_AND_bin_hint_no_match_falls_back_to_cardinality() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + // There is no index on "intBin99", so fallback: intBin2 selected by highest cardinality + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and $.intBin3 > 100"), filter, exp, + IndexContext.withBinHint(TestUtils.NAMESPACE, indexes, "intBin99")); + } + + @Test + void binLogical_AND_AND_bin_hint_namespace_mismatch_falls_back_to_automatic() { + List indexes = List.of( + Index.builder().namespace("other_namespace").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + // intBin1 bin matches but belongs to a different namespace, so falls back to automatic selection + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.gt(Exp.intBin("intBin1"), Exp.val(100)); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100"), filter, exp, + IndexContext.withBinHint(TestUtils.NAMESPACE, indexes, "intBin1")); + } + + @Test + void binLogical_AND_AND_bin_hint_null_falls_back_to_automatic() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + // Null falls back to automatic selection (highest cardinality = intBin2) + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and $.intBin3 > 100"), filter, exp, + IndexContext.withBinHint(TestUtils.NAMESPACE, indexes, null)); + } + + @Test + void binLogical_AND_AND_bin_hint_multiple_indexes_same_bin_falls_back_to_automatic() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(5).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.STRING).binValuesRatio(0).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build() + ); + // Two indexes on intBin1 → ambiguous → fallback to full automatic; intBin1 NUMERIC wins by cardinality + Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); + Exp exp = Exp.gt(Exp.intBin("intBin2"), Exp.val(100)); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100"), filter, exp, + IndexContext.withBinHint(TestUtils.NAMESPACE, indexes, "intBin1")); + } + + @Test + void binLogical_AND_AND_bin_hint_overrides_alphabetical_same_cardinality() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(100).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(100).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin3").indexType(IndexType.NUMERIC).binValuesRatio(100).build() + ); + // Without hint intBin1 would be chosen alphabetically (same cardinality). With bin hint, intBin2 is selected. + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 and $.intBin2 > 100 and $.intBin3 > 100"), filter, exp, + IndexContext.withBinHint(TestUtils.NAMESPACE, indexes, "intBin2")); + } + + @Test + void binLogical_OR_bin_hint_produces_no_filter() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(1).build(), + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + // SI filter is never produced for a top-level OR expression, even with a bin hint + Filter filter = null; + Exp exp = Exp.or( + Exp.gt(Exp.intBin("intBin1"), Exp.val(100)), + Exp.gt(Exp.intBin("intBin2"), Exp.val(100)) + ); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100 or $.intBin2 > 100"), filter, exp, + IndexContext.withBinHint(TestUtils.NAMESPACE, indexes, "intBin1")); + } + + @Test + void binLogical_single_bin_bin_hint_exact_match() { + List indexes = List.of( + Index.builder().namespace(TestUtils.NAMESPACE).bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(0).build() + ); + Filter filter = Filter.range("intBin1", 101, Long.MAX_VALUE); + parseDslExpressionAndCompare(ExpressionContext.of("$.intBin1 > 100"), filter, null, + IndexContext.withBinHint(TestUtils.NAMESPACE, indexes, "intBin1")); + } + @Test void binLogical_EXCL_no_indexes() { Filter filter = null; From e9e25e46ce8f8564875115847c98d9f36edaca54 Mon Sep 17 00:00:00 2001 From: Andrey G Date: Wed, 25 Feb 2026 14:20:11 +0100 Subject: [PATCH 9/9] Guard against empty index name hints and null indexes collection, add tests --- .../java/com/aerospike/dsl/IndexContext.java | 5 +- .../com/aerospike/dsl/IndexContextTests.java | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/aerospike/dsl/IndexContext.java b/src/main/java/com/aerospike/dsl/IndexContext.java index 52f1ab4..3f91c1d 100644 --- a/src/main/java/com/aerospike/dsl/IndexContext.java +++ b/src/main/java/com/aerospike/dsl/IndexContext.java @@ -52,6 +52,9 @@ public static IndexContext of(String namespace, Collection indexes) { */ public static IndexContext of(String namespace, Collection indexes, String indexToUse) { validateNamespace(namespace); + if (indexes == null || indexToUse == null || indexToUse.isBlank()) { + return new IndexContext(namespace, indexes); + } List matchingIndexes = indexes.stream() .filter(idx -> indexMatches(idx, namespace, indexToUse)) .toList(); @@ -73,7 +76,7 @@ public static IndexContext of(String namespace, Collection indexes, Strin */ public static IndexContext withBinHint(String namespace, Collection indexes, String binToUse) { validateNamespace(namespace); - if (binToUse == null || binToUse.isBlank()) { + if (indexes == null || binToUse == null || binToUse.isBlank()) { return new IndexContext(namespace, indexes); } List matchingIndexes = indexes.stream() diff --git a/src/test/java/com/aerospike/dsl/IndexContextTests.java b/src/test/java/com/aerospike/dsl/IndexContextTests.java index 70418c5..7ae28d7 100644 --- a/src/test/java/com/aerospike/dsl/IndexContextTests.java +++ b/src/test/java/com/aerospike/dsl/IndexContextTests.java @@ -8,6 +8,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; class IndexContextTests { @@ -19,6 +20,13 @@ class IndexContextTests { .indexType(IndexType.NUMERIC) .binValuesRatio(0) .build(); + private static final Index VALID_NAMED_INDEX = Index.builder() + .namespace(NAMESPACE) + .bin("bin1") + .name("idx_bin1") + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build(); @Test void of_rejects_null_namespace() { @@ -56,6 +64,75 @@ void of_3arg_rejects_blank_namespace() { .hasMessage("namespace must not be blank"); } + @Test + void of_3arg_null_index_name_returns_all_indexes() { + Collection indexes = List.of(VALID_NAMED_INDEX); + IndexContext ctx = IndexContext.of(NAMESPACE, indexes, null); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } + + @Test + void of_3arg_empty_index_name_returns_all_indexes() { + Collection indexes = List.of(VALID_NAMED_INDEX); + IndexContext ctx = IndexContext.of(NAMESPACE, indexes, ""); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } + + @Test + void of_3arg_single_match_returns_that_index() { + Index other = Index.builder().namespace(NAMESPACE).bin("bin2").name("idx_bin2") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(); + Collection indexes = List.of(VALID_NAMED_INDEX, other); + + IndexContext ctx = IndexContext.of(NAMESPACE, indexes, "idx_bin1"); + + assertThat(ctx.getIndexes()).containsExactly(VALID_NAMED_INDEX); + } + + @Test + void of_3arg_no_match_returns_all_indexes() { + Collection indexes = List.of(VALID_NAMED_INDEX); + IndexContext ctx = IndexContext.of(NAMESPACE, indexes, "idx_nonExistent"); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } + + @Test + void of_3arg_blank_index_name_returns_all_indexes() { + Index blankNamedIndex = Index.builder().namespace(NAMESPACE).bin("bin1").name(" ") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(); + Collection indexes = List.of(blankNamedIndex); + + IndexContext ctx = IndexContext.of(NAMESPACE, indexes, " "); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } + + @Test + void of_3arg_null_indexes_does_not_throw() { + assertThatCode(() -> IndexContext.of(NAMESPACE, null, "idx_bin1")) + .doesNotThrowAnyException(); + } + + @Test + void of_3arg_namespace_mismatch_returns_all_indexes() { + Index wrongNs = Index.builder().namespace("other_ns").bin("bin1").name("idx_bin1") + .indexType(IndexType.NUMERIC).binValuesRatio(0).build(); + Collection indexes = List.of(wrongNs); + + IndexContext ctx = IndexContext.of(NAMESPACE, indexes, "idx_bin1"); + + assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); + } + + @Test + void withBinHint_null_indexes_does_not_throw() { + assertThatCode(() -> IndexContext.withBinHint(NAMESPACE, null, "bin1")) + .doesNotThrowAnyException(); + } + @Test void withBinHint_rejects_null_namespace() { assertThatThrownBy(() -> IndexContext.withBinHint(null, List.of(VALID_INDEX), "bin1"))