Spring Boot Cassandra CRUD Example

July 26, 2023 · 837 words · 4 min

Apache Cassandra is an open source NoSQL distributed database trusted by thousands of companies for scalability and high availability without compromising performance. Linear scalability and proven fault-tolerance on commodity hardware or cloud infrastructure make it the perfect platform for mission-critical data.

In this article, I’ll share a whole project to demonstrate how to perform CRUD operation in Cassandra with Spring Boot.

Note: Pagination in Cassandra is a little tricky, Cassandra don’t support offset-based limit, so we need to use cursor-based limit to implement pagination.

Project Source Code

database setup

Just following the Quick Start of Cassandra official document to install a local Cassandra instance in Docker. Since we use Spring Data Cassandra to operate Cassandra, there is no DDL required.

pom.xml

I use the Spring Initializer to create a simple project with web and cassandra starter. This is the content of pom.xml.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>cassandra-timeline</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>cassandra-timeline</name>
    <description>cassandra-timeline</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-cassandra</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

CassandraApplication.java

package com.example.cassandratimeline;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CassandraApplication {

    public static void main(String[] args) {
        SpringApplication.run(CassandraApplication.class, args);
    }
}

application.yml

spring:
  cassandra:
    schema-action: CREATE_IF_NOT_EXISTS
    local-datacenter: datacenter1
    keyspace-name: spring_cassandra

Vet.java

Vet is our core model definition. In this example, I just set the partition key (id) to a fixed number to ensure that all data is in the same partition, so we can get automatically sorted data, which is not a best practice, it’s just for demonstration.

package com.example.cassandratimeline;

import java.time.LocalDateTime;
import java.util.Set;
import org.springframework.data.cassandra.core.cql.Ordering;
import org.springframework.data.cassandra.core.cql.PrimaryKeyType;
import org.springframework.data.cassandra.core.mapping.PrimaryKeyColumn;
import org.springframework.data.cassandra.core.mapping.Table;

@Table
public class Vet {

    @PrimaryKeyColumn(ordinal = 0, type = PrimaryKeyType.PARTITIONED)
    private int id;

    private String firstName;
    private String lastName;
    private Set<String> specialties;

  	// cluster key, so we can get automatically sorted data
    @PrimaryKeyColumn(ordinal = 1, type = PrimaryKeyType.CLUSTERED, ordering = Ordering.DESCENDING, value = "created_at")
    private LocalDateTime createdAt;

    public Vet(int id, String firstName, String lastName, Set<String> specialties, LocalDateTime createdAt) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
        this.specialties = specialties;
        this.createdAt = createdAt;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }

    public int getId() {
        return id;
    }

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

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public Set<String> getSpecialties() {
        return specialties;
    }

    public void setSpecialties(Set<String> specialties) {
        this.specialties = specialties;
    }
}

VetRepository.java

package com.example.cassandratimeline;

import java.util.UUID;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.repository.CrudRepository;

public interface VetRepository extends CrudRepository<Vet, UUID> {
    Slice<Vet> findAll(Pageable pageable);
}

VetController.java

Cassandra don’t support offset-based limit, so the following code uses a cursor-based limit to implement pagination.

Cassandra returns a PageState of ByteBuffer, so we need to encode it to Base64 string to achive transimition in the URL.

package com.example.cassandratimeline;

import java.nio.ByteBuffer;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.cassandra.core.query.CassandraPageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/vet")
public class VetController {

    private final VecRepository repository;

    public VetController(VecRepository repository) {
        this.repository = repository;
    }

    @PostMapping("/create")
    public Vet create(@RequestBody Vet vet) {
        vet.setId(1); // Demonstration Only, Bad Practice
        vet.setCreatedAt(LocalDateTime.now());
        repository.save(vet);
        return vet;
    }

    @PostMapping("/update")
    public Vet update(@RequestBody Vet vet, @RequestParam("id") UUID id) {
        Optional<Vet> optionalVet = repository.findById(id);
        if (optionalVet.isEmpty()) {
            throw new RuntimeException("not found");
        }
        Vet vet1 = optionalVet.get();
        vet1.setFirstName(vet.getFirstName());
        vet1.setLastName(vet.getLastName());
        vet1.setSpecialties(vet.getSpecialties());
        repository.save(vet1);
        return vet1;
    }

    @GetMapping("show")
    public Vet show(@RequestParam("id") UUID id) {
        Optional<Vet> optionalVet = repository.findById(id);
        if (optionalVet.isEmpty()) {
            throw new RuntimeException("not found");
        }
        return optionalVet.get();
    }

    @GetMapping("list")
    public Map<String, Object> showAll(@RequestParam(value = "page_state", defaultValue = "") String pageState, @RequestParam(value = "size", defaultValue = "10") int size) {
        var pagingState = pageState.isEmpty() ? null : ByteBuffer.wrap(Base64.getUrlDecoder().decode(pageState));
        var pageable = CassandraPageRequest.of(Pageable.ofSize(size), pagingState);
        var result = repository.findAll(pageable);
        var nextPageState = ((CassandraPageRequest) result.getPageable()).getPagingState();
        var nextPageStateData = "";
        if (nextPageState != null) {
            var bytes = new byte[nextPageState.remaining()];
            nextPageState.get(bytes, 0, bytes.length);
            nextPageStateData = Base64.getUrlEncoder().encodeToString(bytes);
        }
        return Map.of("errcode", 0, "errmsg", "ok", "list", result.getContent(), "pageState", nextPageStateData);
    }

    @GetMapping("/delete-all")
    public Map<String, Object> deleteAll() {
        repository.deleteAll();
        return Map.of("errcode", 0, "errmsg", "ok");
    }
}

Testing

I use Postman and Chrome to test this project.

Create

---REQUEST---
POST localhost:8080/vet/create
Content-Type: application/json

{
    "firstName":"Foo",
    "lastName":"Bar",
    "specialties":[
        "a","b","c"
    ]
}

---RESPONSE---
{
    "id": 1,
    "firstName": "Foo",
    "lastName": "Bar",
    "specialties": [
        "a",
        "b",
        "c"
    ],
    "createdAt": "2023-07-26T12:18:26.868478"
}

Pagination1

---REQUEST---
GET http://localhost:8080/vet/list?size=2

---RESPONSE---
{
    "pageState":"BAAAAAEKAAgAAAGJkGw-KPB____98H____0=",
    "errmsg":"ok",
    "list":[
        {
            "id":1,
            "firstName":"Foo",
            "lastName":"Bar",
            "specialties":[
                "a",
                "b",
                "c"
            ],
            "createdAt":"2023-07-26T12:19:20.985"
        },
        {
            "id":1,
            "firstName":"Foo",
            "lastName":"Bar",
            "specialties":[
                "a",
                "b",
                "c"
            ],
            "createdAt":"2023-07-26T12:19:20.232"
        }
    ],
    "errcode":0
}

Pagination2

---REQUEST---
GET http://localhost:8080/vet/list?size=2&page_state=BAAAAAEKAAgAAAGJkGw-KPB____98H____0=

---RESPONSE---
{
    "pageState":"BAAAAAEKAAgAAAGJkGw3nfB____78H____s=",
    "errmsg":"ok",
    "list":[
        {
            "id":1,
            "firstName":"Foo",
            "lastName":"Bar",
            "specialties":[
                "a",
                "b",
                "c"
            ],
            "createdAt":"2023-07-26T12:19:19.457"
        },
        {
            "id":1,
            "firstName":"Foo",
            "lastName":"Bar",
            "specialties":[
                "a",
                "b",
                "c"
            ],
            "createdAt":"2023-07-26T12:19:18.557"
        }
    ],
    "errcode":0
}

As you can see, the page_state works as expected.

Reference