diff --git a/src/main/java/com/aerospike/ael/Index.java b/src/main/java/com/aerospike/ael/Index.java index b67771d..b695bfc 100644 --- a/src/main/java/com/aerospike/ael/Index.java +++ b/src/main/java/com/aerospike/ael/Index.java @@ -13,16 +13,22 @@ * Mandatory fields: {@code namespace}, {@code bin}, {@code indexType}. * These are validated on build and must not be null/blank (for strings). * {@code binValuesRatio} defaults to 0 if not set and must not be negative. + *

+ * Optional {@code setName} is the Aerospike set this index is defined on; it is used together with + * {@link IndexContext} when a query set is supplied for secondary-index selection. */ -@Builder -@EqualsAndHashCode @Getter +@EqualsAndHashCode public class Index { /** * Namespace of the indexed bin */ private final String namespace; + /** + * Aerospike set this index is defined on, if known ({@code null} when unspecified). + */ + private final String setName; /** * Name of the indexed bin */ @@ -49,18 +55,28 @@ public class Index { */ 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); + @Builder + private Index(String namespace, String setName, String bin, String name, IndexType indexType, + Integer binValuesRatio, IndexCollectionType indexCollectionType, CTX[] ctx) { + int ratio = binValuesRatio != null ? binValuesRatio : 0; + validateMandatory(namespace, bin, indexType, ratio); this.namespace = namespace; + this.setName = normalizeSetName(setName); this.bin = bin; this.name = name; this.indexType = indexType; - this.binValuesRatio = binValuesRatio; + this.binValuesRatio = ratio; this.indexCollectionType = indexCollectionType; this.ctx = ctx; } + private static String normalizeSetName(String setName) { + if (setName == null || setName.isBlank()) { + return null; + } + return setName; + } + private static void validateMandatory(String namespace, String bin, IndexType indexType, int binValuesRatio) { requireNonBlank(namespace, "namespace"); requireNonBlank(bin, "bin"); diff --git a/src/main/java/com/aerospike/ael/IndexContext.java b/src/main/java/com/aerospike/ael/IndexContext.java index 0daebe0..8e4615c 100644 --- a/src/main/java/com/aerospike/ael/IndexContext.java +++ b/src/main/java/com/aerospike/ael/IndexContext.java @@ -5,7 +5,11 @@ import java.util.Collection; /** - * This class stores namespace and indexes required to build secondary index Filter + * This class stores namespace and indexes required to build secondary index Filter. + *

+ * When {@link #querySet} is set (same value as {@code Statement.setSetName}), only {@link Index} entries whose + * {@code setName} matches, or have no set name, are used for secondary-index selection; when it is + * {@code null} or blank, no set-based filtering is applied. */ @Getter public class IndexContext { @@ -27,11 +31,17 @@ public class IndexContext { * falling back to cardinality-based selection. */ private final String preferredBin; + /** + * Aerospike set name for the query (same as {@code Statement.setSetName}). When non-null, indexes are + * filtered by each {@link Index}'s {@code setName} via {@link #indexMatchesQuerySet(Index, String)}. + */ + private final String querySet; - private IndexContext(String namespace, Collection indexes, String preferredBin) { + private IndexContext(String namespace, Collection indexes, String preferredBin, String querySet) { this.namespace = namespace; this.indexes = indexes; this.preferredBin = preferredBin; + this.querySet = querySet; } /** @@ -44,7 +54,7 @@ private IndexContext(String namespace, Collection indexes, String preferr */ public static IndexContext of(String namespace, Collection indexes) { validateNamespace(namespace); - return new IndexContext(namespace, indexes, null); + return new IndexContext(namespace, indexes, null, null); } /** @@ -63,14 +73,58 @@ 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, null); + return new IndexContext(namespace, indexes, null, null); + } + String resolvedBin = indexes.stream() + .filter(idx -> indexMatches(idx, namespace, indexToUse)) + .map(Index::getBin) + .findFirst() + .orElse(null); + return new IndexContext(namespace, indexes, resolvedBin, null); + } + + /** + * Create index context with namespace, indexes, and query set. + * Same as {@link #of(String, Collection)} but applies set-based filtering: only indexes whose + * {@link Index} {@code setName} equals {@code querySet}, or have no set name, are used for selection. + * + * @param namespace Namespace to be used for creating {@link com.aerospike.ael.client.query.Filter}. + * Must not be null or blank + * @param querySet Aerospike set name for the query; null or blank disables set filtering + * @param indexes Collection of {@link Index} objects to be used for creating Filter + * @return A new instance of {@code IndexContext} + */ + public static IndexContext withQuerySet(String namespace, String querySet, Collection indexes) { + validateNamespace(namespace); + return new IndexContext(namespace, indexes, null, normalizeQuerySet(querySet)); + } + + /** + * Create index context with query set and an index-name hint. + * The hint is resolved only among indexes that match namespace and {@link #indexMatchesQuerySet(Index, String)}. + * + * @param namespace Namespace to be used for creating {@link com.aerospike.ael.client.query.Filter}. + * Must not be null or blank + * @param querySet Aerospike set name for the query; null or blank disables set filtering + * @param indexes Collection of {@link Index} objects to be used for creating Filter + * @param indexToUse The name of an index to use. If null, blank, or not found among eligible indexes, + * index is chosen automatically by cardinality then alphabetically + * @return A new instance of {@code IndexContext} + */ + public static IndexContext withQuerySet(String namespace, String querySet, Collection indexes, + String indexToUse) { + validateNamespace(namespace); + String normalized = normalizeQuerySet(querySet); + if (indexes == null || indexToUse == null || indexToUse.isBlank()) { + return new IndexContext(namespace, indexes, null, normalized); } String resolvedBin = indexes.stream() .filter(idx -> indexMatches(idx, namespace, indexToUse)) + .filter(idx -> indexMatchesQuerySet(idx, normalized)) .map(Index::getBin) .findFirst() .orElse(null); - return new IndexContext(namespace, indexes, resolvedBin); + return new IndexContext(namespace, indexes, resolvedBin, normalized); } /** @@ -90,10 +144,61 @@ public static IndexContext of(String namespace, Collection indexes, Strin public static IndexContext withBinHint(String namespace, Collection indexes, String binToUse) { validateNamespace(namespace); if (indexes == null || binToUse == null || binToUse.isBlank()) { - return new IndexContext(namespace, indexes, null); + return new IndexContext(namespace, indexes, null, null); } boolean hasMatch = indexes.stream().anyMatch(idx -> binMatches(idx, namespace, binToUse)); - return new IndexContext(namespace, indexes, hasMatch ? binToUse : null); + return new IndexContext(namespace, indexes, hasMatch ? binToUse : null, null); + } + + /** + * Create index context with a bin name hint and query set. + * Same behavior as {@link #withBinHint(String, Collection, String)}, but the bin must exist on an index + * that {@link #indexMatchesQuerySet(Index, String)} allows for {@code querySet}. + * + * @param namespace Namespace to be used for creating {@link com.aerospike.ael.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 + * @param querySet Aerospike set name for the query; null or blank disables set filtering + * @return A new instance of {@code IndexContext} + */ + public static IndexContext withBinHint(String namespace, Collection indexes, String binToUse, + String querySet) { + validateNamespace(namespace); + String normalized = normalizeQuerySet(querySet); + if (indexes == null || binToUse == null || binToUse.isBlank()) { + return new IndexContext(namespace, indexes, null, normalized); + } + boolean hasMatch = indexes.stream() + .anyMatch(idx -> binMatches(idx, namespace, binToUse) && indexMatchesQuerySet(idx, normalized)); + return new IndexContext(namespace, indexes, hasMatch ? binToUse : null, normalized); + } + + private static String normalizeQuerySet(String querySet) { + if (querySet == null || querySet.isBlank()) { + return null; + } + return querySet; + } + + /** + * Whether {@code idx} may be used when building filters for the given query set. + * If {@code querySet} is null, any index is eligible. Otherwise, an index with no set name is eligible; + * otherwise its {@link Index} {@code setName} must equal {@code querySet}. + * + * @param idx secondary index metadata; must not be null + * @param querySet normalized query set, or null to disable filtering + * @return {@code true} if the index should be included in the catalog passed to filter selection + */ + public static boolean indexMatchesQuerySet(Index idx, String querySet) { + if (querySet == null) { + return true; + } + String indexSet = idx.getSetName(); + if (indexSet == null || indexSet.isBlank()) { + return true; + } + return querySet.equals(indexSet); } private static void validateNamespace(String namespace) { diff --git a/src/main/java/com/aerospike/ael/api/AelParser.java b/src/main/java/com/aerospike/ael/api/AelParser.java index 8746605..d00a517 100644 --- a/src/main/java/com/aerospike/ael/api/AelParser.java +++ b/src/main/java/com/aerospike/ael/api/AelParser.java @@ -164,7 +164,9 @@ public interface AelParser { * @param input {@link ExpressionContext} containing input string of dot separated elements. If the input string has * placeholders, matching values must be provided within {@code input} too * @param indexContext Class containing namespace and collection of {@link Index} objects that represent - * existing secondary indexes. Required for creating {@link Filter}. Can be null + * existing secondary indexes (optionally including per-index {@code setName} and a query set + * on {@link IndexContext} aligned with {@code Statement.setSetName}). + * Required for creating {@link Filter}. Can be null * @return {@link ParsedExpression} object * @throws AelParseException in case of invalid syntax */ diff --git a/src/main/java/com/aerospike/ael/client/fluent/AerospikeComparator.java b/src/main/java/com/aerospike/ael/client/fluent/AerospikeComparator.java index bdd321a..37d63c3 100644 --- a/src/main/java/com/aerospike/ael/client/fluent/AerospikeComparator.java +++ b/src/main/java/com/aerospike/ael/client/fluent/AerospikeComparator.java @@ -28,8 +28,7 @@ /** * Comparator that orders objects according to the Aerospike server's * type ordering hierarchy: - * NIL(1) < BOOLEAN(2) < INTEGER(3) < STRING(4) < LIST(5) < MAP(6) - * < BYTES(7) < DOUBLE(8) < GEOJSON(9). + * {@code NIL(1) < BOOLEAN(2) < INTEGER(3) < STRING(4) < LIST(5) < MAP(6) < BYTES(7) < DOUBLE(8) < GEOJSON(9)}. *

* Cross-type comparison is strictly by type ordinal — there is no * numeric promotion between INTEGER and DOUBLE. diff --git a/src/main/java/com/aerospike/ael/impl/AelParserImpl.java b/src/main/java/com/aerospike/ael/impl/AelParserImpl.java index 3702405..7acd3a7 100644 --- a/src/main/java/com/aerospike/ael/impl/AelParserImpl.java +++ b/src/main/java/com/aerospike/ael/impl/AelParserImpl.java @@ -82,15 +82,22 @@ private ParseTree getParseTree(String input) { private ParsedExpression getParsedExpression(ParseTree parseTree, PlaceholderValues placeholderValues, IndexContext indexContext) { - final String namespace = Optional.ofNullable(indexContext) - .map(IndexContext::getNamespace) - .orElse(null); + String namespace = null; + String querySet = null; + Collection indexes = null; + String preferredBin = null; + + if (indexContext != null) { + namespace = indexContext.getNamespace(); + querySet = indexContext.getQuerySet(); + indexes = indexContext.getIndexes(); + preferredBin = indexContext.getPreferredBin(); + } Map> indexesMap = buildIndexesMap( - Optional.ofNullable(indexContext).map(IndexContext::getIndexes).orElse(null), namespace); - String preferredBin = Optional.ofNullable(indexContext) - .map(IndexContext::getPreferredBin) - .orElse(null); + indexes, + namespace, + querySet); AbstractPart resultingPart = new ExpressionConditionVisitor().visit(parseTree); @@ -101,10 +108,12 @@ private ParsedExpression getParsedExpression(ParseTree parseTree, PlaceholderVal return new ParsedExpression(resultingPart, placeholderValues, indexesMap, preferredBin); } - private Map> buildIndexesMap(Collection indexes, String namespace) { + private Map> buildIndexesMap(Collection indexes, String namespace, + String querySet) { if (indexes == null || indexes.isEmpty() || namespace == null) return Collections.emptyMap(); return indexes.stream() .filter(idx -> namespace.equals(idx.getNamespace())) + .filter(idx -> IndexContext.indexMatchesQuerySet(idx, querySet)) .collect(Collectors.groupingBy(Index::getBin)); } } diff --git a/src/test/java/com/aerospike/ael/index/IndexContextTests.java b/src/test/java/com/aerospike/ael/index/IndexContextTests.java index bba905e..d5c2b3e 100644 --- a/src/test/java/com/aerospike/ael/index/IndexContextTests.java +++ b/src/test/java/com/aerospike/ael/index/IndexContextTests.java @@ -50,6 +50,7 @@ void of_accepts_valid_namespace() { assertThat(ctx.getNamespace()).isEqualTo(NAMESPACE); assertThat(ctx.getIndexes()).isEmpty(); + assertThat(ctx.getQuerySet()).isNull(); } @Test @@ -214,4 +215,96 @@ void withBinHint_namespace_mismatch_returns_all_indexes() { assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); } + + @Test + void resolves_preferred_bin_by_index_name_when_indexes_differ_by_set_name() { + Index idxSet = Index.builder().namespace(NAMESPACE).setName("set").bin("age").name("ageidx") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + Index idxScan = Index.builder().namespace(NAMESPACE).setName("testScan").bin("age").name("age_idx") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + List indexes = List.of(idxSet, idxScan); + + IndexContext ctx = IndexContext.of(NAMESPACE, indexes, "age_idx"); + + assertThat(ctx.getPreferredBin()).isEqualTo("age"); + } + + @Test + void withQuerySet_stores_query_set() { + IndexContext ctx = IndexContext.withQuerySet(NAMESPACE, "testScan", List.of(VALID_INDEX)); + + assertThat(ctx.getQuerySet()).isEqualTo("testScan"); + assertThat(ctx.getPreferredBin()).isNull(); + } + + @Test + void withQuerySet_blank_normalized_to_null() { + IndexContext ctx = IndexContext.withQuerySet(NAMESPACE, " ", List.of(VALID_INDEX)); + + assertThat(ctx.getQuerySet()).isNull(); + } + + @Test + void withQuerySet_4arg_resolves_hint_only_for_matching_set() { + Index idxSet = Index.builder().namespace(NAMESPACE).setName("set").bin("age").name("ageidx") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + Index idxScan = Index.builder().namespace(NAMESPACE).setName("testScan").bin("age").name("age_idx") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + List indexes = List.of(idxSet, idxScan); + + IndexContext ctx = IndexContext.withQuerySet(NAMESPACE, "testScan", indexes, "age_idx"); + + assertThat(ctx.getQuerySet()).isEqualTo("testScan"); + assertThat(ctx.getPreferredBin()).isEqualTo("age"); + } + + @Test + void withQuerySet_4arg_wrong_set_does_not_resolve_index_name() { + Index idxSet = Index.builder().namespace(NAMESPACE).setName("set").bin("age").name("ageidx") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + List indexes = List.of(idxSet); + + IndexContext ctx = IndexContext.withQuerySet(NAMESPACE, "testScan", indexes, "ageidx"); + + assertThat(ctx.getPreferredBin()).isNull(); + } + + @Test + void withBinHint_4arg_requires_bin_on_matching_set() { + Index idxSet = Index.builder().namespace(NAMESPACE).setName("set").bin("age") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + List indexes = List.of(idxSet); + + IndexContext ctx = IndexContext.withBinHint(NAMESPACE, indexes, "age", "testScan"); + + assertThat(ctx.getPreferredBin()).isNull(); + } + + @Test + void indexMatchesQuerySet_null_query_set_matches_all() { + Index idx = Index.builder().namespace(NAMESPACE).setName("set").bin("age") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + assertThat(IndexContext.indexMatchesQuerySet(idx, null)).isTrue(); + } + + @Test + void indexMatchesQuerySet_blank_index_set_matches_any() { + Index idx = Index.builder().namespace(NAMESPACE).bin("age") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + assertThat(IndexContext.indexMatchesQuerySet(idx, "testScan")).isTrue(); + } + + @Test + void indexMatchesQuerySet_equal_set_matches() { + Index idx = Index.builder().namespace(NAMESPACE).setName("testScan").bin("age") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + assertThat(IndexContext.indexMatchesQuerySet(idx, "testScan")).isTrue(); + } + + @Test + void indexMatchesQuerySet_mismatched_set_excluded() { + Index idx = Index.builder().namespace(NAMESPACE).setName("set").bin("age") + .indexType(IndexType.NUMERIC).binValuesRatio(1).build(); + assertThat(IndexContext.indexMatchesQuerySet(idx, "testScan")).isFalse(); + } } diff --git a/src/test/java/com/aerospike/ael/index/IndexTests.java b/src/test/java/com/aerospike/ael/index/IndexTests.java index 58d0af7..e273d6e 100644 --- a/src/test/java/com/aerospike/ael/index/IndexTests.java +++ b/src/test/java/com/aerospike/ael/index/IndexTests.java @@ -153,5 +153,32 @@ void build_succeeds_with_all_mandatory() { assertThat(index.getBin()).isEqualTo(BIN); assertThat(index.getIndexType()).isEqualTo(IndexType.STRING); assertThat(index.getBinValuesRatio()).isOne(); + assertThat(index.getSetName()).isNull(); + } + + @Test + void build_accepts_optional_set_name() { + Index index = Index.builder() + .namespace(NAMESPACE) + .setName("mySet") + .bin(BIN) + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build(); + + assertThat(index.getSetName()).isEqualTo("mySet"); + } + + @Test + void build_blank_set_name_normalized_to_null() { + Index index = Index.builder() + .namespace(NAMESPACE) + .setName(" ") + .bin(BIN) + .indexType(IndexType.NUMERIC) + .binValuesRatio(0) + .build(); + + assertThat(index.getSetName()).isNull(); } } diff --git a/src/test/java/com/aerospike/ael/parsedExpression/QuerySetParsedExpressionTests.java b/src/test/java/com/aerospike/ael/parsedExpression/QuerySetParsedExpressionTests.java new file mode 100644 index 0000000..e69de29