--- url: /introduction.md description: >- Overview of Kuery Client's features, motivation, and the SQL builder with string interpolation (R2DBC / JDBC). --- # Introduction ::: info Prerequisites This documentation assumes you are already familiar with Spring / Spring Boot and does not explain them. ::: It looks like plain string interpolation... ```kotlin val user: User? = kueryClient .sql { +"SELECT * FROM users WHERE user_id = $userId" } .singleOrNull() ``` ...but it executes as a **parameterized query**. A Kotlin compiler plugin rewrites the interpolated runtime value into named parameter binding at compile time — so you get the readability of raw SQL without the risk of SQL injection: ```sql SELECT * FROM users WHERE user_id = :p0 -- :p0 = userId, bound as a named parameter ``` ## Features * ♥️ **Love SQL** * ORM libraries are convenient, but they each require learning their own DSL, which we believe is a steep cost. Kuery Client emphasizes writing SQL as it is. * 🛡️ **Safe by design** * Interpolated runtime values are converted into bind parameters by the compiler plugin, so the normal SQL-building path never concatenates them into the SQL text. * ✅ **Compile-time checks** * The compiler plugin [warns when a SQL string cannot be converted safely](/compiler-safety-check), and can optionally [validate SQL syntax](/sql-syntax-check) — mistakes surface at compile time, not in production. * 🍃 **Built on Spring Data** * Kuery Client is implemented on top of `spring-data-r2dbc` and `spring-data-jdbc`. Use whichever you prefer. You can keep using Spring's ecosystem as is, such as `@Transactional`. * 🔭 **Observability** * It supports [Micrometer Observation](/observation), so you can collect and customize metrics, tracing, and logging. * 🧩 **Extensible** * When dealing with complex data schemas, you often want to share common query logic. Kotlin's extension functions make this easy. ## Motivation We have used numerous ORM libraries, but in the end, we preferred libraries like [MyBatis](https://github.com/mybatis/mybatis-3) that allow writing SQL directly. To construct SQL dynamically, custom template syntax (such as if/foreach) is often used, but we prefer to write logic using the syntax provided by the programming language as much as possible. We want to write dynamic SQL using Kotlin syntax, similar to [kotlinx.html](https://github.com/Kotlin/kotlinx.html). To meet these needs, we implemented `Kuery Client`. Kuery Client simply provides the SQL builder shown below on top of the well-established `spring-data-r2dbc` / `spring-data-jdbc`. It is designed to be usable alongside plain spring-data code, so you can start small. ## Overview By using the following SQL builder, you can easily build and execute SQL. Whether using R2DBC or JDBC, the way of writing is almost the same. A Kotlin compiler plugin converts string interpolation into parameter binding. ::: code-group ```kotlin [kuery-client-spring-data-r2dbc] data class User(...) class UserRepository(private val kueryClient: KueryClient) { suspend fun findById(userId: Int): User? = kueryClient .sql { +"SELECT * FROM users WHERE user_id = $userId" } .singleOrNull() suspend fun search(status: String, vip: Boolean?): List = kueryClient .sql { +""" SELECT * FROM users WHERE status = $status """ if (vip != null) { +"AND vip = $vip" } } .list() suspend fun insertMany(users: List): Long = kueryClient .sql { +"INSERT INTO users (username, email)" // useful helper function values(users) { listOf(it.username, it.email) } } .rowsUpdated() } ``` ```kotlin [kuery-client-spring-data-jdbc] data class User(...) class UserRepository(private val kueryClient: KueryBlockingClient) { fun findById(userId: Int): User? = kueryClient .sql { +"SELECT * FROM users WHERE user_id = $userId" } .singleOrNull() fun search(status: String, vip: Boolean?): List = kueryClient .sql { +""" SELECT * FROM users WHERE status = $status """ if (vip != null) { +"AND vip = $vip" } } .list() fun insertMany(users: List): Long = kueryClient .sql { +"INSERT INTO users (username, email)" // useful helper function values(users) { listOf(it.username, it.email) } } .rowsUpdated() } ``` ::: This SQL builder is very simple. There are only two things you need to remember: * Add SQL fragments with unary `+`; use normal Kotlin control flow for dynamic queries. * Interpolate runtime values to bind them as parameters. Continue to [Getting Started](/getting-started) to add Kuery Client to a project, or read [Building SQL](/basics) for the complete interpolation and dynamic-query rules. ## For LLMs The entire documentation is also available in the [llms.txt](https://llmstxt.org/) format, which is convenient for feeding into AI assistants and LLM-based tools: * [llms.txt](https://kuery-client.hsbrysk.dev/llms.txt) — an index of all documentation pages * [llms-full.txt](https://kuery-client.hsbrysk.dev/llms-full.txt) — the full documentation in a single file --- --- url: /getting-started.md description: >- Add Kuery Client to an existing Kotlin/Spring Boot project, choose R2DBC or JDBC, configure the client, and run the first query. --- # Getting Started This guide adds Kuery Client to an existing Kotlin/Spring Boot project. If you would rather start from a complete application, clone the repository and follow the [runnable Examples](/examples). ## Requirements * Java 17 or later * Gradle 8.4 or later * An existing Kotlin/JVM Spring project * A Kotlin, Spring Boot, and Spring Data version compatible with the Kuery Client release; see [Compatibility](/compatibility) Parameter binding relies on the Kuery Client Kotlin compiler plugin, which is applied through its Gradle plugin. Maven, Kotlin/JS, Kotlin/Native, and Kotlin/Wasm are not currently supported. ## Choose R2DBC or JDBC | | R2DBC | JDBC | |---|---|---| | Client type | `KueryClient` | `KueryBlockingClient` | | Regular terminal operations | suspending | blocking | | Streaming | cold coroutine `Flow` | eager, resource-backed `CloseableSequence` | | Statement options | `fetchSize` | `fetchSize`, `maxRows`, `queryTimeoutSeconds` | Apart from these execution and streaming differences, SQL construction and typed row mapping use the same API. ## Install The following examples use MySQL. Add the Kuery Client plugin, the matching Spring Boot starter, Kuery Client module, and database driver to the module that contains your repository code. ::: code-group ```kotlin [R2DBC · build.gradle.kts] plugins { id("dev.hsbrysk.kuery-client") version "1.2.0" } repositories { mavenCentral() } dependencies { implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") implementation("dev.hsbrysk.kuery-client:kuery-client-spring-data-r2dbc:1.2.0") runtimeOnly("io.asyncer:r2dbc-mysql") } ``` ```kotlin [JDBC · build.gradle.kts] plugins { id("dev.hsbrysk.kuery-client") version "1.2.0" } repositories { mavenCentral() } dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("dev.hsbrysk.kuery-client:kuery-client-spring-data-jdbc:1.2.0") runtimeOnly("com.mysql:mysql-connector-j") } ``` ::: Kuery Client does not provide a Spring Boot starter and does not bring in a database driver. For PostgreSQL or H2 driver choices, see [Supported Platforms](/supported-platforms). ::: warning Multi-module projects Apply `dev.hsbrysk.kuery-client` to every module that contains `sql { ... }` calls. Applying it only to the application module does not transform repository code compiled in another module. ::: ::: details Runtime error: `SqlBuilder.add` / `String.unaryPlus` must be rewritten This means the Gradle plugin was not applied to the module that compiled the call. `+"..."` / `add(...)` are intentionally broken without the compiler plugin; otherwise interpolation could be executed as raw SQL. Apply the plugin as shown above, then clean and rebuild the affected module. ::: ### Recommended compiler settings For a new project, enable the settings below. They remain opt-in because they were introduced after Kuery Client's initial releases, but they are intended to become the defaults in a future release. This guide uses MySQL; replace `mysql` with the project's dialect when needed: ```kotlin kueryClient { autoTrimIndent = true sqlSyntaxCheck = "mysql" strict = true } ``` Dialect validation can report false positives for uncommon vendor-specific SQL. See [Configuration](/configuration) before enabling these options in an existing codebase. ## Configure the database Configure Spring Boot as usual. For a local MySQL instance listening on port `3306`: ::: code-group ```yaml [R2DBC · application.yml] spring: r2dbc: url: r2dbc:mysql://localhost:3306/app username: app password: secret ``` ```yaml [JDBC · application.yml] spring: datasource: url: jdbc:mysql://localhost:3306/app username: app password: secret ``` ::: This guide assumes a table created by your preferred migration tool: ```sql CREATE TABLE users ( user_id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL ); ``` ## Register the client Kuery Client deliberately has no auto-configuration. Register one shared client using Spring Boot's auto-configured `ConnectionFactory` or `DataSource`: ::: code-group ```kotlin [R2DBC] @Configuration(proxyBeanMethods = false) class KueryClientConfiguration { @Bean fun kueryClient(connectionFactory: ConnectionFactory): KueryClient = SpringR2dbcKueryClient.builder() .connectionFactory(connectionFactory) .build() } ``` ```kotlin [JDBC] @Configuration(proxyBeanMethods = false) class KueryClientConfiguration { @Bean fun kueryClient(dataSource: DataSource): KueryBlockingClient = SpringJdbcKueryClient.builder() .dataSource(dataSource) .build() } ``` ::: ## Run the first query The compiler plugin converts `$userId` into a named bind parameter. The database receives SQL equivalent to `WHERE user_id = :p0`; the value is never concatenated into the SQL text. ::: code-group ```kotlin [R2DBC] data class User( val userId: Int, val username: String, ) @Repository class UserRepository(private val kueryClient: KueryClient) { suspend fun findById(userId: Int): User? = kueryClient .sql { +"SELECT user_id, username FROM users WHERE user_id = $userId" } .singleOrNull() } ``` ```kotlin [JDBC] data class User( val userId: Int, val username: String, ) @Repository class UserRepository(private val kueryClient: KueryBlockingClient) { fun findById(userId: Int): User? = kueryClient .sql { +"SELECT user_id, username FROM users WHERE user_id = $userId" } .singleOrNull() } ``` ::: Next, read [Building SQL](/basics), [Fetching Results](/fetching-results), and [Row Mapping](/row-mapping). --- --- url: /basics.md description: >- Build safe static and dynamic SQL with +/add, interpolation, constants, collections, reusable helpers, and the lower-level addUnsafe/bind APIs. --- # Building SQL ## SQL fragments ### `+` (`unaryPlus`) Add SQL fragments with the unary `+` operator. Fragments are joined with newlines. ```kotlin kueryClient .sql { +"SELECT * FROM users" +"WHERE user_id = 1" } ``` A fragment can also span multiple lines: ```kotlin kueryClient .sql { +""" SELECT * FROM users WHERE user_id = 1 """ } ``` ### `add(sql: String)` `add(...)` is an alias for unary `+`. Its argument is annotated with `org.intellij.lang.annotations.Language`, so JetBrains IDEs can provide SQL syntax assistance. ### Automatic `trimIndent` (opt-in) With a multi-line string, the source indentation stays in the SQL body. That is harmless to the database, but it makes logged SQL noisy, so `.trimIndent()` is commonly appended. If you would rather not write it every time, enable `autoTrimIndent` in the Gradle plugin: ```kotlin kueryClient { autoTrimIndent = true } ``` Every string passed to `+` / `add()` then gets `trimIndent()` applied automatically. For string literals and templates the trimming is computed **at compile time** by the compiler plugin, so it adds no runtime cost. Arguments the plugin cannot see through (e.g. a variable) are trimmed at runtime instead. Notes: * [`addUnsafe()`](#addunsafe-and-bind) is not affected — use it when you need to keep indentation as-is. * An explicit `.trimIndent()` is redundant: it prevents compile-time trimming, so the string is trimmed twice at runtime. The compiler reports a `KUERY_REDUNDANT_TRIM_INDENT` warning for such calls — remove them when enabling the option. (Behavior stays correct either way; the double trim only differs when the explicitly trimmed string still starts or ends with a blank line, which the automatic trim then drops.) * Unlike `.trimIndent()`, `.trimMargin()` is not redundant and does not produce a compiler warning. Auto-trim still applies `trimIndent()` to its result, which removes any common indentation left after the margin prefix. Use [`addUnsafe()`](#addunsafe-and-bind) if that indentation must be preserved. * The option defaults to `false`, so existing builds are unaffected. ## Binding Parameters When you want to bind parameters, use string interpolation. ```kotlin val userId = "..." kueryClient .sql { +""" SELECT * FROM users WHERE user_id = $userId """ } ``` ### How interpolated values are handled Compile-time `String` / `Char` constants inside a template are expanded into the SQL text. All other interpolated values — including compile-time constants of other types — are bound as parameters: | Interpolated expression | Examples | Behavior | |---|---|---| | Runtime value | `$userId`, `${user.id}`, `${find()}` | Bound as a parameter (`:p0`) | | `String` / `Char` constant (literal or `const val`) | `$TABLE` where `const val TABLE = "users"`; `${"users"}`, `${'$'}` | Expanded into the SQL text | | Constant of any other type (literal or `const val`) | `$LIMIT` where `const val LIMIT = 100`; `${1}`, `${true}`, `${null}` | Bound as a parameter | For example, the `String` constant `TABLE` is expanded into the SQL text. The `Int` constant `LIMIT` and the runtime value `userId` are both bound as parameters: ```kotlin const val TABLE = "users" const val LIMIT = 100 kueryClient .sql { +"SELECT * FROM $TABLE WHERE user_id = $userId LIMIT $LIMIT" // SQL body: SELECT * FROM users WHERE user_id = :p0 LIMIT :p1 // Parameters: p0 = userId, p1 = 100 } ``` Sometimes the SQL itself needs a literal `$` — JSON path syntax, for example. `$` cannot be written as-is in a Kotlin string template (and raw strings have no backslash escaping), so the idiomatic escape is the `Char` constant `${'$'}`. Since `Char` constants are expanded as text, the `$` simply comes out in the SQL: ```kotlin kueryClient .sql { +"SELECT data->>'${'$'}.name' FROM articles WHERE article_id = $articleId" // SQL body: SELECT data->>'$.name' FROM articles WHERE article_id = :p0 } ``` ::: warning A `String` constant intended as a *value* (e.g. `WHERE name = $NAME_CONST`) is expanded without quoting, so the query will most likely fail with a database error. Since it is a compile-time constant this cannot cause SQL injection, but if you want it bound, use a non-const `val`. ::: ### Collections and arrays A `Collection` is bound as a single named parameter that Spring expands into the individual elements — this is what you want for `IN` clauses: ```kotlin val statuses = listOf(UserStatus.ACTIVE, UserStatus.INACTIVE) kueryClient .sql { +"SELECT * FROM users WHERE status IN ($statuses)" // Kuery SQL: SELECT * FROM users WHERE status IN (:p0) // p0 = [ACTIVE, INACTIVE] // Spring expands it at execution: SELECT * FROM users WHERE status IN (?, ?) } ``` The exact placeholders sent to the database depend on the driver; `?, ?` above illustrates that Spring creates one placeholder for each collection element. ::: warning Empty collections Spring expands an empty collection to `IN ()`. H2 accepts that syntax, but MySQL and PostgreSQL reject it. Return early or build a different predicate when the collection is empty. See [Supported Platforms](/supported-platforms#empty-collections-in-in-clauses). ::: An array is different: it is passed to the driver as a single array value with its element type preserved. It is *not* expanded for `IN`. Use arrays with databases that support them natively, such as PostgreSQL (`= ANY(...)`, array columns): ```kotlin val usernames = arrayOf("user1", "user2") kueryClient .sql { +"SELECT * FROM users WHERE username = ANY($usernames)" } ``` MySQL has no array type, so use a `Collection` for `IN` clauses there. `ByteArray` is a special case: it is passed to the driver as one binary value, not as a SQL array or an expanded parameter list. Other primitive arrays are client- and driver-dependent. R2DBC boxes `IntArray`, `LongArray`, and the other non-byte primitive arrays into object arrays before binding; JDBC passes primitive arrays to the driver unchanged. Check that the selected driver supports the resulting value. ### Enums An enum value is bound by its name (`Enum.name`) by default. This also applies to enums inside collections and arrays. ```kotlin val status = UserStatus.ACTIVE kueryClient .sql { +"SELECT * FROM users WHERE status = $status" // bound as the string 'ACTIVE' } ``` If you want a different representation, register a custom `@WritingConverter` — it takes precedence over the default. See [Type Conversion](/type-conversion). ### Value classes A Kotlin value class is bound as its underlying value. This also applies to value classes inside collections and arrays, and the unwrapped value goes through the usual conversion rules again — so a value class wrapping an enum is bound by the enum's name. ```kotlin @JvmInline value class UserName(val value: String) val name = UserName("hoge") kueryClient .sql { +"SELECT * FROM users WHERE username = $name" // bound as the string 'hoge' } ``` A value class wrapping a nullable type is bound as SQL `NULL` when the wrapped value is null. If you want a different representation, register a custom `@WritingConverter` for the value class — it takes precedence over automatic unwrapping. See [Type Conversion](/type-conversion). ::: warning Generic value classes in arrays The element type of a generic value class array (e.g. `arrayOf(Wrapped("a"))` where `value class Wrapped(val value: T)`) is inferred from the unwrapped elements, so a non-empty array binds correctly. An **empty or all-`null`** generic value class array cannot be inferred — there is nothing to infer from and the underlying type is erased — so no driver-compatible array type can be produced and most drivers reject it. Use a concrete (non-generic) value class, or the underlying array type, in that case. ::: Value classes are also supported on the fetch side. See [Row Mapping](/row-mapping#kotlin-value-classes). ### Null values A `null` value is bound as SQL `NULL`. Be careful with comparison operators: `column = NULL` never matches anything in SQL. If a value can be null, branch explicitly: ```kotlin kueryClient .sql { +"SELECT * FROM users" if (email != null) { +"WHERE email = $email" } else { +"WHERE email IS NULL" } } ``` ## Dynamic SQL with Kotlin control flow Use normal Kotlin `if`, `when`, loops, and function calls. There is no separate template language. ```kotlin enum class UserSort { NAME, CREATED_AT } kueryClient .sql { +"SELECT u.* FROM users u" +"WHERE u.tenant_id = $tenantId" if (email != null) { +"AND u.email = $email" } if (!includeDeleted) { +"AND u.deleted_at IS NULL" } for (role in requiredRoles) { +"AND EXISTS (SELECT 1 FROM user_roles ur WHERE ur.user_id = u.user_id AND ur.role_name = $role)" } when (sort) { UserSort.NAME -> +"ORDER BY u.name" UserSort.CREATED_AT -> +"ORDER BY u.created_at DESC" } } .list() ``` The `if` blocks add optional filters, the loop requires every requested role, and `when` selects a fixed, safe `ORDER BY` clause. Interpolated values are still bound as parameters in every branch. ## Reusable query parts Query parts shared across queries can be extracted into plain extension functions on `SqlBuilder`. String interpolation inside them is converted into bind parameters as usual, and Kotlin control flow works as usual — no special API is needed: ```kotlin fun SqlBuilder.whereActiveUsers(tenantId: Int, username: String? = null) { +"WHERE tenant_id = $tenantId" +"AND status = 'ACTIVE'" if (username != null) { +"AND username = $username" } } class UserRepository(private val kueryClient: KueryClient) { suspend fun search(tenantId: Int, username: String?): List = kueryClient .sql { +"SELECT * FROM users" whereActiveUsers(tenantId, username) +"ORDER BY username" } .list() suspend fun count(tenantId: Int): Long = kueryClient .sql { +"SELECT COUNT(*) FROM users" whereActiveUsers(tenantId) } .single() } ``` For fragments that must be assembled as a string dynamically, use the lower-level APIs described next. ## `addUnsafe()` and `bind()` Prefer `+` / `add()` and ordinary string interpolation whenever possible. The compiler plugin can then bind runtime values automatically and check that the SQL string is safe. For a fragment that must itself be assembled programmatically—such as a variable number of assignments—`addUnsafe()` appends the completed SQL text without compiler transformation. `bind(value)` registers one named parameter and returns its placeholder (for example, `:p0`) for that text: ```kotlin enum class UserColumn(val sqlName: String) { USERNAME("username"), EMAIL("email"), } @OptIn(DelicateKueryClientApi::class) fun SqlBuilder.addAssignments(values: Map) { require(values.isNotEmpty()) { "values must not be empty" } val assignments = values.entries.joinToString(", ") { (column, value) -> "${column.sqlName} = ${bind(value)}" } addUnsafe("SET $assignments") } kueryClient.sql { +"UPDATE users" addAssignments(mapOf(UserColumn.USERNAME to username, UserColumn.EMAIL to email)) +"WHERE user_id = $userId" } // SQL body: // UPDATE users // SET username = :p0, email = :p1 // WHERE user_id = :p2 ``` Both functions require an explicit `@OptIn(DelicateKueryClientApi::class)`. Text passed to `addUnsafe()` becomes part of the SQL body as-is, so never include untrusted input in it. Keep runtime values in `bind(...)`. In the example above, SQL identifiers come only from the closed `UserColumn` enum; arbitrary input can never become a column name. `bind()` is only for SQL passed to `addUnsafe()`. Do not interpolate its result into `+"..."` or `add("...")`: those APIs already bind interpolated expressions, so doing both is a compile error ([`KUERY_BIND_CALL_IN_SQL_TEMPLATE`](/compiler-safety-check#bind-in-a-string-template)). For multi-row `INSERT` statements, use the built-in [`values` helper](/helpers#values). ## Fetching Results `sql { ... }` returns a `FetchSpec`; a terminal operation such as `single()`, `list()`, `flow()`, `sequence()`, or `rowsUpdated()` executes it. Continue to [Fetching Results](/fetching-results) for row-count rules, execution timing, JDBC resource management, generated-key portability, and statement options. --- --- url: /fetching-results.md description: >- Execute queries and map results with single, list, flow, sequence, rowsUpdated, generatedValues, and statement options. --- # Fetching Results `sql { ... }` builds a `FetchSpec`; it does not execute the statement. A terminal operation executes the SQL and returns its result. R2DBC terminal operations are suspending except for `flow()` / `flowMap()`. JDBC operations are blocking. ## Operation summary | Method | Result | R2DBC | JDBC | |---|---|:---:|:---:| | `single()` / `singleMap()` | Exactly one row | ✓ | ✓ | | `singleOrNull()` / `singleMapOrNull()` | Zero or one row | ✓ | ✓ | | `list()` / `listMap()` | All rows in memory | ✓ | ✓ | | `flow()` / `flowMap()` | Coroutine `Flow` | ✓ | — | | `sequence()` / `sequenceMap()` | `CloseableSequence` over a JDBC result set | — | ✓ | | `rowsUpdated()` | Affected-row count | ✓ | ✓ | | `generatedValues(vararg columns)` | One row of generated values | ✓ | ✓ | Typed methods use [Row Mapping](/row-mapping). `*Map` methods return `Map` values keyed by column label. ## Exactly one or at most one row `single()` expects exactly one row. It throws when no rows or multiple rows are returned. For a simple scalar result, it also throws when the selected value is SQL `NULL`. `singleOrNull()` returns `null` when there is no row. For a simple scalar result, SQL `NULL` is also returned as Kotlin `null`, so those two cases cannot be distinguished with this operation. Both methods throw for multiple rows. ```kotlin val user: User = kueryClient .sql { +"SELECT * FROM users WHERE user_id = $userId" } .single() val userOrNull: User? = kueryClient .sql { +"SELECT * FROM users WHERE user_id = $userId" } .singleOrNull() ``` The `singleMap()` and `singleMapOrNull()` variants have the same row-count rules without typed conversion. ## Materializing all rows `list()` loads all rows into memory: ```kotlin val users: List = kueryClient .sql { +"SELECT * FROM users WHERE status = $status" } .list() ``` When a nullable SQL column is fetched as a simple scalar, `list()` preserves `NULL` elements. Because the public signature is `List` rather than `List`, prefer selecting a non-null expression or use `listMap()` when nulls are expected. ## Streaming with R2DBC `flow()` is cold: the query executes when the returned flow is collected, and collecting the same flow again executes the query again. ```kotlin val users: Flow = kueryClient .sql { +"SELECT * FROM users ORDER BY user_id" } .flow() users.collect { user -> consume(user) } ``` `flow()` / `flowMap()` currently do not create a Kuery Client observation. See [Streaming operations are not observed](/observation#streaming-operations-are-not-observed). ## Streaming with JDBC `sequence()` executes the statement immediately when the sequence is created. Row consumption is deferred, but the sequence is backed by an open JDBC `ResultSet` and can be iterated only once. Fully iterating it closes the resource. If iteration may stop early—or user code may throw—use `use`: ```kotlin kueryClient .sql { +"SELECT * FROM users ORDER BY user_id" } .sequence() .use { users -> users.take(10).forEach(::consume) } ``` Keep iteration inside the active transaction. `sequence()` / `sequenceMap()` currently do not create a Kuery Client observation. ## Raw maps and column labels Use `singleMap()`, `listMap()`, `flowMap()`, or `sequenceMap()` when typed mapping is unnecessary. ```kotlin val rows: List> = kueryClient .sql { +"SELECT user_id, username FROM users" } .listMap() ``` Always alias duplicate labels in joins. With duplicate labels, the JDBC mapper keeps the first value while the R2DBC mapper keeps the last, so relying on either result is not portable: ```sql SELECT users.id AS user_id, orders.id AS order_id FROM users JOIN orders ON ... ``` ## Updating rows `rowsUpdated()` returns the number of affected rows as a `Long`: ```kotlin val count: Long = kueryClient .sql { +"UPDATE users SET status = $status WHERE user_id = $userId" } .rowsUpdated() ``` ## Generated values `generatedValues(...)` returns exactly one generated-key row. The column names and JVM value types in the map are driver-dependent, even when column names are requested. ```kotlin val generated: Map = kueryClient .sql { +"INSERT INTO users (username, email) VALUES ($username, $email)" } .generatedValues("user_id") val userId = (generated.values.single() as Number).toLong() ``` It throws an `EmptyResultDataAccessException` when no generated values are returned and an `IncorrectResultSizeDataAccessException` when multiple generated-key rows are returned. See [Supported Platforms](/supported-platforms#generated-values) for tested driver differences. ## Statement options Statement options return a new `FetchSpec`; the original remains unchanged. | Method | R2DBC | JDBC | Notes | |---|:---:|:---:|---| | `fetchSize(Int)` | ✓ | ✓ | Driver fetch-size hint | | `maxRows(Int)` | — | ✓ | Maximum rows returned | | `queryTimeoutSeconds(Int)` | — | ✓ | Query timeout in seconds | ```kotlin val users: List = kueryClient .sql { +"SELECT * FROM users" } .fetchSize(100) .list() ``` The driver ultimately defines the effect of fetch size and timeout settings. --- --- url: /row-mapping.md description: >- How query results are mapped to Kotlin types: single-column scalars, data classes via constructor mapping (snake_case to camelCase), enums, nullability, value classes, and raw maps. --- # Row Mapping When you fetch results with a typed terminal operation such as `single()`, `list()`, `flow()`, or `sequence()`, Kuery Client converts each row into the specified type. There are two mapping strategies, chosen automatically based on the return type. ## Data classes Typically, you will map rows to a data class. Each row is mapped with Spring's `DataClassRowMapper` ([spring-r2dbc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/r2dbc/core/DataClassRowMapper.html) / [spring-jdbc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jdbc/core/DataClassRowMapper.html), respectively): the constructor of the target class is invoked, and each constructor parameter is populated from the column with the matching name. A `snake_case` column name matches a `camelCase` parameter name. ```kotlin data class User( val userId: Int, // <- user_id column val username: String, // <- username column val email: String?, // <- email column (nullable) ) val users: List = kueryClient .sql { +"SELECT user_id, username, email FROM users" } .list() ``` Declare a property as nullable when the column can be `NULL`. If a `NULL` value ends up in a non-nullable parameter, an exception is thrown at runtime. ## Simple types If the return type is a simple value type, select one column and its value is converted directly to the target type. JDBC rejects a scalar query with multiple columns; R2DBC reads the first column. Selecting exactly one column is therefore required for portable behavior. Whether a type is "simple" is determined by Spring's [`BeanUtils.isSimpleProperty`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/beans/BeanUtils.html#isSimpleProperty\(java.lang.Class\)), which covers primitives and their wrappers, `String`, enums, `Number` types, date/time types, `UUID`, and other JDK value types. ```kotlin val count: Long = kueryClient .sql { +"SELECT COUNT(*) FROM users" } .single() val usernames: List = kueryClient .sql { +"SELECT username FROM users" } .list() ``` ::: info SQL NULL in multi-row results When fetching a simple scalar type with `list()` / `flow()` / `sequence()`, a SQL `NULL` is kept as a `null` element even though this cannot be expressed in the `List` / `Flow` type. Filter or handle nulls yourself if the column is nullable. ::: ## Enums An enum column is read by its name by default: ```kotlin enum class UserStatus { ACTIVE, INACTIVE } data class User( val userId: Int, val status: UserStatus, // 'ACTIVE' / 'INACTIVE' in the status column ) ``` Enums are also written by name when used as bind parameters. See [Binding Parameters](/basics#binding-parameters). If you want a different representation (for example, a numeric code), register custom converters. See [Type Conversion](/type-conversion). ## Custom types Conversion always happens per column, through Spring's `ConversionService`. Any `@ReadingConverter` you register participates in it — typically when a column value is mapped into a data class property of your custom type. See [Type Conversion](/type-conversion). ::: warning Custom types cannot be the return type itself The mapping strategy is chosen solely by whether the return type is a simple value type, regardless of registered converters. So a custom type as the return type (e.g. `single()`) does not go through your `@ReadingConverter` — it goes down the constructor-mapping path and fails unless the column names happen to match. Receive custom types as properties of a data class instead. ::: ## Kotlin value classes Kotlin value classes are supported in both positions on the fetch side: * As the return type itself (e.g. `single()`): like a simple type, select one column; its value is converted to the underlying type and boxed into the value class. * As a data class property: the column matched by parameter name (same `snake_case` / `camelCase` rules as above) is converted to the underlying type and boxed. ```kotlin @JvmInline value class UserName(val value: String) data class User( val userId: Int, val username: UserName, // <- username column ) val names: List = kueryClient .sql { +"SELECT username FROM users" } .list() ``` Boxing goes through the primary constructor, so `init` validation runs — an invalid database value fails with the same exception the constructor would throw. Value classes wrapping enums (or other value classes) convert recursively. A registered `@ReadingConverter` targeting the value class takes precedence over automatic boxing; see [Type Conversion](/type-conversion). Value classes also work as mutable (`var`) body properties, not just constructor parameters. Generic value classes (e.g. `value class Wrapped(val value: T)`) cannot be boxed automatically — the underlying type is a type parameter — and are rejected with an error, whether used as the return type itself or as a data class property. Register a `@ReadingConverter` for them instead. As a scalar with such a converter, a SQL `NULL` is kept as a `null` element (the underlying type is unknown, so the `NULL` cannot be taken inside the value class). ### Nullable columns How a SQL `NULL` maps depends on whether the value class's **underlying** type is nullable: * **Non-null underlying** (`value class UserName(val value: String)`): a `NULL` cannot be held inside the value class. As a data class property, declare it nullable (`UserName?`) so `NULL` maps to `null`; a non-nullable property fails. As a scalar (`list()`), a `NULL` is kept as a `null` element (the same note as [simple types](#simple-types) above). * **Nullable underlying** (`value class OptionalUserName(val value: String?)`): a `NULL` is taken *into* the value class as `OptionalUserName(null)`, mirroring how such a value is bound on the write side (so it round-trips). A nullable property (`OptionalUserName?`) instead maps `NULL` to the outer `null`: both the property and the underlying could hold the `NULL`, so it is ambiguous, and the outer `null` wins. For a nullable underlying, a scalar fetch always produces `OptionalUserName(null)` (never a `null` element): the element type is non-null (`list`), so element nullability cannot be requested. Use a data class with an `OptionalUserName?` property if you need the outer `null`. Value classes are also supported as bind parameters. See [Binding Parameters](/basics#value-classes). ## Raw maps If you don't need typed mapping, `singleMap()` / `listMap()` / `flowMap()` / `sequenceMap()` return each row as a `Map` keyed by column name. ```kotlin val rows: List> = kueryClient .sql { +"SELECT * FROM users" } .listMap() ``` Alias duplicate column labels in joins. JDBC and R2DBC resolve duplicate map keys differently; see [Raw maps and column labels](/fetching-results#raw-maps-and-column-labels). --- --- url: /transaction.md description: >- Use Spring transactions with Kuery Client — programmatic (TransactionalOperator / TransactionTemplate) and declarative @Transactional. --- # Transaction Kuery Client does not have its own transaction API — you use the transaction mechanisms provided by Spring as is. This page gives a brief overview; for details, please refer to the [Spring documentation](https://docs.spring.io/spring-framework/reference/data-access/transaction.html). ## Programmatic Transaction Management Use `TransactionalOperator` (R2DBC) or `TransactionTemplate` (JDBC) to manage transactions programmatically. With Spring Boot's matching data starter, these transaction helpers are normally available as beans. Applications with multiple databases or custom transaction managers must select and configure the appropriate manager explicitly. ::: code-group ```kotlin [kuery-client-spring-data-r2dbc] @Service class UserService( private val userRepository: UserRepository, private val transaction: TransactionalOperator, // registered as a bean ) { suspend fun addUser( username: String, email: Email, ): Int { // Programmatically apply transactions return transaction.executeAndAwait { userRepository.insert(username, email) } } } @Repository class UserRepository(private val kueryClient: KueryClient) { suspend fun insert( username: String, email: Email, ): Int { // ... } } ``` ```kotlin [kuery-client-spring-data-jdbc] @Service class UserService( private val userRepository: UserRepository, private val transaction: TransactionTemplate, // registered as a bean ) { fun addUser( username: String, email: Email, ): Int { // Programmatically apply transactions return transaction.execute { userRepository.insert(username, email) }!! } } @Repository class UserRepository(private val kueryClient: KueryBlockingClient) { fun insert( username: String, email: Email, ): Int { // ... } } ``` ::: ## Declarative (`@Transactional`) Transaction Management For declarative transaction management, add `@Transactional` to a Spring-managed method. Standard Spring proxy rules still apply—for example, self-invocation does not pass through the transactional proxy. ::: code-group ```kotlin [kuery-client-spring-data-r2dbc] @Service class UserService( private val userRepository: UserRepository, ) { // Apply transactions using AOP @Transactional suspend fun addUser( username: String, email: Email, ): Int { return userRepository.insert(username, email) } } @Repository class UserRepository(private val kueryClient: KueryClient) { suspend fun insert( username: String, email: Email, ): Int { // ... } } ``` ```kotlin [kuery-client-spring-data-jdbc] @Service class UserService( private val userRepository: UserRepository, ) { // Apply transactions using AOP @Transactional fun addUser( username: String, email: Email, ): Int { return userRepository.insert(username, email) } } @Repository class UserRepository(private val kueryClient: KueryBlockingClient) { fun insert( username: String, email: Email, ): Int { // ... } } ``` ::: ## Streaming inside a transaction R2DBC `flow()` executes when collected, so collect it inside the reactive transaction that should own the query. JDBC `sequence()` opens a `ResultSet` immediately and must be consumed or closed inside the active transaction. See [Fetching Results](/fetching-results#streaming-with-jdbc) for the resource-management rules. --- --- url: /compiler-safety-check.md description: >- All compile-time diagnostics reported by the kuery-client compiler plugin — unsafe SQL strings, bind() misuse, SQL syntax validation, redundant trimIndent — and the strict option that escalates the safety warnings into compile errors. --- # Compile-Time Checks The kuery-client compiler plugin does more than rewrite interpolated runtime values into bind parameters — it also **checks your SQL code at compile time**. This page is the overview of every diagnostic the plugin can report: | Diagnostic | Description | Severity | Enabled | |---|---|---|---| | [`KUERY_UNSAFE_SQL_STRING`](#unsafe-sql-strings) | Detects SQL expressions whose automatic parameter binding cannot be verified | warning ([`strict`](#strict-mode) → error) | always | | [`KUERY_BIND_CALL_IN_SQL_TEMPLATE`](#bind-in-a-string-template) | Prevents a placeholder returned by `bind()` from being bound again as a value | always error | always | | [`KUERY_SQL_SYNTAX`](#sql-syntax-errors) | Reports syntax errors in SQL reconstructed at compile time | warning ([`strict`](#strict-mode) → error) | [`sqlSyntaxCheck`](/sql-syntax-check) | | [`KUERY_SQL_DIALECT`](#unsupported-sql-dialect-features) | Reports SQL features unsupported by the configured dialect | warning ([`strict`](#strict-mode) → error) | [`sqlSyntaxCheck` with a dialect](/sql-syntax-check#choosing-a-dialect) | | [`KUERY_REDUNDANT_TRIM_INDENT`](#redundant-trimindent) | Reports unnecessary `trimIndent()` calls when automatic trimming is enabled | warning (style — never escalated) | [`autoTrimIndent`](/basics#automatic-trimindent-opt-in) | Every diagnostic can be [suppressed per declaration](#configuring-the-severity-manually) with `@Suppress`. The warning-based diagnostics can also be reconfigured project-wide with `-Xwarning-level`; the unconditional `KUERY_BIND_CALL_IN_SQL_TEMPLATE` error cannot. ## Unsafe SQL strings (`KUERY_UNSAFE_SQL_STRING`) {#unsafe-sql-strings} The plugin's conversion of string interpolation into named bind parameters only works when the SQL string is written **directly as a string literal/template at the call site**. If you pass anything else — for example a variable — the plugin cannot see the interpolation, and the string would be executed as raw SQL. To prevent this from happening silently, the compiler plugin reports a `KUERY_UNSAFE_SQL_STRING` **warning** when it cannot guarantee that interpolation in an `add()` / `+` expression will be converted into bind parameters. ### Noncompliant code ```kotlin // String variable: interpolation already happened before add() val sql = "SELECT * FROM users WHERE user_id = $userId" kueryClient.sql { add(sql) // KUERY_UNSAFE_SQL_STRING } // String concatenation kueryClient.sql { add("SELECT * FROM users WHERE " + condition) // KUERY_UNSAFE_SQL_STRING } // Function call: the plugin cannot see what the function returns kueryClient.sql { add(buildWhere()) // KUERY_UNSAFE_SQL_STRING } // Runtime transformation of a literal kueryClient.sql { add("WHERE aaa".replace("aaa", input)) // KUERY_UNSAFE_SQL_STRING } // Scope function: the plugin does not inspect the lambda body kueryClient.sql { add(run { "SELECT 1" }) // KUERY_UNSAFE_SQL_STRING } // Every branch must be safe; fragment is a String variable kueryClient.sql { add(if (id > 0) "SELECT 1" else fragment) // KUERY_UNSAFE_SQL_STRING } ``` ### Compliant code ```kotlin // String literal / template kueryClient.sql { +"SELECT * FROM users WHERE user_id = $userId" // userId is bound as :p0 } // trimIndent() / trimMargin() on a literal kueryClient.sql { +""" SELECT * FROM users """.trimIndent() } kueryClient.sql { +""" |SELECT * |FROM users """.trimMargin() } // const val; a Java constant (static final String with a constant initializer) works the same const val SELECT_ALL_USERS = "SELECT * FROM users" kueryClient.sql { +SELECT_ALL_USERS } // if expression with a safe string in every branch kueryClient.sql { +"SELECT * FROM users" add(if (asc) "ORDER BY user_id" else "ORDER BY user_id DESC") } // when expression; a throwing branch never produces a SQL string kueryClient.sql { +"SELECT * FROM users" add( when (sort) { "name" -> "ORDER BY name" "created" -> "ORDER BY created_at DESC" else -> error("Unsupported sort: $sort") }, ) } ``` Each SQL-producing expression is known at compile time, so the plugin can safely rewrite any interpolated values into bind parameters. With `autoTrimIndent` enabled, omit the explicit `trimIndent()` as described in [`KUERY_REDUNDANT_TRIM_INDENT`](#redundant-trimindent). ### Responding to the warning In order of preference: 1. **Restructure into safe forms.** Most dynamic SQL can be expressed with Kotlin control flow inside the `sql { }` block — `if` / `when` / loops around `+"..."` — which the plugin handles safely. See [Building SQL](/basics#dynamic-sql-with-kotlin-control-flow). 2. **Suppress the warning** with `@Suppress("KUERY_UNSAFE_SQL_STRING")` on the declaration, when you know the string is safe but the checker cannot see it. ## `bind()` in a string template (`KUERY_BIND_CALL_IN_SQL_TEMPLATE`) {#bind-in-a-string-template} [`bind()`](/basics#addunsafe-and-bind) only makes sense together with `addUnsafe()`. Calling it inside a string template passed to `add()` / `+` is reported as a `KUERY_BIND_CALL_IN_SQL_TEMPLATE` compile **error**. Interpolated values there are already bound automatically, so the placeholder returned by `bind()` would itself be bound as a value. The SQL would compare `user_id` against the literal string `:p0` and silently match nothing. ### Noncompliant code ```kotlin kueryClient.sql { +"SELECT * FROM users WHERE user_id = ${bind(userId)}" // KUERY_BIND_CALL_IN_SQL_TEMPLATE } ``` ### Compliant code ```kotlin kueryClient.sql { +"SELECT * FROM users WHERE user_id = $userId" } ``` This diagnostic is an **error regardless of strict mode**. Interpolate the value directly, or use `addUnsafe()` when you need `bind()` for dynamically assembled SQL — see [`addUnsafe()` and `bind()`](/basics#addunsafe-and-bind). ## SQL syntax errors (`KUERY_SQL_SYNTAX`) {#sql-syntax-errors} With the `sqlSyntaxCheck` option enabled, the plugin also validates the **SQL text itself** at compile time: blocks whose complete statement is statically known are assembled exactly like at runtime and run through a SQL parser. A parse failure is reported as `KUERY_SQL_SYNTAX`. See [SQL Syntax Check](/sql-syntax-check) for configuration and examples. ### Noncompliant code ```kotlin kueryClient.sql { +"SELECT *" +"FORM users" // KUERY_SQL_SYNTAX: FORM should be FROM } ``` ### Compliant code ```kotlin kueryClient.sql { +"SELECT *" +"FROM users" } ``` ## Unsupported SQL dialect features (`KUERY_SQL_DIALECT`) {#unsupported-sql-dialect-features} When `sqlSyntaxCheck` is configured with a dialect, the plugin also reports SQL features that the selected dialect does not support as `KUERY_SQL_DIALECT`. See [Choosing a dialect](/sql-syntax-check#choosing-a-dialect) for the available dialects and examples. The following examples assume `sqlSyntaxCheck = "postgresql"`. ### Noncompliant code ```kotlin // KUERY_SQL_DIALECT: ON DUPLICATE KEY UPDATE is a MySQL feature kueryClient.sql { +"INSERT INTO users (id, name) VALUES ($id, $name) ON DUPLICATE KEY UPDATE name = $name" } ``` ### Compliant code ```kotlin kueryClient.sql { +"INSERT INTO users (id, name) VALUES ($id, $name) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name" } ``` ## Redundant `trimIndent()` (`KUERY_REDUNDANT_TRIM_INDENT`) {#redundant-trimindent} With the [`autoTrimIndent`](/basics#automatic-trimindent-opt-in) option enabled, every string passed to `add()` / `+` is trimmed automatically, so an explicit `.trimIndent()` left behind is redundant — it prevents the compile-time trimming and only adds an extra runtime trim. The plugin reports a `KUERY_REDUNDANT_TRIM_INDENT` **warning** for such calls; remove them when enabling the option. Details are in [Basics](/basics#automatic-trimindent-opt-in). This is a style issue, not a safety issue, so [strict mode](#strict-mode) does not escalate it. ### Noncompliant code ```kotlin kueryClient.sql { +""" SELECT * FROM users """.trimIndent() // KUERY_REDUNDANT_TRIM_INDENT } ``` ### Compliant code ```kotlin kueryClient.sql { +""" SELECT * FROM users """ } ``` ## Strict mode Enable `strict` in the Gradle plugin to report the SQL-safety diagnostics as compile **errors** instead of warnings. The option remains opt-in because it was added after Kuery Client's initial releases and changing the existing build behavior would be disruptive. It is recommended for new projects and is intended to become the default in a future release: ```kotlin kueryClient { strict = true } ``` This makes the compiler plugin register `KUERY_UNSAFE_SQL_STRING` — and, when the [SQL Syntax Check](/sql-syntax-check) is enabled, `KUERY_SQL_SYNTAX` / `KUERY_SQL_DIALECT` — as **error**-severity diagnostics. `@Suppress` on the enclosing declaration keeps working for individual call sites, and `addUnsafe()` + `bind()` remain the sanctioned way to build SQL dynamically. `KUERY_REDUNDANT_TRIM_INDENT` stays a warning — a style issue, not a safety issue. The escalated set may grow in minor releases as new safety diagnostics are added — enabling strict mode opts into those too. An explicit `-Xwarning-level` for one of these diagnostics always wins over strict mode: strict only changes the **default** severity of the diagnostics you have not configured yourself. So a single diagnostic can still be lowered back to a warning (or disabled) module-wide with strict enabled: ```kotlin kotlin { compilerOptions { // keep everything else strict, but let the dialect check stay advisory freeCompilerArgs.add("-Xwarning-level=KUERY_SQL_DIALECT:warning") } } ``` ## Configuring the severity manually The checks are regular Kotlin compiler diagnostics, so the standard Kotlin mechanisms apply to every diagnostic in the table (except `KUERY_BIND_CALL_IN_SQL_TEMPLATE`, whose error severity cannot be lowered): * **Suppress for specific code** with `@Suppress("")` on the enclosing declaration, e.g. `@Suppress("KUERY_SQL_SYNTAX")`. This also works for diagnostics escalated to errors. * **Escalate a single diagnostic** without strict mode: ```kotlin kotlin { compilerOptions { freeCompilerArgs.add("-Xwarning-level=KUERY_UNSAFE_SQL_STRING:error") } } ``` * **Lower one back to a warning** with strict mode enabled (see [above](#strict-mode)), or **disable one project-wide** with `-Xwarning-level=:disabled` (not recommended). * `allWarningsAsErrors` (`-Werror`) likewise turns all warnings into errors. --- --- url: /sql-syntax-check.md description: >- The opt-in KUERY_SQL_SYNTAX compiler warning that validates statically-known SQL with a SQL parser at compile time. --- # SQL Syntax Check (opt-in) Kuery Client lets you write raw SQL, so a typo like `SELCT` or a missing parenthesis normally surfaces only at runtime, as a database error. The compiler plugin can optionally validate the SQL syntax of your `sql { }` blocks **at compile time**. Enable it in the Gradle plugin with a single option — `"generic"` for a dialect-agnostic syntax check, or a dialect name to also check that dialect's feature set (see [below](#choosing-a-dialect)): ```kotlin kueryClient { sqlSyntaxCheck = "generic" // or "mysql", "postgresql", ... } ``` A block whose SQL fails to parse is then reported as a `KUERY_SQL_SYNTAX` **warning** (or a compile **error** when `strict` is enabled — see [Compile-Time Checks](/compiler-safety-check#strict-mode)), anchored to the offending line: ```kotlin kueryClient.sql { +"SELECT *" +"FORM users" // KUERY_SQL_SYNTAX: Encountered unexpected token: "users" ... +"WHERE user_id = $userId" } ``` ## What is checked A block is validated only when the **complete statement can be reconstructed statically**. The checker joins its fragments with newlines, expands compile-time `String` / `Char` constants into the SQL text, replaces other interpolated values with their `:pN` bind placeholders, and parses the result. ### Checked code The simplest case is a multiline SQL template written directly in the block: ```kotlin kueryClient.sql { +""" SELECT user_id, username FROM users WHERE tenant_id = $tenantId """ } // Parsed as: // SELECT user_id, username // FROM users // WHERE tenant_id = :p0 ``` String literals/templates, direct-literal `const val` references, and `trimIndent()` / `trimMargin()` on those forms can all be reconstructed: ```kotlin const val SELECT_USERS = "SELECT * FROM users" kueryClient.sql { +SELECT_USERS +""" WHERE tenant_id = $tenantId """.trimIndent() +""" |ORDER BY user_id """.trimMargin() } // Parsed as: // SELECT * FROM users // WHERE tenant_id = :p0 // ORDER BY user_id ``` The checker can evaluate a `const val` here when its initializer is a single literal. A computed initializer such as `const val TABLE = "app_" + "users"`, a reference to another constant, or a Java constant field is still safe to execute, but the checker cannot reconstruct its text and silently skips syntax validation for the block. Your own `SqlBuilder` extension functions are also checked when their source code is in the **same Gradle module** as the `sql { }` call. Helpers from another module are skipped because the checker cannot inspect their source body. An eligible same-module helper can be a top-level function or a final member function. Its body must contain only statically reconstructable `add()` / `+"..."` calls, or calls to other eligible helpers. The checker inlines that body and replaces its interpolated parameters with the same `:pN` binds used at runtime, regardless of the arguments passed to the helper: ```kotlin fun SqlBuilder.paging(limit: Int, offset: Int) { add("LIMIT $limit OFFSET $offset") } kueryClient.sql { +"SELECT * FROM users WHERE id = $id" paging(10, 0) // checked as "... LIMIT :p1 OFFSET :p2" } ``` ### Skipped code If control flow determines which fragments are added, the final SQL depends on runtime state, so the whole block is skipped: ```kotlin kueryClient.sql { +"SELECT * FROM users" +"WHERE tenant_id = $tenantId" if (activeOnly) { +"AND active = TRUE" } for (role in requiredRoles) { +"AND role_name = $role" } } // No KUERY_SQL_SYNTAX check: the final statement varies at runtime. ``` A helper with dynamic control flow also makes its caller impossible to reconstruct: ```kotlin fun SqlBuilder.activeUsersOnly(enabled: Boolean) { if (enabled) { +"WHERE active = TRUE" } } kueryClient.sql { +"SELECT * FROM users" activeUsersOnly(activeOnly) } // No KUERY_SQL_SYNTAX check. ``` `addUnsafe()` and non-literal arguments likewise opt the block out of syntax validation: ```kotlin val orderBy = "ORDER BY user_id" kueryClient.sql { +"SELECT * FROM users" add(orderBy) // Also reports KUERY_UNSAFE_SQL_STRING. } @OptIn(DelicateKueryClientApi::class) fun customQuery(customSql: String) = kueryClient.sql { +"SELECT * FROM users" addUnsafe(customSql) } ``` The same skip applies to `when`, `?.let { ... }`, `return@sql`, and helpers that are overridable or compiled in another module. Skipping is silent: it suppresses only `KUERY_SQL_SYNTAX` / `KUERY_SQL_DIALECT`, not other diagnostics such as [`KUERY_UNSAFE_SQL_STRING`](/compiler-safety-check#unsafe-sql-strings). This mirrors how compile-time-checked SQL works elsewhere (e.g. Rust's sqlx): statements that are fully known are verified, dynamically assembled ones are not — no false alarms on dynamic SQL. ## Choosing a dialect The parser (JSqlParser) parses a superset of all dialects, so under `"generic"` a MySQL-only construct passes even in a PostgreSQL project. If you set `sqlSyntaxCheck` to your dialect instead, statements that pass the syntax check are additionally validated against that dialect's feature set: ```kotlin kueryClient { sqlSyntaxCheck = "postgresql" } ``` Values: `generic`, `ansi`, `oracle`, `mysql`, `sqlserver`, `mariadb`, `postgresql`, `h2`. `generic` runs the syntax check with no feature validation (the fewest false positives); `ansi` is a real, strict ANSI SQL feature set, distinct from `generic`. A dialect feature violation is reported as a separate `KUERY_SQL_DIALECT` warning. Unlike the syntax warning, it is anchored to the whole `sql { }` call (the parser's feature validation carries no line positions): ```kotlin // KUERY_SQL_DIALECT: insertUseDuplicateKeyUpdate not supported. (dialect: postgresql) kueryClient.sql { +"INSERT INTO users (id, name) VALUES ($id, $name) ON DUPLICATE KEY UPDATE name = $name" } ``` This uses JSqlParser's validation framework, which is a **feature-level allow-list, not a full dialect grammar**: it catches whole features the database does not have (upserts, `RETURNING`, ...), but some cross-dialect syntax still passes, and an incomplete allow-list can produce a false positive — suppress those with `@Suppress("KUERY_SQL_DIALECT")`. ## False positives and limitations The check uses [JSqlParser](https://github.com/JSQLParser/JSqlParser), a generic multi-dialect SQL parser, and runs without connecting to a database. Consequences: * **It is a syntax check only.** Table/column names, types, and schema are not verified. * **Some invalid SQL passes.** JSqlParser is deliberately lenient; the check catches broken statements, not every mistake. * **Rare vendor-specific syntax may be flagged although your database accepts it**, when JSqlParser has no grammar for it (a known example: H2's `MERGE INTO ... KEY(...)` upsert). In that case suppress the warning for the declaration: ```kotlin @Suppress("KUERY_SQL_SYNTAX") fun listUsersWithVendorSyntax(): List = ... ``` ## Configuring the severity Like every diagnostic of the plugin, the standard Kotlin mechanisms (`strict` mode, `@Suppress`, `-Xwarning-level`) apply — note there are two diagnostic names, `KUERY_SQL_SYNTAX` and `KUERY_SQL_DIALECT`, configured independently. See [Compile-Time Checks](/compiler-safety-check#configuring-the-severity-manually) for the details. The option is unset by default, so existing builds are unaffected. --- --- url: /type-conversion.md description: >- Register Spring's @WritingConverter / @ReadingConverter via the builder's converters(...) to support custom types. --- # Type Conversion Kuery Client uses [Spring Type Conversion](https://docs.spring.io/spring-framework/reference/core/validation/convert.html) for values written to bind parameters and values read into mapped properties. Register Spring `@WritingConverter` and `@ReadingConverter` implementations on the client builder. ## Default behavior Without any configuration: * Types the driver supports natively (numbers, strings, date/time types, and so on) are passed through unchanged. * Enums are written by their name (`Enum.name`) and read back by name. * Kotlin value classes are written as their underlying value and read back by boxing the column value through the constructor. Custom converters registered via `converters(...)` take precedence over these defaults. For example, registering a `@WritingConverter` from your enum to `Int` overrides the write-by-name default for that enum. ## Example ### Define a custom type ```kotlin data class StringWrapper(val value: String) ``` ### Create the converters ```kotlin @WritingConverter class StringWrapperToStringConverter : Converter { override fun convert(source: StringWrapper): String { return source.value } } @ReadingConverter class StringToStringWrapperConverter : Converter { override fun convert(source: String): StringWrapper { return StringWrapper(source) } } ``` ### Register the converters ::: code-group ```kotlin {4-9} [R2DBC] val kueryClient = SpringR2dbcKueryClient.builder() .connectionFactory(connectionFactory) .converters( listOf( StringWrapperToStringConverter(), StringToStringWrapperConverter(), ) ) .build() ``` ```kotlin {4-9} [JDBC] val kueryClient = SpringJdbcKueryClient.builder() .dataSource(dataSource) .converters( listOf( StringWrapperToStringConverter(), StringToStringWrapperConverter(), ) ) .build() ``` ::: ### Use the type ```kotlin suspend fun write(str: StringWrapper): Long = kueryClient .sql { +"INSERT INTO test_table (text) VALUES ($str)" } .rowsUpdated() data class Record( val text: StringWrapper, ) suspend fun read(): List = kueryClient .sql { +"SELECT * FROM test_table" } .list() ``` The blocking client uses the same SQL and mapping code without `suspend`. Conversion is performed per bound value and per mapped column. A non-simple custom type used as the top-level return type does not automatically use its `@ReadingConverter`; put it in a mapped data class property instead. (Kotlin value classes are the exception: they work in both positions without converters. See [Kotlin value classes](/row-mapping#kotlin-value-classes) in Row Mapping.) --- --- url: /observation.md description: >- Wire Micrometer ObservationRegistry and ObservationConvention; recorded tags, sql_id generation and its constraints, with a Prometheus/Actuator example. --- # Observation Kuery Client supports [Micrometer Observation](https://micrometer.io/). If you want to use this feature, please specify the `ObservationRegistry` when creating the `KueryClient`. ```kotlin {4} // e.g. In the case of kuery-client-spring-data-r2dbc val kueryClient = SpringR2dbcKueryClient.builder() .connectionFactory(connectionFactory) .observationRegistry(...) .build() ``` If you want to customize the metrics name or other settings, please implement and specify the `ObservationConvention` also. ```kotlin {4-5} // e.g. In the case of kuery-client-spring-data-r2dbc val kueryClient = SpringR2dbcKueryClient.builder() .connectionFactory(connectionFactory) .observationRegistry(...) .observationConvention(...) .build() ``` ## Recorded data Each non-streaming terminal operation is recorded as an observation named `kuery.client.fetches` with the following tags. Streaming operations are not observed; see [Streaming operations are not observed](#streaming-operations-are-not-observed). | Tag | Cardinality | Description | |---|---|---| | `sql.id` | low | Identifies the query; derived from the calling class/method by default (see [below](#sql-id)) | | `sql` | high | The SQL body with placeholders (e.g. `:p0`) — bound values are not included, so sensitive data does not leak. High-cardinality tags are attached to spans, not to metrics | To change the name or the tags, implement `KueryClientFetchObservationConvention` and pass it to `observationConvention(...)`. ## Example: spring-boot-starter-actuator & Prometheus ::: info We won't go into detail about Spring Boot, Micrometer, and Prometheus here. The documentation is written concisely, assuming you are familiar with these. ::: First, add `org.springframework.boot:spring-boot-starter-actuator` and `io.micrometer:micrometer-registry-prometheus` as dependencies. ```kotlin // ... // other dependencies // ... implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("io.micrometer:micrometer-registry-prometheus") ``` Then, write the following and register KueryClient as a Bean: ```kotlin @Configuration(proxyBeanMethods = false) class ExampleConfiguration { @Bean fun kueryClient(connectionFactory: ConnectionFactory, observationRegistry: ObservationRegistry): KueryClient { return SpringR2dbcKueryClient.builder() .connectionFactory(connectionFactory) .observationRegistry(observationRegistry) .build() } } ``` Suppose you are implementing a repository like the following. ```kotlin package com.example.spring.data.r2dbc // ... @Repository class UserRepository(private val kueryClient: KueryClient) { suspend fun selectByUserId(userId: Int): User? = kueryClient .sql { +"SELECT * FROM users WHERE user_id = $userId" } .singleOrNull() } ``` With these assumptions, you can obtain Prometheus metrics as follows: ```shell curl {host}/actuator/prometheus | grep kuery # HELP kuery_client_fetches_active_seconds # TYPE kuery_client_fetches_active_seconds summary kuery_client_fetches_active_seconds_count{sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0 kuery_client_fetches_active_seconds_sum{sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0.0 # HELP kuery_client_fetches_active_seconds_max # TYPE kuery_client_fetches_active_seconds_max gauge kuery_client_fetches_active_seconds_max{sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0.0 # HELP kuery_client_fetches_seconds # TYPE kuery_client_fetches_seconds summary kuery_client_fetches_seconds_count{error="none",sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 14 kuery_client_fetches_seconds_sum{error="none",sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0.13953154 # HELP kuery_client_fetches_seconds_max # TYPE kuery_client_fetches_seconds_max gauge kuery_client_fetches_seconds_max{error="none",sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0.026267833 ``` As shown above, the `sql_id` label is automatically derived from the repository class and method that call `kueryClient.sql { ... }`. ## `sql_id` The id is derived at compile time by the compiler plugin: every `sql { ... }` call site is rewritten to carry the fully qualified name of its enclosing declaration (e.g. `com.example.UserRepository.selectByUserId`), so no stack inspection happens at runtime and a given call site always produces the same id. Calls inside lambdas fold into the enclosing named method. The id is attached only when the block is written literally at the call site — a lambda or a function reference. Everything else resolves to the fixed id `NONE`: a block passed via a variable, a function reference that needs adaptation (the referenced function declares default arguments or varargs), call sites that are not compiled with the compiler plugin (e.g. Java callers or reflective invocations), and so on. Specify the id explicitly in such cases. A few sharing rules follow from "the id is the enclosing declaration's FQN": overloads of the same method share one id, as do same-named top-level functions in different files of the same package; and a call inside an `inline` function uses the inline function's own FQN, not its caller's. Whether the derived id is actually used is controlled by the builder's `enableAutoSqlIdGeneration(...)` flag. It defaults to `true` when an `ObservationRegistry` is specified, and `false` otherwise — in which case the fixed id `NONE` is used. ### Explicit SQL IDs When an id must remain stable across method renames and other refactorings, specify it at the call site instead of relying on automatic generation: ```kotlin kueryClient.sql("users.find-by-id") { +"SELECT * FROM users WHERE user_id = $userId" } ``` An explicit id is used as-is and is not affected by `enableAutoSqlIdGeneration(...)`. ### Multiple calls in one method If a single method contains multiple `kueryClient.sql {...}` calls, each call site gets its own id: a `#N` suffix is appended in source order. ```kotlin @Repository class UserRepository(private val kueryClient: KueryClient) { suspend fun selectByUserId(userId: Int): UserAndDetail { val user: User = kueryClient .sql { // sql_id: com.example.UserRepository.selectByUserId#1 +"SELECT * FROM users WHERE user_id = $userId" } .single() val userDetail: UserDetail = kueryClient .sql { // sql_id: com.example.UserRepository.selectByUserId#2 +"SELECT * FROM user_details WHERE user_id = $userId" } .single() return UserAndDetail(user, userDetail) } } ``` A method with a single call keeps the plain id without the suffix. Note that this means adding a second call to a method changes the first call's id from `...selectByUserId` to `...selectByUserId#1`; specify the `sql_id` explicitly if you want ids that are independent of such refactorings. The suffix is an ordinal among the auto-generated ids that would otherwise collide — not the call's position in the method. Calls with an explicit `sql("...")` id neither receive nor shift numbers: in a method with one explicit-id call followed by one auto-id call, the auto id stays plain (no `#2`), exactly as if the explicit call were not there. ```kotlin val user: User = kueryClient .sql("my_sql_id_1") { +"SELECT * FROM users WHERE user_id = $userId" } .single() ``` ## Streaming operations are not observed Observation is not recorded for the streaming terminal operations: * `flow()` / `flowMap()` (kuery-client-spring-data-r2dbc) * `sequence()` / `sequenceMap()` (kuery-client-spring-data-jdbc) These operations return before the query results are consumed, so there is no obvious point at which the observation should stop. Both "until the stream terminates" and "until the first element arrives" are reasonable but different semantics. Until this is settled, these operations simply do not record metrics. If you need metrics for such a query, use `list()` / `listMap()` instead, or measure the consumption of the stream yourself. --- --- url: /helpers.md description: >- Use the built-in values helper to create multi-row INSERT statements with every value safely bound. --- # Helpers ## `values` Use `values` to build a multi-row insert while binding every value: ```kotlin data class UserParam(val username: String, val email: String?, val age: Int) suspend fun insertMany(params: List): Long = kueryClient .sql { +"INSERT INTO users (username, email, age)" values(params) { listOf(it.username, it.email, it.age) } } .rowsUpdated() // INSERT INTO users (username, email, age) VALUES (:p0, :p1, :p2), (:p3, :p4, :p5), ... ``` The built-in `values` overloads reject an empty input, an empty row, and rows with different sizes by throwing `IllegalArgumentException`. Internally, `values` builds the required placeholders with the lower-level `addUnsafe()` / `bind()` APIs. See [`addUnsafe()` and `bind()`](/basics#addunsafe-and-bind) when writing a different kind of custom SQL fragment. --- --- url: /configuration.md description: >- Reference for the Kuery Client Gradle plugin and runtime client-builder options, including defaults and recommended settings. --- # Configuration Kuery Client has two separate configuration layers: 1. The Gradle plugin configures compile-time SQL transformation and diagnostics. 2. The R2DBC/JDBC client builder configures runtime database access, conversion, and observation. ## Gradle plugin Apply the plugin to every JVM or Android/JVM module that contains a `sql { ... }` call: ```kotlin plugins { id("dev.hsbrysk.kuery-client") version "1.2.0" } ``` The compiler plugin is applied only to JVM and Android/JVM compilations. Maven is not currently supported. In a multi-module build, applying the plugin only to the application module is not enough when repository code lives in another module. ### Options | Option | Type | Default | Purpose | Accepted values | |---|---|---|---|---| | `autoTrimIndent` | `Boolean` | `false` | Applies `trimIndent()` automatically to strings passed to `+` / `add()` | — | | `sqlSyntaxCheck` | `String` | unset | Enables compile-time SQL parsing and optionally selects a dialect | genericansioraclemysqlsqlservermariadbpostgresqlh2Values are case-insensitive and surrounding whitespace is ignored. Any other value fails the Gradle build. | | `strict` | `Boolean` | `false` | Escalates SQL-safety diagnostics from warnings to errors | — | For example: ```kotlin kueryClient { autoTrimIndent = true sqlSyntaxCheck = "postgresql" strict = true } ``` ::: tip Recommended settings and planned defaults These options were added after Kuery Client's initial releases and remain opt-in today to avoid changing the behavior of existing projects. They are intended to become the defaults in a future release, equivalent to: ```kotlin kueryClient { autoTrimIndent = true sqlSyntaxCheck = "mysql" strict = true } ``` New projects can enable them now. Replace `mysql` with the project's dialect when needed; dialect validation is stricter but can report false positives for uncommon vendor-specific SQL. ::: See [Building SQL](/basics#automatic-trimindent-opt-in), [Compile-Time Checks](/compiler-safety-check), and [SQL Syntax Check](/sql-syntax-check) for the exact behavior. ## Runtime client builder The R2DBC and JDBC builders expose the same configuration concepts, with a different required connection object. | Builder method | R2DBC | JDBC | Default | |---|---|---|---| | `connectionFactory(...)` | required | — | none | | `dataSource(...)` | — | required | none | | `converters(...)` | available | available | empty list | | `observationRegistry(...)` | available | available | observation disabled | | `observationConvention(...)` | available | available | built-in convention | | `enableAutoSqlIdGeneration(...)` | available | available | `true` when an observation registry is set; otherwise `false` | Calling `build()` without the required `ConnectionFactory` or `DataSource` throws an `IllegalArgumentException`. Both built clients are designed to be shared across the application. ::: code-group ```kotlin [R2DBC] val kueryClient: KueryClient = SpringR2dbcKueryClient.builder() .connectionFactory(connectionFactory) .converters(listOf(MyWritingConverter(), MyReadingConverter())) .observationRegistry(observationRegistry) .build() ``` ```kotlin [JDBC] val kueryClient: KueryBlockingClient = SpringJdbcKueryClient.builder() .dataSource(dataSource) .converters(listOf(MyWritingConverter(), MyReadingConverter())) .observationRegistry(observationRegistry) .build() ``` ::: See [Type Conversion](/type-conversion) and [Observation](/observation) for the optional runtime settings. --- --- url: /supported-platforms.md description: >- Databases, drivers, and JVM platforms exercised by Kuery Client's automated test suite, plus database-specific behavior to account for. --- # Supported Platforms Kuery Client delegates database access to Spring Data and the configured JDBC/R2DBC driver. It may work with other Spring-supported databases, but the combinations below are the ones exercised by this repository's automated tests. ## Continuously tested databases | Database | JDBC driver | R2DBC driver | Test environment | |---|---|---|---| | MySQL | `com.mysql:mysql-connector-j` | `io.asyncer:r2dbc-mysql` | MySQL 8.0.37 Testcontainer | | PostgreSQL | `org.postgresql:postgresql` | `org.postgresql:r2dbc-postgresql` | PostgreSQL 16 Testcontainer | | H2 | `com.h2database:h2` | `io.r2dbc:r2dbc-h2` | In-memory database | The suite runs the shared client behavior against both JDBC and R2DBC. Driver-dependent features—including parameter binding, arrays, generated values, and database error handling—also run against real MySQL and PostgreSQL containers. ::: info What “tested” means The table describes the project's automated test matrix, not an exclusive allow-list. A database supported by Spring Data may work, but behavior outside this matrix is not continuously verified by this project. ::: For compatible Java, Gradle, Kotlin, Spring Boot, and Spring Data versions, see [Compatibility](/compatibility). The compiler plugin targets JVM and Android/JVM compilations; JavaScript, Native, and Wasm compilations are not supported. ## Database-dependent behavior ### Empty collections in `IN` clauses Spring expands an empty collection to `IN ()`. H2 accepts that syntax and returns no rows, while MySQL and PostgreSQL reject it as invalid SQL. Guard the empty case before building the query: ```kotlin suspend fun findByIds(ids: List): List { if (ids.isEmpty()) return emptyList() return kueryClient .sql { +"SELECT * FROM users WHERE user_id IN ($ids)" } .list() } ``` The same rule applies to the blocking client without `suspend`. ### SQL arrays An object array such as `Array` is bound as one driver array value; it is not expanded as an `IN` list. This is useful with PostgreSQL array columns and `= ANY(...)`. MySQL has no equivalent native array type, so use a non-empty `Collection` for an `IN` clause instead. `ByteArray` is bound as one binary value rather than a SQL array. For other primitive arrays, R2DBC boxes the elements into an object array before binding, while JDBC passes the primitive array to the driver unchanged; support therefore depends on the client and driver combination. ### Generated values Generated-key labels and JVM value types come from the driver. For example, the tested MySQL R2DBC driver returns the requested `user_id` key as a `Long`, while MySQL Connector/J reports `GENERATED_KEY` as a `BigInteger` even when `"user_id"` was requested. When only one value is requested and its label is not important, use the single map value and convert it to the application type: ```kotlin val generated = kueryClient .sql { +"INSERT INTO users (username) VALUES ($username)" } .generatedValues("user_id") val userId = (generated.values.single() as Number).toLong() ``` `generatedValues(...)` expects exactly one generated-key row. It throws when no keys are produced or when a statement produces multiple key rows. The exact keys available are database- and driver-dependent. ### SQL syntax dialects The dialect selected by [`sqlSyntaxCheck`](/sql-syntax-check#choosing-a-dialect) controls compile-time parser validation only. It does not configure the runtime driver and is not a declaration that every feature of that database has been tested. --- --- url: /examples.md description: >- Run the repository's Spring WebFlux/R2DBC and Spring WebMVC/JDBC example applications against MySQL. --- # Examples The repository contains two runnable Spring Boot applications that expose the same REST API: | Project | Web stack | Kuery Client API | |---|---|---| | [`spring-data-r2dbc`](https://github.com/be-hase/kuery-client/tree/main/examples/spring-data-r2dbc) | Spring WebFlux + coroutines | `KueryClient` | | [`spring-data-jdbc`](https://github.com/be-hase/kuery-client/tree/main/examples/spring-data-jdbc) | Spring WebMVC | `KueryBlockingClient` | Both demonstrate dynamic SQL, collection binding, generated values, custom converters, programmatic and declarative transactions, and Micrometer Observation with Prometheus. ## Prerequisites * Java 17 or later * Docker with the Compose plugin * A clone of the Kuery Client repository The examples use the repository source through Gradle composite builds, so you do not need to publish artifacts locally. ## Start MySQL From the repository root: ```shell cd examples docker compose up -d ./init_mysql.sh ``` This starts MySQL 8.0.37 on `localhost:13306`; the initialization script waits for it to accept connections, creates the `testdb` database, and inserts sample users and orders. The script is not idempotent, so run it once for a newly created container. ## Run an application Continue from the `examples` directory. Run one application at a time because both listen on port `8080`: ::: code-group ```shell [R2DBC] ../gradlew :spring-data-r2dbc:bootRun ``` ```shell [JDBC] ../gradlew :spring-data-jdbc:bootRun ``` ::: Wait until Spring Boot reports that the application has started, then try the API from another terminal. ## Try the API ```shell # List the two seeded users curl http://localhost:8080/users # Fetch one user curl http://localhost:8080/users/1 # Exercise collection binding with an IN clause curl 'http://localhost:8080/users?usernames=user1&usernames=user2' # Fetch a mapped join result curl http://localhost:8080/users/1/orders # Inspect Kuery Client metrics curl http://localhost:8080/actuator/prometheus | grep kuery_client ``` The list request returns data shaped like: ```json [ {"userId":1,"username":"user1","email":"user1@example.com"}, {"userId":2,"username":"user2","email":"user2@example.com"} ] ``` Read the matching `ExampleApplication.kt` to compare the R2DBC and JDBC implementations: * [R2DBC application](https://github.com/be-hase/kuery-client/blob/main/examples/spring-data-r2dbc/src/main/kotlin/com/example/spring/data/r2dbc/ExampleApplication.kt) * [JDBC application](https://github.com/be-hase/kuery-client/blob/main/examples/spring-data-jdbc/src/main/kotlin/com/example/spring/data/jdbc/ExampleApplication.kt) ## Stop and reset Stop the application with Ctrl+C, then remove the example container and its data: ```shell docker compose down ``` Run the start and initialization steps again to get a clean database. --- --- url: /compatibility.md description: >- API stability policy, Kotlin version policy, and the version compatibility matrix between Kuery Client releases and Kotlin / Spring Boot / Spring Data versions. --- # Compatibility This page covers language and framework versions. For the database and driver test matrix, see [Supported Platforms](/supported-platforms). ## API stability policy Since version 1.0.0, Kuery Client follows [Semantic Versioning](https://semver.org/). Backward-incompatible changes to the public API are only introduced in major releases. The public API surface is verified mechanically: Kotlin's [explicit API mode](https://kotlinlang.org/docs/api-guidelines-simplicity.html#use-explicit-api-mode) and the Kotlin Gradle plugin's [ABI validation](https://kotlinlang.org/docs/gradle-binary-compatibility-validation.html) run in CI, so unintended API changes are caught before release. The following are **not** covered by the compatibility guarantee: * Declarations annotated with `@KueryClientInternalApi`. These are internal APIs that are technically public only because they are shared across Kuery Client modules. * `kuery-client-compiler` internals. The compiler plugin depends on Kotlin compiler internal APIs and its implementation may change at any time. Declarations annotated with `@DelicateKueryClientApi` (such as [`addUnsafe()` and `bind()`](/basics#addunsafe-and-bind)) are covered by the guarantee, but require care in use as documented. ## Kotlin version policy `kuery-client-compiler` (applied automatically via the Gradle plugin) depends on the Kotlin compiler's internal IR APIs, which can change between Kotlin releases. Therefore, please align the Kotlin version of your project with the Kotlin version listed in the table below for the kuery-client version you use. When a new Kotlin version is released, we follow up with a kuery-client release built against it (as a minor or patch release, as long as there are no other breaking changes). ## Java version Kuery Client is built with a Java 17 toolchain, so Java 17 or later is required at runtime. ## Gradle version The Gradle plugin supports **Gradle 8.4 or later** — the first version where the Kotlin DSL property assignment syntax used throughout this documentation (e.g. `autoTrimIndent = true`) is enabled by default. The minimum version is exercised in CI by running the published plugin against it, so it only changes deliberately and will be noted here when it does. ## Compatibility matrix The following table lists the versions of Kotlin, Spring Boot, and Spring Data that each kuery-client release was built against. Aligning your project's dependencies with the versions listed below is recommended for the best compatibility. | kuery-client | Kotlin | Spring Boot | Spring Data | |--------------|--------|-------------|-------------| | 1.2.0 | 2.4.10 | 4.1.0 | 4.1.0 | | 1.1.0 | 2.4.10 | 4.1.0 | 4.1.0 | | 1.0.0 | 2.4.10 | 4.1.0 | 4.1.0 | | 0.17.0 | 2.4.0 | 4.1.0 | 4.1.0 | | 0.16.0 | 2.4.0 | 4.0.6 | 4.0.5 | | 0.15.0 | 2.3.21 | 4.0.6 | 4.0.5 | | 0.14.0 | 2.3.21 | 4.0.6 | 4.0.5 | | 0.13.0 | 2.3.10 | 4.0.3 | 4.0.3 | | 0.12.0 | 2.3.10 | 3.5.10 | 3.5.9 | | 0.11.0 | 2.2.20 | 3.5.6 | 3.5.4 | | 0.10.0 | 2.2.0 | 3.5.3 | 3.5.1 | | 0.9.1 | 2.1.21 | 3.5.3 | 3.5.1 | | 0.9.0 | 2.1.21 | 3.5.0 | 3.5.0 | | 0.8.0 | 2.1.21 | 3.4.5 | 3.4.5 | | 0.7.2 | 2.0.21 | 3.4.4 | 3.4.4 | | 0.7.1 | 2.0.21 | 3.4.2 | 3.4.2 | | 0.7.0 | 2.0.21 | 3.4.1 | 3.4.1 | | 0.6.1 | 2.0.21 | 3.4.1 | 3.4.1 | | 0.6.0 | 2.0.21 | 3.3.6 | 3.4.1 | | 0.4.1 | 1.9.24 | 3.3.1 | 3.3.1 | | 0.4.0 | 1.9.24 | 3.3.1 | 3.3.1 | | 0.3.0 | 1.9.24 | 3.3.0 | 3.3.1 | | 0.2.1 | 1.9.24 | 3.3.0 | 3.3.1 | | 0.2.0 | 1.9.24 | 3.3.0 | 3.3.1 | | 0.1.1 | 1.9.24 | 3.3.0 | 3.3.1 | | 0.1.0 | 1.9.24 | 3.3.0 | 3.3.1 | | 0.0.1 | 1.9.24 | 3.2.6 | 3.2.6 |