Working with Relationships¶
Learn how to query and manage entity relationships using Zygarde's search DSL.
Relationship Types¶
One-to-Many¶
@Entity
@ZyModel
data class Author(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
@OneToMany(mappedBy = "author", cascade = [CascadeType.ALL])
val books: MutableList<Book> = mutableListOf()
) : AutoLongIdEntity()
@Entity
@ZyModel
data class Book(
@Id @GeneratedValue
val id: Long? = null,
val title: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
val author: Author
) : AutoLongIdEntity()
Many-to-One¶
@Entity
@ZyModel
data class Order(
@Id @GeneratedValue
val id: Long? = null,
val orderNumber: String,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
val customer: Customer
) : AutoLongIdEntity()
Many-to-Many¶
@Entity
@ZyModel
data class Student(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
@ManyToMany
@JoinTable(
name = "student_course",
joinColumns = [JoinColumn(name = "student_id")],
inverseJoinColumns = [JoinColumn(name = "course_id")]
)
val courses: MutableSet<Course> = mutableSetOf()
) : AutoLongIdEntity()
@Entity
@ZyModel
data class Course(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
@ManyToMany(mappedBy = "courses")
val students: MutableSet<Student> = mutableSetOf()
) : AutoLongIdEntity()
One-to-One¶
@Entity
@ZyModel
data class User(
@Id @GeneratedValue
val id: Long? = null,
val username: String,
@OneToOne(mappedBy = "user", cascade = [CascadeType.ALL])
val profile: UserProfile?
) : AutoLongIdEntity()
@Entity
@ZyModel
data class UserProfile(
@Id @GeneratedValue
val id: Long? = null,
val bio: String,
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
val user: User
) : AutoLongIdEntity()
Querying Relationships¶
Query via Related Entity¶
// Find all books by a specific author
dao.book.search {
author().id() eq authorId
}
// Find books by author name
dao.book.search {
author().name() contains "Martin"
}
Query with Nested Relationships¶
@Entity
@ZyModel
data class Book(
@Id @GeneratedValue
val id: Long? = null,
val title: String,
@ManyToOne
val author: Author
)
@Entity
@ZyModel
data class Author(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
@ManyToOne
val country: Country
)
@Entity
@ZyModel
data class Country(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
val continent: String
)
// Query across multiple levels
dao.book.search {
author().country().name() eq "United States"
author().country().continent() eq "North America"
}
Multiple Relationship Conditions¶
dao.book.search {
// Author criteria
author().name() contains "Robert"
author().active() eq true
// Publisher criteria
publisher().name() eq "O'Reilly"
publisher().country() eq "USA"
// Book criteria
publishedYear() gte 2020
}
CRUD with Relationships¶
Creating with Relationships¶
Approach 1: Set Foreign Key¶
@Transactional
fun createBook(authorId: Long, title: String): Book {
val author = dao.author.findById(authorId)
.orElseThrow { NotFoundException("Author not found") }
val book = Book(
title = title,
author = author
)
return dao.book.save(book)
}
Approach 2: Cascade Save (One-to-Many)¶
@Transactional
fun createAuthorWithBooks(
authorName: String,
bookTitles: List<String>
): Author {
val author = Author(name = authorName)
bookTitles.forEach { title ->
val book = Book(title = title, author = author)
author.books.add(book)
}
// Cascade will save books
return dao.author.save(author)
}
Approach 3: Many-to-Many¶
@Transactional
fun enrollStudentInCourses(
studentId: Long,
courseIds: List<Long>
): Student {
val student = dao.student.findById(studentId)
.orElseThrow { NotFoundException("Student not found") }
val courses = dao.course.findAllById(courseIds)
student.courses.addAll(courses)
return dao.student.save(student)
}
Updating Relationships¶
Change Parent Reference¶
@Transactional
fun changeBookAuthor(bookId: Long, newAuthorId: Long): Book {
val book = dao.book.findById(bookId)
.orElseThrow { NotFoundException("Book not found") }
val newAuthor = dao.author.findById(newAuthorId)
.orElseThrow { NotFoundException("Author not found") }
val updated = book.copy(author = newAuthor)
return dao.book.save(updated)
}
Add to Collection¶
@Transactional
fun addBookToAuthor(authorId: Long, bookTitle: String): Author {
val author = dao.author.findById(authorId)
.orElseThrow { NotFoundException("Author not found") }
val book = Book(title = bookTitle, author = author)
author.books.add(book)
return dao.author.save(author)
}
Remove from Collection¶
@Transactional
fun removeStudentFromCourse(studentId: Long, courseId: Long) {
val student = dao.student.findById(studentId)
.orElseThrow { NotFoundException("Student not found") }
student.courses.removeIf { it.id == courseId }
dao.student.save(student)
}
Deleting with Relationships¶
Delete Child (Orphan)¶
@Transactional
fun deleteBook(bookId: Long) {
// Simply delete the book
// Author remains
dao.book.deleteById(bookId)
}
Delete with Orphan Removal¶
@Entity
@ZyModel
data class Author(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
@OneToMany(
mappedBy = "author",
cascade = [CascadeType.ALL],
orphanRemoval = true // Delete books when removed from collection
)
val books: MutableList<Book> = mutableListOf()
)
@Transactional
fun removeBookFromAuthor(authorId: Long, bookId: Long) {
val author = dao.author.findById(authorId)
.orElseThrow { NotFoundException("Author not found") }
// Remove from collection - book will be deleted
author.books.removeIf { it.id == bookId }
dao.author.save(author)
}
Cascade Delete¶
@Transactional
fun deleteAuthor(authorId: Long) {
// If CascadeType.ALL or CascadeType.REMOVE is set,
// all books will be deleted too
dao.author.deleteById(authorId)
}
Complex Relationship Queries¶
Filter by Collection Size¶
// Authors with more than 5 books
// This requires custom repository method
interface AuthorDao : JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a WHERE SIZE(a.books) > :count")
fun findAuthorsWithMoreThanNBooks(@Param("count") count: Int): List<Author>
}
Exists in Collection¶
// Students enrolled in a specific course
dao.student.search {
// This requires proper join handling
// May need custom specification
}
Collection is Empty¶
// Authors with no books
// Requires custom repository method
@Query("SELECT a FROM Author a WHERE a.books IS EMPTY")
fun findAuthorsWithoutBooks(): List<Author>
Fetch Strategies¶
Lazy Loading (Default)¶
Usage:
val book = dao.book.findById(id).get()
// Author not loaded yet
val authorName = book.author.name
// Author loaded on access (might cause N+1)
Eager Loading¶
Fetch Join in Query¶
// Using JPQL with fetch join
interface BookDao : JpaRepository<Book, Long> {
@Query("SELECT b FROM Book b JOIN FETCH b.author WHERE b.id = :id")
fun findByIdWithAuthor(@Param("id") id: Long): Book?
@Query("SELECT DISTINCT b FROM Book b JOIN FETCH b.author")
fun findAllWithAuthors(): List<Book>
}
Entity Graph¶
interface BookDao : JpaRepository<Book, Long> {
@EntityGraph(attributePaths = ["author", "publisher"])
override fun findAll(): List<Book>
@EntityGraph(attributePaths = ["author"])
fun findByTitle(title: String): List<Book>
}
Avoiding N+1 Queries¶
Problem: N+1 Query¶
// BAD: N+1 queries
val books = dao.book.findAll() // 1 query
books.forEach { book ->
println(book.author.name) // N queries (one per book)
}
Solution 1: Fetch Join¶
// Define in custom repository
@Query("SELECT DISTINCT b FROM Book b JOIN FETCH b.author")
fun findAllWithAuthors(): List<Book>
// Usage
val books = dao.book.findAllWithAuthors() // Single query
books.forEach { book ->
println(book.author.name) // No additional queries
}
Solution 2: Entity Graph¶
Solution 3: Batch Fetching¶
@Entity
@BatchSize(size = 10)
data class Author(/* ... */)
// Or on collection
@OneToMany(mappedBy = "author")
@BatchSize(size = 10)
val books: List<Book>
Bidirectional Relationships¶
Keeping Both Sides in Sync¶
@Entity
@ZyModel
data class Author(
@Id @GeneratedValue
val id: Long? = null,
val name: String,
@OneToMany(mappedBy = "author", cascade = [CascadeType.ALL])
val books: MutableList<Book> = mutableListOf()
) : AutoLongIdEntity() {
fun addBook(book: Book) {
books.add(book)
// Ensure bidirectional consistency
if (book.author != this) {
book.copy(author = this)
}
}
fun removeBook(book: Book) {
books.remove(book)
}
}
@Entity
@ZyModel
data class Book(
@Id @GeneratedValue
val id: Long? = null,
val title: String,
@ManyToOne(fetch = FetchType.LAZY)
val author: Author
) : AutoLongIdEntity()
Usage:
@Transactional
fun createAuthorWithBooks(): Author {
val author = Author(name = "Robert Martin")
val book1 = Book(title = "Clean Code", author = author)
val book2 = Book(title = "Clean Architecture", author = author)
author.addBook(book1)
author.addBook(book2)
return dao.author.save(author)
}
Real-World Examples¶
Blog System¶
@Entity
@ZyModel
data class Post(
@Id @GeneratedValue
val id: Long? = null,
val title: String,
val content: String,
@ManyToOne
val author: User,
@OneToMany(mappedBy = "post", cascade = [CascadeType.ALL])
val comments: MutableList<Comment> = mutableListOf(),
@ManyToMany
@JoinTable(name = "post_tag")
val tags: MutableSet<Tag> = mutableSetOf()
)
// Find posts by author
dao.post.search {
author().id() eq userId
orderBy(Post::createdAt, desc = true)
}
// Find posts by tag
dao.post.search {
tags().name() eq "kotlin"
}
// Find posts with comments count > 10
// Requires custom repository method
E-Commerce System¶
@Entity
@ZyModel
data class Order(
@Id @GeneratedValue
val id: Long? = null,
val orderNumber: String,
@ManyToOne
val customer: Customer,
@OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
val items: MutableList<OrderItem> = mutableListOf()
)
@Entity
@ZyModel
data class OrderItem(
@Id @GeneratedValue
val id: Long? = null,
val quantity: Int,
val price: Double,
@ManyToOne
val order: Order,
@ManyToOne
val product: Product
)
// Find orders by customer
dao.order.search {
customer().id() eq customerId
orderBy(Order::createdAt, desc = true)
}
// Find orders containing a specific product
dao.orderItem.search {
product().id() eq productId
}
Zygarde Framework Tips¶
1. Use DTOs with Zygarde Model Mapping¶
Combine Zygarde search with model mapping for clean API responses:
// Use Zygarde's model mapping DSL
data class BookDto(
val id: Long,
val title: String,
val authorName: String
)
// Define mapping (see Model Mapping guide)
BookDto {
fromAutoLongId(Book::id)
from(Book::title)
fromRef(Book::author) { it.name }
}
// Use in service
fun searchBooks(keyword: String): List<BookDto> {
return dao.book.search {
title() containsIgnoreCase keyword
}.map { it.toBookDto() } // Generated by Zygarde
}
Next Steps¶
- Search DSL Guide - Complete DSL reference
- Model Mapping - DTO transformations