Spring Data Aerospike - Documentation

© 2018-2026 The original authors.

Note
Copies of this document may be made for your own use and for distribution to others, provided that you do not charge any fee for such copies and further provided that each copy contains this Copyright Notice, whether distributed in print or electronically.

Preface

The Spring Data Aerospike project applies core Spring concepts and provides interface for using Aerospike key-value style data store. We provide a "repository" and a "template" as high-level abstractions for storing and querying data. You will notice similarities to the JDBC support in the Spring Framework.

This chapter provides some basic introduction to Spring and Aerospike, it explains Aerospike concepts and syntax. The rest of the documentation refers to Spring Data Aerospike features and assumes the user is familiar with Aerospike as well as Spring concepts.

Knowing Spring

Spring Data uses Spring framework’s core functionality, such as the IoC container, type conversion system, DAO exception hierarchy etc. While it is not important to know the Spring APIs, understanding the concepts behind them is. At a minimum, the idea behind IoC should be familiar regardless of IoC container you choose to use.

To learn more about Spring, you can refer to the comprehensive documentation that explains in detail the Spring Framework. There are a lot of articles, blog entries and books on the matter - take a look at the Spring framework documentation reference for more information.

Knowing NoSQL and Aerospike

NoSQL stores have taken the storage world by storm. It is a vast domain with a plethora of solutions, terms and patterns (to make things worthwhile even the term itself has multiple meanings). While some principles are common, it is crucial that the user is familiar to some degree with Aerospike key-value store operations that supply the mechanism for associating keys with a set of named values, similar to a row in standard RDBMS terminology. The data layer in Aerospike Database is optimized to store data in solid state drives, RAM, or traditional rotational media. The database indices are stored in RAM for quick availability, and data writes are optimized through large block writes to reduce latency. The software also employs two sub-programs that are codenamed Defragmenter and Evictor. Defragmenter removes data blocks that have been deleted, and Evictor frees RAM space by removing references to expired records.

The jumping off ground for learning about Aerospike is www.aerospike.com. Here is a list of other useful resources:

Requirements

Spring Data Aerospike binaries require JDK level 17.0 and above.

In terms of server, it is required to use at least Aerospike server version 6.1 (recommended to use the latest version when possible).

Additional Help Resources

Learning a new framework is not always straightforward. In this section, we try to provide what we think is an easy-to-follow guide for starting with Spring Data Aerospike module. However, if you encounter issues, or you are just looking for advice, feel free to use one of the links below:

Support

There are a few support options available:

Questions & Answers

Developers post questions and answers on Stack Overflow. The two key tags to search for related answers to this project are:

Following Development

If you encounter a bug or want to suggest an improvement, please create an issue on GitHub.

Reference documentation

Functionality

Spring Data Aerospike project aims to provide a familiar and consistent Spring-based programming model providing integration with the Aerospike database.

Spring Data Aerospike supports a wide range of features summarized below:

  • Supporting Repository interfaces (out-of-the-box CRUD operations and query implementations, for more information see Aerospike Repositories)

  • AerospikeTemplate for lower-level access to common Aerospike operations and fine-tuning (for more information see AerospikeTemplate)

  • Feature Rich Object Mapping integrated with Spring’s Conversion Service

  • Translating exceptions into Spring’s Data Access Exception hierarchy

  • Annotation-based metadata mapping

  • Ability to directly utilize Aerospike Java client functionality

Installation & Usage

Getting Started

First, you need a running Aerospike server to connect to.

To use Spring Data Aerospike you can either set up Spring Boot or Spring application. Basic setup of Spring Boot application is described here: https://projects.spring.io/spring-boot.

In case you do not want to use Spring Boot, the best way to manage Spring dependencies is to declare spring-framework-bom of the needed version in the dependencyManagement section of your pom.xml:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-framework-bom</artifactId>
            <version>${spring-data-aerospike.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
Note
To create a Spring project in STS (Spring Tool Suite) go to File → New → Spring Template Project → Simple Spring Utility Project → press "Yes" when prompted. Then enter a project and a package name such as org.spring.aerospike.example.

Adding Dependency

The first step is to add Spring Data Aerospike to your build process. It is recommended to use the latest version which can be found on the GitHub Releases page.

Adding Spring Data Aerospike dependency in Maven:

<dependency>
    <groupId>com.aerospike</groupId>
    <artifactId>spring-data-aerospike</artifactId>
    <version>${spring-data-aerospike.version}</version>
</dependency>

Adding Spring Data Aerospike dependency in Gradle:

implementation group: 'com.aerospike', name: 'spring-data-aerospike', version: '${spring-data-aerospike.version}'

Connecting to Aerospike DB

There are two ways of configuring a basic connection to Aerospike DB:

  • Overriding getHosts() and nameSpace() methods via the AbstractAerospikeDataConfiguration class:

@Configuration
@EnableAerospikeRepositories(basePackageClasses = { PersonRepository.class})
public class AerospikeConfiguration extends AbstractAerospikeDataConfiguration {

    @Override
    protected Collection<Host> getHosts() {
        return Collections.singleton(new Host("localhost", 3000));
    }

    @Override
    protected String nameSpace() {
        return "test";
    }
}

When setting the configuration this way, you can also optionally override configureDataSettings() method which allows to set non-mandatory properties.

@Configuration
@EnableAerospikeRepositories(basePackageClasses = { PersonRepository.class})
public class AerospikeConfiguration extends AbstractAerospikeDataConfiguration {

    @Override
    protected Collection<Host> getHosts() {
        return Collections.singleton(new Host("localhost", 3000));
    }

    @Override
    protected String nameSpace() {
        return "test";
    }

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setScansEnabled(true);
    }
}
  • Using application.properties:

Basic configuration in this case requires enabling repositories and then setting hosts and namespace in the application.properties file.

@Configuration
@EnableAerospikeRepositories(basePackageClasses = { PersonRepository.class})
public class AerospikeConfiguration extends AbstractAerospikeDataConfiguration {

}

In application.properties:

# application.properties
spring.aerospike.hosts=localhost:3000
spring.data.aerospike.namespace=test
Note
Return values of getHosts(), nameSpace() and configureDataSettings() methods have precedence over hosts and namespace parameters set via application.properties.

For more detailed information see Configuration.

Runnable framework-owned reference examples live in examples/. They compile against the current framework sources through the opt-in examples Maven profile and can be run with commands such as ./examples/run_examples blocking-crud --hosts localhost:3000 --namespace test (requires Maven version >= 3.9.0).

Creating Functionality

The base functionality is provided by AerospikeRepository interface.

It typically takes 2 parameters:

  1. The type managed by a class (it is typically entity class) to be stored in the database.

  2. The type of ID.

Application code typically extends this interface for each of the types to be managed, and methods can be added to the interface to determine how the application can access the data. For example, consider a class Person with a simple structure:

@AllArgsConstructor
@NoArgsConstructor
@Data
@Document
public class Person {
    @Id
    private long id;
    private String firstName;
    private String lastName;
    @Field("dob")
    private Date dateOfBirth;
}

Note that this example uses the Project Lombok annotations to remove the need for explicit constructors and getters and setters. Normal POJOs which define these on their own can ignore the @AllArgsConstructor, @NoArgsConstructor and @Data annotations. The @Document annotation tells Spring Data Aerospike that this is a domain object to be persisted in the database, and @Id identifies the primary key of this class. The @Field annotation is used to create a shorter name for the bin in the Aerospike database (dateOfBirth will be stored in a bin called dob in this example).

For the Person object to be persisted to Aerospike, you must create an interface with the desired methods for retrieving data. For example:

public interface PersonRepository extends AerospikeRepository<Person, Long> {
    List<Person> findByLastName(String lastName);
}

This defines a repository that can write Person entities and also query them by last name. The AerospikeRepository extends both PagingAndSortingRepository and CrudRepository, so methods like count(), findById(), save() and delete() are there by default. Those who need reactive flow can use ReactiveAerospikeRepository instead.

Note
Repository is just an interface and not an actual class. In the background, when your context gets initialized, actual implementations for your repository descriptions get created, and you can access them through regular beans. This means you will omit lots of boilerplate code while still exposing full CRUD semantics to your service layer and application.

For copyable source that demonstrates the same repository flow, see BlockingRepositoryCrudExample. Projection-specific repository source is available in ProjectionExample.

Example repository is ready for use. A sample Spring Controller which uses this repository could be the following:

@RestController
public class ApplicationController {
    @Autowired
    private PersonRepository personRepsitory;

    @GetMapping("/seed")
    public int seedData() {
        Person person = new Person(1, "Bob", "Jones", new GregorianCalendar(1971, 12, 19).getTime());
        personRepsitory.save(person);
        return 1;
    }

    @GetMapping("/findByLastName/{lastName}")
    public List<Person> findByLastName(@PathVariable(name = "lastName", required=true) String lastName) {
        return personRepsitory.findByLastName(lastName);
    }
}

Invoking the seed method above gives you a record in the Aerospike database which looks like:

aql> select * from test.Person where pk = "1"
+-----+-----------+----------+-------------+-------------------------------------+
| PK  | firstName | lastName | dob         | @_class                             |
+-----+-----------+----------+-------------+-------------------------------------+
| "1" | "Bob"     | "Jones"  | 64652400000 | "com.aerospike.sample.model.Person" |
+-----+-----------+----------+-------------+-------------------------------------+
1 row in set (0.001 secs)
Note
The fully qualified path of the class is listed in each record. This is needed to instantiate the class correctly, especially in cases when the compile-time type and runtime type of the object differ. For example, where a field is declared as a super class but the instantiated class is a subclass.
Note
By default, the type of the field annotated with @id is turned into a String to be stored in Aerospike database. If the original type cannot be persisted (see keepOriginalKeyTypes for details), it must be convertible to String and will be stored in the database as such, then converted back to the original type when the object is read. This is transparent to the application but needs to be considered if using external tools like AQL to view the data.

Configuration

Configuration can be applied using one of these ways:

  • Via parameters with spring.aerospike* and spring.data.aerospike* prefixes in application.properties file, and using configuration class to enable repositories.

  • By overriding methods getHosts(), nameSpace(), configureDataSettings() and enabling repositories in a configuration class. These methods have precedence over parameters set in application.properties.

Application.properties and Enabling Repositories

Here is an example of this approach:

application.properties
# Aerospike
spring.aerospike.hosts=localhost:3000
### Auth (if required)
# spring.aerospike.user=user
# spring.aerospike.password=password
spring.data.aerospike.namespace=test
spring.data.aerospike.scans-enabled=false
spring.data.aerospike.send-key=true
spring.data.aerospike.create-indexes-on-startup=true
spring.data.aerospike.index-cache-refresh-seconds=3600
spring.data.aerospike.server-version-refresh-seconds=3600
spring.data.aerospike.query-max-records=10000
spring.data.aerospike.batch-write-size=100
spring.data.aerospike.keep-original-key-types=false
Configuration class
@Configuration
@EnableAerospikeRepositories(basePackageClasses = {TestRepository.class})
public class AerospikeConfiguration extends AbstractAerospikeDataConfiguration {

}
Note
Depending on the use case it might be required to override optional configuration methods like customConverters() or getClientPolicy().

Overriding Configuration Methods

Configuration can also be set by overriding getHosts(), nameSpace() and configureDataSettings() methods.

Here is an example:

@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected Collection<Host> getHosts() {
        return Collections.singleton(new Host("localhost", 3000));
    }

    @Override
    protected String nameSpace() {
        return "test";
    }

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setScansEnabled(false);
        aerospikeDataSettings.setCreateIndexesOnStartup(true);
        aerospikeDataSettings.setIndexCacheRefreshSeconds(3600);
        aerospikeDataSettings.setServerVersionRefreshSeconds(3600);
        aerospikeDataSettings.setQueryMaxRecords(10000L);
        aerospikeDataSettings.setBatchWriteSize(100);
        aerospikeDataSettings.setKeepOriginalKeyTypes(false);
    }
}
Note
Return values of getHosts(), nameSpace() and configureDataSettings() methods have precedence over the parameters set via application.properties.

Depending on the use case it might be required to override other configuration methods like customConverters() or getClientPolicy().

Configuration Parameters

hosts

# application.properties
spring.aerospike.hosts=hostname1:3001, hostname2:tlsName2:3002

A String of hosts separated by , in form of hostname1[:tlsName1][:port1],…​

IP addresses must be given in one of the following formats:

IPv4: xxx.xxx.xxx.xxx
IPv6: [xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx]
IPv6: [xxxx::xxxx]

IPv6 addresses must be enclosed by brackets. tlsName is optional.

Note
Another way of defining hosts is overriding the getHosts() method. It has precedence over spring.aerospike.hosts parameter from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected Collection<Host> getHosts() {
        return Collections.singleton(new Host("hostname1", 3001));
    }
}

Default: localhost:3000.

user

# application.properties
spring.aerospike.user=user

Specifies the username used to authenticate against the Aerospike database cluster. This property is optional and only needed when the Aerospike cluster requires authentication.

Note
Another way of defining username is overriding the getClientPolicy() method. It has precedence over spring.aerospike.user parameter from application.properties.

See ClientPolicy for more details.

password

# application.properties
spring.aerospike.password=password

Defines the password associated with the configured Aerospike user. Like the username, this property is optional and only needed when the Aerospike cluster requires authentication.

Note
Another way of defining password is overriding the getClientPolicy() method. It has precedence over spring.aerospike.password parameter from application.properties.

See ClientPolicy for more details.

namespace

# application.properties
spring.data.aerospike.namespace=test

Aerospike DB namespace.

Note
Another way of defining namespace is overriding the nameSpace() method. It has precedence over spring.data.aerospike.namespace parameter from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected String nameSpace() {
        return "test";
    }
}
Note
To use multiple namespaces it is required to override nameSpace() and AerospikeTemplate for each configuration class per namespace. See multiple namespaces example for implementation details.

Default: test.

scansEnabled

# application.properties
spring.data.aerospike.scans-enabled=false

Whether to enable scan operations.

Due to the cost of performing this operation, scans from Spring Data Aerospike are disabled by default.

Note
Another way of defining the parameter is overriding the configureDataSettings() method. It has precedence over reading from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setScansEnabled(false);
    }
}
Note
Once this flag is enabled, scans run whenever needed with no warnings. This may or may not be optimal in a particular use case.

Default: false.

createIndexesOnStartup

# application.properties
spring.data.aerospike.create-indexes-on-startup=true

Create secondary indexes specified using @Indexed annotation on startup.

Note
Another way of defining the parameter is overriding the configureDataSettings() method. It has precedence over reading from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setCreateIndexesOnStartup(true);
    }
}

Default: true.

indexCacheRefreshSeconds

# application.properties
spring.data.aerospike.index-cache-refresh-seconds=3600

Automatically refresh indexes cache every <N> seconds.

Note
Another way of defining the parameter is overriding the configureDataSettings() method. It has precedence over reading from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setIndexCacheRefreshSeconds(3600);
    }
}

Default: 3600.

serverVersionRefreshSeconds

# application.properties
spring.data.aerospike.server-version-refresh-seconds=3600

Automatically refresh cached server version every <N> seconds.

Note
Another way of defining the parameter is overriding the configureDataSettings() method. It has precedence over reading from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setServerVersionRefreshSeconds(3600);
    }
}

Default: 3600.

queryMaxRecords

# application.properties
spring.data.aerospike.query-max-records=10000

Limit amount of results returned by server. Non-positive value means no limit.

Note
Another way of defining the parameter is overriding the configureDataSettings() method. It has precedence over reading from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setQueryMaxRecords(10000L);
    }
}

Default: 10 000.

batchReadSize

# application.properties
spring.data.aerospike.batch-read-size=100

Maximal batch size for batch read operations. Query data larger than the specified batch-read-size will be firstly chunked into segments. Non-positive value of the batch-read-size parameter means no limit.

Note
Another way of defining the parameter is overriding the configureDataSettings() method. It has precedence over reading from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setBatchReadSize(1000);
    }
}

Default: 100.

batchWriteSize

# application.properties
spring.data.aerospike.batch-write-size=100

Maximal batch size for batch write operations. Query data larger than the specified batch-write-size will be firstly chunked into segments. Non-positive value of the batch-write-size parameter means no limit.

Note
Another way of defining the parameter is overriding the configureDataSettings() method. It has precedence over reading from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setBatchWriteSize(1000);
    }
}

Default: 100.

keepOriginalKeyTypes

# application.properties
spring.data.aerospike.keep-original-key-types=false

Define how @Id fields (primary keys) and Map keys are stored in the Aerospike database: false - always as String, true - preserve original type if supported.

@Id field type keepOriginalKeyTypes = false keepOriginalKeyTypes = true

long

String

long

int

String

long

String

String

String

byte[]

String

byte[]

other types

String

String

Note
If @Id field’s type cannot be persisted as is, it must be convertible to String and will be stored in the database as such, then converted back to the original type when the object is read. This is transparent to the application but needs to be considered if using external tools like AQL to view the data.
Map key type keepOriginalKeyTypes = false keepOriginalKeyTypes = true

long

String

long

int

String

long

double

String

double

String

String

String

byte[]

String

byte[]

other types

String

String

Note
Another way of defining the parameter is overriding the configureDataSettings() method. It has precedence over reading from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setKeepOriginalKeyTypes(false);
    }
}

Default: false (store keys only as String).

writeSortedMaps

# application.properties
spring.data.aerospike.writeSortedMaps=true

Define how Maps and POJOs are written: true - as sorted maps (TreeMap, default), false - as unsorted (HashMap).

Writing as unsorted maps (false) degrades performance of Map-related operations and does not allow comparing Maps, so it is strongly recommended to change the default value only if required during upgrade from older versions of Spring Data Aerospike.

Note
Another way of defining the parameter is overriding the configureDataSettings() method. It has precedence over reading from application.properties. Here is an example:
// overriding method
@EnableAerospikeRepositories(basePackageClasses = TestRepository.class)
class ApplicationConfig extends AbstractAerospikeDataConfiguration {

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setWriteSortedMaps(true);
    }
}

Default: true (write Maps and POJOs as sorted maps).

ClientPolicy

To set the necessary ClientPolicy parameters of the underlying Java client, override the getClientPolicy() method and call super.getClientPolicy() to apply default values first.

Here is an example with several parameters set for running a test:

public class MyConfiguration extends AbstractAerospikeDataConfiguration {

    @Override
    protected ClientPolicy getClientPolicy() {
        ClientPolicy clientPolicy = super.getClientPolicy(); // applying default values first
        int totalTimeout = 2000;
        clientPolicy.readPolicyDefault.totalTimeout = totalTimeout;
        clientPolicy.writePolicyDefault.totalTimeout = totalTimeout;
        clientPolicy.batchPolicyDefault.totalTimeout = totalTimeout;
        clientPolicy.infoPolicyDefault.timeout = totalTimeout;
        clientPolicy.readPolicyDefault.maxRetries = 3;
        // optionally set user authentication properties
        // clientPolicy.setUser("user");
        // clientPolicy.setPassword("password");
        return clientPolicy;
    }
}

TLS Configuration

If a TLS connection is required, initialize new TlsPolicy for the underlying Java client.

Here is an example:

public class MyConfiguration extends AbstractAerospikeDataConfiguration {

    @Override
    protected ClientPolicy getClientPolicy() {
        ClientPolicy clientPolicy = super.getClientPolicy(); // applying default values first
        clientPolicy.tlsPolicy = new TlsPolicy();
        return clientPolicy;
    }
}
Note
For running with TLS, an application must have the appropriate certificate. When using default TlsPolicy with the regular TLS it is typically required to provide the certificate via JVM argument -Djavax.net.ssl.trustStore. For details on configuring TLS see TLS Configuration, Aerospike TLS Example.

Aerospike Repositories

Introduction

One of the main goals of the Spring Data is to significantly reduce the amount of boilerplate code required to implement data access layers for various persistence stores.

One of the core interfaces of Spring Data is Repository. This interface acts primarily to capture the types to work with and to help user to discover interfaces that extend Repository.

In other words, it allows user to have basic and complicated queries without writing the implementation. This builds on the Core Spring Data Repository Support, so make sure you’ve got a sound understanding of this concept.

Usage

To access entities stored in Aerospike you can leverage repository support that eases implementing those quite significantly. To do so, define a mapped entity and a repository interface.

Example 1. Sample movie entity
@Document(collection = "sda_examples_blocking_movies")
public class MovieDocument {

    @Id
    private String id;
    private String title;
    private int releaseYear;
    private double rating;

    public MovieDocument() {
    }

    public MovieDocument(String id, String title, int releaseYear, double rating) {
        this.id = id;
        this.title = title;
        this.releaseYear = releaseYear;
        this.rating = rating;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public int getReleaseYear() {
        return releaseYear;
    }

    public void setReleaseYear(int releaseYear) {
        this.releaseYear = releaseYear;
    }

    public double getRating() {
        return rating;
    }

    public void setRating(double rating) {
        this.rating = rating;
    }
}

We have a quite simple domain object here. The default serialization mechanism used in AerospikeTemplate (which is backing the repository support) regards properties named id as document id. Currently we support String and long as id-types.

Example 2. Basic repository interface to persist movie entities
public interface MovieRepository extends AerospikeRepository<MovieDocument, String> {
}

Right now this interface simply serves typing purposes, but we will add additional methods to it later.

Runnable framework-owned source examples are available for blocking repository CRUD, indexed derived queries, @Indexed startup index creation, and declared DSL queries. They can be run from the repository with ./examples/run_examples all --hosts localhost:3000 --namespace test.

We are going to use the @EnableAerospikeRepositories annotation. If no base package is configured the infrastructure will scan the package of the annotated configuration class.

Example 3. JavaConfig for repositories
@Configuration(proxyBeanMethods = false)
// examples-application.properties is repo-specific; user applications can utilize application.properties.
@PropertySource("classpath:examples-application.properties")
@EnableAerospikeRepositories(basePackageClasses = MovieRepository.class)
// Enables the blocking repository proxy used by the CRUD example bean.
public class AerospikeConfiguration extends AbstractAerospikeDataConfiguration {

    @Bean
    BlockingRepositoryCrudExample blockingRepositoryCrudExample(MovieRepository repository) {
        return new BlockingRepositoryCrudExample(repository);
    }

    @Override
    protected String getMappingBasePackage() {
        return MovieDocument.class.getPackageName();
    }

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setCreateIndexesOnStartup(false);
        aerospikeDataSettings.setScansEnabled(true);
    }
}

MovieRepository extends AerospikeRepository, which itself extends PagingAndSortingRepository and CrudRepository. That gives repository instances inherited CRUD operations as well as paginated and sorted access methods. For source-backed pagination and sorting examples, see Pagination and Sorting in Spring Data Aerospike.

Query methods

Most of the data access operations you usually trigger on a repository result in a query being executed against the Aerospike databases. Defining such a query is just a matter of declaring a method on the repository interface

Example 4. Repository with query methods
public interface QueryMethodsMovieRepository extends AerospikeRepository<QueryMethodsMovieDocument, String> {

    List<QueryMethodsMovieDocument> findByGenre(String genre);

    List<QueryMethodsMovieDocument> findByReleaseYearBetween(int fromInclusive, int toInclusive);

    List<QueryMethodsMovieDocument> findByIdAndGenre(QueryParam ids, QueryParam genre);

    boolean existsByGenre(String genre);

    long countByReleaseYearBetween(int fromInclusive, int toInclusive);

    void deleteByGenre(String genre);
}

Examples

The following snippets are taken from the runnable blocking-crud example.

Save and read one entity
// save(...) writes the entity to the Aerospike set declared by @Document
MovieDocument saved = repository.save(movie);

// findById(...) loads the saved record by its @Id value
MovieDocument loaded = repository.findById(saved.getId())
    .orElseThrow(() -> new IllegalStateException("Saved movie was not found"));

String loadedTitle = loaded.getTitle();

// existsById(...) checks whether a record is present without loading the whole entity
boolean savedMovieExists = repository.existsById(saved.getId());
Save and read several entities
List<MovieDocument> movies = List.of(
    new MovieDocument("blocking-crud-2", "The Conversation", 1974, 7.8),
    new MovieDocument("blocking-crud-3", "The Third Man", 1949, 8.1),
    new MovieDocument("blocking-crud-4", "The Long Goodbye", 1973, 7.5)
);

// saveAll(...) persists a batch of entities and returns the saved instances
List<MovieDocument> savedMovies = toSortedList(repository.saveAll(movies),
    Comparator.comparing(MovieDocument::getId));
require(savedMovies.size() == 3, "Expected three saved movies");

// findAllById(...) loads several known ids without relying on findAll() ordering
List<MovieDocument> selectedMovies = toSortedList(
    repository.findAllById(List.of("blocking-crud-2", "blocking-crud-4")),
    Comparator.comparing(MovieDocument::getId));
Count and read all entities
// count() sees all records currently owned by this example's set
long count = repository.count();

// findAll() is intentionally sorted in memory because Aerospike does not guarantee scan ordering
List<MovieDocument> allMovies = toSortedList(repository.findAll(), Comparator.comparing(MovieDocument::getId));
Delete entities
MovieDocument deleteByEntity = new MovieDocument("blocking-crud-delete-entity", "Thief", 1981, 7.4);
MovieDocument deleteById = new MovieDocument("blocking-crud-delete-id", "Ronin", 1998, 7.2);
MovieDocument deleteByIdsOne = new MovieDocument("blocking-crud-delete-ids-1", "Charade", 1963, 7.9);
MovieDocument deleteByIdsTwo = new MovieDocument("blocking-crud-delete-ids-2", "Klute", 1971, 7.1);

repository.saveAll(List.of(deleteByEntity, deleteById, deleteByIdsOne, deleteByIdsTwo));

// delete(entity) removes the record represented by the entity instance
repository.delete(deleteByEntity);

// deleteById(...) removes one record by its id
repository.deleteById(deleteById.getId());

// deleteAllById(...) removes a batch of records by id
repository.deleteAllById(List.of(deleteByIdsOne.getId(), deleteByIdsTwo.getId()));

// deleteAll() clears the remaining records in this example set
repository.deleteAll();

Reactive Aerospike Repositories

Introduction

This chapter will point out the specialties for reactive repository support for Aerospike. This builds on the core repository support explained in repositories. So make sure you’ve got a sound understanding of the basic concepts explained there.

Reactive Composition Libraries

The reactive space offers various reactive composition libraries. The most common library is Project Reactor.

Spring Data Aerospike is built on top of the Aerospike Reactor Java Client Library. To provide maximal interoperability it relies on the Reactive Streams initiative. Static APIs, such as ReactiveAerospikeOperations, are provided by using Project Reactor’s Flux and Mono types. Project Reactor offers various adapters to convert reactive wrapper types (Flux to Observable and vice versa).

Spring Data’s Repository abstraction is a dynamic API, mostly defined by you and your requirements as you declare query methods. Reactive Aerospike repositories can be implemented by using Project Reactor wrapper types by extending from the following library-specific repository interface:

  • ReactiveAerospikeRepository

Usage

To access domain entities stored in an Aerospike you can use our sophisticated repository support that eases implementing those quite significantly. To do so, create an interface similar to your repository. Before you can do that, though, you need an entity, such as the entity defined in the following example:

Example 5. Sample reactive movie entity
@Document(collection = "sda_examples_reactive_movies")
public class ReactiveMovieDocument {

    @Id
    private String id;
    private String title;
    private int releaseYear;
    private double rating;

    public ReactiveMovieDocument() {
    }

    public ReactiveMovieDocument(String id, String title, int releaseYear, double rating) {
        this.id = id;
        this.title = title;
        this.releaseYear = releaseYear;
        this.rating = rating;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public int getReleaseYear() {
        return releaseYear;
    }

    public void setReleaseYear(int releaseYear) {
        this.releaseYear = releaseYear;
    }

    public double getRating() {
        return rating;
    }

    public void setRating(double rating) {
        this.rating = rating;
    }
}

We have a quite simple domain object here. The default serialization mechanism used in ReactiveAerospikeTemplate (which is backing the repository support) regards properties named id as document ID. Currently, we support String and long as id-types. The following example shows how to create an interface that defines queries against the ReactiveMovieDocument object from the preceding example:

Example 6. Basic repository interface to persist reactive movie entities
public interface ReactiveMovieRepository extends ReactiveAerospikeRepository<ReactiveMovieDocument, String> {
}

Right now this interface simply serves typing purposes but we will add additional methods to it later.

For a runnable source-backed example of reactive repository CRUD operations, see ReactiveRepositoryCrudExample. It can be run from the repository with ./examples/run_examples reactive-crud --hosts localhost:3000 --namespace test.

For Java configuration, use the @EnableReactiveAerospikeRepositories annotation. The annotation carries the base packages attribute. These base packages are to be scanned for interfaces extending ReactiveAerospikeRepository and create Spring beans for each of them found. If no base package is configured, the infrastructure scans the package of the annotated configuration class.

The following listing shows how to use Java configuration for a repository:

Example 7. Java configuration for repositories
@Configuration(proxyBeanMethods = false)
// examples-application.properties is repo-specific; user applications can utilize application.properties.
@PropertySource("classpath:examples-application.properties")
@EnableReactiveAerospikeRepositories(basePackageClasses = ReactiveMovieRepository.class)
// Enables the reactive repository proxy used by the CRUD example bean.
public class ReactiveAerospikeConfiguration extends AbstractReactiveAerospikeDataConfiguration {

    @Bean
    ReactiveRepositoryCrudExample reactiveRepositoryCrudExample(ReactiveMovieRepository repository) {
        return new ReactiveRepositoryCrudExample(repository);
    }

    @Override
    protected String getMappingBasePackage() {
        return ReactiveMovieDocument.class.getPackageName();
    }

    @Override
    protected void configureDataSettings(AerospikeDataSettings aerospikeDataSettings) {
        aerospikeDataSettings.setCreateIndexesOnStartup(false);
        aerospikeDataSettings.setScansEnabled(true);
    }
}

As our domain repository extends ReactiveAerospikeRepository it provides CRUD operations that return Reactor publishers. The source-backed examples below show repository usage with Mono and Flux return values.

Query methods

Most of the data access operations you usually trigger on a repository result in a query being executed against the Aerospike databases. Defining such a query is just a matter of declaring a method on the repository interface

Example 8. Reactive repository with query methods
public interface ReactiveQueryMethodsMovieRepository
    extends ReactiveAerospikeRepository<ReactiveQueryMethodsMovieDocument, String> {

    Flux<ReactiveQueryMethodsMovieDocument> findByGenre(String genre);

    Flux<ReactiveQueryMethodsMovieDocument> findByReleaseYearBetween(int fromInclusive, int toInclusive);

    Flux<ReactiveQueryMethodsMovieDocument> findByIdAndGenre(QueryParam ids, QueryParam genre);

    Mono<Boolean> existsByGenre(String genre);

    Mono<Long> countByReleaseYearBetween(int fromInclusive, int toInclusive);

    Mono<Void> deleteByGenre(String genre);
}

Examples

The following snippets are taken from the runnable reactive-crud example.

Save and read one entity
// save(...) returns a Mono; block() is used here only to keep the example sequential
ReactiveMovieDocument saved = requireValue(repository.save(movie).block(), "Saved movie should be emitted");

// findById(...) emits the saved record by its @Id value
ReactiveMovieDocument loaded = repository.findById(saved.getId())
    .blockOptional()
    .orElseThrow(() -> new IllegalStateException("Saved movie was not found"));

String loadedTitle = loaded.getTitle();

// existsById(...) checks presence and returns the result asynchronously
Boolean savedMovieExists = repository.existsById(saved.getId()).block();
Save and read several entities
List<ReactiveMovieDocument> movies = List.of(
    new ReactiveMovieDocument("reactive-crud-2", "The Vast of Night", 2019, 6.7),
    new ReactiveMovieDocument("reactive-crud-3", "Moon", 2009, 7.8),
    new ReactiveMovieDocument("reactive-crud-4", "Gattaca", 1997, 7.8)
);

// saveAll(Publisher) persists a stream of entities and emits the saved instances
List<ReactiveMovieDocument> savedMovies = toSortedList(repository.saveAll(Flux.fromIterable(movies))
    .collectList()
    .block(), Comparator.comparing(ReactiveMovieDocument::getId), "Expected a list of movies");
require(savedMovies.size() == 3, "Expected three saved movies");

// findAllById(Publisher) demonstrates the reactive id-stream overload
List<ReactiveMovieDocument> selectedMovies = toSortedList(repository
    .findAllById(Flux.just("reactive-crud-2", "reactive-crud-4"))
    .collectList()
    .block(), Comparator.comparing(ReactiveMovieDocument::getId), "Expected a list of movies");
Count and read all entities
Long count = repository.count().block();

// findAll() is sorted in memory because live scan ordering is not deterministic
List<ReactiveMovieDocument> allMovies = toSortedList(repository.findAll().collectList().block(),
    Comparator.comparing(ReactiveMovieDocument::getId), "Expected a list of movies");
Delete entities
ReactiveMovieDocument deleteByEntity = new ReactiveMovieDocument("reactive-crud-delete-entity", "Primer", 2004, 6.8);
ReactiveMovieDocument deleteById = new ReactiveMovieDocument("reactive-crud-delete-id", "Coherence", 2013, 7.2);
ReactiveMovieDocument deleteByIdsOne = new ReactiveMovieDocument("reactive-crud-delete-ids-1", "Upgrade", 2018, 7.5);
ReactiveMovieDocument deleteByIdsTwo = new ReactiveMovieDocument("reactive-crud-delete-ids-2", "Possessor", 2020, 6.5);

repository.saveAll(List.of(deleteByEntity, deleteById, deleteByIdsOne, deleteByIdsTwo))
    .collectList()
    .block();

// delete(entity) removes the record represented by the entity instance
repository.delete(deleteByEntity).block();

// deleteById(Publisher) removes the record whose id is emitted by the publisher
repository.deleteById(Flux.just(deleteById.getId())).block();

// deleteAllById(...) removes a batch of records by id
repository.deleteAllById(List.of(deleteByIdsOne.getId(), deleteByIdsTwo.getId())).block();

Projections with Aerospike

Spring Data Aerospike supports Projections, a mechanism that allows you to fetch only relevant fields from Aerospike for a particular use case. This results in better performance, less network traffic, and a better understanding of what is required for the rest of the flow.

For more details, refer to Spring Data documentation: Projections.

For example, consider a movie document:

@Document(collection = "sda_examples_projection_movies")
public class ProjectedMovieDocument {

    @Id
    private String id;
    private String title;
    private String director;
    private int releaseYear;
    private double rating;

    public ProjectedMovieDocument() {
    }

    public ProjectedMovieDocument(String id, String title, String director, int releaseYear, double rating) {
        this.id = id;
        this.title = title;
        this.director = director;
        this.releaseYear = releaseYear;
        this.rating = rating;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getDirector() {
        return director;
    }

    public void setDirector(String director) {
        this.director = director;
    }

    public int getReleaseYear() {
        return releaseYear;
    }

    public void setReleaseYear(int releaseYear) {
        this.releaseYear = releaseYear;
    }

    public double getRating() {
        return rating;
    }

    public void setRating(double rating) {
        this.rating = rating;
    }
}

The use case might call for a compact result that shows only the title and releaseYear fields while the full document still stores more data.

A simple projection of this object might be:

public class MovieSummary {

    private String title;
    private int releaseYear;

    public MovieSummary() {
    }

    public MovieSummary(String title, int releaseYear) {
        this.title = title;
        this.releaseYear = releaseYear;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public int getReleaseYear() {
        return releaseYear;
    }

    public void setReleaseYear(int releaseYear) {
        this.releaseYear = releaseYear;
    }
}

Now the repository interface can be extended to return this projection:

public interface ProjectionMovieRepository extends AerospikeRepository<ProjectedMovieDocument, String> {

    List<MovieSummary> findMovieSummaryById(String id);

    <T> List<T> findById(String id, Class<T> type);
}

The repository can return a projection from a dedicated method or from a dynamic target-class method:

// findMovieSummaryById(...) returns the DTO projection declared by the repository method
List<MovieSummary> dtoProjection = repository.findMovieSummaryById(saved.getId());

int dtoProjectionCount = dtoProjection.size();

String dtoProjectionTitle = dtoProjection.get(0).getTitle();

// findById(..., type) selects the projection target dynamically at call time
List<MovieSummary> dynamicProjection = repository.findById(saved.getId(), MovieSummary.class);

int dynamicProjectionCount = dynamicProjection.size();

int dynamicProjectionReleaseYear = dynamicProjection.get(0).getReleaseYear();

Spring Data maps the returned records into MovieSummary, reducing the data exposed to the rest of the application.

A blog post with more details on projections can be found here.

Query Methods

Spring Data Aerospike supports derived queries: query methods declared in a repository interface where Spring Data creates the query from the method name. The format of method names is fairly flexible, comprising a verb and criteria.

Some of the verbs include find, query, read, get, count and delete. For example, queries might look like findByFirstName, countByLastName etc.

For more details, refer to basic Spring Data documentation: Defining Query Methods.

Query Terminology

Derived Query

A derived query is a repository query created using Spring query method name recognition. For example: List<Person> findByLastNameAndFirstNameContaining(String lastName, String firstName); This query method gets created by user in the repository interface, and Spring Data Aerospike derives the query (operation and criteria) from it.

Declared Query

A declared query is a repository query method with an explicit Spring Data Aerospike @Query annotation. The annotation supplies the DSL expression, and can also name an Aerospike secondary index with indexToUse. For example, @Query( expression = "$.bin_genre == ?0 and $.bin_year == ?1", indexToUse = Movie.GENRE_INDEX ) List<Movie> findByGenreAndReleaseYear(String genre, int releaseYear);

Custom Query

A custom query is a repository query built programmatically by passing an org.springframework.data.aerospike.repository.query.Query with one or more Qualifier objects to findUsingQuery(…​). Use this form when the query shape needs runtime composition or qualifier types that are not practical as a repository method name. For example, Qualifier scienceFiction = Qualifier.builder() .setPath("genre") .setFilterOperation(FilterOperation.EQ) .setValue("science-fiction") .build();

Combined Query

A combined query is any derived query, declared query, or custom query that joins multiple predicates with logical conjunction (AND) or disjunction (OR). Spring Data Aerospike supports combined forms for all three query styles (only within each query style): method-name connectors such as And and Or in derived queries, boolean operators inside declared @Query DSL expressions, and Qualifier.and(…​) or Qualifier.or(…​) in custom queries.

Using Secondary Index

Derived queries in Spring Data Aerospike can be divided into 3 groups:

  • Queries that utilize secondary index (if at least one corresponding index is available)

  • Queries that only use scan operation

  • Queries that use both secondary index and scan (when looking for a nested property).

If no corresponding secondary index is found, each query does a fallback to using scan only.

For details on particular derived queries see the tables referring to query examples below.

Repository Query Keywords

Here are the references to the examples of derived query methods:

Note
Repository read queries featuring id equality (e.g., findById(), findByIds(), findByFirstNameAndId(), findAllById(), countById(), existsById() etc.) utilize get() operation of the underlying Java client. Repository read queries without id or with 'string id like' (e.g., findByFirstName(), findByFirstNameAndLastName(), findAll() , findByIdLike() etc.) utilize query() operation of the underlying Java client.

Repository Interface Example

Below is an example of an interface with several query methods:

public interface PersonRepository extends AerospikeRepository<Person, Long> {
    List<Person> findByLastName(String lastName);
    List<Person> findByLastNameContaining(String lastName);
    List<Person> findByLastNameStartingWith(String lastName);
    List<Person> findByLastNameAndFirstNameContaining(String lastName, String firstName);
    List<Person> findByAgeBetween(long startAge, long endAge);
    Optional<Person> findById(Long id);
}

Simple Property Repository Queries

Note
Simple property repository queries utilize query() operation of the underlying Java client.
Keyword Repository query sample Snippet Uses secondary index Notes

Is, Equals

or no keyword

findByLastName(String lastName)

…​where x.lastName = ?

only for integers, strings and byte arrays

Both secondary index and scan are used for queries featuring nested integer or string property, e.g. findByFriendAddressZipCode()

Not, IsNot

findByLastNameNot(String lastName)

…​where x.lastName <> ?

only scan

True, isTrue

findByEnabledTrue()

…​where x.enabled = true

only scan

Currently there is no secondary index for a boolean bin

False, isFalse

findByEnabledFalse()

…​where x.enabled = false

only scan

Currently there is no secondary index for a boolean bin

In, IsIn

findByLastNameIn(Collection<String>)

…​where x.lastName in ?

only for integers, strings and byte arrays

NotIn, IsNotIn

findByLastNameNotIn(Collection<String>)

…​where x.lastName not in ?

only scan

Null, IsNull

findByEmailAddressIsNull()

…​where x.emailAddress = null or x.emailAddress does not exist

only scan

The same as "does not exist", objects and fields exist in AerospikeDB when their value is not equal to null.

Exists

NotNull, IsNotNull

findByEmailAddressExists()
findByEmailAddressNotNull()

…​where x.emailAddress != null

only scan

"Exists" and "IsNotNull" represent the same functionality and can be used interchangeably, objects and fields exist in AerospikeDB when their value is not equal to null.

LessThan, IsLessThan

findByAgeLessThan(int age)

findByFirstNameLessThan(String string)

…​where x.age < ?

…​where x.firstName < ?

only for integers

Strings are compared by order of each byte, assuming they have UTF-8 encoding. See information about ordering.

LessThanEqual, IsLessThanEqual

findByAgeLessThanEqual(int age)

findByFirstNameLessThanEqual(String string)

…​where x.age < = ?

…​where x.firstName < = ?

only for integers

Strings are compared by order of each byte, assuming they have UTF-8 encoding. See information about ordering.

GreaterThan, IsGreaterThan

findByAgeGreaterThan(int age)

findByFirstNameGreaterThan(String string)

…​where x.age > ?

…​where x.firstName > ?

only for integers

Strings are compared by order of each byte, assuming they have UTF-8 encoding. See information about ordering.

GreaterThanEqual, IsGreaterThanEqual

findByAgeGreaterThanEqual(int age)

findByFirstNameGreaterThanEqual(String string)

…​where x.age >= ?

…​where x.firstName >= ?

only for integers

Strings are compared by order of each byte, assuming they have UTF-8 encoding. See information about ordering.

Between, IsBetween

findByAgeBetween(int lowerLimit, int upperLimit)

findByFirstNameBetween(String lowerLimit, String upperLimit)

…​where x.age between ? and ?

…​where x.firstName between ? and ?

only for integers

Strings are compared by order of each byte, assuming they have UTF-8 encoding. See information about ordering.

Before, IsBefore

findByDateOfBirthBefore(Date date)

…​where x.dateOfBirth < ?

yes

After, IsAfter

findByDateOfBirthAfter(Date date)

…​where x.dateOfBirth > ?

yes

StartingWith, IsStartingWith, StartsWith

findByLastNameStartingWith(String string)

…​where x.lastName like 'abc%'

only scan

EndingWith, IsEndingWith, EndsWith

findByLastNameEndingWith(String string)

…​where x.lastName like '%abc'

only scan

Like, IsLike, MatchesRegex

findByLastNameLike(String lastNameRegex)

…​where x.lastName like ?

only scan

Containing, IsContaining, Contains

findByLastNameContaining(String substring)

…​where x.lastName like '%abc%'

only scan

NotContaining, IsNotContaining, NotContains

findByLastNameNotContaining(String substring)

…​where x.lastName not like '%abc%'

only scan

And

findByLastNameAndFirstName(String lastName, String firstName)

…​where x.lastName = ? and x.firstName = ?

depending on each part of query

Or

findByLastNameOrFirstName(String lastName, String firstName)

…​where x.lastName = ? or x.firstName = ?

depending on each part of query

Collection Repository Queries

Note
Collection repository queries utilize query() operation of the underlying Java client.
Keyword Repository query sample Snippet Uses secondary index Notes

Is, Equals

or no keyword

findByStringList(Collection<String> stringList)

…​where x.stringList = ?

only scan

Not, IsNot

findByStringListNot(Collection<String> stringList)

…​where x.stringList <> ?

only scan

In

findByStringListIn(Collection<Collection<String>>)

…​where x.stringList in ?

only scan

Find records where stringList bin value equals one of the collections in the given argument.

Not In

findByStringListNotIn(Collection<Collection<String>>)

…​where x.stringList not in ?

only scan

Find records where stringList bin value is not equal to any of the collections in the given argument.

Null, IsNull

findByStringListIsNull()

…​where x.stringList = null or x.stringList does not exist

only scan

The same as "does not exist", objects and fields exist in AerospikeDB when their value is not equal to null.

Exists

NotNull, IsNotNull

findByStringListExists()
findByStringListNotNull()

…​where x.stringList != null

only scan

("Exists" and "IsNotNull" represent the same functionality and can be used interchangeably, objects and fields exist in AerospikeDB when their value is not equal to null).

LessThan, IsLessThan

findByStringListLessThan(Collection<String> stringList)

…​where x.stringList < ?

only scan

Find records where stringList bin value has fewer elements or has a corresponding element lower in ordering than in the given argument. See information about ordering.

LessThanEqual, IsLessThanEqual

findByStringListLessThanEqual(Collection<String> stringList)

…​where x.stringList < = ?

only scan

Find records where stringList bin value has smaller or the same amount of elements or has each corresponding element lower in ordering or the same as in the given argument. See information about ordering.

GreaterThan, IsGreaterThan

findByStringListGreaterThan(Collection<String> stringList)

…​where x.stringList > ?

only scan

Find records where stringList bin value has more elements or has a corresponding element higher in ordering than in the given argument. See information about ordering.

GreaterThanEqual, IsGreaterThanEqual

findByStringListGreaterThanEqual(Collection<String> stringList)

…​where x.stringList >= ?

only scan

Find records where stringList bin value has larger or the same amount of elements or has each corresponding element higher in ordering or the same as in the given argument. See information about ordering.

Between, IsBetween

findByStringListBetween(Collection<String> lowerLimit, Collection<String> upperLimit)

…​where x.stringList between ? and ?

only scan

Find records where stringList bin value is in the range between the given arguments. See information about ordering.

Containing, IsContaining, Contains

findByStringListContaining(String string)

…​where x.stringList contains ?

only scan

NotContaining, IsNotContaining, NotContains

findByStringListNotContaining(String string)

…​where x.stringList not contains ?

only scan

And

findByStringListAndIntList(QueryParam stringList, QueryParam intList)

…​where x.stringList = ? and x.intList = ?

only scan

Or

findByStringListOrIntList(QueryParam stringList, QueryParam intList)

…​where x.stringList = ? or x.intList = ?

only scan

Map Repository Queries

Note
Map repository queries utilize query() operation of the underlying Java client.
Keyword Repository query sample Snippet Uses secondary index Notes

Is, Equals

or no keyword

findByStringMap(Map<String, String> stringMap)

…​where x.stringMap = ?

only scan

Not, IsNot

findByStringMapNot(Map<String, String> stringMap)

…​where x.stringMap <> ?

only scan

In

findByStringMapIn(Collection<Map<String, String>>)

…​where x.stringMap in ?

only scan

Find records where stringMap bin value equals one of the maps in the given argument.

Not In

findByStringMapNotIn(Collection<Map<String, String>>)

…​where x.stringMap not in ?

only scan

Find records where stringMap bin value is not equal to any of the maps in the given argument.

Null, IsNull

findByStringMapIsNull()

…​where x.stringMap = null or x.stringMap does not exist

only scan

The same as "does not exist", objects and fields exist in AerospikeDB when their value is not equal to null.

Exists

NotNull, IsNotNull

findByStringMapExists()
findByStringMapNotNull()

…​where x.stringMap != null

only scan

"Exists" and "IsNotNull" represent the same functionality and can be used interchangeably, objects and fields exist when their value is not equal to null.

LessThan, IsLessThan

findByStringMapLessThan(Map<String, String> stringMap)

…​where x.stringMap < ?

only scan

Find records where stringMap bin value has fewer elements or has a corresponding element lower in ordering than in the given argument. See information about ordering.

LessThanEqual, IsLessThanEqual

findByStringMapLessThanEqual(Map<String, String> stringMap)

…​where x.stringMap < = ?

only scan

Find records where stringMap bin value has smaller or the same amount of elements or has each corresponding element lower in ordering or the same as in the given argument. See information about ordering.

GreaterThan, IsGreaterThan

findByStringMapGreaterThan(Map<String, String> stringMap)

…​where x.stringMap > ?

only scan

Find records where stringMap bin value has more elements or has a corresponding element higher in ordering than in the given argument. See information about ordering.

GreaterThanEqual, IsGreaterThanEqual

findByStringMapGreaterThanEqual(Map<String, String> stringMap)

…​where x.stringMap >= ?

only scan

Find records where stringMap bin value has larger or the same amount of elements or has each corresponding element higher in ordering or the same as in the given argument. See information about ordering.

Between, IsBetween

findByStringMapBetween(Map<String, String> lowerLimit, Map<String, String> upperLimit)

…​where x.stringMap between ? and ?

only scan

Find records where stringMap bin value is in the range between the given arguments. See information about ordering.

Containing, IsContaining, Contains

findByStringMapContaining(AerospikeQueryCriterion criterion, String string)

findByStringMapContaining(AerospikeQueryCriterion criterionPair, String string, String value)

…​where x.stringMap contains ?

only scan

  • Find records where stringMap bin value (which is a Map) contains key "key1":

findByStringMapContaining(KEY, "key1")

  • Find records where stringMap bin value (which is a Map) contains value "value1":

findByStringMapContaining(VALUE, "value1")

  • Find records where stringMap bin value (which is a Map) contains key "key1" with the value "value1":

findByStringMapContaining(KEY_VALUE_PAIR, "key1", "value1")

NotContaining, IsNotContaining, NotContains

findByStringNameNotContaining(AerospikeQueryCriterion criterion, String string)

…​where x.stringMap not contains ?

only scan

findByStringMapNotContaining(KEY, "key1")

findByStringMapNotContaining(VALUE, "value1")

findByStringMapNotContaining(KEY_VALUE_PAIR, "key1", "value1")

And

findByStringMapAndIntMap(QueryParam stringMap, QueryParam intMap)

…​where x.stringMap = ? and x.intMap = ?

only scan

Or

findByStringMapOrIntMap(QueryParam stringMap, QueryParam intMap)

…​where x.stringMap = ? or x.intMap = ?

only scan

POJO Repository Queries

Note
These repository queries utilize query() operation of the underlying Java client.
Keyword Repository query sample Snippet Uses secondary index Notes

Is, Equals

or no keyword

findByAddress(Address address)

…​where x.address = ?

only scan

Not, IsNot

findByAddressNot(Address address)

…​where x.address <> ?

only scan

In

findByAddressIn(Collection<Address>)

…​where x.address in ?

only scan

Find records where address bin value equals one of the Address objects in the given argument.

Not In

findByAddressNotIn(Collection<Address>)

…​where x.address not in ?

only scan

Find records where address bin value is not equal to any of the Address objects in the given argument.

Null, IsNull

findByAddressIsNull()

…​where x.address = null or x.address does not exist

only scan

The same as "does not exist", objects and fields exist in AerospikeDB when their value is not equal to null.

Exists

NotNull, IsNotNull

findByAddressExists()
findByAddressNotNull()

…​where x.address != null

only scan

"Exists" and "IsNotNull" represent the same functionality and can be used interchangeably, objects and fields exist when their value is not equal to null.

LessThan, IsLessThan

findByAddressLessThan(Address address)

…​where x.address < ?

only scan

Find records where address bin value (POJOs are stored in AerospikeDB as maps) has fewer elements or has a corresponding element lower in ordering than in the given argument. See information about ordering.

LessThanEqual, IsLessThanEqual

findByAddressLessThanEqual(Address address)

…​where x.address < = ?

only scan

Find records where address bin value (POJOs are stored in AerospikeDB as maps) has smaller or the same amount of elements or has each corresponding element lower in ordering or the same as in the given argument. See information about ordering.

GreaterThan, IsGreaterThan

findByAddressGreaterThan(Address address)

…​where x.address > ?

only scan

Find records where address bin value (POJOs are stored in AerospikeDB as maps) has more elements or has a corresponding element higher in ordering than in the given argument. See information about ordering.

GreaterThanEqual, IsGreaterThanEqual

findByAddressGreaterThanEqual(Address address)

…​where x.address >= ?

only scan

Find records where address bin value (POJOs are stored in AerospikeDB as maps) has larger or the same amount of elements or has each corresponding element higher in ordering or the same as in the given argument. See information about ordering.

Between, IsBetween

findByAddressBetween(Address lowerLimit, Address upperLimit)

…​where x.address between ? and ?

only scan

Find records where address bin value (POJOs are stored in AerospikeDB as maps) is in the range between the given arguments. See information about ordering.

And

findByAddressAndFriend(QueryParam address, QueryParam friend)

…​where x.address = ? and x.friend = ?

only scan

Or

findByAddressOrFriend(QueryParam address, QueryParam friend)

…​where x.address = ? or x.friend = ?

only scan

Id Repository Queries

Repository reading queries featuring id equality (like findById(), findByIds(), findByFirstNameAndId(), findAllById(), countById(), existsById() etc.) utilize get operation of the underlying Java client (client.get()).

Keyword Repository query sample Snippet Uses secondary index Notes

no keyword

findById(String id)

…​where x.PK = ?

uses primary key directly

This query utilizes get operation of Java client

Like

findByIdLike(String idPattern)

…​where x.PK like ?

only scan

idPattern must be a String. This query utilizes query operation of Java client

And

findByIdAndFirstName(QueryParam ids, QueryParam firstName)

findByIdLikeAndFirstName(QueryParam id, QueryParam firstName)

…​where x.PK = ? and x.firstName = ?

…​where x.PK like ? and x.firstName = ?

depending on each part of query

Runnable ID and Bin Criteria Examples

The runnable examples include derived and custom queries where an id criterion is combined with a regular bin criterion.

Derived Query Methods

Derived query methods wrap the id part in QueryParam, which can carry either one id or several ids.

List<QueryMethodsMovieDocument> findByIdAndGenre(QueryParam ids, QueryParam genre);
// QueryParam lets a derived id criterion combine one id with a regular bin criterion.
List<QueryMethodsMovieDocument> singleIdAndGenre = toSortedList(
    repository.findByIdAndGenre(of("blocking-id-bin-1"), of("science-fiction")),
    Comparator.comparing(QueryMethodsMovieDocument::getId));

// The same method can narrow several ids before applying the genre criterion.
List<QueryMethodsMovieDocument> idsAndGenre = toSortedList(
    repository.findByIdAndGenre(
        of(List.of("blocking-id-bin-1", "blocking-id-bin-3")),
        of("science-fiction")),
    Comparator.comparing(QueryMethodsMovieDocument::getId));
Flux<ReactiveQueryMethodsMovieDocument> findByIdAndGenre(QueryParam ids, QueryParam genre);
// QueryParam lets a derived id criterion combine one id with a regular bin criterion.
List<ReactiveQueryMethodsMovieDocument> singleIdAndGenre = toSortedList(
    repository.findByIdAndGenre(of("reactive-id-bin-1"), of("science-fiction"))
        .collectList()
        .block(),
    Comparator.comparing(ReactiveQueryMethodsMovieDocument::getId), "Expected a list of movies");

// The same method can narrow several ids before applying the genre criterion.
List<ReactiveQueryMethodsMovieDocument> idsAndGenre = toSortedList(
    repository.findByIdAndGenre(
            of(List.of("reactive-id-bin-1", "reactive-id-bin-3")),
            of("science-fiction"))
        .collectList()
        .block(),
    Comparator.comparing(ReactiveQueryMethodsMovieDocument::getId), "Expected a list of movies");

Custom Queries

Custom queries use Qualifier.idEquals or Qualifier.idIn with a regular bin qualifier.

// Qualifier.idEquals targets the Aerospike user key; genreQualifier targets a regular bin.
Query singleIdAndGenre = new Query(Qualifier.and(
    Qualifier.idEquals("blocking-custom-id-bin-1"),
    genreQualifier("science-fiction")));

List<ProgrammaticCustomQueryMovieDocument> singleIdResult = toSortedList(
    repository.findUsingQuery(singleIdAndGenre),
    Comparator.comparing(ProgrammaticCustomQueryMovieDocument::getId));

// Qualifier.idIn lets the same custom query shape narrow several ids before applying the bin qualifier.
Query idsAndGenre = new Query(Qualifier.and(
    Qualifier.idIn("blocking-custom-id-bin-1", "blocking-custom-id-bin-3"),
    genreQualifier("science-fiction")));

List<ProgrammaticCustomQueryMovieDocument> idsResult = toSortedList(
    repository.findUsingQuery(idsAndGenre),
    Comparator.comparing(ProgrammaticCustomQueryMovieDocument::getId));
// Qualifier.idEquals targets the Aerospike user key; genreQualifier targets a regular bin.
Query singleIdAndGenre = new Query(Qualifier.and(
    Qualifier.idEquals("reactive-custom-id-bin-1"),
    genreQualifier("science-fiction")));

List<ReactiveProgrammaticCustomQueryMovieDocument> singleIdResult = toSortedList(
    repository.findUsingQuery(singleIdAndGenre)
        .collectList()
        .block(),
    Comparator.comparing(ReactiveProgrammaticCustomQueryMovieDocument::getId),
    "Expected a list of custom query results");

// Qualifier.idIn lets the same custom query shape narrow several ids before applying the bin qualifier.
Query idsAndGenre = new Query(Qualifier.and(
    Qualifier.idIn("reactive-custom-id-bin-1", "reactive-custom-id-bin-3"),
    genreQualifier("science-fiction")));

List<ReactiveProgrammaticCustomQueryMovieDocument> idsResult = toSortedList(
    repository.findUsingQuery(idsAndGenre)
        .collectList()
        .block(),
    Comparator.comparing(ReactiveProgrammaticCustomQueryMovieDocument::getId),
    "Expected a list of custom query results");

Combined Derived Queries

In Spring Data, combined derived queries use method-name connectors such as And and Or to combine multiple conditions. And forms a conjunction, Or forms a disjunction, and both are created from repository method names. For the broader definition across derived, declared, and custom query styles, see Combined Query.

For more details, see Defining Query Methods.

For instance, a method like findByFirstNameAndLastName will fetch records matching both conditions, while findByFirstNameOrLastName will return records that match either condition. These query methods simplify database interaction by reducing boilerplate code and relying on convention over configuration for readability and maintainability.

In Spring Data Aerospike you define such queries by adding query method signatures to a Repository, as you would typically, wrapping each query parameter with QueryParam.of() method. This method is required to pass arguments to each part of a combined query, it can receive one or more objects of the same type.

This way QueryParam stores arguments passed to each part of a combined repository query, e.g., repository.findByNameAndEmail(QueryParam.of("John"), QueryParam.of("email")).

Here are some examples:

public interface BlockingDerivedQueryRepository extends AerospikeRepository<Movie, String> {

    List<Movie> findByGenreAndReleaseYear(QueryParam genre, QueryParam releaseYear);

    List<Movie> findByGenreAndReleaseYearAndTitle(
        QueryParam genre, QueryParam releaseYear, QueryParam title);

    List<Movie> findByGenreOrReleaseYear(QueryParam genre, QueryParam releaseYear);

    List<Movie> findByGenreOrReleaseYearOrTitle(
        QueryParam genre, QueryParam releaseYear, QueryParam title);

    List<Movie> findByGenreAndReleaseYearOrTitle(
        QueryParam genre, QueryParam releaseYear, QueryParam title);
}
Conjunctions can use an eligible secondary index
// One AND: this context disables scans and has secondary indexes available.
// For this method, Movie.GENRE_BIN (`bin_genre`) is the only indexed predicate, so QueryContextBuilder
// uses it as the Aerospike
// secondary-index filter and evaluates releaseYear as a filter expression on the indexed records.
List<Movie> genreAndYear = repository.findByGenreAndReleaseYear(of(SCIENCE_FICTION), of(1979));

// Multiple AND: Spring Data builds a three-part derived AND method as a nested AND:
// AND(AND(genre, releaseYear), title). The outer title branch is visible to QueryContextBuilder, so this
// fixture also creates Movie.TITLE_INDEX on Movie.TITLE_BIN (`bin_title`), and that index becomes the
// secondary-index filter for this method.
List<Movie> genreYearAndTitle = repository.findByGenreAndReleaseYearAndTitle(
    of(SCIENCE_FICTION), of(1979), of("Alien"));
Disjunctions are scan-backed
// One OR: Movie.GENRE_INDEX exists on Movie.GENRE_BIN (`bin_genre`), but a top-level OR widens the result set.
// QueryContextBuilder cannot represent that as one secondary-index filter, so scans must be enabled.
List<Movie> genreOrYear = repository.findByGenreOrReleaseYear(of(CRIME), of(1979));

// Multiple OR: adding title keeps OR at the top level and still produces no secondary-index filter.
List<Movie> genreOrYearOrTitle = repository.findByGenreOrReleaseYearOrTitle(
    of(CRIME), of(1979), of("Network"));

// Mixed derived query: Spring Data parses this method as
// OR(AND(Movie.GENRE_BIN, Movie.RELEASE_YEAR_BIN), Movie.TITLE_BIN),
// not as AND(genre, OR(releaseYear, title)).
// Because OR is the top-level operator, no single Aerospike secondary-index filter can be used.
List<Movie> genreAndYearOrTitle = repository.findByGenreAndReleaseYearOrTitle(
    of(SCIENCE_FICTION), of(1979), of("Heat"));
No-index contexts require scans
// No secondary index exists in this context, so even AND-shaped derived queries are scans.
List<Movie> genreAndYear = repository.findByGenreAndReleaseYear(of(SCIENCE_FICTION), of(1979));

// Top-level OR has no secondary-index filter shape and this context also has no indexes at all.
List<Movie> genreOrYear = repository.findByGenreOrReleaseYear(of(CRIME), of(1979));

// Multiple AND is still expression-only here because no Movie.GENRE_INDEX was created on
// Movie.GENRE_BIN (`bin_genre`).
List<Movie> genreYearAndTitle = repository.findByGenreAndReleaseYearAndTitle(
    of(SCIENCE_FICTION), of(1979), of("Alien"));

// Multiple OR remains expression-only and scan-backed.
List<Movie> genreOrYearOrTitle = repository.findByGenreOrReleaseYearOrTitle(
    of(CRIME), of(1979), of("Network"));

// Derived mixed logic parses as OR(AND(genre, releaseYear), title).
// With no indexes present, the whole expression is evaluated by a scan.
List<Movie> genreAndYearOrTitle = repository.findByGenreAndReleaseYearOrTitle(
    of(SCIENCE_FICTION), of(1979), of("Heat"));

Combined Queries and Secondary Index

There are certain rules applied to processing such queries regarding secondary index in Spring Data Aerospike:

  • If no query parts correspond to a secondary index, a scan operation is used

  • If only one part of a query has a corresponding secondary index eligible for use, that index will be used. For instance, if an index was created for the name String bin, then it will be used in queries like findByNameAndJob(), findByNameAndJobAndEmail(), findByJobAndName() etc.

  • If two or more query parts have corresponding secondary indexes eligible for use, one index will be chosen based on cardinality.

Secondary Indexes Cardinality

Cardinality is calculated the following way: when indexes info is retrieved (configurable, typically on startup and then once an hour), Spring Data Aerospike uses sindex-stat command to gather statistics for secondary indexes to look at the ratio of entries to unique bin values for a given secondary index on the node (entries_per_bval), which is used as cardinality based on distribution of data within the secondary index.

So when a query has more than one SI, cardinality of each query part (qualifier) is compared using cache of indexes, and the index with the lowest ratio of entries to unique bin values is selected. If two or more parts have the same cardinality, the first of them will be used.

Query Modification

Query Modifiers

Keyword Sample Snippet Notes

IgnoreCase

findByLastNameIgnoreCase

…​where UPPER(x.lastName) = UPPER(?)

OrderBy

findByLastNameOrderByFirstNameDesc

…​where x.lastName = ? order by x.firstName desc

Ordering is done applicatively in Spring Data Aerospike as post-processing of the query results. If there is a secondary index created for the ordering bin (in this example firstName), it is not used, indexes are relevant only for the main query part before OrderBy. Ordering affects performance depending on the amount of query results

Limiting Query Results

Keyword Sample Snippet

First

findFirstByAge

select top 1 where x.age = ?

First N

findFirst3ByAge

select top 3 where x.age = ?

Top

findTopByLastNameStartingWith

select top 1 where x.lastName like 'abc%' = ?

Top N

findTop4ByLastNameStartingWith

select top 4 where x.lastName like 'abc%'

Distinct

findDistinctByFirstNameContaining

select distinct …​ where x.firstName like 'abc%'

Note
Query result limiting is performed in Spring Data Aerospike at the application level as a post-processing step. This limiting can affect performance, depending on the number of query results and limiting parameters.

Custom Queries

Introduction

Derived queries are helpful for most querying tasks. Each repository adds more functionality by providing query methods.

There are some cases, however, when data finding tasks require building query methods which are more complex, more flexible or unsupported in a standard repository. Custom queries are such a special mechanism in Spring Data Aerospike. Currently only finding (reading) queries are supported.

Find Using Query

Users can perform a custom query by passing a Query with a custom Qualifier to findUsingQuery(Query). The qualifier may contain other qualifiers and combine them into conjunctions with Qualifier.and(…​) or disjunctions with Qualifier.or(…​), with some limitations due to qualifier type.

Custom Qualifier can be created for queries of the following types: regular (Aerospike bins), metadata, DSL expression, indexed with expression, ids (primary keys) and filtering (accepting Expression and/or secondary index Filter for additional flexibility, manual handling or when working with legacy code). Below are examples for the mentioned custom queries types.

Regular Custom Query Example

// A regular qualifier maps one document property to a comparison operation.
Qualifier scienceFiction = Qualifier.builder()
    .setPath("genre")
    .setFilterOperation(FilterOperation.EQ)
    .setValue("science-fiction")
    .build();

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(new Query(scienceFiction));

ID Custom Query Example

// An id qualifier targets the Aerospike user key instead of a document bin.
Qualifier keyEquals = Qualifier.idEquals("custom-query-types-3");

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(new Query(keyEquals));

ID and Regular Custom Query Example

// An id qualifier can be combined with a bin qualifier to narrow by key and record content.
Query query = new Query(Qualifier.and(
    Qualifier.idEquals("custom-query-types-1"),
    genreQualifier("science-fiction")));

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(query);

Metadata Custom Query Example

// Metadata qualifiers compare Aerospike record metadata such as last update time.
Qualifier recentlyUpdated = Qualifier.metadataBuilder()
    .setMetadataField(SINCE_UPDATE_TIME)
    .setFilterOperation(FilterOperation.LT)
    .setValue(60_000L)
    .build();

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(new Query(recentlyUpdated));

DSL expression Custom Query Example

// DSL expression qualifiers bind method-style values into a server-side filter expression.
Query query = new Query(Qualifier.dslExpressionBuilder()
    .setDSLExpressionString("$.genre == ?0 and $.releaseYear >= ?1")
    .setDSLExpressionValues(new Object[]{"science-fiction", 2000})
    .build());

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(query);

Indexed Using Expression Custom Query Example

Note
Expression indexes require Aerospike Server 8.1.0 or later. The runnable example skips this section on older server versions.
// Expression indexes must exist before the qualifier can use them as the statement filter.
Expression releaseYearForScienceFiction = Exp.build(Exp.cond(
    Exp.eq(Exp.stringBin("genre"), Exp.val("science-fiction")),
    Exp.intBin("releaseYear"),
    Exp.unknown()
));

template.createIndex(setName, CustomQueryTypesMovieDocument.EXPRESSION_INDEX, IndexType.NUMERIC,
    IndexCollectionType.DEFAULT, releaseYearForScienceFiction);

Qualifier usingExpressionIndex = Qualifier.indexedWithExpressionBuilder()
    .setIndexName(CustomQueryTypesMovieDocument.EXPRESSION_INDEX)
    .setFilterOperation(FilterOperation.BETWEEN)
    .setValue(1970)
    .setSecondValue(1980)
    .build();

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(new Query(usingExpressionIndex));

Filtering Custom Query Example

// A filtering qualifier combines a secondary-index filter with an Aerospike expression.
Expression directorContainsFord = Exp.build(Exp.regexCompare(
    ".*ford.*",
    RegexFlag.ICASE,
    Exp.stringBin("director")
));
Filter releasedAfter1969 = Filter.range("releaseYear", 1970, Long.MAX_VALUE);

Qualifier filterQualifier = Qualifier.filterBuilder()
    .setExpression(directorContainsFord)
    .setFilter(releasedAfter1969)
    .build();

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(new Query(filterQualifier));

Using DSL Expressions

Introduction

Aerospike DSL (Domain Specific Language) expressions represent a functional language for applying predicates to bin data and record metadata. They provide a powerful and flexible way to query Aerospike data using a dot-separated infix notation. DSL expressions can act as the WHERE clause in your queries, allowing to filter records based on bin values, metadata, navigating through complex nested structures.

DSL expressions offer several advantages:

  • Human-readable syntax: Use recognizable text-based notation ($.binName > 0)

  • Aerospike data structures support: Support for strings, numbers, booleans, and complex data types

  • Flexible querying: Work with maps, lists, and nested structures

  • Two usage patterns in Spring Data Aerospike: Available both as annotations on repository methods and programmatically in custom queries.

The DSL Parser API converts dot-separated string paths into Aerospike filters for applying predicates.

DSL Expressions API

Path Syntax

DSL expressions use the following syntax for navigating through basic elements:

Table 1. Basic Path Elements
Path Element Description

$.binName

Reference to bin "binName"

a

Map key "a"

'1'

Map key (String) "1"

1

Map key 1 (numeric)

{1}

Map index 1

{=1}

Map value (int) 1

{=bb}

Map value "bb"

{='1'}

Map value (String) "1"

{#1}

Map rank 1

[1]

List index 1

[=1]

List value 1

[#1]

List rank 1

Here are some examples for navigating through nested elements:

Table 2. Examples of Nested Element Navigation
Nested Element Description

$.mapBinName.a

mapBinName → mapKey("a")

$.mapBinName.a.aa.aaa

mapBinName → mapKey("a") → mapKey("aa") → mapKey("aaa")

$.mapBinName.a.55

mapBinName → mapKey("a") → mapKey(55)

$.listBinName.[1].aa

listBinName → listIndex(1) → mapKey("aa")

$.mapBinName.ab.cd.[-1].'10'

mapBinName → mapKey("ab") → mapKey("cd") → listIndex(-1) → mapKey("10")

Supported Operators

DSL expressions support standard comparison and logical operators:

  • Comparison: ==, !=, >, >=, <, <=

  • Logical: and, or, not, exclusive

  • Grouping: ()

  • Type Access: .get(type: TYPE_NAME) for explicit Aerospike type casting

  • Control Structures: with, when

For more details, see DSLParser project.

Usage with Declared Queries

You can use declared queries in Spring Data Aerospike by annotating repository methods with @Query. The declared DSL expressions are executed automatically when the annotated repository method is called.

Static Expressions in Declared Queries

Static expressions have fixed values embedded directly in the DSL string:

Example 9. Static DSL expression example
@Query(expression = "$.bin_genre == 'crime' or $.bin_year == 1979")
List<Movie> findCrimeOr1979();
Note
Static declared expressions embed their values directly in the annotation. The method above declares no parameters because the query always uses the fixed values in the expression.

Dynamic Expressions with Placeholders in Declared Queries

Dynamic expressions use placeholders that are replaced with actual method parameter values at runtime:

  • ?0 - First parameter

  • ?1 - Second parameter

  • And so on

Example 10. Examples of declared queries with @Query
public interface DeclaredQueryMovieRepository extends AerospikeRepository<DeclaredQueryMovieDocument, String> {

    @Query(
        expression = "$.releaseYear >= ?0 and $.releaseYear < ?1",
        indexToUse = DeclaredQueryMovieDocument.RELEASE_YEAR_INDEX
    )
    List<DeclaredQueryMovieDocument> findByReleaseYearBetween(int fromInclusive, int toExclusive);
}

Usage with Custom Queries

For more complex scenarios or programmatic query construction, you can use DSL expressions with the custom query API through Qualifier.dslExpressionBuilder().

Static DSL Expressions in Custom Queries

Create qualifiers with fixed values embedded in the expression:

Example 11. Static DSL expression in custom query
// Static DSL expressions keep all predicate values inside the expression string.
Qualifier seventiesScienceFiction = Qualifier.dslExpressionBuilder()
    .setDSLExpressionString("$.genre == 'science-fiction' and $.releaseYear < 1980")
    .build();

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(
    new Query(seventiesScienceFiction));

Dynamic DSL Expressions in Custom Queries

Create qualifiers with placeholders and provide values separately:

Example 12. Examples of custom queries using DSL expressions
// DSL expression qualifiers bind method-style values into a server-side filter expression.
Query query = new Query(Qualifier.dslExpressionBuilder()
    .setDSLExpressionString("$.genre == ?0 and $.releaseYear >= ?1")
    .setDSLExpressionValues(new Object[]{"science-fiction", 2000})
    .build());

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(query);

Placeholder values are transferred as an array of Objects (Object[]).

Complex Expressions

You can create comprehensive DSL expressions that combine multiple conditions:

Example 13. Complex DSL expression with multiple conditions
// Complex DSL expressions can group AND/OR logic that is awkward to express with plain qualifiers.
Query query = new Query(Qualifier.dslExpressionBuilder()
    .setDSLExpressionString(
        "($.genre == 'science-fiction' or $.director == 'Francis Ford Coppola') and $.releaseYear >= 1970")
    .build());

Iterable<CustomQueryTypesMovieDocument> result = repository.findUsingQuery(query);

Secondary Indexes and DSL Expressions

DSL expressions work seamlessly with Aerospike secondary indexes. When a declared query names an index with indexToUse, Spring Data Aerospike uses that index to build the secondary-index Filter and evaluates the remaining DSL predicates as a filtering Expression.

Example 14. Declared DSL expression with an explicit secondary index
@Query(
    expression = "$.releaseYear >= ?0 and $.releaseYear < ?1",
    indexToUse = DeclaredQueryMovieDocument.RELEASE_YEAR_INDEX
)
List<DeclaredQueryMovieDocument> findByReleaseYearBetween(int fromInclusive, int toExclusive);

When no index is explicitly named and multiple eligible secondary indexes are available, Spring Data Aerospike follows the same selection rules described in Combined Derived Queries: it prefers the lowest non-zero entries_per_bval/binValuesRatio value from the index cache.

Limitations and Restrictions

Warning

Combining custom Qualifiers: For custom queries, it is currently NOT allowed to combine DSL expression qualifiers with non-DSL qualifiers using Qualifier.and() or Qualifier.or(). This limitation does not apply to declared queries that put the DSL expression directly on a repository method with @Query.

Example 15. Invalid qualifier combination
// DSL expression qualifier
Qualifier colorEqGreen = Qualifier.dslExpressionBuilder()
    .setDSLExpressionString("$.favColor == 'green'")
    .build();

// Regular qualifier
Qualifier ageBetween28And29 = Qualifier.builder()
    .setPath("age")
    .setFilterOperation(FilterOperation.BETWEEN)
    .setValue(28)
    .setSecondValue(29)
    .build();

// This will throw UnsupportedOperationException
Query invalidQuery = new Query(Qualifier.and(colorEqGreen, ageBetween28And29));

Solution: Incorporate all conditions into one comprehensive DSL expression or combine only non-DSL qualifiers:

Example 16. Valid alternatives for custom queries
// Option 1: Combine everything in one DSL expression
Qualifier combined = Qualifier.dslExpressionBuilder()
    .setDSLExpressionString("$.favColor == 'green' and $.age >= 28 and $.age < 29")
    .build();

Query validQuery = new Query(combined);

// Option 2: Use only non-DSL qualifiers with AND/OR
Qualifier color = Qualifier.builder()
    .setPath("favColor")
    .setFilterOperation(FilterOperation.EQ)
    .setValue("green")
    .build();
Qualifier age = Qualifier.builder()
    .setPath("age")
    .setFilterOperation(FilterOperation.BETWEEN)
    .setValue(28)
    .setSecondValue(29)
    .build();

Query validQuery2 = new Query(Qualifier.and(color, age));

Best Practices

  • Use static expressions for fixed filters that don’t change

  • Use placeholders when filter values are determined at runtime

  • Consolidate conditions into comprehensive DSL expressions if using custom DSL qualifier

  • Leverage indexes by including indexed bin conditions in your DSL expressions

  • Use explicit type casting (e.g., .get(type: BOOL)) when working with bins that may have ambiguous types

Aerospike Object Mapping

Rich mapping support is provided by the AerospikeMappingConverter which has a rich metadata model that provides a full feature set of functionality to map domain objects to Aerospike objects. The mapping metadata model is populated using annotations on your domain objects. However, the infrastructure is not limited to using annotations as the only source of metadata information. The AerospikeMappingConverter also allows you to map objects without providing any additional metadata, by following a set of conventions.

In this section, we will describe the features of the AerospikeMappingConverter, how to use conventions for mapping objects to documents and how to override those conventions with annotation-based mapping metadata.

For more details, refer to Spring Data documentation: Object Mapping.

Convention Based Mapping

AerospikeMappingConverter has a few conventions for mapping objects to documents when no additional mapping metadata is provided. The conventions are:

How the 'id' Field Is Handled in the Mapping Layer

Aerospike DB requires that you have an id field for all objects. The id field can be of any primitive type as well as String or byte[].

The following table outlines the requirements for the id field:

Table 3. Examples for the translation of '_id'-field definitions
Field definition Description

String id

A field named 'id' without an annotation

@Id String myId

A field annotated with @Id (org.springframework.data.annotation.Id)

The following description outlines what type of conversion, if any, will be done on the property mapped to the id document field:

  • By default, the type of the field annotated with @id is turned into a String to be stored in Aerospike database. If the original type cannot be persisted (see keepOriginalKeyTypes for details), it must be convertible to String and will be stored in the database as such, then converted back to the original type when the object is read. This is transparent to the application but needs to be considered if using external tools like AQL and the Aerospike JDBC Driver to view the data.

  • If no field named "id" is present in the Java class then an implicit '_id' file will be generated by the driver but not mapped to a property or field of the Java class.

When querying and updating AerospikeTemplate will use the converter to handle conversions of the Query and Update objects that correspond to the above rules for saving documents so field names and types used in your queries will be able to match what is in your domain classes.

Mapping Configuration

Unless explicitly configured, an instance of AerospikeMappingConverter is created by default when creating a AerospikeTemplate. You can create your own instance of the MappingAerospikeConverter so as to tell it where to scan the classpath at the startup of your domain classes in order to extract metadata and construct indexes. Also, to have more control over the conversion process (if needed), you can register converters to use for mapping specific classes to and from the database.

Note
AbstractAerospikeConfiguration will create an AerospikeTemplate instance and register with the container under the name 'AerospikeTemplate'.

Mapping Annotation Overview

The MappingAerospikeConverter can use metadata to drive the mapping of objects to documents using annotations. An overview of the annotations is provided below

  • @Id - applied at the field level to mark the field used for identity purposes.

  • @Field - applied at the field level, describes the name of the field as it will be represented in the AerospikeDB BSON document thus allowing the name to be different from the field name of the class.

  • @Version - applied at the field level to mark record modification count. The value must be effectively integer. In Spring Data Aerospike, documents come in two forms – non-versioned and versioned. Documents with an @Version annotation have a version field populated by the corresponding record’s generation count. Version can be passed to a constructor or not (in that case it stays equal to zero).

  • @Expiration - applied at the field level to mark a property to be used as expiration field. Expiration can be specified in two flavors: as an offset in seconds from the current time (then field value must be effectively integer) or as an absolute Unix timestamp. Client system time must be synchronized with Aerospike server system time, otherwise expiration behaviour will be unpredictable.

The mapping metadata infrastructure is defined in a separate spring-data-commons project that is technology-agnostic. Specific subclasses are used in the AerospikeDB support to support annotation-based metadata. Other strategies are also possible to put in place if there is demand.

Here is an example of a more complex mapping.

public class Person<T extends Address> {

  @Id
  private String id;

  private Integer ssn;

  @Field("fName")
  private String firstName;

  private String lastName;

  private Integer age;

  private Integer accountTotal;

  private List<Account> accounts;

  private T address;

  @Version
  private int version; // must be integer

  public Person(Integer ssn) {
    this.ssn = ssn;
  }

  public Person(Integer ssn, String firstName, String lastName, Integer age, T address, int version) {
    this.ssn = ssn;
    this.firstName = firstName;
    this.lastName = lastName;
    this.age = age;
    this.address = address;
    this.version = version;
  }

  public String getId() {
    return id;
  }

  // no setter for Id.  (getter is only exposed for some unit testing)

  public Integer getSsn() {
    return ssn;
  }

// other getters/setters omitted
}

Troubleshooting "Parameter Does Not Have a Name": Known Issue

Let’s assume we have a complex entity called Person. In order to get a smaller subset of fields (only firstName, lastName and email) from Person object we want to run a simple find query using DTO projection, so we create a DTO entity called PersonSomeFields:

@Data
@Builder
public class PersonSomeFields {

    private String firstName;
    private String lastName;
    @Field("email")
    private String emailAddress;
}

List<PersonSomeFields> result = repository.findPersonSomeFieldsByFirstName("Carter");

However, when we run this query, it might lead to a MappingException with the message "Parameter %s does not have a name".

When mapping to a DTO / entity class like PersonSomeFields (often annotated with Lombok’s @Data and @Builder), Spring Data attempts to create an instance of PersonSomeFields. During this process, it tries to identify the constructor parameters and their corresponding property names.

Without a no-argument constructor explicitly defined or generated, Spring Data attempts to use the implicitly generated constructor. To correctly map constructor parameters to entity properties, it relies on the parameter names being available at runtime. However, Java compiler or Lombok’s generated constructors often do not retain parameter names, leading to null being returned for parameter name.

The solution is to ensure that Spring Data has a way to instantiate the PersonSomeFields class without relying on extracting parameter names from a constructor that might not have them available at runtime. The simplest and most robust way to achieve this is by providing a no-argument constructor.

When using Lombok, this is easily done by adding the @NoArgsConstructor annotation to your DTO class. This instructs Lombok to generate a public no-argument constructor.

If you are using @Builder alongside @NoArgsConstructor, it’s typically required by Lombok to also include @AllArgsConstructor.

Here is an example of the modified class:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PersonSomeFields {

    private String firstName;
    private String lastName;
    @Field("email")
    private String emailAddress;
}

Aerospike Custom Converters

Spring type converters are components used to convert data between different types, particularly when interacting with databases or binding data from external sources. They facilitate seamless transformation of data, such as converting between String and database-specific types (e.g., LocalDate to DATE or String to enumerations).

For more details, see Spring Type Conversion.

Spring provides a set of default type converters for common conversions. Spring Data Aerospike has its own built-in converters in DateConverters and AerospikeConverters classes.

However, in certain cases, custom converters are necessary to handle specific logic or custom serialization requirements. Custom converters allow developers to define precise conversion rules, ensuring data integrity and compatibility between application types and database representations.

In order to add a custom converter you can leverage Spring’s Converter SPI to implement type conversion logic and override customConverters() method available in AerospikeDataConfigurationSupport.

Here is an example:

@Override
protected List<Object> customConverters() {
    return List.of(
        ConverterOrderId.ConverterOrderIdToStringConverter.INSTANCE,
        ConverterOrderId.StringToConverterOrderIdConverter.INSTANCE
    );
}

The converters themselves implement Spring’s Converter SPI:

@WritingConverter
public enum ConverterOrderIdToStringConverter implements Converter<ConverterOrderId, String> {
    INSTANCE;

    @Override
    public String convert(ConverterOrderId source) {
        return source.accountId + "::" + source.orderNumber;
    }
}
@ReadingConverter
public enum StringToConverterOrderIdConverter implements Converter<String, ConverterOrderId> {
    INSTANCE;

    @Override
    public ConverterOrderId convert(String source) {
        String[] parts = source.split("::", 2);
        return new ConverterOrderId(parts[0], Long.parseLong(parts[1]));
    }
}

The converted id can then be used with regular template operations:

// The custom write converter turns the composite id into the Aerospike key on save.
template.save(new ConverterOrderDocument(firstId, "first converted-id order", 1));

// The matching read converter rebuilds the composite id from the stored key value.
ConverterOrderDocument found = template.findById(firstId, ConverterOrderDocument.class);

// Batch operations use the same conversion pair for every id in the request.
template.insertAll(List.of(
    new ConverterOrderDocument(secondId, "second converted-id order", 2),
    new ConverterOrderDocument(thirdId, "third converted-id order", 3)
));

List<ConverterOrderDocument> orders = template.findByIds(List.of(firstId, secondId, thirdId),
    ConverterOrderDocument.class);
boolean secondOrderExists = template.exists(secondId, ConverterOrderDocument.class);

// exists(...) and deleteById(...) also pass the composite id through the key converter.
boolean deleted = template.deleteById(firstId, ConverterOrderDocument.class);

For more examples see the Custom Converters Guide in the demo repository.

Pagination and Sorting in Spring Data Aerospike

Spring Data Aerospike provides support for sorting and pagination of results of repository query methods, allowing to retrieve and display data from database using familiar Spring Data interfaces like Pageable and Slice, and objects like Sort and PageRequest.

Standalone Sorting

Spring Data Aerospike supports standalone sorting for repository query methods. This allows to order the results of a query without applying pagination. It can be achieved by simply providing a Spring Data Sort object as a parameter to your repository method.

Example: Standalone Sorting
public interface PaginationMovieRepository extends AerospikeRepository<PaginationMovieDocument, String> {

    List<PaginationMovieDocument> findByGenre(String genre, Sort sort);

    Page<PaginationMovieDocument> findByReleaseYearLessThan(int releaseYear, Pageable pageable);

    Slice<PaginationMovieDocument> findByReleaseYearGreaterThan(int releaseYear, Pageable pageable);

    Page<PaginationMovieDocument> findAllById(Iterable<String> ids, Pageable pageable);
}
// Sort can be passed by itself when no page metadata is needed.
List<PaginationMovieDocument> scienceFiction = repository.findByGenre(
    "science-fiction", Sort.by("releaseYear").ascending());

Paginated Queries: Returning Page and Slice

Paginated repository queries in Spring Data allow retrieving a subset of data from a larger result set, along with optional sorting, to manage and display data in manageable chunks.

When performing paginated repository queries, Spring Data Aerospike offers two return types:

  • Page: This type extends Slice and includes additional information about the total number of elements and total pages available. This is useful when you need the full context of your data, such as "Page X of Y" or the total record count.

Example: Page usage
// Page requests include content and total-count metadata.
Page<PaginationMovieDocument> page = repository.findByReleaseYearLessThan(
    2010, PageRequest.of(0, 2, Sort.by("releaseYear")));
  • Slice: This type represents a sub-list of data and provides information about whether there’s a next or previous slice available. It can be used for infinite scrolling or continuous loading scenarios where the total count is not immediately required.

Example: Slice usage
// Slice requests fetch one extra row to report whether another slice exists.
Slice<PaginationMovieDocument> slice = repository.findByReleaseYearGreaterThan(
    1990, PageRequest.of(0, 2, Sort.by("releaseYear")));

PageRequest for Pagination

The PageRequest class is used to specify pagination parameters, typically consisting of a pageNumber and pageSize.

  • pageNumber: Represents the requested page. Pages are 0-indexed, meaning pageNumber = 0 refers to the first page, pageNumber = 1 to the second, and so on.

  • pageSize: Defines the maximum number of records to be returned on a single page.

For instance, PageRequest.of(1, 4) would request the second page (offset by 1) containing a maximum of 4 records.

Sorting with Pagination

A key consideration in Spring Data Aerospike is that when query methods receive a PageRequest with an offset (i.e., pageNumber greater than 0), they must also include Sort criteria. This ensures consistent and predictable pagination results, as Aerospike itself does not guarantee natural order across all queries.

This applies to regular queries and to combined ID-plus-filter queries.

Example: Paginated query with sorting
// Sorted offset pagination applies the Sort before selecting the requested page window.
Page<PaginationMovieDocument> secondPage = repository.findByReleaseYearLessThan(
    2020, PageRequest.of(1, 2, Sort.by("releaseYear")));

Pure ID-Based Pagination without Explicit Sorting

The exception is a query that is based only on IDs. For pure ID queries, Spring Data Aerospike can apply offset and limit using the order of the provided IDs. This exception does not apply once ID criteria are combined with other filters.

Example: Pure ID-based pagination
// ID pagination preserves the caller-provided id order when no Sort is supplied.
List<String> movieIds = seedMovies().stream()
    .map(PaginationMovieDocument::getId)
    .toList();
Page<PaginationMovieDocument> secondIdPage = repository.findAllById(movieIds, PageRequest.of(1, 2));

Application-Level Operations

It is important to note that sorting and pagination operations in Spring Data Aerospike are performed at the application level. Spring Data Aerospike first retrieves all query results and then applies the sorting and/or pagination as a post-processing step within the application.

Aerospike Template

Aerospike Template provides a set of features for interacting with the database. It allows lower-level access than a Repository and also serves as the foundation for repositories.

Template is the central support class for Aerospike database operations. It provides the following functionality:

  • Methods to interact with the database

  • Mapping between Java objects and Aerospike Bins (see Object Mapping)

  • Providing connection callback

  • Translating exceptions into Spring’s technology-agnostic DAO exceptions hierarchy

Instantiating AerospikeTemplate

If you are subclassing AbstractAerospikeDataConfiguration then the aerospikeTemplate bean is already present in your context, and you can use it.

@Bean
BlockingTemplateExample blockingTemplateExample(AerospikeTemplate template) {
    return new BlockingTemplateExample(template);
}

An alternative is to instantiate it yourself, you can see the bean in AbstractAerospikeDataConfiguration.

In case if you need to use custom WritePolicy, the persist operation can be used.

For CAS updates save operation must be used.

Methods for interacting with database

AerospikeOperations interface provides operations for interacting with the database (exists, find, insert, update etc.) as well as basic operations with indexes: createIndex, deleteIndex, indexExists.

The names of operations are typically self-descriptive. To read from Aerospike you can use findById, findByIds and find methods, to delete - delete methods, and so on.

// Batch reads by id are useful when the caller already knows the keys to load
List<TemplateMovieDocument> firstTwoMovies = toSortedList(template.findByIds(
    List.of("blocking-template-1", "blocking-template-2"), TemplateMovieDocument.class),
    Comparator.comparing(TemplateMovieDocument::getId));

For indexed documents use find with provided Query object.

Query scienceFiction = matchingGenre("science-fiction");
// find(...), exists(...), and count(...) can reuse the same Query shape.
List<TemplateMovieDocument> queryMatches =
    toSortedList(template.find(scienceFiction, TemplateMovieDocument.class).toList(),
        Comparator.comparing(TemplateMovieDocument::getId));
boolean scienceFictionExists = template.exists(scienceFiction, TemplateMovieDocument.class);
long ninetiesMovieCount = template.count(releasedBetween(1990, 2000), TemplateMovieDocument.class);

Example

The simple case of using the save operation is to save a POJO.

Note
For more information about Id property when inserting or saving see Mapping Conventions: Id Field for more information.
@Document(collection = "sda_examples_blocking_template_movies")
public class TemplateMovieDocument {

    public static final String GENRE_INDEX = "sda_examples_blocking_template_genre_idx";
    public static final String RELEASE_YEAR_INDEX = "sda_examples_blocking_template_year_idx";

    @Id
    private String id;
    private String title;
    private String genre;
    private int releaseYear;
    private int rating;
    private long views;

    public TemplateMovieDocument() {
    }

    public TemplateMovieDocument(String id, String title, String genre, int releaseYear, int rating, long views) {
        this.id = id;
        this.title = title;
        this.genre = genre;
        this.releaseYear = releaseYear;
        this.rating = rating;
        this.views = views;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getGenre() {
        return genre;
    }

    public void setGenre(String genre) {
        this.genre = genre;
    }

    public int getReleaseYear() {
        return releaseYear;
    }

    public void setReleaseYear(int releaseYear) {
        this.releaseYear = releaseYear;
    }

    public int getRating() {
        return rating;
    }

    public void setRating(int rating) {
        this.rating = rating;
    }

    public long getViews() {
        return views;
    }

    public void setViews(long views) {
        this.views = views;
    }
}
// save(...) writes a mapped entity directly through AerospikeTemplate.
template.save(movie);

// findById(...) reads the same record by its mapped @Id value.
TemplateMovieDocument loaded = template.findById(movie.getId(), TemplateMovieDocument.class);

Template also supports projections and record-level mutations:

// The target-class overload maps matching records into a projection DTO.
List<TemplateMovieSummary> summaries = template
    .find(scienceFiction, TemplateMovieDocument.class, TemplateMovieSummary.class)
    .sorted(Comparator.comparingInt(TemplateMovieSummary::getReleaseYear))
    .toList();
TemplateMovieDocument movie =
    new TemplateMovieDocument("blocking-template-mutation", "trix", "science-fiction", 1999, 5, 10);
template.insert(movie);

// add(...) performs an atomic numeric bin mutation on the server.
TemplateMovieDocument viewed = template.add(movie, "views", 5);

// prepend(...) and append(...) mutate string bins without replacing the whole entity.
TemplateMovieDocument prefixed = template.prepend(viewed, "title", "The Ma");
TemplateMovieDocument renamed = template.append(prefixed, "title", " Reloaded");

Use update to write selected fields only, and persist when a custom WritePolicy is needed:

TemplateMovieDocument movie =
    new TemplateMovieDocument("blocking-template-partial", "Primer", "science-fiction", 2004, 4, 100);
template.insert(movie);

// update(..., fields) writes only the selected mapped property.
template.update(new TemplateMovieDocument("blocking-template-partial", null, null, 0, 5, 0),
    List.of("rating"));

TemplateMovieDocument updated = template.findById("blocking-template-partial", TemplateMovieDocument.class);
TemplateMovieDocument createdOnly =
    new TemplateMovieDocument("blocking-template-policy", "Moon", "science-fiction", 2009, 5, 60);
WritePolicy createOnly = WritePolicyBuilder.builder(template.getAerospikeClient().getWritePolicyDefault())
    .recordExistsAction(RecordExistsAction.CREATE_ONLY)
    .build();

// persist(..., WritePolicy) lets one operation override the template default policy.
template.persist(createdOnly, createOnly);

Secondary Indexes

A secondary index (SI) is a data structure that locates all the records in a namespace, or a set within it, based on a bin value in the record. When a value is updated in the indexed record, the secondary index automatically updates.

You can read more about secondary index implementation and usage in Aerospike on the official documentation page.

Why Secondary Index

Let’s consider a simple query for finding by equality:

List<IndexedMovieDocument> findByGenre(String genre);

Notice that findByGenre is not a simple lookup by key, but rather finding all records in a set. Aerospike has 2 ways of achieving this:

  1. Scanning all the records in the set and extracting the appropriate records.

  2. Defining a secondary index on the field genre and using this secondary index to satisfy the query.

The second approach is far more efficient. Aerospike stores the secondary indexes in a memory structure, allowing exceptionally fast identification of the records that match.

It relies on a secondary index having been created.

Ways to Create Secondary Indexes

In SpringData Aerospike secondary indexes can either be created by systems administrators using the asadm tool, or by developers telling SpringData that such an index is necessary.

There are two ways to accomplish this task with the help of SpringData Aerospike:

  1. Using AerospikeTemplate createIndex method.

  2. Using @Indexed annotation on the necessary field of an entity.

Creating Secondary Index via AerospikeTemplate

For more information about AerospikeTemplate see the documentation page.

Setting a secondary index via AerospikeTemplate can be helpful, for example, in cases when an index creation does not change a lot.

Here is an example of a string secondary index for the genre field in the IndexedMovieDocument entity:

// createIndex(...) creates the secondary index required by the derived query method
template.createIndex(IndexedMovieDocument.class, IndexedMovieDocument.GENRE_INDEX, "genre", IndexType.STRING);

Creating Secondary Index using @Indexed annotation

You can use @Indexed annotation on the field where the index is required. Here is an example of a movie document getting indexed by genre:

@Indexed(type = IndexType.STRING, name = GENRE_INDEX)
private String genre;

The annotation allows to specify also bin name, collectionType and ctx (context) if needed. For the details on using @Indexed annotation see Indexed Annotation.

Matching the Secondary Index

Note
In Aerospike, secondary indexes are case-sensitive, they match the exact queries.

Following the query from the example above, assume there was a new requirement to be able to find by genre containing a String (rather than having an equality match):

List<IndexedMovieDocument> findByGenreContaining(String genre);

In this case findByGenreContaining query is not satisfied by the created secondary index. Aerospike would need to scan the data which can be an expensive operation as all records in the set must be read by the Aerospike server, and then the condition is applied to see if they match.

Due to the cost of performing this operation, scans from Spring Data Aerospike are disabled by default.

For the details on how to enable scans see scan operation.

Following the query from the example above, assume there was a new requirement to be able to find by title with an exact match:

List<IndexedMovieDocument> findByTitle(String title);

In this case title is not marked as @Indexed, so SpringData Aerospike is not instructed to create an index on it. Hence, it will scan the repository (a costly operation that could be avoided by using an index).

Note
There are relevant configuration parameters: create indexes on startup and indexes cache refresh frequency.

Indexed Annotation

The @Indexed annotation allows to create secondary index based on a specific field of a Java object. For the details on secondary indexes in Aerospike see Secondary Indexes.

The annotation allows to specify the following parameters:

parameter index type mandatory example

name

index name

yes

"friend_address_keys_idx"

type

index type

yes

IndexType.STRING

bin

indexed bin type

no

"friend"

collectionType

index type

no

IndexCollectionType.MAPKEYS

ctx

context (path to the indexed elements)

no

"address"

Here is an example of creating a complex secondary index for fields of a person’s friend address.

public class IndexedAddress {

    private String street;
    private Integer apartment;
    private String zipCode;
    private String city;

    public IndexedAddress() {
    }

    public IndexedAddress(String street, Integer apartment, String zipCode, String city) {
        this.street = street;
        this.apartment = apartment;
        this.zipCode = zipCode;
        this.city = city;
    }

    public String getStreet() {
        return street;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public Integer getApartment() {
        return apartment;
    }

    public void setApartment(Integer apartment) {
        this.apartment = apartment;
    }

    public String getZipCode() {
        return zipCode;
    }

    public void setZipCode(String zipCode) {
        this.zipCode = zipCode;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }
}
public class IndexedFriend {

    private String name;
    private IndexedAddress address;

    public IndexedFriend() {
    }

    public IndexedFriend(String name, IndexedAddress address) {
        this.name = name;
        this.address = address;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public IndexedAddress getAddress() {
        return address;
    }

    public void setAddress(IndexedAddress address) {
        this.address = address;
    }
}
@Document(collection = "sda_examples_indexed_context_people")
public class IndexedPersonDocument {

    public static final String FRIEND_ADDRESS_KEYS_INDEX = "sda_examples_friend_address_keys_idx";

    @Id
    private String id;

    @Indexed(type = IndexType.STRING, name = FRIEND_ADDRESS_KEYS_INDEX,
        collectionType = IndexCollectionType.MAPKEYS, ctx = "address")
    private IndexedFriend friend;

    public IndexedPersonDocument() {
    }

    public IndexedPersonDocument(String id, IndexedFriend friend) {
        this.id = id;
        this.friend = friend;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public IndexedFriend getFriend() {
        return friend;
    }

    public void setFriend(IndexedFriend friend) {
        this.friend = friend;
    }
}
IndexedAddress address = new IndexedAddress("Main Street", 14, "12345", "Portland");
IndexedFriend friend = new IndexedFriend("Carter", address);

IndexedPersonDocument person = repository.save(new IndexedPersonDocument("indexed-context-1", friend));

An IndexedPersonDocument object in this example has a field called "friend" (IndexedFriend object). An IndexedFriend object has a field called "address" (IndexedAddress object). So when "friend" field is set to an IndexedFriend with existing IndexedAddress, we have a person in the example above with a friend (carter) who has a particular address.

IndexedAddress object on its own has certain fields: street, apartment, zipCode, city.

Note
In Aerospike DB a POJO (such as Address) is represented by a Map, so the fields of POJO become map keys.

Thus, if we want to index by Address object fields, we set collectionType to IndexCollectionType.MAPKEYS.

Ctx parameter represents context, or path to the necessary element in the specified bin ("friend") - which is "address", because we want to index by fields of friend’s address.

Secondary Index Context DSL

Secondary index context (ctx parameter in @Indexed annotation) represents path to a necessary element in hierarchy. It uses infix notation.

The document path is described as dot-separated context elements (e.g., "a.b.[2].c") written as a string. A path is made of singular path elements and ends with one (a leaf element) or more elements (leaves) - for example, "a.b.[2].c.[0:3]".

Path Element Matches Notes

"a"

Map key “a”

Single element by key

"1" or '1'

Map key (numeric string) “1”

1

Map key (integer) 1

{1}

Map index 1

{=1}

Map value (integer) 1

{=bb}

Map value “bb”

Also {="bb"}

{="1"} or {='1'}

Map value (string) “1”

{#1}

Map rank 1

[1]

List index 1

[=1]

List value 1

[#1]

List rank 1

Example

Let’s consider a Map bin example:

{
  1: a,
  2: b,
  4: d,
  "5": e,
  a: {
    55: ee,
    "66": ff,
    aa: {
      aaa: 111,
      bbb: 222,
      ccc: 333,
    },
    bb: {
      bba: 221,
      bbc: 223
    },
    cc: [ 22, 33, 44, 55, 43, 32, 44 ],
    dd: [ {e: 5, f:6}, {z:26, y:25}, {8: h, "9": j} ]
  }
}

So the following will be true:

Path CTX Matched Value

a.aa.aaa

[mapKey("a"), mapKey("aa"), mapKey("aaa")]

111

a.55

[mapKey("a"), mapKey(55)]

ee

a."66"

[mapKey("a"), mapKey("66")]

ff

a.aa.{2}

[mapKey("a"), mapKey("aa"),mapIndex(2)]

333

a.aa.{=222}

[mapKey("a"), mapKey("aa"),mapValue(222)]

222

a.bb.{#-1}

[mapKey("a"), mapKey("bb"),mapRank(-1)]

223

a.cc.[0]

[mapKey("a"), mapKey("cc"),listIndex(0)]

22

a.cc.[#1]

[mapKey("a"), mapKey("cc"),listRank(1)]

32

a.cc.[=44]

[mapKey("a"), mapKey("cc"),listValue(44)]

[44, 44]

a.dd.[0].e

[mapKey("a"), mapKey("dd"),listIndex(0), mapKey("e")]

5

a.dd.[2].8

[mapKey("a"), mapKey("dd"),listIndex(2), mapKey(8)]

h

a.dd.[-1]."9"

[mapKey("a"), mapKey("dd"),listIndex(-1), mapKey("9")]

j

a.dd.[1].{#0}

[mapKey("a"), mapKey("dd"),listIndex(1), mapRank(0)]

y

Note
There are relevant configuration parameters: create indexes on startup and indexes cache refresh frequency.

Scan Operation

A scan can be an expensive operation as all records in the set must be read by the Aerospike server, and then the condition is applied to see if they match.

Due to the cost of performing this operation, scans from Spring Data Aerospike are disabled by default.

Enabling Scan

If the cost of the scans is acceptable to an organization, they can be enabled by setting scansEnabled parameter to true.

spring.data.aerospike.scans-enabled=true
Note
Once this flag is enabled, scans run whenever needed with no warnings. This may or may not be optimal in a particular use case.

Caching

Caching is the process of storing data in a cache or temporary storage location, usually to improve application performance and make data access faster.

The caching process also provides an efficient way to reuse previously retrieved or computed data. The cache is used to reduce the need for accessing the underlying storage layer which is slower.

Spring Cache with Aerospike database allows you to use annotations such as @Cacheable, @CachePut and @CacheEvict that provide a fully managed cache store using Aerospike database.

Introduction

In this example, we are going to use the annotations on BlockingCachingMovieService methods to create/read/update and delete movie data from the cache.

If a CachedMovie is stored in the cache, calling a method with @Cacheable annotation will fetch the movie from the cache instead of executing the method’s body responsible for the underlying data lookup.

If the CachedMovie does not exist in the cache, the movie data will be fetched from the database and put in the cache for later usage (a “cache miss”).

With Spring Cache and Aerospike database, we can achieve that with only a few lines of code.

Motivation

Let’s say that we are using another database as our main data store. We don’t want to fetch the results from it every time we request the data, instead, we want to get the data from a cache layer.

There is a number of benefits of using a cache layer, here are some of them:

  1. Performance: Aerospike can work purely in RAM but reading a record from Aerospike in Hybrid Memory (primary index in memory, data stored on Flash drives) is extremely fast as well (~1ms).

  2. Reduce database load: Moving a significant part of the read load from the main database to Aerospike can help balance the resources on heavy loads.

  3. Scalability: Aerospike scales horizontally by adding more nodes to the cluster, scaling a relational database might be tricky and expensive, so if you are facing a read heavy load you can easily scale up the cache layer.

Example

We will not use an actual database as our main data store for this example. Instead, the example simulates a database read by returning a specific CachedMovie.

Configuration

The runnable example uses BlockingCachingAerospikeConfiguration, which extends AbstractAerospikeDataConfiguration and enables Spring cache support. AbstractAerospikeDataConfiguration supplies the IAerospikeClient and MappingAerospikeConverter; the example declares the cache-specific bean:

// AerospikeCacheManager stores cache entries in a dedicated example cache set.
@Bean
CacheManager cacheManager(IAerospikeClient client, MappingAerospikeConverter converter,
                          AerospikeCacheKeyProcessor cacheKeyProcessor,
                          @Value("${spring.data.aerospike.namespace:test}") String namespace) {
    AerospikeCacheConfiguration cacheConfiguration =
        new AerospikeCacheConfiguration(namespace, CacheEntryDocument.SET_NAME);
    return new AerospikeCacheManager(client, converter, cacheConfiguration, cacheKeyProcessor);
}

AerospikeCacheManager

The heart of the cache layer, to define an AerospikeCacheManager you need:

  1. IAerospikeClient, supplied by AbstractAerospikeDataConfiguration.

  2. MappingAerospikeConverter, supplied by the mapping configuration.

  3. AerospikeCacheKeyProcessor, used to map Spring cache keys to Aerospike keys.

  4. AerospikeCacheConfiguration, a default cache configuration that applies when creating new caches. Cache configuration contains a namespace, a set (null by default meaning write directly to the namespace w/o specifying a set) and an expirationInSeconds (AKA TTL, default is 0 meaning use Aerospike server’s default).

Note
A cache name is only a link to the cache configuration. The runnable example configures a dedicated sda_examples_cache_entries set instead of leaving the cache set null. That keeps cleanup constrained to records owned by the example.

Objects

CachedMovie
public class CachedMovie {

    @Id
    private String id;
    private String title;

    public CachedMovie() {
    }

    public CachedMovie(String id, String title) {
        this.id = id;
        this.title = title;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }
}

Cache annotations

BlockingCachingMovieService
@Cacheable(cacheNames = "movies", key = "#id")
public CachedMovie findMovie(String id) {
    loads++;
    return new CachedMovie(id, "Sneakers");
}
@CachePut(cacheNames = "movies", key = "#movie.id")
public CachedMovie updateMovie(CachedMovie movie) {
    return movie;
}
@CacheEvict(cacheNames = "movies", key = "#id")
public void evictMovie(String id) {
}

The cache annotations require a cache name, declared here through cacheNames = "movies". The AerospikeCacheManager maps that cache name to the configured Aerospike cache storage (Configuration > AerospikeCacheManager).

Usage

The runnable example calls the cached service methods through a Spring-managed bean, so cache advice is applied.

Cache hit
// Spring calls this service through a proxy, so @Cacheable can store the first result.
CachedMovie first = service.findMovie("cache-movie-1");
// The second call has the same cache key and is served from Aerospike-backed cache storage.
CachedMovie cached = service.findMovie("cache-movie-1");
Cache update
// @CachePut updates the cache entry while still running the service method body.
service.updateMovie(new CachedMovie("cache-movie-1", "Sneakers Updated"));
// The next lookup reads the replacement value from the cache without another load.
CachedMovie updated = service.findMovie("cache-movie-1");
Cache eviction
// @CacheEvict removes the cached value so the following @Cacheable call reloads it.
service.evictMovie("cache-movie-1");
CachedMovie reloaded = service.findMovie("cache-movie-1");

Transactions

In the context of database operations, a transaction is a sequence of statements that are executed as a single unit of work. Transactions typically follow the A.C.I.D. principle:

  1. Atomicity ensures that a transaction is treated as a single, indivisible unit; either all operations within the transaction are completed successfully, or none of them are applied.

  2. Consistency ensures that a transaction brings the database from one valid state to another, maintaining all predefined rules and constraints.

  3. Isolation ensures that transactions operate independently of one another, so that intermediate states of a transaction are not visible to others.

  4. Durability guarantees that once a transaction has been committed, its changes are permanent.

For more details, see Spring Transaction Management.

Note
Aerospike transactions require Aerospike Server 8.0.0 or later and a transaction-enabled namespace. The runnable examples report SKIPPED when those requirements are not met.

Choosing Transaction Management Model

Spring offers two models of transaction management: declarative and programmatic. When choosing between them, consider the complexity and requirements of your application.

Declarative transaction management is typically preferred for its simplicity and ease of maintenance, as it allows to define transaction boundaries using annotations without altering the business logic code. This model suits for most applications where transaction boundaries are straightforward and the business logic does not require intricate transaction control.

Programmatic transaction management is chosen when you need more fine-grained control over transactions, such as handling complex transaction scenarios. This approach is useful in situations where specific transaction behavior needs to be dynamically adjusted or when integrating with legacy code that requires explicit transaction management. When using this approach, it is possible to explicitly start, commit, and rollback transactions within the code if needed.

In general, declarative management is more straightforward and reduces boilerplate code, while programmatic management offers more control but at the cost of increased complexity.

Declarative Transaction Management

Declarative transaction management uses annotations to define transaction boundaries and behavior without changing the business logic code. It’s usually more common in Spring applications due to its simplicity and ease of use.

You can annotate methods and/or classes with @Transactional to automatically handle transactions, including committing or rolling back based on execution.

Couple other things needed to start working with transactions using declarative approach:

  1. A transaction manager must be specified in your Spring Configuration.

  2. Spring Configuration must be annotated with the @EnableTransactionManagement annotation.

Example

Here is an example that shows applying @Transactional to a method. It ensures that the entire method runs within a transaction context, and Spring manages the transaction lifecycle (automatically committing the transaction if the method succeeds or rolling back if it encounters an exception).

// Spring uses this manager for @Transactional blocking Aerospike operations.
@Bean
public AerospikeTransactionManager aerospikeTransactionManager(IAerospikeClient client) {
    return new AerospikeTransactionManager(client);
}

The service method declares the transaction boundary:

@Transactional(transactionManager = "aerospikeTransactionManager")
public void saveCommittedMovies() {
    repository.save(new BlockingTransactionalMovieDocument(
        "blocking-transaction-1", "The Conversation", "committed"));
    repository.save(new BlockingTransactionalMovieDocument(
        "blocking-transaction-2", "Michael Clayton", "committed"));
}

@Transactional(transactionManager = "aerospikeTransactionManager")
public void rollbackDuplicateInsert() {
    BlockingTransactionalMovieDocument duplicate =
        new BlockingTransactionalMovieDocument("blocking-transaction-duplicate", "Duplicate", "rollback");

    template.insert(duplicate);
    template.insert(duplicate);
}

Programmatic Transaction Management

Programmatic transaction management gives developers fine-grained control over transactions through code. This approach involves manually managing transactions using Spring’s API.

The Spring Framework offers two ways for programmatic transaction management:

  1. Using TransactionTemplate or TransactionalOperator which use callback approach (for programmatic transaction management in imperative code it is typically recommended to use TransactionTemplate; for reactive code, TransactionalOperator is preferred).

  2. Directly using a TransactionManager implementation.

Example

Here is an example that shows using a programmatic reactive transaction. You can define a TransactionalOperator and wrap a reactive chain so the transaction is automatically committed if successful or rolled back if an exception occurs.

// TransactionalOperator applies the manager to a reactive publisher chain.
@Bean
public TransactionalOperator transactionalOperator(AerospikeReactiveTransactionManager transactionManager) {
    return TransactionalOperator.create(transactionManager, new DefaultTransactionDefinition());
}

Then wrap the reactive chain with TransactionalOperator:

// TransactionalOperator commits both reactive writes when the chain completes.
repository.save(new ReactiveTransactionalMovieDocument(
        "reactive-transaction-1", "The Conversation", "committed"))
    .then(repository.save(new ReactiveTransactionalMovieDocument(
        "reactive-transaction-2", "Michael Clayton", "committed")))
    .then()
    .as(transactionalOperator::transactional)
    .block();

Rollback happens when an operation in the transactional chain fails:

// The second insert fails with the same key, causing the reactive transaction to roll back.
template.insert(duplicate)
    .then(template.insert(duplicate))
    .then()
    .as(transactionalOperator::transactional)
    .block();

Aerospike Operations Support

Behind the curtains Aerospike transaction manager uses an Aerospike feature allowing to group together multiple Aerospike operation requests into a single transaction.

Note
Not all the Aerospike operations can participate in transactions.

Here is a list of Aerospike operations that participate in transactions:

  1. all single record operations (insert, save, update, add, append, persist, findById, exists, delete)

  2. all batch operations without query (insertAll, saveAll, findByIds, deleteAll)

  3. queries that include id (e.g., repository queries like findByIdAndName)

The following operations do not participate in transactions (will not become part of a transaction if included into it):

  1. truncate

  2. queries that do not include id (e.g., repository queries like findByName)

  3. operations that perform info commands (e.g., indexExists)

  4. operations that perform scans (using ScanPolicy)

Appendix