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