Spring Data Projections
A quick introduction to Spring data Projections!
Spring Data supports custom queries in Repositories where developers just need to follow a set way of writing repository methods for the functionality they are looking for. Just defining the method in the interface does the job, for most types of searches at least. Developers pick the functionality and the fields to search by, create the method and pass in the required parameters and the method does what it is supposed to do.
This is pretty straightforward and easy. But say you don’t (for whatever reason) want the whole entity or object returned as a part of your query. Say you only want a subset of fields from the original entity: Spring data projections to the rescue!
Spring supports different types of projections: interface-based, class-based, and dynamic projections. For the sake of this article, I am going to stick to interface-based projections. The Spring Data documentation has excellent information on the other types!
Before we jump in, I am using the following versions of tools and languages:
- Kotlin: 1.4.21
- Gradle: 6.7.1
- Spring Boot: 2.4.1
- JDK: 15
On that note, let us talk about how to use spring data projections step by step, using books as the resource we want to manage:
Step 1: Defining the Entity
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.Id
@Entity
class BookEntity(
val name: String,
val author: String,
val isSeries: Boolean,
val yearPublished: Int,
val publisherName: String,
val genre: String
) {
@Id
@GeneratedValue
var id: Long = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BookEntity
if (name != other.name) return false
if (author != other.author) return false
if (isSeries != other.isSeries) return false
if (yearPublished != other.yearPublished) return false
if (publisherName != other.publisherName) return false
if (genre != other.genre) return false
return true
}
override fun hashCode(): Int {
var result = name?.hashCode() ?: 0
result = 31 * result + (author?.hashCode() ?: 0)
result = 31 * result + isSeries.hashCode()
result = 31 * result + (yearPublished ?: 0)
result = 31 * result + (publisherName?.hashCode() ?: 0)
result = 31 * result + (genre?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "BookEntity(" +
"name='$name', " +
"author='$author', " +
"isSeries=$isSeries, " +
"yearPublished=$yearPublished, " +
"publisherName='$publisherName', " +
"genre='$genre', " +
"id=$id" +
")"
}
}
We define a very simple class to manage books. We let the ID field be auto-generated (so that the user does not have to set a value for it) and all other fields are required!
Step 2: Defining the Repository
import org.springframework.data.repository.CrudRepository
interface BookRepository : CrudRepository<BookEntity, Long> {
fun findByAuthor(author: String): List<BookEntity>
}
Next, we define a simple repository to manage books. For the sake of simplicity, we assume we don’t want any pagination or sorting capabilities. So we just extend from Spring Data’s CrudRepository
.
Step 3: Using the Repository to Save and Retrieve data
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class Application : CommandLineRunner {
@Autowired
private lateinit var bookRepository: BookRepository
override fun run(vararg args: String?) {
bookRepository.saveAll(
listOf(
BookEntity(
name = "Harry Potter and the Chamber of Secrets",
author = "J K Rowling",
isSeries = true,
yearPublished = 1998,
publisherName = "Bloomsbury Publishing",
genre = "Fantasy"
),
BookEntity(
name = "Harry Potter and the Goblet of Fire",
author = "J K Rowling",
isSeries = true,
yearPublished = 2000,
publisherName = "Bloomsbury Publishing",
genre = "Fantasy"
),
BookEntity(
name = "Harry Potter and the Deathly Hallows",
author = "J K Rowling",
isSeries = true,
yearPublished = 2007,
publisherName = "Bloomsbury Publishing",
genre = "Fantasy"
),
BookEntity(
name = "Lord of the Rings: The Fellowship of the Ring",
author = "J.R.R. Tolkien",
isSeries = true,
yearPublished = 1954,
publisherName = "Allen & Unwin",
genre = "Adventure"
),
BookEntity(
name = "The Lion, the Witch, and the Wardrobe",
author = "C. S. Lewis",
isSeries = true,
yearPublished = 1950,
publisherName = "Geoffrey Bles",
genre = "Children's Fantasy"
),
BookEntity(
name = "Gone Girl",
author = "Gillian Flynnp",
isSeries = false,
yearPublished = 2012,
publisherName = "Crown Publishing Group",
genre = "Thriller"
)
)
)
bookRepository.findByAuthor("J K Rowling")
.forEach { book -> println(book) }
}
}
fun main(args: Array<String>) {
runApplication<Application>(*args)
}
Our DB currently has no data. So we first load it up with some data. We add some books and then we fetch them all by the author using the custom query that we created in our Repository.
If we were to run this app at this point we would see the following in the logs:
BookEntity(name='Harry Potter and the Chamber of Secrets', author='J K Rowling', isSeries=true, yearPublished=1998, publisherName='Bloomsbury Publishing', genre='Fantasy', id=1)BookEntity(name='Harry Potter and the Goblet of Fire', author='J K Rowling', isSeries=true, yearPublished=2000, publisherName='Bloomsbury Publishing', genre='Fantasy', id=2)BookEntity(name='Harry Potter and the Deathly Hallows', author='J K Rowling', isSeries=true, yearPublished=2007, publisherName='Bloomsbury Publishing', genre='Fantasy', id=3)
Step 4: Introducing Projections!
Say we only want to get the book names that were written by a certain author vs getting all the information about books. So we create an interface with a single method that starts with get
followed by the name of the field we are looking to retrieve (which in our case is name
).
import org.springframework.beans.factory.annotation.Value
interface BookName {
fun getName(): String
}
Step 5: Updating the Repository
import org.springframework.data.repository.CrudRepository
interface BookRepository : CrudRepository<BookEntity, Long> {
fun findByAuthor(author: String): List<BookName>
}
We update the return type of the findByAuthor
method to return a list of BookName
vs BookEntity
. As a final step, we also update our print statement to print the projection method
bookRepository.findByAuthor("J K Rowling")
.forEach { bookName -> println(bookName.getName()) }
Step 6: Projections in action
Now when we run our app, we should see the following in the console:
Harry Potter and the Chamber of Secrets
Harry Potter and the Goblet of Fire
Harry Potter and the Deathly Hallows
Step 7: Another Projection Method
If we wanted to combine multiple fields from the BookEntity
into a single field in our projection interface, we can update our getName()
method to the following:
import org.springframework.beans.factory.annotation.Value
interface BookName {
@Value("#{target.name + ', ' + target.yearPublished}")
fun getName(): String
}
And when we run the app we should see the following in the logs:
Harry Potter and the Chamber of Secrets, 1998
Harry Potter and the Goblet of Fire, 2000
Harry Potter and the Deathly Hallows, 2007
Spring data projections are that easy and convenient! For further reading please refer to Spring Data JPA’s documentation on projections!