Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ DSLParser parser = new DSLParserImpl();
// Providing list of existing secondary indexes
// At most only one index will be chosen based on its namespace and binValuesRatio (cardinality)
// If cardinality of multiple indexes is the same, the index is chosen alphabetically
// binValuesRatio is mandatory for each Index and must be non-negative
List<Index> indexes = List.of(
Index.builder().namespace("namespace").bin("intBin1").indexType(IndexType.NUMERIC).binValuesRatio(1).build(),
Index.builder().namespace("namespace2").bin("intBin2").indexType(IndexType.NUMERIC).binValuesRatio(1).build()
Expand Down
27 changes: 26 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,33 @@ This class holds the final, concrete outputs of the parsing and substitution pro
A container for the information required for automatic secondary index optimization.

* **`static IndexContext of(String namespace, Collection<Index> indexes)`**: Creates a context.
* `namespace`: The namespace the query will be run against.
* `namespace`: The namespace the query will be run against. Must not be null or blank.
* `indexes`: A collection of `Index` objects representing the available secondary indexes for that namespace.
Comment on lines 50 to 52
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new 3-parameter IndexContext.of method with indexToUse parameter should be documented here. This is a public API addition that allows users to specify an index hint, which is a key feature mentioned in the PR title CLIENT-4226.

Copilot uses AI. Check for mistakes.
* **`static IndexContext of(String namespace, Collection<Index> indexes, String indexToUse)`**:
Creates a context with an explicit index name hint.
* `namespace`: The namespace the query will be run against. Must not be null or blank.
* `indexes`: A collection of `Index` objects representing the available secondary indexes for that namespace.
* `indexToUse`: The name of the index to use for the secondary index filter.
If not found, `null`, or empty, the index is chosen automatically by cardinality
(higher `binValuesRatio` preferred), then alphabetically by bin name.
* **`static IndexContext withBinHint(String namespace, Collection<Index> indexes, String binToUse)`**:
Creates a context with an explicit bin name hint. Use this when you want to direct the parser to
an index on a specific bin without knowing the index name.
* `namespace`: The namespace the query will be run against. Must not be null or blank.
* `indexes`: A collection of `Index` objects representing the available secondary indexes for that namespace.
* `binToUse`: The name of the bin whose index should be used. If exactly one index in the collection
matches the given bin name and namespace, that index is used. If the bin matches multiple indexes,
no index matches, the value is `null`, or the value is blank, the parser falls back to fully automatic
selection (cardinality, then alphabetically).

### `com.aerospike.dsl.Index`

Represents an available secondary index for optimization.

* **Mandatory fields**: `namespace`, `bin`, `indexType`, `binValuesRatio`.
* **`binValuesRatio` validation**:
* Must be explicitly provided.
* Must be non-negative (`>= 0`).

## Example API Flow

Expand Down
79 changes: 74 additions & 5 deletions docs/guides/06-using-secondary-indexes.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,75 @@ The parser then does the following:

> **Note:** Only one secondary index can be used per query. The index will be chosen based on cardinality (preferring indexes with a higher `binValuesRatio`), otherwise alphabetically.

## Choosing a Specific Index (Index Hint)

When multiple indexes could satisfy your query, the parser selects one automatically.
You can override this by providing an explicit hint in two ways:

### Hint by Index Name

If you know the exact name of the index you want to use, pass it as the third argument to
the three-parameter `IndexContext.of` overload:

```java
Index cityIndex = Index.builder()
.namespace("test") // is mandatory
.bin("city") // is mandatory
.indexType(IndexType.STRING) // is mandatory
.binValuesRatio(1) // is mandatory and must be non-negative
.name("idx_users_city")
.build();

Index ageIndex = Index.builder()
.namespace("test")
.bin("age")
.indexType(IndexType.NUMERIC)
.binValuesRatio(10)
.name("idx_users_age")
.build();

// Force use of the city index even though age has higher cardinality
IndexContext indexContext = IndexContext.of("test", List.of(cityIndex, ageIndex), "idx_users_city");
```

If the named index is not found in the collection or does not match the namespace,
the parser falls back to automatic selection (cardinality, then alphabetically).
Passing `null` or an empty string as the third parameter also triggers automatic selection.

### Hint by Bin Name

If you know which bin should be used for the secondary index filter but not the specific index name,
use `IndexContext.withBinHint`:

```java
Index cityIndex = Index.builder()
.namespace("test")
.bin("city")
.indexType(IndexType.STRING)
.binValuesRatio(1)
.name("idx_users_city")
.build();

Index ageIndex = Index.builder()
.namespace("test")
.bin("age")
.indexType(IndexType.NUMERIC)
.binValuesRatio(10)
.name("idx_users_age")
.build();

// Force use of an index on the "city" bin even though age has higher cardinality
IndexContext indexContext = IndexContext.withBinHint("test", List.of(cityIndex, ageIndex), "city");
```

The bin hint behaves as follows:

- If exactly one index in the indexes collection matches the given bin name and namespace, that index is used.
- If the bin name matches multiple indexes (for example, indexes of different types on the same bin),
or if there is no match, the hint is ignored and the parser falls back to fully automatic selection
(by cardinality, then alphabetically).
- Passing `null` or a blank string bin name hint also triggers fallback to automatic selection.

## Usage Example

Let's assume you have a secondary index named `idx_users_city` on the `city` bin in the `users` set.
Expand All @@ -39,17 +108,17 @@ import com.aerospike.dsl.Index;
import com.aerospike.dsl.IndexContext;
import java.util.List;

// Describe the available secondary index
// Describe the available secondary index (namespace, bin, indexType, binValuesRatio are required)
Index cityIndex = Index.builder()
.name("idx_users_city")
.namespace("test")
.bin("city")
.indexType(IndexType.STRING)
.binValuesRatio(1) // Cardinality can be retrieved from the Aerospike DB or set manually
.binValuesRatio(1) // Cardinality from Aerospike sindex-stat or set manually
.name("idx_users_city")
.build();

// Create index context
IndexContext indexContext = IndexContext.of("namespace", List.of(cityIndex));
// Create index context (namespace must not be null or blank)
IndexContext indexContext = IndexContext.of("test", List.of(cityIndex));
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a section to this guide explaining how to use the new index hint feature with the 3-parameter IndexContext.of method. This would help users understand when and how to explicitly specify which secondary index to use, which is the main feature of this PR (CLIENT-4226).

Copilot uses AI. Check for mistakes.
```

### 2. Parse the Expression using IndexContext
Expand Down
45 changes: 42 additions & 3 deletions src/main/java/com/aerospike/dsl/Index.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@

/**
* This class represents a secondary index created in the cluster.
* <p>
* Mandatory fields: {@code namespace}, {@code bin}, {@code indexType}, {@code binValuesRatio}.
* These are validated on build and must not be null/blank (for strings).
* {@code binValuesRatio} must be provided and is validated to ensure it is not negative.
*/
@Builder
@EqualsAndHashCode
Expand All @@ -35,13 +39,48 @@ public class Index {
* Cardinality of the index calculated using "sindex-stat" command and looking at the ratio of entries
* to unique bin values for the given secondary index on the node (entries_per_bval)
*/
private int binValuesRatio;
private final Integer binValuesRatio;
/**
* {@link IndexCollectionType} of the index
*/
private IndexCollectionType indexCollectionType;
private final IndexCollectionType indexCollectionType;
/**
* Array of {@link CTX} representing context of the index
*/
private CTX[] ctx;
private final CTX[] ctx;

public Index(String namespace, String bin, String name, IndexType indexType, Integer binValuesRatio,
IndexCollectionType indexCollectionType, CTX[] ctx) {
validateMandatory(namespace, bin, indexType, binValuesRatio);
this.namespace = namespace;
this.bin = bin;
this.name = name;
this.indexType = indexType;
this.binValuesRatio = binValuesRatio;
this.indexCollectionType = indexCollectionType;
this.ctx = ctx;
}

private static void validateMandatory(String namespace, String bin, IndexType indexType, Integer binValuesRatio) {
requireNonBlank(namespace, "namespace");
requireNonBlank(bin, "bin");
requireNonNull(indexType, "indexType");
requireNonNull(binValuesRatio, "binValuesRatio");
if (binValuesRatio < 0) {
throw new IllegalArgumentException("binValuesRatio must not be negative");
}
}

private static void requireNonBlank(String value, String fieldName) {
requireNonNull(value, fieldName);
if (value.isBlank()) {
throw new IllegalArgumentException(fieldName + " must not be blank");
}
}

private static void requireNonNull(Object value, String fieldName) {
if (value == null) {
throw new IllegalArgumentException(fieldName + " must not be null");
}
}
}
105 changes: 101 additions & 4 deletions src/main/java/com/aerospike/dsl/IndexContext.java
Original file line number Diff line number Diff line change
@@ -1,25 +1,122 @@
package com.aerospike.dsl;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.Collection;
import java.util.List;

/**
* This class stores namespace and indexes required to build secondary index Filter
*/
@AllArgsConstructor(staticName = "of")
@Getter
public class IndexContext {

/**
* Namespace to be used for creating secondary index Filter. Is matched with namespace of indexes
*/
private String namespace;
private final String namespace;
/**
* Collection of {@link Index} objects to be used for creating secondary index Filter.
* Namespace of indexes is matched with the given {@link #namespace}, bin name and index type are matched
* with bins in DSL String
*/
private Collection<Index> indexes;
private final Collection<Index> indexes;

private IndexContext(String namespace, Collection<Index> indexes) {
this.namespace = namespace;
this.indexes = indexes;
}

/**
* Create index context with namespace and indexes.
*
* @param namespace Namespace to be used for creating {@link com.aerospike.dsl.client.query.Filter}.
* Must not be null or blank
* @param indexes Collection of {@link Index} objects to be used for creating Filter
* @return A new instance of {@code IndexContext}
*/
public static IndexContext of(String namespace, Collection<Index> indexes) {
validateNamespace(namespace);
return new IndexContext(namespace, indexes);
}

/**
* Create index context specifying the index to be used
*
* @param namespace Namespace to be used for creating {@link com.aerospike.dsl.client.query.Filter}.
* Must not be null or blank
* @param indexes Collection of {@link Index} objects to be used for creating Filter
* @param indexToUse The name of an index to use. If not found, null, or empty, index is chosen
* by cardinality or alphabetically
* @return A new instance of {@code IndexContext}
*/
public static IndexContext of(String namespace, Collection<Index> indexes, String indexToUse) {
validateNamespace(namespace);
if (indexes == null || indexToUse == null || indexToUse.isBlank()) {
return new IndexContext(namespace, indexes);
}
List<Index> matchingIndexes = indexes.stream()
.filter(idx -> indexMatches(idx, namespace, indexToUse))
.toList();
return new IndexContext(namespace, matchingIndexes.isEmpty() ? indexes : matchingIndexes);
Comment on lines 53 to 61
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IndexContext.of(namespace, indexes, indexToUse) calls indexes.stream() without guarding against indexes == null, which will throw a NPE (while the 2-arg of overload currently tolerates null and the parser treats it as empty). Either reject null indexes explicitly (with a clear IllegalArgumentException) or treat null as an empty collection here to keep overload behavior consistent.

Copilot uses AI. Check for mistakes.
}

/**
* Create index context with a bin name hint specifying which bin's index to use.
* If exactly one index in the collection matches the given bin name and namespace,
* that index is used. Otherwise, all indexes are kept and selection falls back to
* the automatic cardinality / alphabetical strategy.
*
* @param namespace Namespace to be used for creating {@link com.aerospike.dsl.client.query.Filter}.
* Must not be null or blank
* @param indexes Collection of {@link Index} objects to be used for creating Filter
* @param binToUse The name of the bin whose index should be used. If not found, null,
* blank, or matches multiple indexes, index is chosen automatically
* @return A new instance of {@code IndexContext}
*/
public static IndexContext withBinHint(String namespace, Collection<Index> indexes, String binToUse) {
validateNamespace(namespace);
if (indexes == null || binToUse == null || binToUse.isBlank()) {
return new IndexContext(namespace, indexes);
}
List<Index> matchingIndexes = indexes.stream()
.filter(idx -> binMatches(idx, namespace, binToUse))
.toList();
return new IndexContext(namespace, matchingIndexes.size() == 1 ? matchingIndexes : indexes);
Comment on lines 77 to 85
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IndexContext.withBinHint(...) calls indexes.stream() without guarding against indexes == null, which will throw a NPE. Consider either validating indexes is non-null (and failing fast with IllegalArgumentException) or treating null as an empty collection, consistent with how the parser treats a null index collection.

Copilot uses AI. Check for mistakes.
}

private static void validateNamespace(String namespace) {
if (namespace == null) {
throw new IllegalArgumentException("namespace must not be null");
}
if (namespace.isBlank()) {
throw new IllegalArgumentException("namespace must not be blank");
}
}

private static boolean binMatches(Index idx, String namespace, String binToUse) {
if (idx == null || binToUse == null) {
return false;
}

String binName = idx.getBin();
if (binName == null || !binName.equals(binToUse)) {
return false;
}

return namespace.equals(idx.getNamespace());
}

private static boolean indexMatches(Index idx, String namespace, String indexToUse) {
if (idx == null || indexToUse == null) {
return false;
}

String indexName = idx.getName();
if (indexName == null || !indexName.equals(indexToUse)) {
return false;
}

return namespace.equals(idx.getNamespace());
}
Comment on lines +110 to +121
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indexMatches treats only null as “no hint”. The docs/Javadoc for IndexContext.of(..., indexToUse) state that an empty hint should trigger automatic selection, but passing "" can still match an index whose name is also "" (since Index.name isn’t validated). Consider ignoring blank/empty hints (indexToUse == null || indexToUse.isBlank()) so behavior matches the documented contract.

Copilot uses AI. Check for mistakes.
}
4 changes: 1 addition & 3 deletions src/main/java/com/aerospike/dsl/impl/DSLParserImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,7 @@ private ParsedExpression getParsedExpression(ParseTree parseTree, PlaceholderVal
.orElse(Collections.emptyList());

Map<String, List<Index>> indexesMap = indexes.stream()
// Filtering the indexes with the given namespace
.filter(idx -> idx.getNamespace() != null && idx.getNamespace().equals(namespace))
// Group the indexes by bin name
.filter(idx -> namespace != null && namespace.equals(idx.getNamespace()))
.collect(Collectors.groupingBy(Index::getBin));

AbstractPart resultingPart = new ExpressionConditionVisitor().visit(parseTree);
Expand Down
Loading
Loading