From 0ea623e5a1bebb226aa29261ec4006aca12f607f Mon Sep 17 00:00:00 2001 From: Gagan Mishra Date: Wed, 15 Apr 2026 10:57:40 -0700 Subject: [PATCH 1/5] use querySet in AEL parsing and index selection --- .../main/java/com/aerospike/ael/Index.java | 36 ++-- .../java/com/aerospike/ael/IndexContext.java | 127 ++++++++++++-- .../com/aerospike/ael/impl/AelParserImpl.java | 11 +- .../client/sdk/AbstractFilterableBuilder.java | 9 +- .../client/sdk/BackgroundUdfBuilder.java | 2 +- .../client/sdk/BinsValuesBuilder.java | 9 +- .../aerospike/client/sdk/IndexesMonitor.java | 1 + .../aerospike/client/sdk/ObjectBuilder.java | 14 +- .../sdk/query/WhereClauseProcessor.java | 32 ++-- .../ael/index/IndexContextTests.java | 37 ++++ .../com/aerospike/ael/index/IndexTests.java | 27 +++ .../QuerySetParsedExpressionTests.java | 166 ++++++++++++++++++ 12 files changed, 424 insertions(+), 47 deletions(-) create mode 100644 client/src/test/java/com/aerospike/ael/parsedExpression/QuerySetParsedExpressionTests.java diff --git a/client/src/main/java/com/aerospike/ael/Index.java b/client/src/main/java/com/aerospike/ael/Index.java index 45d7592b..689f1d4c 100644 --- a/client/src/main/java/com/aerospike/ael/Index.java +++ b/client/src/main/java/com/aerospike/ael/Index.java @@ -9,10 +9,10 @@ * the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations + * under the License. */ package com.aerospike.ael; @@ -30,16 +30,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 */ @@ -66,18 +72,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/client/src/main/java/com/aerospike/ael/IndexContext.java b/client/src/main/java/com/aerospike/ael/IndexContext.java index ca163dce..ce46467c 100644 --- a/client/src/main/java/com/aerospike/ael/IndexContext.java +++ b/client/src/main/java/com/aerospike/ael/IndexContext.java @@ -9,10 +9,10 @@ * the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations under - * the License. + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations + * under the License. */ package com.aerospike.ael; @@ -21,7 +21,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 the query's set name), 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 { @@ -43,11 +47,17 @@ public class IndexContext { * falling back to cardinality-based selection. */ private final String preferredBin; + /** + * Aerospike set name for the query. 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; } /** @@ -60,7 +70,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); } /** @@ -79,14 +89,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 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 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); } /** @@ -106,10 +160,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 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/client/src/main/java/com/aerospike/ael/impl/AelParserImpl.java b/client/src/main/java/com/aerospike/ael/impl/AelParserImpl.java index b6a0026d..7b6d0ec8 100644 --- a/client/src/main/java/com/aerospike/ael/impl/AelParserImpl.java +++ b/client/src/main/java/com/aerospike/ael/impl/AelParserImpl.java @@ -103,8 +103,13 @@ private ParsedExpression getParsedExpression(ParseTree parseTree, PlaceholderVal .map(IndexContext::getNamespace) .orElse(null); + String querySet = Optional.ofNullable(indexContext) + .map(IndexContext::getQuerySet) + .orElse(null); Map> indexesMap = buildIndexesMap( - Optional.ofNullable(indexContext).map(IndexContext::getIndexes).orElse(null), namespace); + Optional.ofNullable(indexContext).map(IndexContext::getIndexes).orElse(null), + namespace, + querySet); String preferredBin = Optional.ofNullable(indexContext) .map(IndexContext::getPreferredBin) .orElse(null); @@ -118,12 +123,14 @@ 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/client/src/main/java/com/aerospike/client/sdk/AbstractFilterableBuilder.java b/client/src/main/java/com/aerospike/client/sdk/AbstractFilterableBuilder.java index fde1036a..86f19738 100644 --- a/client/src/main/java/com/aerospike/client/sdk/AbstractFilterableBuilder.java +++ b/client/src/main/java/com/aerospike/client/sdk/AbstractFilterableBuilder.java @@ -53,10 +53,17 @@ protected void setWhereClause(WhereClauseProcessor clause) { * Process where clause and return Expression, or null if no clause exists. */ protected Expression processWhereClause(String namespace, Session session) { + return processWhereClause(namespace, null, session); + } + + /** + * Process where clause with optional Aerospike set name for secondary-index catalog filtering. + */ + protected Expression processWhereClause(String namespace, String querySet, Session session) { if (this.ael == null) { return null; } - ParseResult parseResult = this.ael.process(namespace, session); + ParseResult parseResult = this.ael.process(namespace, querySet, session); return Exp.build(parseResult.getExp()); } diff --git a/client/src/main/java/com/aerospike/client/sdk/BackgroundUdfBuilder.java b/client/src/main/java/com/aerospike/client/sdk/BackgroundUdfBuilder.java index 51d88cfa..a67d9839 100644 --- a/client/src/main/java/com/aerospike/client/sdk/BackgroundUdfBuilder.java +++ b/client/src/main/java/com/aerospike/client/sdk/BackgroundUdfBuilder.java @@ -274,7 +274,7 @@ public ExecuteTask execute() { Expression filterExp = null; if (ael != null) { - ParseResult pr = ael.process(dataset.getNamespace(), session); + ParseResult pr = ael.process(dataset.getNamespace(), dataset.getSet(), session); filter = pr.getFilter(); filterExp = Exp.build(pr.getExp()); } diff --git a/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java b/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java index 9cdf2733..1c8d2300 100644 --- a/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java +++ b/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java @@ -875,7 +875,8 @@ private RecordStream executeIndividualSync(ErrorDisposition disposition) { Partitions partitions = getPartitions(cluster, firstKey.namespace); Settings policy = session.getBehavior().getSettings(OpKind.WRITE_RETRYABLE, OpShape.POINT, partitions.scMode); - final Expression filterExp = processWhereClause(keys.get(0).namespace, opBuilder.getSession()); + final Expression filterExp = processWhereClause(keys.get(0).namespace, keys.get(0).setName, + opBuilder.getSession()); if (txnToUse != null) { TxnMonitor.addKeys(txnToUse, session, keys); @@ -968,7 +969,7 @@ private RecordStream executeIndividualAsync() { String namespace = keys.get(0).namespace; Partitions partitions = getPartitions(cluster, namespace); Settings policy = session.getBehavior().getSettings(OpKind.WRITE_RETRYABLE, OpShape.POINT, partitions.scMode); - final Expression filterExp = getFilterExp(session, namespace); + final Expression filterExp = getFilterExp(session, namespace, keys.get(0).setName); AsyncRecordStream asyncStream = new AsyncRecordStream(keys.size()); if (txnToUse != null) { @@ -1049,10 +1050,10 @@ private Partitions getPartitions(Cluster cluster, String namespace) { return partitions; } - private Expression getFilterExp(Session session, String namespace) { + private Expression getFilterExp(Session session, String namespace, String querySet) { if (ael != null) { // Apply filter expression clause. - ParseResult parseResult = ael.process(namespace, session); + ParseResult parseResult = ael.process(namespace, querySet, session); return Exp.build(parseResult.getExp()); } else { return null; diff --git a/client/src/main/java/com/aerospike/client/sdk/IndexesMonitor.java b/client/src/main/java/com/aerospike/client/sdk/IndexesMonitor.java index a46f9375..4feb32ee 100644 --- a/client/src/main/java/com/aerospike/client/sdk/IndexesMonitor.java +++ b/client/src/main/java/com/aerospike/client/sdk/IndexesMonitor.java @@ -124,6 +124,7 @@ synchronized boolean startMonitor(Session session, Duration frequency) { session.info().secondaryIndexDetails(sindex, false).ifPresent(details -> { indexes.add(Index.builder() .namespace(sindex.getNamespace()) + .setName(sindex.getSet()) .bin(sindex.getBin()) .indexType(IndexType.valueOf(sindex.getType().name())) .name(sindex.getIndexName()) diff --git a/client/src/main/java/com/aerospike/client/sdk/ObjectBuilder.java b/client/src/main/java/com/aerospike/client/sdk/ObjectBuilder.java index 113611ae..339b581f 100644 --- a/client/src/main/java/com/aerospike/client/sdk/ObjectBuilder.java +++ b/client/src/main/java/com/aerospike/client/sdk/ObjectBuilder.java @@ -1004,7 +1004,7 @@ private BatchCommand prepareBatch() { records.add(new BatchWrite(key, null, attr, opBuilder.getOpType(), ops, generation, ttl)); } - final Expression filterExp = getFilterExp(namespace); + final Expression filterExp = getFilterExp(namespace, opBuilder.getDataSet().getSet()); return new BatchCommand(cluster, partitions, txnToUse, namespace, records, filterExp, opBuilder.includeMissingKeys, opBuilder.failOnFilteredOut, false, settings); @@ -1053,7 +1053,7 @@ private RecordStream executeIndividualSync(ErrorDisposition disposition) { .getSettings(OpKind.WRITE_RETRYABLE, OpShape.POINT, partitions.scMode); // Apply where clause if present - final Expression filterExp = getFilterExp(firstKey.namespace); + final Expression filterExp = getFilterExp(firstKey.namespace, firstKey.setName); int ttl = (int) resolveTtl(expirationInSeconds, defaultExpirationInSeconds); if (txnToUse != null) { @@ -1124,7 +1124,7 @@ private RecordStream executeIndividualAsync() { .getSettings(OpKind.WRITE_RETRYABLE, OpShape.POINT, partitions.scMode); // Apply where clause if present - final Expression filterExp = getFilterExp(firstKey.namespace); + final Expression filterExp = getFilterExp(firstKey.namespace, firstKey.setName); int ttl = (int) resolveTtl(expirationInSeconds, defaultExpirationInSeconds); boolean stackTraceOnException = settings.getStackTraceOnException(); @@ -1157,7 +1157,7 @@ private RecordStream executeSingleSync(T element, ErrorDisposition disposition) .getSettings(OpKind.WRITE_RETRYABLE, OpShape.POINT, partitions.scMode); // Apply where clause if present - final Expression filterExp = getFilterExp(key.namespace); + final Expression filterExp = getFilterExp(key.namespace, key.setName); if (txnToUse != null) { // Assume all operations are write operations. @@ -1199,7 +1199,7 @@ private RecordStream executeSingleAsync(T element) { .getSettings(OpKind.WRITE_RETRYABLE, OpShape.POINT, partitions.scMode); // Apply where clause if present - final Expression filterExp = getFilterExp(key.namespace); + final Expression filterExp = getFilterExp(key.namespace, key.setName); int ttl = (int) resolveTtl(expirationInSeconds, defaultExpirationInSeconds); boolean stackTraceOnException = settings.getStackTraceOnException(); @@ -1284,9 +1284,9 @@ private Partitions getPartitions(Cluster cluster, String namespace) { return partitions; } - private Expression getFilterExp(String namespace) { + private Expression getFilterExp(String namespace, String querySet) { if (opBuilder.getAel() != null && !elements.isEmpty()) { - ParseResult parseResult = opBuilder.getAel().process(namespace, opBuilder.getSession()); + ParseResult parseResult = opBuilder.getAel().process(namespace, querySet, opBuilder.getSession()); return Exp.build(parseResult.getExp()); } return null; diff --git a/client/src/main/java/com/aerospike/client/sdk/query/WhereClauseProcessor.java b/client/src/main/java/com/aerospike/client/sdk/query/WhereClauseProcessor.java index 07cb5cb3..a6dd5ad9 100644 --- a/client/src/main/java/com/aerospike/client/sdk/query/WhereClauseProcessor.java +++ b/client/src/main/java/com/aerospike/client/sdk/query/WhereClauseProcessor.java @@ -38,8 +38,18 @@ public abstract class WhereClauseProcessor { protected final boolean allowsIndex; - public abstract ParseResult process(String namespace, Session session); + /** + * Parse AEL with no query-set filtering of secondary indexes (legacy behavior). + */ + public final ParseResult process(String namespace, Session session) { + return process(namespace, null, session); + } + /** + * Parse AEL; when {@code querySet} is non-null and non-blank, only indexes whose set matches + * (or have no set) participate in secondary-index selection. + */ + public abstract ParseResult process(String namespace, String querySet, Session session); public WhereClauseProcessor(boolean allowsIndex) { this.allowsIndex = allowsIndex; @@ -77,7 +87,7 @@ protected String formStringOfFilter(Filter filter, IndexContext indexContext) { .append(valTypeToString(filter.getValType())) .append(" ] ") .append(filterCriteriaToString(filter)); - if (indexContext != null) { + if (indexContext != null && indexContext.getIndexes() != null) { Collection indexes = indexContext.getIndexes(); sb.append("{"); for (Index index : indexes) { @@ -89,7 +99,7 @@ protected String formStringOfFilter(Filter filter, IndexContext indexContext) { return sb.toString(); } - public ParseResult process(String ael, String namespace, Session session) { + protected ParseResult process(String ael, String namespace, String querySet, Session session) { AelParser parser = new AelParserImpl(); ParsedExpression parseResult; @@ -97,7 +107,7 @@ public ParseResult process(String ael, String namespace, Session session) { ExpressionContext context = ExpressionContext.of(ael); if (allowsIndex) { Set indexes = session.getCluster().getIndexes(); - indexContext = IndexContext.of(namespace, indexes); + indexContext = IndexContext.withQuerySet(namespace, querySet, indexes); parseResult = parser.parseExpression(context, indexContext); } else { @@ -137,8 +147,8 @@ public WhereStringImpl(boolean allowsIndex, String ael) { } @Override - public ParseResult process(String namespace, Session session) { - return process(this.ael, namespace, session); + public ParseResult process(String namespace, String querySet, Session session) { + return process(this.ael, namespace, querySet, session); } } @@ -152,10 +162,10 @@ public WherePreparedImpl(boolean allowsIndex, PreparedAel ael, Object... params) } @Override - public ParseResult process(String namespace, Session session) { + public ParseResult process(String namespace, String querySet, Session session) { // TODO: For now, until AEL supports prepared statements String aelStr = ael.formValue(params); - return process(aelStr, namespace, session); + return process(aelStr, namespace, querySet, session); } } @@ -167,7 +177,7 @@ public WhereBoolExprImpl(boolean allowsIndex, BooleanExpression ael) { } @Override - public ParseResult process(String namespace, Session session) { + public ParseResult process(String namespace, String querySet, Session session) { return new ParseResult(null, ael.toAerospikeExp()); } } @@ -180,7 +190,7 @@ public WhereExpImpl(boolean allowsIndex, Exp exp) { } @Override - public ParseResult process(String namespace, Session session) { + public ParseResult process(String namespace, String querySet, Session session) { return new ParseResult(null, exp); } } @@ -200,4 +210,4 @@ public static WhereClauseProcessor from(Exp exp) { public static WhereClauseProcessor from(Expression exp) { return from(Exp.expr(exp)); } -} \ No newline at end of file +} diff --git a/client/src/test/java/com/aerospike/ael/index/IndexContextTests.java b/client/src/test/java/com/aerospike/ael/index/IndexContextTests.java index 2bdb4699..d5a76be8 100644 --- a/client/src/test/java/com/aerospike/ael/index/IndexContextTests.java +++ b/client/src/test/java/com/aerospike/ael/index/IndexContextTests.java @@ -51,6 +51,7 @@ void of_accepts_valid_namespace() { assertThat(ctx.getNamespace()).isEqualTo(NAMESPACE); assertThat(ctx.getIndexes()).isEmpty(); + assertThat(ctx.getQuerySet()).isNull(); } @Test @@ -215,4 +216,40 @@ void withBinHint_namespace_mismatch_returns_all_indexes() { assertThat(ctx.getIndexes()).containsExactlyElementsOf(indexes); } + + @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.INTEGER).binValuesRatio(1).build(); + Index idxScan = Index.builder().namespace(NAMESPACE).setName("testScan").bin("age").name("age_idx") + .indexType(IndexType.INTEGER).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 indexMatchesQuerySet_mismatched_set_excluded() { + Index idx = Index.builder().namespace(NAMESPACE).setName("set").bin("age") + .indexType(IndexType.INTEGER).binValuesRatio(1).build(); + assertThat(IndexContext.indexMatchesQuerySet(idx, "testScan")).isFalse(); + } } diff --git a/client/src/test/java/com/aerospike/ael/index/IndexTests.java b/client/src/test/java/com/aerospike/ael/index/IndexTests.java index a089726b..47c69474 100644 --- a/client/src/test/java/com/aerospike/ael/index/IndexTests.java +++ b/client/src/test/java/com/aerospike/ael/index/IndexTests.java @@ -154,5 +154,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.INTEGER) + .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.INTEGER) + .binValuesRatio(0) + .build(); + + assertThat(index.getSetName()).isNull(); } } diff --git a/client/src/test/java/com/aerospike/ael/parsedExpression/QuerySetParsedExpressionTests.java b/client/src/test/java/com/aerospike/ael/parsedExpression/QuerySetParsedExpressionTests.java new file mode 100644 index 00000000..1bfb80de --- /dev/null +++ b/client/src/test/java/com/aerospike/ael/parsedExpression/QuerySetParsedExpressionTests.java @@ -0,0 +1,166 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.aerospike.ael.parsedExpression; + +import com.aerospike.ael.ExpressionContext; +import com.aerospike.ael.Index; +import com.aerospike.ael.IndexContext; +import com.aerospike.client.sdk.cdt.ListReturnType; +import com.aerospike.client.sdk.exp.Exp; +import com.aerospike.client.sdk.exp.ListExp; +import com.aerospike.client.sdk.query.Filter; +import com.aerospike.client.sdk.query.IndexType; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static com.aerospike.ael.util.TestUtils.NAMESPACE; +import static com.aerospike.ael.util.TestUtils.parseAelExpressionAndCompare; + +/** + * Parser-level tests for {@link IndexContext#withQuerySet} via {@link com.aerospike.ael.util.TestUtils#parseAelExpressionAndCompare} + * (same pattern as {@link InExprTests}). + */ +class QuerySetParsedExpressionTests { + + private static final String TEST_SCAN = "testScan"; + + @Test + void inAndEq_withQuerySet_eqBinIndexedOnlyOnOtherSet_noSiFilter() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).setName("customers").bin("intBin1") + .indexType(IndexType.INTEGER).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).setName("orders").bin("intBin2") + .indexType(IndexType.INTEGER).binValuesRatio(1).build() + ); + Filter filter = null; + Exp exp = Exp.and( + ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))), + Exp.eq(Exp.intBin("intBin2"), Exp.val(100))); + parseAelExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.withQuerySet(NAMESPACE, "customers", indexes)); + } + + @Test + void inAndEq_withQuerySet_indexesOnQuerySet_sameAsInExpr() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("intBin1") + .indexType(IndexType.INTEGER).binValuesRatio(0).build(), + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("intBin2") + .indexType(IndexType.INTEGER).binValuesRatio(1).build() + ); + Filter filter = Filter.equal("intBin2", 100); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(1, 2, 3))); + parseAelExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [1, 2, 3] and $.intBin2 == 100"), + filter, exp, IndexContext.withQuerySet(NAMESPACE, TEST_SCAN, indexes)); + } + + @Test + void tripleGt_withoutQuerySet_otherSetWinsCardinality_withQuerySet_usesQuerySetIndexes() { + List catalog = List.of( + Index.builder().namespace(NAMESPACE).setName("other").bin("intBin1").name("idx_bin1") + .indexType(IndexType.INTEGER).binValuesRatio(200).build(), + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("intBin2").name("idx_bin2") + .indexType(IndexType.INTEGER).binValuesRatio(100).build(), + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("intBin3").name("idx_bin3") + .indexType(IndexType.INTEGER).binValuesRatio(100).build() + ); + String ael = "$.intBin1 > 50 and $.intBin2 > 50 and $.intBin3 > 50"; + Exp expWhenSiOnIntBin1 = Exp.and( + Exp.gt(Exp.intBin("intBin2"), Exp.val(50)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(50))); + Exp expWhenSiOnIntBin2 = Exp.and( + Exp.gt(Exp.intBin("intBin1"), Exp.val(50)), + Exp.gt(Exp.intBin("intBin3"), Exp.val(50))); + + parseAelExpressionAndCompare( + ExpressionContext.of(ael), + Filter.range("intBin1", 51, Long.MAX_VALUE), + expWhenSiOnIntBin1, + IndexContext.of(NAMESPACE, catalog)); + + parseAelExpressionAndCompare( + ExpressionContext.of(ael), + Filter.range("intBin2", 51, Long.MAX_VALUE), + expWhenSiOnIntBin2, + IndexContext.withQuerySet(NAMESPACE, TEST_SCAN, catalog)); + } + + @Test + void inAndGt_withQuerySet_onMatchingSets() { + List indexes = List.of( + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("intBin1") + .indexType(IndexType.INTEGER).binValuesRatio(1).build(), + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("intBin2") + .indexType(IndexType.INTEGER).binValuesRatio(0).build() + ); + Filter filter = Filter.range("intBin2", 101, Long.MAX_VALUE); + Exp exp = ListExp.getByValue(ListReturnType.EXISTS, + Exp.intBin("intBin1"), Exp.val(List.of(10, 20))); + parseAelExpressionAndCompare( + ExpressionContext.of("$.intBin1 in [10, 20] and $.intBin2 > 100"), + filter, exp, IndexContext.withQuerySet(NAMESPACE, TEST_SCAN, indexes)); + } + + @Test + void withQuerySet_andIndexNameHint_selectsBinForNamedIndexOnThatSet() { + List catalog = List.of( + Index.builder().namespace(NAMESPACE).setName("set").bin("age").name("ageidx") + .indexType(IndexType.INTEGER).binValuesRatio(50).build(), + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("age").name("age_idx") + .indexType(IndexType.INTEGER).binValuesRatio(10).build(), + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("score").name("score_idx") + .indexType(IndexType.INTEGER).binValuesRatio(5).build() + ); + String ael = "$.age > 18 and $.score > 0 and $.flag == 1"; + Exp exp = Exp.and( + Exp.gt(Exp.intBin("score"), Exp.val(0)), + Exp.eq(Exp.intBin("flag"), Exp.val(1))); + + parseAelExpressionAndCompare( + ExpressionContext.of(ael), + Filter.range("age", 19, Long.MAX_VALUE), + exp, + IndexContext.withQuerySet(NAMESPACE, TEST_SCAN, catalog, "age_idx")); + } + + @Test + void withBinHint_withQuerySet_prefersBinWhenPresentOnQuerySet() { + List catalog = List.of( + Index.builder().namespace(NAMESPACE).setName("other").bin("intBin1") + .indexType(IndexType.INTEGER).binValuesRatio(200).build(), + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("intBin2") + .indexType(IndexType.INTEGER).binValuesRatio(100).build(), + Index.builder().namespace(NAMESPACE).setName(TEST_SCAN).bin("intBin3") + .indexType(IndexType.INTEGER).binValuesRatio(100).build() + ); + String ael = "$.intBin1 > 50 and $.intBin2 > 50 and $.intBin3 > 50"; + Exp exp = Exp.and( + Exp.gt(Exp.intBin("intBin1"), Exp.val(50)), + Exp.gt(Exp.intBin("intBin2"), Exp.val(50))); + + parseAelExpressionAndCompare( + ExpressionContext.of(ael), + Filter.range("intBin3", 51, Long.MAX_VALUE), + exp, + IndexContext.withBinHint(NAMESPACE, catalog, "intBin3", TEST_SCAN)); + } +} From 49a761425555be074d1c8eb236c0e8a0d57b1ef2 Mon Sep 17 00:00:00 2001 From: Gagan Mishra Date: Wed, 15 Apr 2026 15:18:49 -0700 Subject: [PATCH 2/5] fix merge conflicts --- .../main/java/com/aerospike/client/sdk/BinsValuesBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java b/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java index 1c8d2300..6aa91e43 100644 --- a/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java +++ b/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java @@ -822,7 +822,7 @@ private BatchCommand prepareBatch() { Partitions partitions = getPartitions(cluster, namespace); settings = session.getBehavior().getSettings(OpKind.WRITE_RETRYABLE, OpShape.BATCH, partitions.scMode); - final Expression filterExp = getFilterExp(session, namespace); + final Expression filterExp = getFilterExp(session, namespace, keys.get(0).setName); BatchAttr attr = new BatchAttr(); attr.setWrite(settings, opBuilder.getOpType()); From 9e8dc2f47f73bec111e0f653533b180f273995fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=93=82=BA?= Date: Thu, 16 Apr 2026 23:32:12 -0700 Subject: [PATCH 3/5] minor code quality improvements --- .../com/aerospike/ael/impl/AelParserImpl.java | 28 +++++++++---------- .../sdk/query/WhereClauseProcessor.java | 2 ++ 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/client/src/main/java/com/aerospike/ael/impl/AelParserImpl.java b/client/src/main/java/com/aerospike/ael/impl/AelParserImpl.java index 7b6d0ec8..f4781113 100644 --- a/client/src/main/java/com/aerospike/ael/impl/AelParserImpl.java +++ b/client/src/main/java/com/aerospike/ael/impl/AelParserImpl.java @@ -99,20 +99,20 @@ 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 querySet = Optional.ofNullable(indexContext) - .map(IndexContext::getQuerySet) - .orElse(null); - Map> indexesMap = buildIndexesMap( - Optional.ofNullable(indexContext).map(IndexContext::getIndexes).orElse(null), - namespace, - querySet); - String preferredBin = Optional.ofNullable(indexContext) - .map(IndexContext::getPreferredBin) - .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(indexes, namespace, querySet); AbstractPart resultingPart = new ExpressionConditionVisitor().visit(parseTree); diff --git a/client/src/main/java/com/aerospike/client/sdk/query/WhereClauseProcessor.java b/client/src/main/java/com/aerospike/client/sdk/query/WhereClauseProcessor.java index a6dd5ad9..da34fe88 100644 --- a/client/src/main/java/com/aerospike/client/sdk/query/WhereClauseProcessor.java +++ b/client/src/main/java/com/aerospike/client/sdk/query/WhereClauseProcessor.java @@ -178,6 +178,7 @@ public WhereBoolExprImpl(boolean allowsIndex, BooleanExpression ael) { @Override public ParseResult process(String namespace, String querySet, Session session) { + // namespace, querySet, session intentionally ignored - not required in this implementation return new ParseResult(null, ael.toAerospikeExp()); } } @@ -191,6 +192,7 @@ public WhereExpImpl(boolean allowsIndex, Exp exp) { @Override public ParseResult process(String namespace, String querySet, Session session) { + // namespace, querySet, session intentionally ignored - not required in this implementation return new ParseResult(null, exp); } } From f4f2d732a094e573d4d8006cf220e88a9e007963 Mon Sep 17 00:00:00 2001 From: Gagan Mishra Date: Wed, 22 Apr 2026 10:41:24 -0700 Subject: [PATCH 4/5] fix signature after merge --- .../main/java/com/aerospike/client/sdk/BinsValuesBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java b/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java index 1d146801..cf12d0a9 100644 --- a/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java +++ b/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java @@ -968,7 +968,7 @@ private RecordStream executeIndividualAsync() { String namespace = keys.get(0).namespace; Partitions partitions = getPartitions(cluster, namespace); ResolvedSettings policy = session.getBehavior().getSettings(OpKind.WRITE_RETRYABLE, OpShape.POINT, partitions.scMode); - final Expression filterExp = getFilterExp(session, namespace); + final Expression filterExp = getFilterExp(session, namespace, keys.get(0).setName); AsyncRecordStream asyncStream = new AsyncRecordStream(keys.size()); if (txnToUse != null) { From ba9493e82599a5b9a546a1a08dd9477fee4de9ef Mon Sep 17 00:00:00 2001 From: Brian Nichols Date: Wed, 22 Apr 2026 13:52:20 -0400 Subject: [PATCH 5/5] Only call keys.get(0) once in a method. Subsequent calls now use firstKey. --- .../aerospike/client/sdk/BinsValuesBuilder.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java b/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java index cf12d0a9..85e173f3 100644 --- a/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java +++ b/client/src/main/java/com/aerospike/client/sdk/BinsValuesBuilder.java @@ -818,11 +818,11 @@ private BatchCommand prepareBatch() { Cluster cluster = session.getCluster(); // Assume all keys have the same namespace. - String namespace = keys.get(0).namespace; - Partitions partitions = getPartitions(cluster, namespace); + Key firstKey = keys.get(0); + Partitions partitions = getPartitions(cluster, firstKey.namespace); settings = session.getBehavior().getSettings(OpKind.WRITE_RETRYABLE, OpShape.BATCH, partitions.scMode); - final Expression filterExp = getFilterExp(session, namespace, keys.get(0).setName); + final Expression filterExp = getFilterExp(session, firstKey.namespace, firstKey.setName); BatchAttr attr = new BatchAttr(); attr.setWrite(settings, opBuilder.getOpType()); @@ -838,7 +838,7 @@ private BatchCommand prepareBatch() { getGeneration(valueSet), ttl)); } - return new BatchCommand(cluster, partitions, opBuilder.getTxnToUse(), namespace, + return new BatchCommand(cluster, partitions, opBuilder.getTxnToUse(), firstKey.namespace, batchRecords, filterExp, opBuilder.isIncludeMissingKeys(), opBuilder.isFailOnFilteredOut(), false, settings); } @@ -875,7 +875,7 @@ private RecordStream executeIndividualSync(ErrorDisposition disposition) { Partitions partitions = getPartitions(cluster, firstKey.namespace); ResolvedSettings settings = session.getBehavior().getSettings(OpKind.WRITE_RETRYABLE, OpShape.POINT, partitions.scMode); - final Expression filterExp = processWhereClause(keys.get(0).namespace, opBuilder.getSession()); + final Expression filterExp = processWhereClause(firstKey.namespace, opBuilder.getSession()); if (txnToUse != null) { TxnMonitor.addKeys(txnToUse, session, keys); @@ -965,10 +965,10 @@ private RecordStream executeIndividualAsync() { Cluster cluster = session.getCluster(); // Assume all keys have the same namespace. - String namespace = keys.get(0).namespace; - Partitions partitions = getPartitions(cluster, namespace); + Key firstKey = keys.get(0); + Partitions partitions = getPartitions(cluster, firstKey.namespace); ResolvedSettings policy = session.getBehavior().getSettings(OpKind.WRITE_RETRYABLE, OpShape.POINT, partitions.scMode); - final Expression filterExp = getFilterExp(session, namespace, keys.get(0).setName); + final Expression filterExp = getFilterExp(session, firstKey.namespace, firstKey.setName); AsyncRecordStream asyncStream = new AsyncRecordStream(keys.size()); if (txnToUse != null) {