1. Overview

In this tutorial, we’ll learn how to use the DataStax Java Driver to map objects to Cassandra tables.

We’ll look at how to define entities, create DAOs, and perform CRUD operations on Cassandra tables using the Java Driver.

2. Project Setup

We’ll use the Spring Boot framework to create a simple application that will interact with a Cassandra database. We’ll create the tables, entities, and DAOs using the Java Driver. Then, we’ll use the DAOs to perform CRUD operations on the tables.

2.1. Dependencies

Let’s start by adding the required dependencies to our project. We’ll use the Spring Boot starter for Cassandra to connect to the database:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-cassandra</artifactId>
</dependency>

In addition, we’ll add the java-driver-mapper-runtime dependency to map objects to Cassandra tables:

<dependency>
    <groupId>com.datastax.oss</groupId>
    <artifactId>java-driver-mapper-runtime</artifactId>
    <version>4.13.0</version>
</dependency>

Finally, let’s configure the annotations processor to generate the DAOs and the mappers at compile time:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.8.1</version>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>com.datastax.oss</groupId>
                <artifactId>java-driver-mapper-processor</artifactId>
                <version>4.13.0</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

3. Cassandra Entities

Let’s define an entity that we can use to map to a Cassandra table. We’ll create a User class that will represent the user_profile table:

@Entity
public class User {
    @PartitionKey
    private int id;
    private String userName;
    private int userAge;
    
    // getters and setters
}

The @Entity annotation tells the mapper to map this class to a table. The @PartitionKey annotation tells the mapper to use the id field as the partition key of the table.

The mapper uses the default constructor to create new instances of the entity. Therefore, we need to ensure that the default no-arg constructor is accessible. If we create a non-default constructor, we need to explicitly declare the default constructor.

By default, entities are mutable, so we must declare getters and setters. We’ll see how to change this behavior later in the tutorial.

3.1. Naming Strategy

The @NamingStrategy annotation allows us to specify a naming convention for the table and columns. The default naming strategy is NamingConvention.SNAKE_CASE_INSENSITIVE. It converts the class and field names to snake-case when interacting with the database.

For example, by default, the userName field is mapped to the user_name column in the database. If we change the naming strategy to NamingConvention.LOWER_CAMEL_CASE, the userName field will be mapped to the userName column in the database.

3.2. Property Strategy

The @PropertyStrategy annotation specifies how the mapper will access the properties of the entity. It has three properties – mutablegetterStyle, and setterStyle.

The mutable property tells the mapper whether the entity is mutable or not. It’s true by default. If we set it to false, the mapper will use the “all columns” constructor to create new instances of the entity.

The “all columns” constructor is a constructor that takes all the columns of the table as parameters in the same order as they are defined in the entity. For example, if we have an entity with the following fields: iduserName, and userAge, the “all columns” constructor will look like this:

public User(int id, String userName, int userAge) {
    this.id = id;
    this.userName = userName;
    this.userAge = userAge;
}

Apart from this, the entity should have getters but doesn’t need to have setters. Optionally, and as per convention, the fields can be final.

The getterStyle and setterStyle properties tell the mapper how to find the getters and setters for the entity. They both have two possible values – FLUENT and JAVA_BEANS.

If set to FLUENT, the mapper will look for methods with the same name as the field. For example, if the field is userName , the mapper will look for a method called userName().

If set to JAVA_BEANS, the mapper will look for methods with the get or set prefix. For example, if the field is userName , the mapper will look for a method called getUserName().

For normal Java classes, the getterStyle and setterStyle properties are set to JAVA_BEANS by default. However, for Records, they are set to FLUENT by default. Similarly, the mutable property is set to false by default for Records.

3.3. @CqlName

The @CqlName annotation specifies the name of the table or column in the Cassandra database. Since we want to map the User entity to the user_profile table and the userName field to the username column in the table, we can use the @CqlName annotation:

@Entity
@CqlName("user_profile")
public class User {
    @PartitionKey
    private int id;
    @CqlName("username")
    private String userName;
    private int userAge;
    
    // getters and setters
}

The annotation is not required for fields that follow the default or specified naming strategy.

3.4. @PartitionKey and @ClusteringColumn

Partition keys and clustering columns are defined using the @PartitionKey and @ClusteringColumn annotations respectively. In our case, the id field is the partition key. If we wanted to order the rows by the userAge field, we could add the @ClusteringColumn annotation to the userAge field.

@ClusteringColumn
private int userAge;

Multiple partition keys and clustering columns can be defined in the entity. The order of partitioning can be specified by passing the order inside the annotation. For example, if we wanted to partition the table by id and then by userName, we could do the following:

@PartitionKey(1)
private int id;
@PartitionKey(2)
@CqlName("username")

And similarly for clustering columns.

3.5. @Transient

The @Transient annotation tells the mapper to ignore the field. A field marked as @Transient will not be mapped to a column in the database. It will only be part of the Java object. The mapper will not try to read or write the value of the field from or to the database.

In addition to the @Transient annotation on fields, we can also use the @TransientProperties annotation on the entity to mark multiple fields as transient.

3.6. @Computed

Fields marked as @Computed are mapped to a column in the database but they cannot be set by the client. They are computed by database functions stored on the server.

Let’s say we want to add a new field to the entity that stores the write timestamp of the row. We can add an entity like the following:

@Computed("writetime(userName)")
private long writeTime;

When a User record is created, the mapper will call the writetime() method and set the value of the field writeTime to the result of the function.

4. Hierarchical Entities

Entities can be defined using inheritance. This can be a good way to model entities that have a lot of common fields.

For example, we can have a user_profile table that has the common fields for all users. We can then have an admin_profile table that has additional fields for admins.

In such a case, we can define an entity for the user_profile table and then extend it to create an entity for the admin_profile table:

@Entity
@CqlName("admin_profile")
public class Admin extends User {
    private String role;
    private String department;
    
    // getters and setters
}

The Admin entity will have all the fields of the User entity and the additional fields of role and department. We should note that the @Entity annotation is required only on concrete classes. It’s not required on abstract classes or interfaces.

4.1. Immutability in Hierarchical Entities

If the child class is made immutable, the “all columns” constructor of the child class needs to call the “all columns” constructor of the parent class. In this case, the parameter ordering should be such that the parameters of the child class are passed first and then the parameters of the parent class.

For example, we might create an “all columns” constructor for the Admin entity:

public Admin(String role, String department, int id, String userName, int userAge) {
    super(id, userName, userAge);
    this.role = role;
    this.department = department;
}

4.2. @HierarchyScanStrategy

The @HierarchyScanStrategy annotation specifies how the mapper should scan the hierarchy of the entity for annotations.

It has three fields:

  • scanAncestors – by default, it is set to true and the mapper will scan the entire hierarchy of the entity. When set to false, the mapper will only scan the entity.
  • highestAncestor – when set to a class, the mapper will scan the hierarchy of the entity until it reaches the specified class. The classes above the specified class will not be scanned.
  • includeHighestAncestor – when set to true, the mapper will also scan the specified highestAncestor. By default, the mapper will only scan the classes above the specified class.

Let’s see how we can use these annotations:

@Entity
@HierarchyScanStrategy(highestAncestor = User.class, includeHighestAncestor = true)
public class Admin extends User {
    private String role;
    private String department;
    
    // getters and setters
}

By setting the highestAncestor property to User.class, the mapper will scan the hierarchy of the Admin entity until it reaches the User entity.

We have set the includeHighestAncestor to true so the mapper will also scan the User entity. By default, the property is set to false so the mapper would not have scanned the User entity.

The scanner will not scan any entities that the User entity extends.

5. DAO Interface

The mapper provides a DAO interface that performs operations on the Cassandra database. We can use the @Dao annotation to create a DAO interface. The methods of the interface must have one of the annotations provided by the mapper.

5.1. CRUD Annotations

The mapper provides the below annotations to perform basic CRUD operations on the database:

  • @Insert – inserts a row into the database
  • @Select – creates a select query with the specified parameters and returns the result. The result can be a single entity or a list of entities.
  • @Update – updates a row in the database
  • @Delete – deletes a row from the database

Let’s see how we can use these annotations:

@Dao
public interface UserDao {

    @Insert
    void insertUser(User user);
    
    @Select
    User getUserById(int id);
    
    @Select
    PagingIterable<User> getAllUsers();
    
    @Update
    void updateUser(User user);
    
    @Delete
    void deleteUser(User user);
}

The important point to take care of is that the parameters of the methods should match the allowed parameters of the annotations.

The insert, update and delete methods should have a single parameter which is the entity to be inserted, updated, or deleted.

The select method has two options:

  • The full primary key of the entity – the parameters start with the partition keys and then the clustering columns in the order they are being applied in the entity. In this case, the method will return a single entity.
  • A subset of the primary key – In this case, the method will return a list of entities.

5.2. Custom Queries Using @Query

There are two ways to perform custom queries on the database. We can use the @Query or the @QueryProvider annotation.

Let’s look at the @Query annotation first:

@Dao
public interface UserDao {

    @Query("select * from user_profile where user_age > :userAge ALLOW FILTERING")
    PagingIterable<User> getUsersOlderThan(int userAge);
}

The ALLOW FILTERING clause is required because we are performing a query on a secondary index without specifying the partition key. Such queries may take longer time and should be avoided on large datasets.

When it’s a simple query, we can use the @Query annotation. When the query is complex, it may be required to use the core driver to execute queries. We can use the @QueryProvider annotation to do this.

5.3. Custom Queries Using @QueryProvider

The @QueryProvider annotation takes a class that takes care of the query execution and returns the result.

Let’s create a query provider for the above query:

public class UserQueryProvider {

    private final CqlSession session;
    private final EntityHelper<User> userHelper;

    public UserQueryProvider(MapperContext context, EntityHelper<User> userHelper) {
        this.session = context.getSession();
        this.userHelper = userHelper;
    }

    public PagingIterable<User> getUsersOlderThanAge(String age) {
        SimpleStatement statement = QueryBuilder.selectFrom("user_profile")
          .all()
          .whereColumn("user_age")
          .isGreaterThan(QueryBuilder
            .bindMarker(age))
          .build();
        PreparedStatement preparedSelectUser = session.prepare(statement);
        return session
          .execute(preparedSelectUser.getQuery())
          .map(result -> userHelper.get(result, true));
    }
}

The entity helper is used to convert the result of the query to the entity. The mapper automatically creates the entity helper beans for entities so the bean will exist for auto wiring.

Now, we can use the @QueryProvider annotation to use the query provider:

@Dao
public interface UserDao {

    @QueryProvider(providerClass = UserQueryProvider.class, entityHelpers = User.class, providerMethod = "getUsersOlderThanAge")
    PagingIterable<User> getUsersOlderThan(int age);
}

The providerClass field specifies the query provider class and the entityHelpers field specifies the entity classes that are used in the query. The providerMethod field specifies the method in the query provider class that executes the query.

It is not required to specify the entityHelpers field if the query does not use any entities. It’s also not necessary to specify the providerMethod field if the method name is the same as the method name in the DAO interface.

5.4. @GetEntity and @SetEntity

Sometimes, it may be required to switch between Cassandra’s core driver’s and the mapper’s operations. If such a requirement arises, we can use the @GetEntity and @SetEntity annotations to define methods that convert between the two.

Let’s see how we can use these annotations:

@GetEntity
User getUser(Row row);

@SetEntity
UdtValue setUser(UdtValue udtValue, User user);

The @GetEntity annotation tells the mapper that the method converts a Row to an entity. This helps when we want to use the core driver to perform a query and then convert the result to an entity.

The @SetEntity annotation tells the mapper that the method converts an entity to a SettableByName object. The first parameter is the object which will be updated and returned. The second parameter is the entity which will provide the values to set.

If the SettableByName object is a statement like BoundStatement, the mapper will automatically bind the parameters to the statement and return the statement. This is useful when making statements using the core driver but using entities for other operations.

When using a value object like UdtValue, the method converts the User object to a generic UdtValue object. This is useful when using entities for database interactions but using the core driver library for result sets.

5.5. Counter Tables

Counters in Cassandra are stored in separate tables. The mapper provides a way to increment the counter value in a counter table. First, let’s create an entity for the counter table:

@Entity
public class Counter {

    @PartitionKey
    private String id;
    private long count;
  
    // getters, setters and constructors
}

We should note that a counter table should only have a counter column and partition keys. The counter column should be of type long. There can be no other data columns in the table.

5.6. Incrementing Counters

Now, we can create a DAO for the counter table:

@Dao
public interface CounterDao {

    @Increment(entityClass = Counter.class)
    void incrementCounter(String id, long count);

    @Select
    Counter getCounterById(String id);
}

Let’s look at the @Increment method first. It is used to create and update counters.

Firstly, the entity class needs to be provided with the entityClass attribute. Next, the method will take all partition key columns and clustering key columns as parameters. Finally, the last parameter will be the value by which we want to increment the field.

To find the column to increment, we may annotate the last parameter with @CqlName and specify the exact column name. If the parameter is not annotated, the mapper looks for a field with the same name as the parameter. In this case, the parameter name is count and the mapper looks for a field with the name count in the entity class.

A DAO for a counter table can only have @Increment@Select, and @Delete methods.

The mapper does not allow us to update the whole counter row using the @Update method. We also cannot use the @Insert method to insert a new row into the counter table. The mapper will throw an exception if we try to do so. The @Increment method itself will create a new row if it does not exist.

6. Mapper Interface

The mapper interface is the entry point to the mapper. It provides methods to obtain DAO instances. We can use the @Mapper annotation to create a mapper interface. For methods that return DAO instances, we can use the @DaoFactory annotation.

Let’s create a mapper interface:

@Mapper
public interface DaoMapper {

    @DaoFactory
    UserDao getUserDao(@DaoKeyspace CqlIdentifier keyspace);

    @DaoFactory
    CounterDao getUserCounterDao(@DaoKeyspace CqlIdentifier keyspace);
}

The @DaoFactory annotation creates a DAO instance. The @DaoKeyspace annotation specifies the keyspace to use for the DAO instance. The interface also takes care of the lifecycle of the DAO instance. The DAO instance has the same lifecycle as the Cassandra session.

7. Testing

Let’s see how to test the mapper. We’ll create a test class that will use the mapper-provided DAOs to perform operations on the database.

Let’s start by creating a test class, creating tables in Cassandra, and setting up DAOs.

To run the tests, an instance of the Cassandra database should be running and a connection should be configured. Alternatively, we can use testcontainers to set up a temporary instance.

7.1. Creating Tables and DAOs

Before using the DAOs, we need to create the tables. We can either create the tables by running queries directly on the Cassandra database or we can use the CQLSession to create the tables programmatically.

Let’s create the tables by executing the CQL statements inside the setupCassandra() method:

class MapperLiveTest {

    static UserDao userDao;
    static CounterDao counterDao;

    @BeforeAll
    static void setupCassandraConnectionProperties() {
        System.setProperty("spring.data.cassandra.keyspace-name", KEYSPACE_NAME);
        System.setProperty("spring.data.cassandra.contact-points", cassandra.getHost());
        System.setProperty("spring.data.cassandra.port", String.valueOf(cassandra.getMappedPort(9042)));
        setupCassandra(new InetSocketAddress(cassandra.getHost(), 
            cassandra.getMappedPort(9042)), cassandra.getLocalDatacenter());
    }

    static void setupCassandra(InetSocketAddress cassandraEndpoint, String localDataCenter) {
        CqlSession session = CqlSession.builder()
            .withLocalDatacenter(localDataCenter)
            .addContactPoint(cassandraEndpoint)
            .build();

        String createKeyspace = "CREATE KEYSPACE IF NOT EXISTS baeldung " + 
            "WITH replication = {'class':'SimpleStrategy', 'replication_factor':1};";
        String useKeyspace = "USE baeldung;";
        String createUserTable = "CREATE TABLE IF NOT EXISTS user_profile " + 
            "(id int, username text, user_age int, writetime bigint, PRIMARY KEY (id, user_age)) " + 
            "WITH CLUSTERING ORDER BY (user_age DESC);";
        String createAdminTable = "CREATE TABLE IF NOT EXISTS admin_profile " + 
            "(id int, username text, user_age int, role text, writetime bigint, department text, " + 
            "PRIMARY KEY (id, user_age)) " + "WITH CLUSTERING ORDER BY (user_age DESC);";
        String createCounter = "CREATE TABLE IF NOT EXISTS counter " + 
            "(id text, count counter, PRIMARY KEY (id));";

        session.execute(createKeyspace);
        session.execute(useKeyspace);
        session.execute(createUserTable);
        session.execute(createAdminTable);
        session.execute(createCounter);

        DaoMapper mapper = new DaoMapperBuilder(session).build();
        userDao = mapper.getUserDao(CqlIdentifier.fromCql("baeldung"));
        counterDao = mapper.getUserCounterDao(CqlIdentifier.fromCql("baeldung"));
    }

    // ...
}

We have created queries to create the keyspace, tables, and counter table.

We have also created a DaoMapper instance and obtained DAO instances from it.

The annotation processor generates the DaoMapperBuilder class automatically. The builder takes a CqlSession instance as a parameter and returns a DaoMapper instance. The DaoMapper methods, as defined above, provide the DAO instances.

7.2. Testing User DAO

Let’s write some tests to look at the syntax for calling the DAO methods. We’ll start by testing the UserDao instance.

Let’s create a user and retrieve it from the database:

@Test
void givenUser_whenInsert_thenRetrievedDuringGet() {
    User user = new User(1, "JohnDoe", 31);
    userDao.insertUser(user);
    User retrievedUser = userDao.getUserById(1);
    Assertions.assertEquals(retrievedUser.getUserName(), user.getUserName());
}

We have created a User object and inserted it into the database using the insertUser() method. We then retrieved the user from the database using the getUserById() method and verified that the user name is the same.

Let’s test the query provider method:

@Test
void givenUser_whenGetUsersOlderThan_thenRetrieved() {
    User user = new User(2, "JaneDoe", 20);
    userDao.insertUser(user);
    List<User> retrievedUsers = userDao.getUsersOlderThanAge(30).all();
    Assertions.assertEquals(retrievedUsers.size(), 1);
}

We have added a new user to the database and then retrieved all users older than 30 using the getUsersOlderThanAge() method.

The getUsersOlderThanAge() method returns a PagingIterable instance. We can use the all() method to retrieve all the results.

The query will only return one user.

7.3. Testing Counter DAO

Finally, let’s look at how to use counters. Let’s create a test that increments a counter:

@Test
void givenCounter_whenIncrement_thenIncremented() {
    Counter users = counterDao.getCounterById("users");
    long initialCount = users != null ? users.getCount(): 0;

    counterDao.incrementCounter("users", 1);

    users = counterDao.getCounterById("users");
    long finalCount = users != null ? users.getCount(): 0;

    Assertions.assertEquals(finalCount - initialCount, 1);
}

We first get the initial count of the counter, then increment the counter by 1 and get the final count from the database. We then verify that the final count is 1 more than the initial count.

8. Conclusion

In this article, we have looked at the DataStax Java Driver Mapper. We have seen how to use the mapper to perform CRUD operations on tables and counters.

We’ve also seen how to use the mapper to use query providers when predefined DAO methods are not enough.

The code examples used in this article can be found over on GitHub.

Course – LSD (cat=Persistence)

Get started with Spring Data JPA through the reference Learn Spring Data JPA course:

>> CHECK OUT THE COURSE
res – Persistence (eBook) (cat=Persistence)
Comments are closed on this article!