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..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,15 +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); - Map> indexesMap = buildIndexesMap( - Optional.ofNullable(indexContext).map(IndexContext::getIndexes).orElse(null), namespace); - 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); @@ -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 3fe763b6..278becc2 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 972b448c..1db47093 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 cdded80e..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); + 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); + final Expression filterExp = getFilterExp(session, firstKey.namespace, firstKey.setName); AsyncRecordStream asyncStream = new AsyncRecordStream(keys.size()); if (txnToUse != null) { @@ -1054,10 +1054,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 dda80c73..57a1aba1 100644 --- a/client/src/main/java/com/aerospike/client/sdk/ObjectBuilder.java +++ b/client/src/main/java/com/aerospike/client/sdk/ObjectBuilder.java @@ -1005,7 +1005,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); @@ -1054,7 +1054,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) { @@ -1125,7 +1125,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(); @@ -1158,7 +1158,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. @@ -1200,7 +1200,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(); @@ -1285,9 +1285,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..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 @@ -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,8 @@ public WhereBoolExprImpl(boolean allowsIndex, BooleanExpression ael) { } @Override - public ParseResult process(String namespace, Session session) { + 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()); } } @@ -180,7 +191,8 @@ public WhereExpImpl(boolean allowsIndex, Exp exp) { } @Override - public ParseResult process(String namespace, Session session) { + public ParseResult process(String namespace, String querySet, Session session) { + // namespace, querySet, session intentionally ignored - not required in this implementation return new ParseResult(null, exp); } } @@ -200,4 +212,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)); + } +}