Kotlin JDSL

repository·main·Indexed 21 days ago

https://github.com/line/kotlin-jdsl

A type-safe library for building and executing queries using a Kotlin Domain-Specific Language without relying on annotation processing. It leverages KClass and KProperty to construct JPQL queries that can be rendered and executed via JPA providers like Hibernate and EclipseLink, or frameworks such as Spring Data JPA and Spring Batch. It includes support for custom JpqlSerializers to handle Kotlin value classes and provides a way to build dynamic queries using predicates.

Tokens
21.4K
Snippets
70
Records
83
Agent score
68%

What's inside Kotlin JDSL

  1. What is Kotlin JDSL?

    main

    Kotlin JDSL is a library designed to build queries using a Kotlin Domain-Specific Language (DSL) without requiring a generated metamodel via Annotation Processing Tools (APT).

    Unlike APT-based libraries that require recompilation whenever entity fields or types change, Kotlin JDSL leverages KClass and KProperty to provide a type-safe way to construct queries.

    Note: Kotlin JDSL is not a query executor. It is strictly a query builder designed to work alongside your existing persistence framework (e.g., JPA/Hibernate).

  2. Overview of Kotlin JDSL

    main
    Kotlin JDSL is a Kotlin library designed to simplify query building and execution. It allows developers to construct queries using their own custom classes and standard Kotlin functions. Unlike many other query builders, it does not require an annotation processor, making it easier to integrate into existing workflows and libraries.
  3. Use Predicates to build conditional expressions

    main

    In Kotlin JDSL, the Predicate interface is used to represent conditional expressions in JPQL. You can build these using comparison operators, logical operators, and specialized functions like isNull(), like(), or between() applied to a path().

    Important Note on Dynamic Queries: When using and() or or() with dynamic predicates, be aware that if all passed Predicate arguments are null or empty:

    • and() will be interpreted as 1 = 1.
    • or() will be interpreted as 0 = 1. This can lead to unexpected query results if not handled carefully.
  4. Use a select statement as an expression with asSubquery()

    main

    You can use a select statement as an Expression (for example, inside a where clause) by calling .asSubquery() on it. This allows you to pass the results of one query into another, such as using a list of IDs returned by a subquery within an in clause of a deleteFrom or select statement.

    val employeeIds = select<Long>(
        path(EmployeeDepartment::employee)(Employee::employeeId),
    ).from(
        entity(Department::class),
        join(EmployeeDepartment::class)
            .on(path(Department::departmentId).equal(path(EmployeeDepartment::departmentId))),
    ).where(
        path(Department::name).like("%03"),
    ).asSubquery()
    
    deleteFrom(
        Employee::class,
    ).where(
        path(Employee::employeeId).`in`(employeeIds),
    )
  5. Understand the example data schema

    main

    The examples provided in the repository use a MySQL-compatible schema and dataset. If you are setting up a local environment to test the examples, ensure your database is configured for MySQL. The schema and data definitions are located in the project resources.

    -- Schema and data are defined in:
    -- ./src/main/resources/schema.sql
    -- ./src/main/resources/data.sql
  6. Understanding nullable return types in Kotlin JDSL support modules

    main

    Methods provided by Kotlin JDSL through certain support modules often return nullable types. This is because the library cannot automatically infer whether a query result is non-null or nullable based on the query structure. Specifically, the nullability depends on whether the underlying database definition or the query logic allows for null values.

    Common scenarios resulting in nullable return types include:

    1. Columns: A column returns null if its definition in the database is nullable.
    2. Entities: An entity returns null if it is part of a nullable join, such as a leftJoin.

    To avoid user confusion and the need for additional learning regarding custom naming conventions (e.g., distinguishing between methods that filter nulls vs. methods that throw exceptions), Kotlin JDSL uses the standard JPQL behavior where return types are nullable by default.

    val query = jpql {
        select(
            path(BookAuthor),
        ).from(
            entity(Author::class),
            leftJoin(BookAuthor::class).on(path(Author::authorId).equal(path(BookAuthor::authorId)))
        )
    }
  7. Use a select statement as a derived entity with asEntity()

    main

    You can treat the result of a select statement as an Entity by calling .asEntity() on it. This is useful when you want to perform a secondary query (like a select or count) on the results of a previous complex selection. To use this pattern, define a data class that matches the shape of the subquery's selection and pass it as the type argument to the select<T> function.

    data class DerivedEntity(
        val employeeId: Long,
        val count: Long,
    )
    
    val query = jpql {
        val subquery = select<DerivedEntity>(
            path(Employee::employeeId).`as`(expression("employeeId")),
            count(Employee::employeeId).`as`(expression("count")),
        ).from(
            entity(Employee::class),
            join(Employee::departments),
        ).groupBy(
            path(Employee::employeeId),
        ).having(
            count(Employee::employeeId).greaterThan(1L),
        )
    
        select(
            count(DerivedEntity::employeeId),
        ).from(
            subquery.asEntity(),
        )
    }
  8. Perform Set Operations (UNION, EXCEPT, INTERSECT)

    main

    Kotlin JDSL supports standard JPQL set operations to combine multiple select queries. These can be used in two ways:

    1. Chained Selects: Call the operation method (e.g., .union()) directly after a select query structure. The orderBy() clause applied after the set operation affects the final result set.
    2. Top-Level Operations: Use the operation methods as top-level functions within a jpql {} block to combine two existing JpqlQueryable instances.

    Supported Operations:

    • union(): Combines results and removes duplicates.
    • unionAll(): Combines results and includes duplicates.
    • except(): Returns rows from the first query not present in the second (removes duplicates).
    • exceptAll(): Returns rows from the first query not present in the second (includes duplicates).
    • intersect(): Returns only rows present in both sets (removes duplicates).
    • intersectAll(): Returns only rows present in both sets (includes duplicates).

    Note on Database Compatibility:

    • PostgreSQL, Oracle, SQL Server: Support all six operations.
    • H2: Supports UNION, UNION ALL, INTERSECT, and EXCEPT, but not the ALL variants.
    • MySQL: Only supports UNION and UNION ALL. EXCEPT and INTERSECT must be emulated via NOT EXISTS/LEFT JOIN or INNER JOIN/IN respectively.
    // Example: Top-level UNION ALL
    val topLevelUnionAllQuery = jpql {
        unionAll(query1, query2)
            .orderBy(path(Book::isbn).asc())
    }
  9. Handling value classes in DTO Projections

    main

    Kotlin JDSL does not support using value class directly in DTO Projections if the property is nullable.

    Recommended Pattern: Use the underlying primitive type (e.g., Long) in the DTO projection and provide a computed property or a conversion step to transform it into the value class after the query results are returned.

    data class ResponseDto(
        private val rawId: Long,
    ) {
        val id: UserId
            get() = UserId(rawId)
    }
    
    val query = jpql(CustomJpql) {
        selectNew<ResponseDto>(
            entity(User::id)
        ).from(
            entity(User::class),
        ).where(
            path(User::id).equalValue(userId)
        )
    }
  10. Use values and parameters in queries

    main

    Kotlin JDSL provides three ways to handle values in queries:

    1. value(): Builds a value interpreted as a query parameter. These parameters cannot be overridden.
    2. param(): Builds a query parameter that can be overridden via a parameter map when executing the query.
    3. xxxLiteral(): Builds a hardcoded literal in the query (e.g., intLiteral, stringLiteral).
    // Using param() which can be overridden
    val query = jpql {
        select(path(Book::isbn)).from(entity(Book::class))
        .where(path(Book::price).eq(param("price")))
    }
    
    val queryParams = mapOf("price" to BigDecimal.valueOf(100))
    emantager.createQuery(query, queryParams, context)
  11. Handle LazyInitializationException in Hibernate Reactive

    main

    In Hibernate Reactive, the session scope is typically limited to the lambda block of methods like withSession or withTransaction. Accessing lazy-loaded associations outside of this active session will cause a LazyInitializationException.

    Strategies for accessing associations:

    1. Access inside the session scope: Perform transformations or data access within the reactive stream pipeline while the session is still active (e.g., using .onItem().transform { ... } in Mutiny).
    2. Use fetch join: If you must access associations after the session has closed, you must initialize them eagerly in the query using fetchJoin.
    // Strategy 1: Accessing inside the session scope
    val bookAuthorSizes: Uni<List<Int>> = sessionFactory.withSession { session ->
        session.createQuery(query, context).resultList.onItem().transform { bookList ->
            bookList.map { it.authors.size } // Safe: session is active
        }
    }
    
    // Strategy 2: Using fetchJoin for access outside the session scope
    val query = jpql {
        select(distinct(entity(Book::class)))
        .from(entity(Book::class), fetchJoin(Book::authors))
    }
    val books: Uni<List<Book>> = sessionFactory.withSession { session ->
        session.createQuery(query, context).resultList
    }
    // Safe to use 'books' here because authors were eagerly fetched
    books.onItem().invoke { bookList ->
        bookList.forEach { println(it.authors.size) }
    }