├── gradle.properties ├── docs ├── logo.png ├── roadmap.md ├── examples.md ├── index.md ├── getting-started.md ├── detekt.md ├── .vitepress │ └── config.mts ├── type-conversion.md ├── helpers.md ├── transaction.md ├── basics.md ├── introduction.md └── observation.md ├── examples ├── detekt.yml ├── run_containers.sh ├── compose.yaml ├── spring-data-jdbc │ ├── src │ │ └── main │ │ │ └── resources │ │ │ └── application.yml │ └── build.gradle.kts ├── spring-data-r2dbc │ ├── src │ │ └── main │ │ │ └── resources │ │ │ └── application.yml │ └── build.gradle.kts ├── README.md ├── settings.gradle.kts └── init_mysql.sh ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── kuery-client-detekt ├── src │ └── main │ │ ├── resources │ │ └── META-INF │ │ │ └── services │ │ │ └── io.gitlab.arturbosch.detekt.api.RuleSetProvider │ │ └── kotlin │ │ └── dev │ │ └── hsbrysk │ │ └── kuery │ │ └── detekt │ │ ├── KueryClientRuleSetProvider.kt │ │ ├── Utils.kt │ │ └── rules │ │ └── UseStringLiteralRule.kt └── build.gradle.kts ├── kuery-client-core ├── src │ ├── main │ │ └── kotlin │ │ │ └── dev │ │ │ └── hsbrysk │ │ │ └── kuery │ │ │ └── core │ │ │ ├── SqlBuilderMarker.kt │ │ │ ├── internal │ │ │ ├── DefaultNamedSqlParameter.kt │ │ │ ├── DefaultSql.kt │ │ │ ├── SqlIds.kt │ │ │ └── DefaultSqlBuilder.kt │ │ │ ├── DelicateKueryClientApi.kt │ │ │ ├── observation │ │ │ ├── KueryClientFetchContext.kt │ │ │ ├── KueryClientFetchObservationConvention.kt │ │ │ ├── KueryClientObservationDocumentation.kt │ │ │ └── internal │ │ │ │ └── DefaultKueryClientFetchObservationConvention.kt │ │ │ ├── SqlBuilderHelpers.kt │ │ │ ├── NamedSqlParameter.kt │ │ │ ├── Sql.kt │ │ │ ├── SqlBuilder.kt │ │ │ ├── KueryBlockingClient.kt │ │ │ └── KueryClient.kt │ ├── jmh │ │ └── kotlin │ │ │ ├── com │ │ │ └── example │ │ │ │ └── core │ │ │ │ ├── MockRepository.kt │ │ │ │ └── MockKueryClient.kt │ │ │ └── dev │ │ │ └── hsbrysk │ │ │ └── kuery │ │ │ └── core │ │ │ └── SqlIdsBenchmark.kt │ └── test │ │ └── kotlin │ │ ├── dev │ │ └── hsbrysk │ │ │ └── kuery │ │ │ └── core │ │ │ ├── NamedSqlParameterTest.kt │ │ │ ├── SqlTest.kt │ │ │ ├── observation │ │ │ ├── KueryClientObservationDocumentationTest.kt │ │ │ ├── KueryClientFetchObservationConventionTest.kt │ │ │ └── internal │ │ │ │ └── DefaultKueryClientFetchObservationConventionTest.kt │ │ │ ├── internal │ │ │ ├── SqlIdsTest.kt │ │ │ └── DefaultSqlBuilderTest.kt │ │ │ └── SqlBuilderHelpersTest.kt │ │ └── com │ │ └── example │ │ └── core │ │ └── ClassA.kt └── build.gradle.kts ├── kuery-client-compiler ├── src │ ├── main │ │ ├── resources │ │ │ └── META-INF │ │ │ │ └── services │ │ │ │ ├── org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor │ │ │ │ └── org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar │ │ └── kotlin │ │ │ └── dev │ │ │ └── hsbrysk │ │ │ └── kuery │ │ │ └── compiler │ │ │ ├── ir │ │ │ ├── misc │ │ │ │ ├── ClassNames.kt │ │ │ │ ├── CallableIds.kt │ │ │ │ ├── ClassIds.kt │ │ │ │ └── StringConcatenationProcessor.kt │ │ │ ├── KueryClientiIrGenerationExtension.kt │ │ │ └── StringInterpolationTransformer.kt │ │ │ ├── KueryClientCompilerCommandLineProcessor.kt │ │ │ └── KueryClientCompilerPluginRegistrar.kt │ └── test │ │ └── kotlin │ │ └── dev │ │ └── hsbrysk │ │ └── kuery │ │ └── compiler │ │ └── KueryClientCompilerTest.kt ├── build.gradle.kts └── functional-test │ ├── build.gradle.kts │ └── src │ └── test │ └── kotlin │ └── dev │ └── hsbrysk │ └── kuery │ └── core │ ├── StringInterpolationTest.kt │ └── SqlTest.kt ├── .gitattributes ├── package.json ├── renovate.json5 ├── .gitignore ├── .github ├── release.yml └── workflows │ ├── ci.yml │ ├── docs.yml │ └── publish.yml ├── config └── detekt │ └── detekt.yml ├── kuery-client-spring-data-jdbc ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── dev │ │ └── hsbrysk │ │ └── kuery │ │ └── spring │ │ └── jdbc │ │ ├── SpringJdbcKueryClient.kt │ │ ├── SpringJdbcKueryClientBuilder.kt │ │ └── internal │ │ ├── DefaultSpringJdbcKueryClientBuilder.kt │ │ └── DefaultSpringJdbcKueryClient.kt │ └── test │ └── kotlin │ ├── dev │ └── hsbrysk │ │ └── kuery │ │ └── spring │ │ └── jdbc │ │ ├── CSVConversionTest.kt │ │ ├── EnumConversionTest.kt │ │ ├── MySqlTestContainer.kt │ │ ├── StringCaseTest.kt │ │ ├── StringWrapperConversionTest.kt │ │ ├── CollectionConversionTest.kt │ │ ├── SingleBasicTypeTest.kt │ │ ├── ValuesHelperTest.kt │ │ ├── CodeEnumConversionTest.kt │ │ └── ObservationTest.kt │ └── com │ └── example │ └── spring │ └── jdbc │ └── SampleRepository.kt ├── kuery-client-spring-data-r2dbc ├── src │ ├── main │ │ └── kotlin │ │ │ └── dev │ │ │ └── hsbrysk │ │ │ └── kuery │ │ │ └── spring │ │ │ └── r2dbc │ │ │ ├── SpringR2dbcKueryClient.kt │ │ │ ├── SpringR2dbcKueryClientBuilder.kt │ │ │ └── internal │ │ │ └── DefaultSpringR2dbcKueryClientBuilder.kt │ ├── jmh │ │ └── kotlin │ │ │ └── dev │ │ │ └── hsbrysk │ │ │ └── kuery │ │ │ └── spring │ │ │ └── r2dbc │ │ │ └── InitDataClassRowMapperBenchmark.kt │ └── test │ │ └── kotlin │ │ ├── dev │ │ └── hsbrysk │ │ │ └── kuery │ │ │ └── spring │ │ │ └── r2dbc │ │ │ ├── CSVConversionTest.kt │ │ │ ├── EnumConversionTest.kt │ │ │ ├── StringCaseTest.kt │ │ │ ├── StringWrapperConversionTest.kt │ │ │ ├── CollectionConversionTest.kt │ │ │ ├── MySqlTestContainer.kt │ │ │ ├── SingleBasicTypeTest.kt │ │ │ ├── ValuesHelperTest.kt │ │ │ ├── CodeEnumConversionTest.kt │ │ │ └── ObservationTest.kt │ │ └── com │ │ └── example │ │ └── spring │ │ └── r2dbc │ │ └── SampleRepository.kt └── build.gradle.kts ├── settings.gradle.kts ├── .editorconfig ├── LICENSE ├── kuery-client-gradle-plugin ├── src │ └── main │ │ └── kotlin │ │ └── dev │ │ └── hsbrysk │ │ └── kuery │ │ └── gradle │ │ └── KueryClientGradlePlugin.kt └── build.gradle.kts ├── gradlew.bat └── README.md /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx2g 2 | org.gradle.parallel=true 3 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/be-hase/kuery-client/HEAD/docs/logo.png -------------------------------------------------------------------------------- /examples/detekt.yml: -------------------------------------------------------------------------------- 1 | kuery-client: 2 | UseStringLiteral: 3 | active: true 4 | -------------------------------------------------------------------------------- /examples/run_containers.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | docker compose rm -f 5 | docker compose up 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/be-hase/kuery-client/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /kuery-client-detekt/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider: -------------------------------------------------------------------------------- 1 | dev.hsbrysk.kuery.detekt.KueryClientRuleSetProvider 2 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/SqlBuilderMarker.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | @DslMarker 4 | annotation class SqlBuilderMarker 5 | -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | ## Towards version 1 4 | 5 | We will release version 1 soon. 6 | Please note that during version 0, we plan to introduce breaking changes. 7 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor: -------------------------------------------------------------------------------- 1 | dev.hsbrysk.kuery.compiler.KueryClientCompilerCommandLineProcessor 2 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar: -------------------------------------------------------------------------------- 1 | dev.hsbrysk.kuery.compiler.KueryClientCompilerPluginRegistrar 2 | -------------------------------------------------------------------------------- /examples/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | kuery-client-example-mysql: 3 | image: mysql:8.0.37 4 | ports: 5 | - 13306:3306 6 | environment: 7 | - MYSQL_ALLOW_EMPTY_PASSWORD=1 8 | - TZ=Asia/Tokyo 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/dealing-with-line-endings/ 3 | # 4 | # Linux start script should use lf 5 | /gradlew text eol=lf 6 | 7 | # These are Windows script files and should use crlf 8 | *.bat text eol=crlf 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "docs:dev": "vitepress dev docs", 4 | "docs:build": "vitepress build docs", 5 | "docs:preview": "vitepress preview docs" 6 | }, 7 | "devDependencies": { 8 | "vitepress": "^1.2.3" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | automerge: true, 7 | "major": { 8 | "automerge": false 9 | }, 10 | "labels": ["renovate"], 11 | } 12 | -------------------------------------------------------------------------------- /examples/spring-data-jdbc/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | 4 | management: 5 | endpoints.web.exposure.include: 6 | - prometheus 7 | spring: 8 | datasource: 9 | url: jdbc:mysql://localhost:13306/testdb 10 | username: admin 11 | password: admin 12 | -------------------------------------------------------------------------------- /examples/spring-data-r2dbc/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | 4 | management: 5 | endpoints.web.exposure.include: 6 | - prometheus 7 | spring: 8 | r2dbc: 9 | url: r2dbc:mysql://localhost:13306/testdb 10 | username: admin 11 | password: admin 12 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/kotlin/dev/hsbrysk/kuery/compiler/ir/misc/ClassNames.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.compiler.ir.misc 2 | 3 | internal object ClassNames { 4 | val STRING = checkNotNull(String::class.qualifiedName) 5 | const val SQL_BUILDER = "dev.hsbrysk.kuery.core.SqlBuilder" 6 | } 7 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Spring WebFlux and `kuery-client-spring-data-r2dbc` 4 | 5 | https://github.com/be-hase/kuery-client/tree/main/examples/spring-data-r2dbc 6 | 7 | ## Spring WebMVC and `kuery-client-spring-data-jdbc` 8 | 9 | https://github.com/be-hase/kuery-client/tree/main/examples/spring-data-jdbc 10 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/internal/DefaultNamedSqlParameter.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.internal 2 | 3 | import dev.hsbrysk.kuery.core.NamedSqlParameter 4 | 5 | internal data class DefaultNamedSqlParameter( 6 | override val name: String, 7 | override val value: Any?, 8 | ) : NamedSqlParameter 9 | -------------------------------------------------------------------------------- /kuery-client-compiler/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions.preset.base") 3 | id("conventions.maven-publish") 4 | } 5 | 6 | description = "Compiler plugin for the Kuery client." 7 | 8 | dependencies { 9 | implementation(kotlin("compiler-embeddable")) 10 | testImplementation(libs.kotlin.compile.testing) 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Gradle ### 2 | .gradle 3 | build 4 | 5 | ### IntelliJ IDEA ### 6 | .idea 7 | *.iws 8 | *.iml 9 | *.ipr 10 | out/ 11 | !**/src/main/**/out/ 12 | !**/src/test/**/out/ 13 | 14 | ### Mac OS ### 15 | .DS_Store 16 | 17 | ### Kotlin ### 18 | .kotlin 19 | 20 | ### Vitepress ### 21 | /node_modules 22 | docs/.vitepress/cache 23 | docs/.vitepress/dist 24 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/internal/DefaultSql.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.internal 2 | 3 | import dev.hsbrysk.kuery.core.NamedSqlParameter 4 | import dev.hsbrysk.kuery.core.Sql 5 | 6 | internal data class DefaultSql( 7 | override val body: String, 8 | override val parameters: List, 9 | ) : Sql 10 | -------------------------------------------------------------------------------- /kuery-client-detekt/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions.preset.base") 3 | id("conventions.maven-publish") 4 | } 5 | 6 | description = "Detekt custom rules provided by kuery client." 7 | 8 | dependencies { 9 | implementation(libs.detekt.api) 10 | testImplementation(libs.detekt.test) 11 | testImplementation(projects.kueryClientCore) 12 | } 13 | -------------------------------------------------------------------------------- /kuery-client-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions.preset.base") 3 | id("conventions.maven-publish") 4 | id("conventions.jmh") 5 | } 6 | 7 | description = "Kuery client's core module." 8 | 9 | dependencies { 10 | api(libs.micrometer.core) 11 | compileOnly(libs.kotlin.coroutines.core) 12 | testImplementation(libs.kotlin.coroutines.core) 13 | } 14 | -------------------------------------------------------------------------------- /kuery-client-compiler/functional-test/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions.preset.base") 3 | // id("dev.hsbrysk.kuery-client") version "0.4.0-SNAPSHOT" 4 | } 5 | 6 | description = "Kuery client's compiler functional test module." 7 | 8 | dependencies { 9 | implementation(projects.kueryClientCore) 10 | kotlinCompilerPluginClasspath(projects.kueryClientCompiler) 11 | } 12 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/kotlin/dev/hsbrysk/kuery/compiler/ir/misc/CallableIds.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.compiler.ir.misc 2 | 3 | import org.jetbrains.kotlin.name.CallableId 4 | import org.jetbrains.kotlin.name.FqName 5 | import org.jetbrains.kotlin.name.Name 6 | 7 | internal object CallableIds { 8 | val LIST_OF = CallableId(FqName("kotlin.collections"), Name.identifier("listOf")) 9 | } 10 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## How to run 4 | 5 | ### Run MySQL container 6 | 7 | ```shell 8 | cd examples 9 | ./run_containers.sh 10 | ``` 11 | 12 | ### Init MySQL databases/tables 13 | 14 | ```shell 15 | cd examples 16 | ./init_mysql.sh 17 | ``` 18 | 19 | ### Run application 20 | 21 | ```shell 22 | cd examples 23 | ./gradlew :spring-data-r2dbc:bootRun 24 | 25 | # or 26 | 27 | ./gradlew :spring-data-jdbc:bootRun 28 | ``` 29 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/kotlin/dev/hsbrysk/kuery/compiler/ir/misc/ClassIds.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.compiler.ir.misc 2 | 3 | import org.jetbrains.kotlin.name.ClassId 4 | import org.jetbrains.kotlin.name.FqName 5 | import org.jetbrains.kotlin.name.Name 6 | 7 | internal object ClassIds { 8 | val DEFAULT_SQL_BUILDER = ClassId( 9 | FqName("dev.hsbrysk.kuery.core.internal"), 10 | Name.identifier("DefaultSqlBuilder"), 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/DelicateKueryClientApi.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | @MustBeDocumented 4 | @Retention(value = AnnotationRetention.BINARY) 5 | @RequiresOptIn( 6 | level = RequiresOptIn.Level.WARNING, 7 | message = "This is a delicate API and its use requires care." + 8 | " Make sure you fully read and understand documentation of the declaration that is marked as a delicate API.", 9 | ) 10 | annotation class DelicateKueryClientApi 11 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: 🌟 New features 4 | labels: 5 | - "new feature" 6 | - title: ☢️ Breaking changes 7 | labels: 8 | - "breaking changes" 9 | - title: 🛠️ Bug fixes 10 | labels: 11 | - "bug" 12 | - title: 📈 Other Changes 13 | labels: 14 | - "*" 15 | exclude: 16 | labels: 17 | - "renovate" 18 | - title: ⛓ Dependencies 19 | labels: 20 | - "renovate" 21 | -------------------------------------------------------------------------------- /kuery-client-core/src/jmh/kotlin/com/example/core/MockRepository.kt: -------------------------------------------------------------------------------- 1 | package com.example.core 2 | 3 | import dev.hsbrysk.kuery.core.Sql 4 | 5 | class MockRepository(private val client: MockKueryClient) { 6 | fun select(id: Int): Pair = client.sql("sqlId") { 7 | addUnsafe("SELECT * FROM users WHERE id = ${bind(id)}") 8 | } 9 | 10 | fun selectWithAutoIdGeneration(id: Int): Pair = client.sql { 11 | addUnsafe("SELECT * FROM users WHERE id = ${bind(id)}") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/observation/KueryClientFetchContext.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.observation 2 | 3 | import dev.hsbrysk.kuery.core.KueryBlockingClient 4 | import dev.hsbrysk.kuery.core.KueryClient 5 | import dev.hsbrysk.kuery.core.Sql 6 | import io.micrometer.observation.Observation 7 | 8 | /** 9 | * [Observation.Context] for [KueryClient] and [KueryBlockingClient] 10 | */ 11 | class KueryClientFetchContext( 12 | val sqlId: String, 13 | val sql: Sql, 14 | ) : Observation.Context() 15 | -------------------------------------------------------------------------------- /kuery-client-core/src/jmh/kotlin/com/example/core/MockKueryClient.kt: -------------------------------------------------------------------------------- 1 | package com.example.core 2 | 3 | import dev.hsbrysk.kuery.core.Sql 4 | import dev.hsbrysk.kuery.core.SqlBuilder 5 | import dev.hsbrysk.kuery.core.internal.SqlIds.id 6 | 7 | class MockKueryClient { 8 | fun sql( 9 | sqlId: String, 10 | block: SqlBuilder.() -> Unit, 11 | ): Pair { 12 | val sql = Sql(block) 13 | return sqlId to sql 14 | } 15 | 16 | fun sql(block: SqlBuilder.() -> Unit): Pair { 17 | val sqlId = block.id() 18 | return sql(sqlId, block) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /config/detekt/detekt.yml: -------------------------------------------------------------------------------- 1 | # Default: 2 | # https://github.com/detekt/detekt/blob/main/detekt-core/src/main/resources/default-detekt-config.yml 3 | 4 | config: 5 | warningsAsErrors: true 6 | 7 | naming: 8 | FunctionNaming: 9 | excludes: [ '**/src/test/**', '**/src/functionalTest/**' ] 10 | 11 | performance: 12 | SpreadOperator: 13 | active: false 14 | 15 | style: 16 | ForbiddenComment: 17 | active: false 18 | MagicNumber: 19 | active: false 20 | MaxLineLength: 21 | active: false # use ktlint 22 | ReturnCount: 23 | max: 4 24 | 25 | #kuery-client: 26 | # UseStringLiteral: 27 | # active: true 28 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions.preset.base") 3 | id("conventions.maven-publish") 4 | } 5 | 6 | description = "Kuery client implementation using spring-data-jdbc." 7 | 8 | dependencies { 9 | api(projects.kueryClientCore) 10 | 11 | api(libs.spring.data.jdbc) 12 | 13 | testImplementation(platform(libs.spring.boot.bom)) 14 | testImplementation("com.mysql:mysql-connector-j") 15 | testImplementation("org.testcontainers:mysql") 16 | testImplementation(libs.micrometer.observation.test) 17 | 18 | kotlinCompilerPluginClasspath(projects.kueryClientCompiler) 19 | } 20 | -------------------------------------------------------------------------------- /kuery-client-detekt/src/main/kotlin/dev/hsbrysk/kuery/detekt/KueryClientRuleSetProvider.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.detekt 2 | 3 | import dev.hsbrysk.kuery.detekt.rules.UseStringLiteralRule 4 | import io.gitlab.arturbosch.detekt.api.Config 5 | import io.gitlab.arturbosch.detekt.api.RuleSet 6 | import io.gitlab.arturbosch.detekt.api.RuleSetProvider 7 | 8 | class KueryClientRuleSetProvider : RuleSetProvider { 9 | override val ruleSetId: String = "kuery-client" 10 | 11 | override fun instance(config: Config): RuleSet = RuleSet( 12 | ruleSetId, 13 | listOf( 14 | UseStringLiteralRule(config), 15 | ), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /kuery-client-core/src/test/kotlin/dev/hsbrysk/kuery/core/NamedSqlParameterTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.internal.DefaultNamedSqlParameter 6 | import org.junit.jupiter.api.Test 7 | 8 | class NamedSqlParameterTest { 9 | @Test 10 | fun of() { 11 | assertThat(NamedSqlParameter("hoge", "hoge-value")) 12 | .isEqualTo(DefaultNamedSqlParameter("hoge", "hoge-value")) 13 | 14 | assertThat(NamedSqlParameter("hoge", "hoge-value")) 15 | .isEqualTo(DefaultNamedSqlParameter("hoge", "hoge-value")) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/kotlin/dev/hsbrysk/kuery/compiler/KueryClientCompilerCommandLineProcessor.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.compiler 2 | 3 | import org.jetbrains.kotlin.compiler.plugin.AbstractCliOption 4 | import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor 5 | import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi 6 | 7 | @OptIn(ExperimentalCompilerApi::class) 8 | class KueryClientCompilerCommandLineProcessor : CommandLineProcessor { 9 | override val pluginId: String = "dev.hsbrysk.kuery-client" 10 | override val pluginOptions: Collection = listOf() 11 | 12 | // NOOP (There are no plugin options) 13 | } 14 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/kotlin/dev/hsbrysk/kuery/compiler/ir/KueryClientiIrGenerationExtension.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.compiler.ir 2 | 3 | import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension 4 | import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext 5 | import org.jetbrains.kotlin.ir.declarations.IrModuleFragment 6 | 7 | class KueryClientiIrGenerationExtension : IrGenerationExtension { 8 | override fun generate( 9 | moduleFragment: IrModuleFragment, 10 | pluginContext: IrPluginContext, 11 | ) { 12 | moduleFragment.transformChildren(StringInterpolationTransformer(pluginContext), null) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /kuery-client-core/src/test/kotlin/dev/hsbrysk/kuery/core/SqlTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.internal.DefaultSql 6 | import org.junit.jupiter.api.Test 7 | 8 | class SqlTest { 9 | @Test 10 | fun of() { 11 | assertThat( 12 | Sql( 13 | "SELECT * FROM some_table", 14 | listOf(NamedSqlParameter("hoge", "hoge-value")), 15 | ), 16 | ) 17 | .isEqualTo( 18 | DefaultSql( 19 | "SELECT * FROM some_table", 20 | listOf(NamedSqlParameter("hoge", "hoge-value")), 21 | ), 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/main/kotlin/dev/hsbrysk/kuery/spring/r2dbc/SpringR2dbcKueryClient.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import dev.hsbrysk.kuery.spring.r2dbc.internal.DefaultSpringR2dbcKueryClientBuilder 4 | import reactor.util.context.ContextView 5 | 6 | object SpringR2dbcKueryClient { 7 | val SQL_ID_REACTOR_CONTEXT_KEY = "${SpringR2dbcKueryClient::class.java.name}:sqlId" 8 | 9 | /** 10 | * Retrieve the current sqlId from reactor context. 11 | */ 12 | fun sqlId(context: ContextView): String? = context.getOrEmpty(SQL_ID_REACTOR_CONTEXT_KEY).orElse(null) 13 | 14 | /** 15 | * Create [SpringR2dbcKueryClientBuilder] 16 | */ 17 | fun builder(): SpringR2dbcKueryClientBuilder = DefaultSpringR2dbcKueryClientBuilder() 18 | } 19 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/kotlin/dev/hsbrysk/kuery/compiler/KueryClientCompilerPluginRegistrar.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.compiler 2 | 3 | import dev.hsbrysk.kuery.compiler.ir.KueryClientiIrGenerationExtension 4 | import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension 5 | import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar 6 | import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi 7 | import org.jetbrains.kotlin.config.CompilerConfiguration 8 | 9 | @ExperimentalCompilerApi 10 | class KueryClientCompilerPluginRegistrar : CompilerPluginRegistrar() { 11 | override val supportsK2: Boolean = true 12 | 13 | override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { 14 | IrGenerationExtension.registerExtension(KueryClientiIrGenerationExtension()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions.preset.base") 3 | id("conventions.maven-publish") 4 | id("conventions.jmh") 5 | } 6 | 7 | description = "Kuery client implementation using spring-data-r2dbc." 8 | 9 | dependencies { 10 | api(projects.kueryClientCore) 11 | 12 | api(libs.spring.data.r2dbc) 13 | api(libs.kotlin.coroutines.core) 14 | api(libs.kotlin.coroutines.reactor) 15 | 16 | testImplementation(platform(libs.spring.boot.bom)) 17 | testImplementation("com.mysql:mysql-connector-j") 18 | testImplementation("io.asyncer:r2dbc-mysql") 19 | testImplementation("org.testcontainers:mysql") 20 | testImplementation(libs.kotlin.coroutines.test) 21 | testImplementation(libs.micrometer.observation.test) 22 | 23 | kotlinCompilerPluginClasspath(projects.kueryClientCompiler) 24 | } 25 | -------------------------------------------------------------------------------- /kuery-client-core/src/test/kotlin/dev/hsbrysk/kuery/core/observation/KueryClientObservationDocumentationTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.observation 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.observation.KueryClientObservationDocumentation.FETCH 6 | import dev.hsbrysk.kuery.core.observation.internal.DefaultKueryClientFetchObservationConvention 7 | import org.junit.jupiter.api.Test 8 | 9 | class KueryClientObservationDocumentationTest { 10 | @Test 11 | fun fetch() { 12 | assertThat(FETCH.defaultConvention).isEqualTo(DefaultKueryClientFetchObservationConvention::class.java) 13 | assertThat(FETCH.lowCardinalityKeyNames.map { it.asString() }).isEqualTo(listOf("sql.id")) 14 | assertThat(FETCH.highCardinalityKeyNames.map { it.asString() }).isEqualTo(listOf("sql")) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/SqlBuilderHelpers.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | @OptIn(DelicateKueryClientApi::class) 4 | fun SqlBuilder.values(input: List>) { 5 | require(input.isNotEmpty()) { "inputted list is empty" } 6 | val firstSize = input.first().size 7 | require(input.all { it.size == firstSize }) { "All inputted child lists must have the same size." } 8 | require(firstSize > 0) { "inputted child list is empty" } 9 | 10 | val placeholders = input.joinToString(", ") { list -> 11 | list.joinToString(separator = ", ", prefix = "(", postfix = ")") { 12 | bind(it) 13 | } 14 | } 15 | addUnsafe("VALUES $placeholders") 16 | } 17 | 18 | fun SqlBuilder.values( 19 | input: List, 20 | transformer: (T) -> List, 21 | ) { 22 | values(input.map { transformer(it) }) 23 | } 24 | -------------------------------------------------------------------------------- /kuery-client-core/src/test/kotlin/com/example/core/ClassA.kt: -------------------------------------------------------------------------------- 1 | package com.example.core 2 | 3 | import dev.hsbrysk.kuery.core.SqlBuilder 4 | import dev.hsbrysk.kuery.core.internal.SqlIds.id 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.withContext 8 | 9 | internal class ClassA { 10 | fun sql1(block: SqlBuilder.() -> Unit): String = block.id() 11 | 12 | @Suppress("InjectDispatcher") 13 | suspend fun sql2(block: SqlBuilder.() -> Unit): String = withContext(Dispatchers.Default) { 14 | delay(10) 15 | withContext(Dispatchers.IO) { 16 | block.id() 17 | } 18 | } 19 | 20 | internal class ClassB { 21 | fun sql3(block: SqlBuilder.() -> Unit): String = block.id() 22 | 23 | internal class ClassC { 24 | fun sql4(block: SqlBuilder.() -> Unit): String = block.id() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /kuery-client-core/src/test/kotlin/dev/hsbrysk/kuery/core/observation/KueryClientFetchObservationConventionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.observation 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import assertk.assertions.isFalse 6 | import assertk.assertions.isTrue 7 | import dev.hsbrysk.kuery.core.Sql 8 | import io.micrometer.observation.Observation 9 | import org.junit.jupiter.api.Test 10 | 11 | class KueryClientFetchObservationConventionTest { 12 | private val target = KueryClientFetchObservationConvention.default() 13 | 14 | @Test 15 | fun getName() { 16 | assertThat(target.name).isEqualTo("kuery.client.fetches") 17 | } 18 | 19 | @Test 20 | fun supportsContext() { 21 | assertThat(target.supportsContext(KueryClientFetchContext("id", Sql("")))).isTrue() 22 | assertThat(target.supportsContext(Observation.Context())).isFalse() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: "Kuery Client" 7 | tagline: A Kotlin/JVM database client for those who want to write SQL 8 | actions: 9 | - theme: brand 10 | text: Introduction 11 | link: /introduction 12 | 13 | features: 14 | - title: Love SQL 15 | details: While ORM libraries in the world are convenient, they often require learning their own DSL, which we believe has a high learning cost. Kuery Client emphasizes writing SQL as it is. 16 | - title: Based on spring-data-r2dbc and spring-data-jdbc 17 | details: Kuery Client is implemented based on spring-data-r2dbc and spring-data-jdbc. Use whichever you prefer. You can use Spring's ecosystem as it is, such as @Transactional. 18 | - title: Observability 19 | details: It supports Micrometer Observation, so Metrics/Tracing/Logging can also be customized. 20 | --- 21 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/main/kotlin/dev/hsbrysk/kuery/spring/jdbc/SpringJdbcKueryClient.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import dev.hsbrysk.kuery.spring.jdbc.internal.DefaultSpringJdbcKueryClientBuilder 4 | 5 | object SpringJdbcKueryClient { 6 | /** 7 | * Retrieve the running sqlId from thread local. 8 | */ 9 | fun sqlId(): String? = sqlIdThreadLocal.get() 10 | 11 | /** 12 | * Create [SpringJdbcKueryClientBuilder] 13 | */ 14 | fun builder(): SpringJdbcKueryClientBuilder = DefaultSpringJdbcKueryClientBuilder() 15 | } 16 | 17 | private val sqlIdThreadLocal = ThreadLocal() 18 | 19 | internal class SqlIdInjector(sqlId: String) : AutoCloseable { 20 | private val old: String? = sqlIdThreadLocal.get() 21 | 22 | init { 23 | sqlIdThreadLocal.set(sqlId) 24 | } 25 | 26 | override fun close() { 27 | sqlIdThreadLocal.set(old) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | includeBuild("build-logic") 3 | repositories { 4 | gradlePluginPortal() 5 | // mavenLocal() 6 | } 7 | } 8 | 9 | dependencyResolutionManagement { 10 | @Suppress("UnstableApiUsage") 11 | repositories { 12 | mavenCentral() 13 | // mavenLocal() 14 | } 15 | } 16 | 17 | rootProject.name = "kuery-client" 18 | 19 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 20 | 21 | plugins { 22 | // Apply the foojay-resolver plugin to allow automatic download of JDKs 23 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" 24 | } 25 | 26 | include("kuery-client-compiler") 27 | include("kuery-client-compiler:functional-test") 28 | include("kuery-client-core") 29 | include("kuery-client-detekt") 30 | include("kuery-client-gradle-plugin") 31 | include("kuery-client-spring-data-jdbc") 32 | include("kuery-client-spring-data-r2dbc") 33 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.{kt,kts}] 11 | ij_kotlin_allow_trailing_comma = true 12 | ij_kotlin_allow_trailing_comma_on_call_site = true 13 | ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL 14 | ij_kotlin_continuation_indent_size = 4 15 | ij_kotlin_line_break_after_multiline_when_entry = false 16 | ij_kotlin_name_count_to_use_star_import = 2147483647 17 | ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 18 | ij_kotlin_packages_to_use_import_on_demand = unset 19 | ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 2 20 | ktlint_code_style = intellij_idea 21 | ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 2 22 | max_line_length = 120 23 | 24 | [**/build/generated/**/*.{kt,kts}] 25 | ktlint = disabled 26 | -------------------------------------------------------------------------------- /examples/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | includeBuild("../build-logic") 3 | includeBuild("../") 4 | repositories { 5 | gradlePluginPortal() 6 | // mavenLocal() 7 | } 8 | } 9 | 10 | dependencyResolutionManagement { 11 | @Suppress("UnstableApiUsage") 12 | repositories { 13 | mavenCentral() 14 | } 15 | versionCatalogs { 16 | create("libs") { 17 | from(files("../gradle/libs.versions.toml")) 18 | } 19 | } 20 | } 21 | 22 | rootProject.name = "examples" 23 | 24 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 25 | 26 | plugins { 27 | // Apply the foojay-resolver plugin to allow automatic download of JDKs 28 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" 29 | } 30 | 31 | include("spring-data-jdbc") 32 | include("spring-data-r2dbc") 33 | 34 | // Include the root project to use `dev.hsbrysk.kuery-client:*` modules 35 | includeBuild("../") 36 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/observation/KueryClientFetchObservationConvention.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.observation 2 | 3 | import dev.hsbrysk.kuery.core.KueryBlockingClient 4 | import dev.hsbrysk.kuery.core.KueryClient 5 | import dev.hsbrysk.kuery.core.observation.internal.DefaultKueryClientFetchObservationConvention 6 | import io.micrometer.observation.Observation 7 | import io.micrometer.observation.ObservationConvention 8 | 9 | /** 10 | * [ObservationConvention] for [KueryClient] and [KueryBlockingClient] 11 | */ 12 | interface KueryClientFetchObservationConvention : ObservationConvention { 13 | override fun getName(): String = "kuery.client.fetches" 14 | 15 | override fun supportsContext(context: Observation.Context): Boolean = context is KueryClientFetchContext 16 | 17 | companion object { 18 | fun default(): KueryClientFetchObservationConvention = DefaultKueryClientFetchObservationConvention() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/NamedSqlParameter.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import dev.hsbrysk.kuery.core.internal.DefaultNamedSqlParameter 4 | 5 | interface NamedSqlParameter { 6 | /** 7 | * parameter name 8 | */ 9 | val name: String 10 | 11 | /** 12 | * value 13 | */ 14 | val value: Any? 15 | 16 | companion object { 17 | /** 18 | * Create [NamedSqlParameter] 19 | */ 20 | @Deprecated( 21 | message = "Use `dev.hsbrysk.kuery.core.NamedSqlParameter` function instead.", 22 | replaceWith = ReplaceWith("NamedSqlParameter(name, value)"), 23 | ) 24 | fun of( 25 | name: String, 26 | value: Any?, 27 | ): NamedSqlParameter = DefaultNamedSqlParameter(name, value) 28 | } 29 | } 30 | 31 | /** 32 | * Create [NamedSqlParameter] 33 | */ 34 | fun NamedSqlParameter( 35 | name: String, 36 | value: Any?, 37 | ): NamedSqlParameter = DefaultNamedSqlParameter(name, value) 38 | -------------------------------------------------------------------------------- /examples/spring-data-jdbc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions.preset.base") 3 | id("dev.hsbrysk.kuery-client") 4 | alias(libs.plugins.kotlin.spring) 5 | alias(libs.plugins.spring.boot) 6 | } 7 | 8 | description = "Example of spring-data-jdbc" 9 | 10 | dependencies { 11 | implementation(platform(libs.spring.boot.bom)) 12 | implementation("org.springframework.boot:spring-boot-starter-web") 13 | implementation("org.springframework.boot:spring-boot-starter-actuator") 14 | implementation("org.springframework.boot:spring-boot-starter-data-jdbc") 15 | implementation("dev.hsbrysk.kuery-client:kuery-client-spring-data-jdbc") 16 | implementation("io.micrometer:micrometer-registry-prometheus") 17 | implementation("com.mysql:mysql-connector-j") 18 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 19 | 20 | detektPlugins("dev.hsbrysk.kuery-client:kuery-client-detekt") 21 | } 22 | 23 | detekt { 24 | config.setFrom("${rootProject.rootDir}/detekt.yml") 25 | disableDefaultRuleSets = true 26 | } 27 | -------------------------------------------------------------------------------- /examples/spring-data-r2dbc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions.preset.base") 3 | id("dev.hsbrysk.kuery-client") 4 | alias(libs.plugins.kotlin.spring) 5 | alias(libs.plugins.spring.boot) 6 | } 7 | 8 | description = "Example of spring-data-r2dbc" 9 | 10 | dependencies { 11 | implementation(platform(libs.spring.boot.bom)) 12 | implementation("org.springframework.boot:spring-boot-starter-webflux") 13 | implementation("org.springframework.boot:spring-boot-starter-actuator") 14 | implementation("org.springframework.boot:spring-boot-starter-data-r2dbc") 15 | implementation("dev.hsbrysk.kuery-client:kuery-client-spring-data-r2dbc") 16 | implementation("io.micrometer:micrometer-registry-prometheus") 17 | implementation("io.asyncer:r2dbc-mysql") 18 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 19 | 20 | detektPlugins("dev.hsbrysk.kuery-client:kuery-client-detekt") 21 | } 22 | 23 | detekt { 24 | config.setFrom("${rootProject.rootDir}/detekt.yml") 25 | disableDefaultRuleSets = true 26 | } 27 | -------------------------------------------------------------------------------- /kuery-client-core/src/test/kotlin/dev/hsbrysk/kuery/core/internal/SqlIdsTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.internal 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import com.example.core.ClassA 6 | import dev.hsbrysk.kuery.core.internal.SqlIds.removeSuffixes 7 | import kotlinx.coroutines.runBlocking 8 | import org.junit.jupiter.api.Test 9 | 10 | class SqlIdsTest { 11 | @Test 12 | fun id() { 13 | assertThat(ClassA().sql1 {}).isEqualTo("com.example.core.ClassA.sql1") 14 | runBlocking { assertThat(ClassA().sql2 {}).isEqualTo("com.example.core.ClassA.sql2") } 15 | assertThat(ClassA.ClassB().sql3 {}).isEqualTo("com.example.core.ClassA.ClassB.sql3") 16 | assertThat( 17 | ClassA.ClassB.ClassC().sql4 {}, 18 | ).isEqualTo("com.example.core.ClassA.ClassB.ClassC.sql4") 19 | } 20 | 21 | @Test 22 | fun removeSuffixes() { 23 | assertThat("a.b.c".removeSuffixes(listOf(".b", ".c"))).isEqualTo("a.b") 24 | assertThat("a.b.c".removeSuffixes(listOf(".a", ".d"))).isEqualTo("a.b.c") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ryosuke Hasebe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /kuery-client-core/src/jmh/kotlin/dev/hsbrysk/kuery/core/SqlIdsBenchmark.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import com.example.core.MockKueryClient 4 | import com.example.core.MockRepository 5 | import org.openjdk.jmh.annotations.Benchmark 6 | import org.openjdk.jmh.annotations.Scope 7 | import org.openjdk.jmh.annotations.State 8 | import org.openjdk.jmh.infra.Blackhole 9 | import kotlin.random.Random 10 | 11 | /* 12 | Thanks to the cache, there is little overhead 13 | 14 | Benchmark Mode Cnt Score Error Units 15 | SqlIdsBenchmark.autoIdGeneration thrpt 2 18483231.616 ops/s 16 | SqlIdsBenchmark.baseline thrpt 2 21015628.298 ops/s 17 | */ 18 | @State(Scope.Benchmark) 19 | open class SqlIdsBenchmark { 20 | private val repository = MockRepository(MockKueryClient()) 21 | 22 | @Benchmark 23 | fun baseline(blackhole: Blackhole) { 24 | blackhole.consume(repository.select(Random.nextInt())) 25 | } 26 | 27 | @Benchmark 28 | fun autoIdGeneration(blackhole: Blackhole) { 29 | blackhole.consume(repository.selectWithAutoIdGeneration(Random.nextInt())) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /kuery-client-gradle-plugin/src/main/kotlin/dev/hsbrysk/kuery/gradle/KueryClientGradlePlugin.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.gradle 2 | 3 | import org.gradle.api.provider.Provider 4 | import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation 5 | import org.jetbrains.kotlin.gradle.plugin.KotlinCompilerPluginSupportPlugin 6 | import org.jetbrains.kotlin.gradle.plugin.SubpluginArtifact 7 | import org.jetbrains.kotlin.gradle.plugin.SubpluginOption 8 | 9 | class KueryClientGradlePlugin : KotlinCompilerPluginSupportPlugin { 10 | override fun applyToCompilation(kotlinCompilation: KotlinCompilation<*>): Provider> = 11 | kotlinCompilation.target.project.provider { emptyList() } 12 | 13 | override fun getCompilerPluginId(): String = "dev.hsbrysk.kuery-client" 14 | 15 | override fun getPluginArtifact(): SubpluginArtifact = SubpluginArtifact( 16 | groupId = "dev.hsbrysk.kuery-client", 17 | artifactId = "kuery-client-compiler", 18 | version = BuildConfig.VERSION, 19 | ) 20 | 21 | override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean = 22 | kotlinCompilation.target.project.plugins.hasPlugin(KueryClientGradlePlugin::class.java) 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | check: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 60 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v6 19 | - name: Set up JDK 20 | uses: actions/setup-java@v5 21 | with: 22 | distribution: temurin 23 | java-version: 17 24 | - name: Set up gradle 25 | uses: gradle/actions/setup-gradle@v5 26 | - name: Check 27 | run: | 28 | ./gradlew check detektMain detektTest 29 | ./gradlew -p examples check detektMain detektTest 30 | 31 | report-gradle-dependency-diff: 32 | runs-on: ubuntu-latest 33 | timeout-minutes: 60 34 | if: github.event_name == 'pull_request' 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v6 38 | - name: Set up JDK 39 | uses: actions/setup-java@v5 40 | with: 41 | distribution: temurin 42 | java-version: 17 43 | - name: Report gradle dependencies diff 44 | uses: be-hase/gradle-dependency-diff-action@v2 45 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/observation/KueryClientObservationDocumentation.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.observation 2 | 3 | import dev.hsbrysk.kuery.core.KueryBlockingClient 4 | import dev.hsbrysk.kuery.core.KueryClient 5 | import dev.hsbrysk.kuery.core.observation.internal.DefaultKueryClientFetchObservationConvention 6 | import io.micrometer.common.docs.KeyName 7 | import io.micrometer.observation.Observation 8 | import io.micrometer.observation.ObservationConvention 9 | import io.micrometer.observation.docs.ObservationDocumentation 10 | 11 | /** 12 | * [ObservationDocumentation] for [KueryClient] and [KueryBlockingClient] 13 | */ 14 | enum class KueryClientObservationDocumentation : ObservationDocumentation { 15 | FETCH { 16 | override fun getDefaultConvention(): Class> = 17 | DefaultKueryClientFetchObservationConvention::class.java 18 | 19 | override fun getLowCardinalityKeyNames(): Array = 20 | DefaultKueryClientFetchObservationConvention.getLowCardinalityKeyNames() 21 | 22 | override fun getHighCardinalityKeyNames(): Array = 23 | DefaultKueryClientFetchObservationConvention.getHighCardinalityKeyNames() 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Install 4 | 5 | ### Gradle 6 | 7 | ::: code-group 8 | 9 | ```kotlin [kuery-client-spring-data-r2dbc] 10 | plugins { 11 | id("dev.hsbrysk.kuery-client") version "{{version}}" 12 | } 13 | 14 | implementation("dev.hsbrysk.kuery-client:kuery-client-spring-data-r2dbc:{{version}}") 15 | ``` 16 | 17 | ```kotlin [kuery-client-spring-data-jdbc] 18 | plugins { 19 | id("dev.hsbrysk.kuery-client") version "{{version}}" 20 | } 21 | 22 | implementation("dev.hsbrysk.kuery-client:kuery-client-spring-data-jdbc:{{version}}") 23 | ``` 24 | 25 | ::: 26 | 27 | ## Build KueryClient 28 | 29 | ::: code-group 30 | 31 | ```kotlin [kuery-client-spring-data-r2dbc] 32 | val connectionFactory: ConnectionFactory = ... 33 | 34 | val kueryClient = SpringR2dbcKueryClient.builder() 35 | .connectionFactory(connectionFactory) 36 | .build() 37 | ``` 38 | 39 | ```kotlin [kuery-client-spring-data-jdbc] 40 | val dataSource: DataSource = ... 41 | 42 | val kueryClient = SpringJdbcKueryClient.builder() 43 | .dataSource(dataSource) 44 | .build() 45 | ``` 46 | 47 | ::: 48 | 49 | ## Let's Use It 50 | 51 | ```kotlin 52 | val userId = "..." 53 | val user: User = kueryClient 54 | .sql { +"SELECT * FROM users WHERE user_id = $userId" } 55 | .singleOrNull() 56 | ``` 57 | -------------------------------------------------------------------------------- /kuery-client-core/src/test/kotlin/dev/hsbrysk/kuery/core/observation/internal/DefaultKueryClientFetchObservationConventionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.observation.internal 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.Sql 6 | import dev.hsbrysk.kuery.core.observation.KueryClientFetchContext 7 | import org.junit.jupiter.api.Test 8 | 9 | class DefaultKueryClientFetchObservationConventionTest { 10 | private val target = DefaultKueryClientFetchObservationConvention() 11 | 12 | @Test 13 | fun getLowCardinalityKeyValues() { 14 | val ctx = KueryClientFetchContext("id", Sql("body")) 15 | val result = target.getLowCardinalityKeyValues(ctx) 16 | assertThat(result.toList().size).isEqualTo(1) 17 | assertThat(result.first().key).isEqualTo("sql.id") 18 | assertThat(result.first().value).isEqualTo("id") 19 | } 20 | 21 | @Test 22 | fun getHighCardinalityKeyValues() { 23 | val ctx = KueryClientFetchContext("id", Sql("body")) 24 | val result = target.getHighCardinalityKeyValues(ctx) 25 | assertThat(result.toList().size).isEqualTo(1) 26 | assertThat(result.first().key).isEqualTo("sql") 27 | assertThat(result.first().value).isEqualTo("body") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/CSVConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import org.junit.jupiter.api.AfterAll 7 | import org.junit.jupiter.api.AfterEach 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | 11 | class CSVConversionTest { 12 | private val kueryClient = mysql.kueryClient() 13 | 14 | data class Record(val text: List) 15 | 16 | @BeforeEach 17 | fun beforeEach() { 18 | mysql.setUpForConverterTest() 19 | } 20 | 21 | @AfterEach 22 | fun afterEach() { 23 | mysql.tearDownForConverterTest() 24 | } 25 | 26 | @Test 27 | fun test() { 28 | val text = "a, b,c" 29 | kueryClient.sql { 30 | +"INSERT INTO converter (text) VALUES ($text)" 31 | }.rowsUpdated() 32 | 33 | val record: Record = kueryClient.sql { 34 | +"SELECT * FROM converter" 35 | }.single() 36 | 37 | assertThat(record.text).isEqualTo(listOf("a", "b", "c")) 38 | } 39 | 40 | companion object { 41 | private val mysql = MySqlTestContainer() 42 | 43 | @AfterAll 44 | @JvmStatic 45 | fun afterAll() { 46 | mysql.close() 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/jmh/kotlin/dev/hsbrysk/kuery/spring/r2dbc/InitDataClassRowMapperBenchmark.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import org.openjdk.jmh.annotations.Benchmark 4 | import org.openjdk.jmh.annotations.Scope 5 | import org.openjdk.jmh.annotations.State 6 | import org.openjdk.jmh.infra.Blackhole 7 | import org.springframework.jdbc.core.DataClassRowMapper 8 | import java.util.concurrent.ConcurrentHashMap 9 | import kotlin.reflect.KClass 10 | 11 | /* 12 | Benchmark Mode Cnt Score Error Units 13 | InitDataClassRowMapperBenchmark.baseline thrpt 2 615235.453 ops/s 14 | InitDataClassRowMapperBenchmark.cache thrpt 2 94589146.574 ops/s 15 | */ 16 | @State(Scope.Benchmark) 17 | open class InitDataClassRowMapperBenchmark { 18 | private val mapperCache = ConcurrentHashMap, DataClassRowMapper<*>>() 19 | 20 | data class User( 21 | val userId: Int, 22 | val username: String, 23 | val email: String, 24 | ) 25 | 26 | @Benchmark 27 | fun baseline(blackhole: Blackhole) { 28 | blackhole.consume(DataClassRowMapper(User::class.java)) 29 | } 30 | 31 | @Benchmark 32 | fun cache(blackhole: Blackhole) { 33 | blackhole.consume( 34 | mapperCache.computeIfAbsent(User::class) { DataClassRowMapper(User::class.java) }, 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/observation/internal/DefaultKueryClientFetchObservationConvention.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.observation.internal 2 | 3 | import dev.hsbrysk.kuery.core.observation.KueryClientFetchContext 4 | import dev.hsbrysk.kuery.core.observation.KueryClientFetchObservationConvention 5 | import io.micrometer.common.KeyValue 6 | import io.micrometer.common.KeyValues 7 | import io.micrometer.common.docs.KeyName 8 | 9 | internal class DefaultKueryClientFetchObservationConvention : KueryClientFetchObservationConvention { 10 | override fun getLowCardinalityKeyValues(context: KueryClientFetchContext): KeyValues = KeyValues.of(sqlId(context)) 11 | 12 | override fun getHighCardinalityKeyValues(context: KueryClientFetchContext): KeyValues = KeyValues.of(sql(context)) 13 | 14 | private fun sqlId(context: KueryClientFetchContext): KeyValue = KeyValue.of(SQL_ID_KEY_NAME, context.sqlId) 15 | 16 | private fun sql(context: KueryClientFetchContext): KeyValue = KeyValue.of(SQL_KEY_NAME, context.sql.body) 17 | 18 | companion object { 19 | private val SQL_ID_KEY_NAME: KeyName = KeyName { "sql.id" } 20 | private val SQL_KEY_NAME: KeyName = KeyName { "sql" } 21 | 22 | internal fun getLowCardinalityKeyNames(): Array = arrayOf(SQL_ID_KEY_NAME) 23 | 24 | internal fun getHighCardinalityKeyNames(): Array = arrayOf(SQL_KEY_NAME) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/CSVConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import kotlinx.coroutines.test.runTest 7 | import org.junit.jupiter.api.AfterAll 8 | import org.junit.jupiter.api.AfterEach 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | 12 | class CSVConversionTest { 13 | private val kueryClient = mysql.kueryClient() 14 | 15 | data class Record(val text: List) 16 | 17 | @BeforeEach 18 | fun beforeEach() = runTest { 19 | mysql.setUpForConverterTest() 20 | } 21 | 22 | @AfterEach 23 | fun afterEach() = runTest { 24 | mysql.tearDownForConverterTest() 25 | } 26 | 27 | @Test 28 | fun test() = runTest { 29 | val text = "a, b,c" 30 | kueryClient.sql { 31 | +"INSERT INTO converter (text) VALUES ($text)" 32 | }.rowsUpdated() 33 | 34 | val record: Record = kueryClient.sql { 35 | +"SELECT * FROM converter" 36 | }.single() 37 | 38 | assertThat(record.text).isEqualTo(listOf("a", "b", "c")) 39 | } 40 | 41 | companion object { 42 | private val mysql = MySqlTestContainer() 43 | 44 | @AfterAll 45 | @JvmStatic 46 | fun afterAll() { 47 | mysql.close() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/main/kotlin/dev/hsbrysk/kuery/spring/jdbc/SpringJdbcKueryClientBuilder.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import dev.hsbrysk.kuery.core.KueryBlockingClient 4 | import dev.hsbrysk.kuery.core.observation.KueryClientFetchObservationConvention 5 | import io.micrometer.observation.ObservationRegistry 6 | import javax.sql.DataSource 7 | 8 | interface SpringJdbcKueryClientBuilder { 9 | /** 10 | * Set [DataSource] 11 | */ 12 | fun dataSource(dataSource: DataSource): SpringJdbcKueryClientBuilder 13 | 14 | /** 15 | * Set converters 16 | */ 17 | fun converters(converters: List): SpringJdbcKueryClientBuilder 18 | 19 | /** 20 | * Set [ObservationRegistry] 21 | */ 22 | fun observationRegistry(observationRegistry: ObservationRegistry): SpringJdbcKueryClientBuilder 23 | 24 | /** 25 | * Set [KueryClientFetchObservationConvention] 26 | */ 27 | fun observationConvention( 28 | observationConvention: KueryClientFetchObservationConvention, 29 | ): SpringJdbcKueryClientBuilder 30 | 31 | /** 32 | * It is a flag to automatically generate a sqlId for metrics. 33 | * When [observationRegistry] is specified, the default is true; otherwise, the default is false. 34 | */ 35 | fun enableAutoSqlIdGeneration(enableAutoSqlIdGeneration: Boolean): SpringJdbcKueryClientBuilder 36 | 37 | /** 38 | * build [KueryBlockingClient] 39 | */ 40 | fun build(): KueryBlockingClient 41 | } 42 | -------------------------------------------------------------------------------- /docs/detekt.md: -------------------------------------------------------------------------------- 1 | # Detekt Custom Rules 2 | 3 | Incorrect usage can result in SQL injection. To detect such cases, we provide custom Detekt rules. 4 | 5 | ## How to use 6 | 7 | First, please add it as a dependency in `detektPlugin`. 8 | 9 | ```kotlin 10 | dependencies { 11 | detektPlugins("dev.hsbrysk.kuery-client:kuery-client-detekt:{{version}}") 12 | } 13 | ``` 14 | 15 | Next, please add the following to the detekt configuration YAML. 16 | (Unfortunately, custom rules do not work unless they are explicitly enabled.) 17 | 18 | ```yaml 19 | kuery-client: 20 | UseStringLiteral: 21 | active: true 22 | ``` 23 | 24 | After that, by running the detektMain task, you can check for any violations. 25 | 26 | ```shell 27 | # Please run the detektMain task, as type resolution is being used. 28 | # ref: https://detekt.dev/docs/gettingstarted/type-resolution/ 29 | ./gradlew detektMain 30 | ``` 31 | 32 | ## Rules 33 | 34 | ### UseStringLiteralRule 35 | 36 | By providing a Kotlin compiler plugin, we are customizing the behavior of string interpolation. 37 | However, this customization is only applied to `SqlBuilder#add` and `SqlBuilder#unaryPlus(+)`. 38 | 39 | Therefore, if incorrectly written as follows, problems may arise. 40 | 41 | #### Noncompliant Code: 42 | 43 | ```kotlin 44 | kueryClient.sql { 45 | // BAD !! 46 | val sql = "SELECT * FROM user WHERE id = $id" 47 | +sql 48 | } 49 | ``` 50 | 51 | #### Compliant Code: 52 | 53 | ```kotlin 54 | kueryClient.sql { 55 | +"SELECT * FROM user WHERE id = $id" 56 | } 57 | ``` 58 | -------------------------------------------------------------------------------- /kuery-client-gradle-plugin/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("conventions.preset.base") 3 | `java-gradle-plugin` 4 | signing 5 | alias(libs.plugins.plugin.publish) 6 | alias(libs.plugins.buildconfig) 7 | } 8 | 9 | description = "Gradle plugin for the Kuery client compiler." 10 | 11 | dependencies { 12 | implementation(kotlin("gradle-plugin-api")) 13 | } 14 | 15 | buildConfig { 16 | packageName = "dev.hsbrysk.kuery.gradle" 17 | buildConfigField("VERSION", project.version.toString()) 18 | } 19 | 20 | @Suppress("UnstableApiUsage") 21 | gradlePlugin { 22 | val kueryClient by plugins.creating { 23 | id = "dev.hsbrysk.kuery-client" 24 | displayName = "Gradle plugin for the Kuery client compiler" 25 | description = """ 26 | To use Kuery client, you need to use the Kotlin compiler plugin. 27 | This is the Gradle plugin for configuring it. 28 | """.trimIndent() 29 | tags = listOf("kotlin", "kuery-client") 30 | implementationClass = "dev.hsbrysk.kuery.gradle.KueryClientGradlePlugin" 31 | } 32 | 33 | website = "https://github.com/be-hase/kuery-client" 34 | vcsUrl = "https://github.com/be-hase/kuery-client" 35 | } 36 | 37 | signing { 38 | if (project.version.toString().endsWith("-SNAPSHOT")) { 39 | isRequired = false 40 | } 41 | useInMemoryPgpKeys( 42 | providers.environmentVariable("SIGNING_PGP_KEY").orNull, 43 | providers.environmentVariable("SIGNING_PGP_PASSWORD").orNull, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/main/kotlin/dev/hsbrysk/kuery/spring/r2dbc/SpringR2dbcKueryClientBuilder.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import dev.hsbrysk.kuery.core.KueryClient 4 | import dev.hsbrysk.kuery.core.observation.KueryClientFetchObservationConvention 5 | import io.micrometer.observation.ObservationRegistry 6 | import io.r2dbc.spi.ConnectionFactory 7 | 8 | interface SpringR2dbcKueryClientBuilder { 9 | /** 10 | * Set [ConnectionFactory] 11 | */ 12 | fun connectionFactory(connectionFactory: ConnectionFactory): SpringR2dbcKueryClientBuilder 13 | 14 | /** 15 | * Set converters 16 | */ 17 | fun converters(converters: List): SpringR2dbcKueryClientBuilder 18 | 19 | /** 20 | * Set [ObservationRegistry] 21 | */ 22 | fun observationRegistry(observationRegistry: ObservationRegistry): SpringR2dbcKueryClientBuilder 23 | 24 | /** 25 | * Set [KueryClientFetchObservationConvention] 26 | */ 27 | fun observationConvention( 28 | observationConvention: KueryClientFetchObservationConvention, 29 | ): SpringR2dbcKueryClientBuilder 30 | 31 | /** 32 | * It is a flag to automatically generate a sqlId for metrics. 33 | * When [observationRegistry] is specified, the default is true; otherwise, the default is false. 34 | */ 35 | fun enableAutoSqlIdGeneration(enableAutoSqlIdGeneration: Boolean): SpringR2dbcKueryClientBuilder 36 | 37 | /** 38 | * Build [KueryClient] 39 | */ 40 | fun build(): KueryClient 41 | } 42 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | lang: "en-US", 6 | title: "Kuery Client", 7 | description: "A Kotlin/JVM database client for those who want to write SQL", 8 | themeConfig: { 9 | // https://vitepress.dev/reference/default-theme-config 10 | nav: [ 11 | {text: "Home", link: "/"}, 12 | {text: "Docs", link: "/introduction"}, 13 | ], 14 | 15 | sidebar: [ 16 | { 17 | text: "Docs", 18 | items: [ 19 | {text: "Introduction", link: '/introduction'}, 20 | {text: "Getting Started", link: '/getting-started'}, 21 | {text: "Basics", link: '/basics'}, 22 | {text: "Transaction", link: '/transaction'}, 23 | {text: "Type Conversion", link: '/type-conversion'}, 24 | {text: "Observation", link: '/observation'}, 25 | {text: "Detekt Custom Rules", link: '/detekt'}, 26 | {text: "Helpers", link: '/helpers'}, 27 | {text: "Examples", link: '/examples'}, 28 | {text: "Roadmap", link: '/roadmap'}, 29 | ] 30 | } 31 | ], 32 | 33 | socialLinks: [ 34 | {icon: "github", link: "https://github.com/be-hase/kuery-client"} 35 | ], 36 | 37 | search: { 38 | provider: 'local' 39 | } 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/EnumConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import org.junit.jupiter.api.AfterAll 7 | import org.junit.jupiter.api.AfterEach 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | 11 | class EnumConversionTest { 12 | private val kueryClient = mysql.kueryClient() 13 | 14 | enum class SampleEnum { 15 | HOGE, 16 | } 17 | 18 | data class Record(val text: SampleEnum) 19 | 20 | @BeforeEach 21 | fun beforeEach() { 22 | mysql.setUpForConverterTest() 23 | } 24 | 25 | @AfterEach 26 | fun afterEach() { 27 | mysql.tearDownForConverterTest() 28 | } 29 | 30 | @Test 31 | fun test() { 32 | kueryClient.sql { 33 | +"INSERT INTO converter (text) VALUES (${SampleEnum.HOGE})" 34 | }.rowsUpdated() 35 | 36 | val record: Record = kueryClient.sql { 37 | +"SELECT * FROM converter" 38 | }.single() 39 | assertThat(record.text).isEqualTo(SampleEnum.HOGE) 40 | 41 | val map = kueryClient.sql { 42 | +"SELECT * FROM converter" 43 | }.singleMap() 44 | assertThat(map["text"]).isEqualTo("HOGE") 45 | } 46 | 47 | companion object { 48 | private val mysql = MySqlTestContainer() 49 | 50 | @AfterAll 51 | @JvmStatic 52 | fun afterAll() { 53 | mysql.close() 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress site to Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 15 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 16 | concurrency: 17 | group: pages 18 | cancel-in-progress: false 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v6 26 | with: 27 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 28 | - name: Setup Node 29 | uses: actions/setup-node@v6 30 | with: 31 | node-version: 24 32 | cache: npm 33 | - name: Setup Pages 34 | uses: actions/configure-pages@v5 35 | - name: Install dependencies 36 | run: npm ci 37 | - name: Build with VitePress 38 | run: npm run docs:build 39 | - name: Upload artifact 40 | uses: actions/upload-pages-artifact@v4 41 | with: 42 | path: docs/.vitepress/dist 43 | 44 | deploy: 45 | environment: 46 | name: github-pages 47 | url: ${{ steps.deployment.outputs.page_url }} 48 | needs: build 49 | runs-on: ubuntu-latest 50 | name: Deploy 51 | steps: 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 55 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/EnumConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import kotlinx.coroutines.test.runTest 7 | import org.junit.jupiter.api.AfterAll 8 | import org.junit.jupiter.api.AfterEach 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | 12 | class EnumConversionTest { 13 | private val kueryClient = mysql.kueryClient() 14 | 15 | enum class SampleEnum { 16 | HOGE, 17 | } 18 | 19 | data class Record(val text: SampleEnum) 20 | 21 | @BeforeEach 22 | fun beforeEach() = runTest { 23 | mysql.setUpForConverterTest() 24 | } 25 | 26 | @AfterEach 27 | fun afterEach() = runTest { 28 | mysql.tearDownForConverterTest() 29 | } 30 | 31 | @Test 32 | fun test() = runTest { 33 | kueryClient.sql { 34 | +"INSERT INTO converter (text) VALUES (${SampleEnum.HOGE})" 35 | }.rowsUpdated() 36 | 37 | val record: Record = kueryClient.sql { 38 | +"SELECT * FROM converter" 39 | }.single() 40 | assertThat(record.text).isEqualTo(SampleEnum.HOGE) 41 | 42 | val map = kueryClient.sql { 43 | +"SELECT * FROM converter" 44 | }.singleMap() 45 | assertThat(map["text"]).isEqualTo("HOGE") 46 | } 47 | 48 | companion object { 49 | private val mysql = MySqlTestContainer() 50 | 51 | @AfterAll 52 | @JvmStatic 53 | fun afterAll() { 54 | mysql.close() 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/Sql.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import dev.hsbrysk.kuery.core.internal.DefaultSql 4 | import dev.hsbrysk.kuery.core.internal.DefaultSqlBuilder 5 | 6 | interface Sql { 7 | /** 8 | * SQL body 9 | */ 10 | val body: String 11 | 12 | /** 13 | * SQL parameters 14 | */ 15 | val parameters: List 16 | 17 | companion object { 18 | /** 19 | * Create [Sql] 20 | */ 21 | @Deprecated( 22 | message = "Use `dev.hsbrysk.kuery.core.Sql` function instead.", 23 | replaceWith = ReplaceWith("Sql(body, parameters)"), 24 | ) 25 | fun of( 26 | body: String, 27 | parameters: List, 28 | ): Sql = DefaultSql(body, parameters) 29 | 30 | /** 31 | * Create [Sql] using [SqlBuilder] 32 | */ 33 | @Deprecated( 34 | message = "Use `dev.hsbrysk.kuery.core.Sql` function instead.", 35 | replaceWith = ReplaceWith("Sql(block)"), 36 | ) 37 | fun create(block: SqlBuilder.() -> Unit): Sql { 38 | val builder = DefaultSqlBuilder() 39 | block(builder) 40 | return builder.build() 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * Create [Sql] 47 | */ 48 | fun Sql( 49 | body: String, 50 | parameters: List = emptyList(), 51 | ): Sql = DefaultSql(body, parameters) 52 | 53 | /** 54 | * Create [Sql] using [SqlBuilder] 55 | */ 56 | fun Sql(block: SqlBuilder.() -> Unit): Sql { 57 | val builder = DefaultSqlBuilder() 58 | block(builder) 59 | return builder.build() 60 | } 61 | -------------------------------------------------------------------------------- /docs/type-conversion.md: -------------------------------------------------------------------------------- 1 | # Type Conversion 2 | 3 | By using Spring Type Conversion, you can support your own custom types. 4 | https://docs.spring.io/spring-framework/reference/core/validation/convert.html 5 | 6 | ## Example 7 | 8 | ### Custom type used as a sample 9 | 10 | ```kotlin 11 | data class StringWrapper(val value: String) 12 | ``` 13 | 14 | ### Prepare Type Converters 15 | 16 | ```kotlin 17 | @WritingConverter 18 | class StringWrapperToStringConverter : Converter { 19 | override fun convert(source: StringWrapper): String { 20 | return source.value 21 | } 22 | } 23 | 24 | @ReadingConverter 25 | class StringToStringWrapperConverter : Converter { 26 | override fun convert(source: String): StringWrapper { 27 | return StringWrapper(source) 28 | } 29 | } 30 | ``` 31 | 32 | ### Specify the converters when creating the `KueryClient` 33 | 34 | ```kotlin {4-9} 35 | // e.g. In the case of kuery-client-spring-data-r2dbc 36 | val kueryClient = SpringR2dbcKueryClient.builder() 37 | .connectionFactory(connectionFactory) 38 | .converters( 39 | listOf( 40 | StringWrapperToStringConverter(), 41 | StringToStringWrapperConverter(), 42 | ) 43 | ) 44 | .build() 45 | ``` 46 | 47 | ### Let's Try 48 | 49 | ```kotlin 50 | suspend fun write(str: StringWrapper): Long = kueryClient 51 | .sql { 52 | +"INSERT INTO test_table (text) VALUES ($str)" 53 | } 54 | .rowsUpdated() 55 | 56 | data class Record( 57 | val text: StringWrapper, 58 | ) 59 | 60 | suspend fun read(): List = kueryClient 61 | .sql { 62 | +"SELECT * FROM test_table" 63 | } 64 | .list() 65 | ``` 66 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/SqlBuilder.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import org.intellij.lang.annotations.Language 4 | 5 | @SqlBuilderMarker 6 | interface SqlBuilder { 7 | /** 8 | * Specify the sql you want to execute. Appended to the internally held [StringBuilder]. 9 | * Due to the Kotlin compiler plugin, the string interpolation within the string template passed to 10 | * this method will be expanded using placeholders. 11 | * 12 | * e.g. 13 | * ``` 14 | * add("SELECT * FROM users WHERE user_id = $userId") 15 | * ``` 16 | */ 17 | fun add(@Language("sql") sql: String) 18 | 19 | /** 20 | * Specify the sql you want to execute. Appended to the internally held [StringBuilder]. 21 | * Due to the Kotlin compiler plugin, the string interpolation within the string template passed to 22 | * this method will be expanded using placeholders. 23 | * 24 | * e.g. 25 | * ``` 26 | * +"SELECT * FROM users WHERE user_id = $userId" 27 | * ``` 28 | */ 29 | operator fun String.unaryPlus() 30 | 31 | /** 32 | * Specify the sql you want to execute. Appended to the internally held [StringBuilder]. 33 | * Please note that string interpolation using placeholders will not be performed in this method. 34 | * 35 | * If you want to insert dynamic values using addUnsafe, please use bind. 36 | * ``` 37 | * addUnsafe("user_id = ${bind(userId)}") 38 | * ``` 39 | */ 40 | @DelicateKueryClientApi 41 | fun addUnsafe(@Language("sql") sql: String) 42 | 43 | /** 44 | * Bind variables to SQL 45 | * It is intended to be used together with addUnsafe. 46 | */ 47 | @DelicateKueryClientApi 48 | fun bind(parameter: Any?): String 49 | } 50 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/com/example/spring/jdbc/SampleRepository.kt: -------------------------------------------------------------------------------- 1 | package com.example.spring.jdbc 2 | 3 | import dev.hsbrysk.kuery.core.KueryBlockingClient 4 | import dev.hsbrysk.kuery.core.list 5 | import dev.hsbrysk.kuery.core.single 6 | import dev.hsbrysk.kuery.core.singleOrNull 7 | 8 | data class User( 9 | val userId: Int, 10 | val username: String, 11 | val email: String, 12 | ) 13 | 14 | class UserRepository(private val kueryClient: KueryBlockingClient) { 15 | fun singleMap(id: Int): Map = 16 | kueryClient.sql { +"SELECT * FROM users WHERE user_id = $id" }.singleMap() 17 | 18 | fun singleMapOrNull(id: Int): Map? = kueryClient 19 | .sql { 20 | +"SELECT * FROM users WHERE user_id = $id" 21 | } 22 | .singleMapOrNull() 23 | 24 | fun single(id: Int): User = kueryClient.sql { +"SELECT * FROM users WHERE user_id = $id" }.single() 25 | 26 | fun singleOrNull(id: Int): User? = kueryClient.sql { +"SELECT * FROM users WHERE user_id = $id" }.singleOrNull() 27 | 28 | fun listMap(): List> = kueryClient.sql { +"SELECT * FROM users" }.listMap() 29 | 30 | fun list(): List = kueryClient.sql { +"SELECT * FROM users" }.list() 31 | 32 | fun rowUpdated( 33 | username: String, 34 | email: String, 35 | ): Long = kueryClient 36 | .sql { 37 | +"INSERT INTO users (username, email) VALUES ($username, $email)" 38 | } 39 | .rowsUpdated() 40 | 41 | fun generatedValues( 42 | username: String, 43 | email: String, 44 | ): Map = kueryClient 45 | .sql { 46 | +"INSERT INTO users (username, email) VALUES ($username, $email)" 47 | } 48 | .generatedValues("user_id") 49 | } 50 | -------------------------------------------------------------------------------- /kuery-client-detekt/src/main/kotlin/dev/hsbrysk/kuery/detekt/Utils.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.detekt 2 | 3 | import org.jetbrains.kotlin.psi.KtCallExpression 4 | import org.jetbrains.kotlin.psi.KtDotQualifiedExpression 5 | import org.jetbrains.kotlin.psi.KtExpression 6 | import org.jetbrains.kotlin.psi.KtUnaryExpression 7 | import org.jetbrains.kotlin.resolve.BindingContext 8 | import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall 9 | import org.jetbrains.kotlin.resolve.descriptorUtil.fqNameOrNull 10 | 11 | internal const val ADD_FQ_NAME = "dev.hsbrysk.kuery.core.SqlBuilder.add" 12 | 13 | internal const val UNARY_PLUS_FQ_NAME = "dev.hsbrysk.kuery.core.SqlBuilder.unaryPlus" 14 | 15 | internal tailrec fun getLastReceiverExpression(expression: KtDotQualifiedExpression): KtExpression { 16 | val dotQualifiedExpression = expression.receiverExpression as? KtDotQualifiedExpression 17 | ?: return expression.receiverExpression 18 | return getLastReceiverExpression(dotQualifiedExpression) 19 | } 20 | 21 | internal fun isSqlBuilderAddExpression( 22 | expression: KtCallExpression, 23 | bindingContext: BindingContext, 24 | ): Boolean { 25 | val maybeAdd = expression.calleeExpression?.text == "add" && 26 | expression.valueArguments.size == 1 27 | if (!maybeAdd) { 28 | return false 29 | } 30 | 31 | val callFqName = expression.getResolvedCall(bindingContext)?.resultingDescriptor?.fqNameOrNull() 32 | return callFqName?.asString() == ADD_FQ_NAME 33 | } 34 | 35 | internal fun isSqlBuilderUnaryExpression( 36 | expression: KtUnaryExpression, 37 | bindingContext: BindingContext, 38 | ): Boolean { 39 | val unaryPlusFqName = expression.getResolvedCall(bindingContext)?.resultingDescriptor?.fqNameOrNull() 40 | return unaryPlusFqName?.asString() == UNARY_PLUS_FQ_NAME 41 | } 42 | -------------------------------------------------------------------------------- /docs/helpers.md: -------------------------------------------------------------------------------- 1 | # Helpers 2 | 3 | ## Functions 4 | 5 | ### `values` 6 | 7 | This is a helpful function for performing multi-row inserts. 8 | 9 | ```kotlin 10 | @Test 11 | fun test() = runTest { 12 | data class UserParam(val username: String, val email: String?, val age: Int) 13 | 14 | val input = listOf( 15 | UserParam("user1", "user1@example.com", 1), 16 | UserParam("user2", null, 2), 17 | UserParam("user3", "user3@example.com", 3), 18 | ) 19 | 20 | kueryClient.sql { 21 | +"INSERT INTO users (username, email, age)" 22 | values(input) { listOf(it.username, it.email, it.age) } 23 | }.rowsUpdated() 24 | } 25 | ``` 26 | 27 | ## You can also write your own helper 28 | 29 | For example, the above `values` function is implemented as follows. 30 | 31 | ```kotlin 32 | fun SqlBuilder.values(input: List>) { 33 | require(input.isNotEmpty()) { "inputted list is empty" } 34 | val firstSize = input.first().size 35 | require(input.all { it.size == firstSize }) { "All inputted child lists must have the same size." } 36 | require(firstSize > 0) { "inputted child list is empty" } 37 | 38 | val placeholders = input.joinToString(", ") { list -> 39 | list.joinToString(separator = ", ", prefix = "(", postfix = ")") { 40 | bind(it) 41 | } 42 | } 43 | addUnsafe("VALUES $placeholders") 44 | } 45 | 46 | fun SqlBuilder.values( 47 | input: List, 48 | transformer: (T) -> List, 49 | ) { 50 | values(input.map { transformer(it) }) 51 | } 52 | ``` 53 | 54 | Feel free to extend it as you wish. 55 | 56 | There may be cases where custom string interpolation is difficult to write. In such situations, please use `addUnsafe` 57 | and `bind`. 58 | (The `values` function above is a good example of this.) 59 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/MySqlTestContainer.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import com.mysql.cj.jdbc.MysqlDataSource 4 | import dev.hsbrysk.kuery.core.KueryBlockingClient 5 | import io.micrometer.observation.ObservationRegistry 6 | import org.springframework.jdbc.core.simple.JdbcClient 7 | import org.testcontainers.containers.MySQLContainer 8 | 9 | class MySqlTestContainer : AutoCloseable { 10 | private val mysqlContainer = MySQLContainer("mysql:8.0.37").also { it.start() } 11 | private val dataSource = MysqlDataSource().apply { 12 | setURL(mysqlContainer.jdbcUrl) 13 | user = mysqlContainer.username 14 | password = mysqlContainer.password 15 | } 16 | val jdbcClient: JdbcClient = JdbcClient.create(dataSource) 17 | 18 | fun kueryClient( 19 | converters: List = emptyList(), 20 | observationRegistry: ObservationRegistry? = null, 21 | ): KueryBlockingClient = SpringJdbcKueryClient.builder() 22 | .dataSource(dataSource) 23 | .converters(converters) 24 | .apply { 25 | observationRegistry?.let { observationRegistry(it) } 26 | } 27 | .build() 28 | 29 | fun setUpForConverterTest() { 30 | jdbcClient.sql( 31 | """ 32 | CREATE TABLE `converter` 33 | ( 34 | `id` BIGINT AUTO_INCREMENT, 35 | `text` VARCHAR(255) DEFAULT NULL, 36 | PRIMARY KEY (`id`) 37 | ) ENGINE = InnoDB 38 | DEFAULT CHARSET = utf8mb4 39 | COLLATE = utf8mb4_bin; 40 | """.trimIndent(), 41 | ).update() 42 | } 43 | 44 | fun tearDownForConverterTest() { 45 | jdbcClient.sql("DROP TABLE converter").update() 46 | } 47 | 48 | override fun close() { 49 | mysqlContainer.close() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/com/example/spring/r2dbc/SampleRepository.kt: -------------------------------------------------------------------------------- 1 | package com.example.spring.r2dbc 2 | 3 | import dev.hsbrysk.kuery.core.KueryClient 4 | import dev.hsbrysk.kuery.core.list 5 | import dev.hsbrysk.kuery.core.single 6 | import dev.hsbrysk.kuery.core.singleOrNull 7 | 8 | data class User( 9 | val userId: Int, 10 | val username: String, 11 | val email: String, 12 | ) 13 | 14 | class UserRepository(private val kueryClient: KueryClient) { 15 | suspend fun singleMap(id: Int): Map = 16 | kueryClient.sql { +"SELECT * FROM users WHERE user_id = $id" }.singleMap() 17 | 18 | suspend fun singleMapOrNull(id: Int): Map? = kueryClient 19 | .sql { 20 | +"SELECT * FROM users WHERE user_id = $id" 21 | } 22 | .singleMapOrNull() 23 | 24 | suspend fun single(id: Int): User = kueryClient.sql { +"SELECT * FROM users WHERE user_id = $id" }.single() 25 | 26 | suspend fun singleOrNull(id: Int): User? = kueryClient 27 | .sql { 28 | +"SELECT * FROM users WHERE user_id = $id" 29 | } 30 | .singleOrNull() 31 | 32 | suspend fun listMap(): List> = kueryClient.sql { +"SELECT * FROM users" }.listMap() 33 | 34 | suspend fun list(): List = kueryClient.sql { +"SELECT * FROM users" }.list() 35 | 36 | suspend fun rowUpdated( 37 | username: String, 38 | email: String, 39 | ): Long = kueryClient 40 | .sql { 41 | +"INSERT INTO users (username, email) VALUES ($username, $email)" 42 | } 43 | .rowsUpdated() 44 | 45 | suspend fun generatedValues( 46 | username: String, 47 | email: String, 48 | ): Map = kueryClient 49 | .sql { 50 | +"INSERT INTO users (username, email) VALUES ($username, $email)" 51 | } 52 | .generatedValues("user_id") 53 | } 54 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/internal/SqlIds.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.internal 2 | 3 | import dev.hsbrysk.kuery.core.SqlBuilder 4 | import java.util.concurrent.ConcurrentHashMap 5 | 6 | object SqlIds { 7 | private val NUMBER_REGEX = "^[0-9]+$".toRegex() 8 | 9 | private val CACHE: ConcurrentHashMap, String> = ConcurrentHashMap() 10 | 11 | private val SUFFIXES = listOf( 12 | ".invokeSuspend", 13 | "${'$'}suspendImpl", 14 | ) 15 | 16 | /** 17 | * Uses StackWalker to retrieve the caller. 18 | */ 19 | fun (SqlBuilder.() -> Unit).id(): String { 20 | return CACHE.computeIfAbsent(this.javaClass) { _ -> 21 | val name = StackWalker.getInstance().walk { frames -> 22 | frames 23 | .filter { 24 | "${it.className}.${it.methodName}" != "java.util.concurrent.ConcurrentHashMap.computeIfAbsent" 25 | } 26 | .filter { 27 | !it.className.startsWith("dev.hsbrysk.kuery") 28 | } 29 | .findFirst() 30 | .map { "${it.className}.${it.methodName}" } 31 | .orElse(null) 32 | } 33 | if (name == null) { 34 | return@computeIfAbsent "UNKNOWN" 35 | } 36 | 37 | val parts = name.removeSuffixes(SUFFIXES).split("$", ".").filterNot { it.matches(NUMBER_REGEX) } 38 | if (parts.isEmpty()) { 39 | "UNKNOWN" 40 | } else { 41 | parts.joinToString(".") 42 | } 43 | } 44 | } 45 | 46 | internal fun String.removeSuffixes(suffixes: List): String { 47 | suffixes.forEach { suffix -> 48 | if (this.endsWith(suffix)) { 49 | return this.removeSuffix(suffix) 50 | } 51 | } 52 | return this 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/StringCaseTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import org.junit.jupiter.api.AfterAll 7 | import org.junit.jupiter.api.AfterEach 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | 11 | class StringCaseTest { 12 | private val kueryClient = mysql.kueryClient() 13 | 14 | @BeforeEach 15 | fun beforeEach() { 16 | mysql.jdbcClient.sql( 17 | """ 18 | CREATE TABLE `string_case` 19 | ( 20 | `id` BIGINT AUTO_INCREMENT, 21 | `hoge_bar` VARCHAR(255) DEFAULT NULL, 22 | `fugaPiyo` VARCHAR(255) DEFAULT NULL, 23 | PRIMARY KEY (`id`) 24 | ) ENGINE = InnoDB 25 | DEFAULT CHARSET = utf8mb4 26 | COLLATE = utf8mb4_bin; 27 | """.trimIndent(), 28 | ).update() 29 | } 30 | 31 | @AfterEach 32 | fun afterEach() { 33 | mysql.jdbcClient.sql("DROP TABLE string_case").update() 34 | } 35 | 36 | data class Record( 37 | val hogeBar: String, 38 | val fugaPiyo: String, 39 | ) 40 | 41 | @Test 42 | fun test() { 43 | kueryClient.sql { 44 | +"INSERT INTO string_case (hoge_bar, fugaPiyo) VALUES ('a', 'b')" 45 | }.rowsUpdated() 46 | 47 | val record: Record = kueryClient.sql { 48 | +"SELECT * FROM string_case" 49 | }.single() 50 | println(record) 51 | 52 | assertThat(record.hogeBar).isEqualTo("a") 53 | assertThat(record.fugaPiyo).isEqualTo("b") 54 | } 55 | 56 | companion object { 57 | private val mysql = MySqlTestContainer() 58 | 59 | @AfterAll 60 | @JvmStatic 61 | fun afterAll() { 62 | mysql.close() 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/kotlin/dev/hsbrysk/kuery/compiler/ir/misc/StringConcatenationProcessor.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.compiler.ir.misc 2 | 3 | import org.jetbrains.kotlin.ir.builders.IrBuilderWithScope 4 | import org.jetbrains.kotlin.ir.builders.irString 5 | import org.jetbrains.kotlin.ir.expressions.IrConst 6 | import org.jetbrains.kotlin.ir.expressions.IrConstKind 7 | import org.jetbrains.kotlin.ir.expressions.IrExpression 8 | import org.jetbrains.kotlin.ir.types.classFqName 9 | import org.jetbrains.kotlin.ir.util.superTypes 10 | 11 | internal class StringConcatenationProcessor(private val builder: IrBuilderWithScope) { 12 | // first: fragments, second: values 13 | fun process(expressions: List): Pair, List> { 14 | val fragments = mutableListOf() 15 | val values = mutableListOf() 16 | 17 | val iterator = expressions.iterator() 18 | var mustAddFragment = true 19 | 20 | while (iterator.hasNext()) { 21 | val current = iterator.next() 22 | if (isFragment(current)) { 23 | fragments.add(current) 24 | mustAddFragment = false 25 | } else { 26 | // The reason for adding an empty string fragment can be found in `DefaultSqlBuilder.interpolate`. 27 | if (mustAddFragment) { 28 | fragments.add(builder.irString("")) 29 | } 30 | values.add(current) 31 | mustAddFragment = true 32 | } 33 | } 34 | 35 | return fragments to values 36 | } 37 | 38 | private fun isFragment(expression: IrExpression): Boolean { 39 | val isString = expression.type.classFqName?.asString() == ClassNames.STRING || 40 | expression.type.superTypes().any { it.classFqName?.asString() == ClassNames.STRING } 41 | return isString && expression is IrConst && expression.kind == IrConstKind.String 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/init_mysql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | mysql_name="kuery-client-example-mysql" 5 | 6 | # Create user 7 | echo "[$mysql_name] Try to create users: admin" 8 | docker compose exec "$mysql_name" mysql -e " 9 | CREATE USER 'admin'@'%' IDENTIFIED BY 'admin'; 10 | GRANT ALL ON *.* TO admin@'%'; 11 | " 12 | echo "[$mysql_name] Success to created users" 13 | 14 | # Create database 15 | echo "[$mysql_name] Try to create database" 16 | docker compose exec "$mysql_name" mysql -e " 17 | CREATE DATABASE testdb; 18 | " 19 | echo "[$mysql_name] Success to create database" 20 | 21 | # Create user and tweet table 22 | echo "[$mysql_name] Try to create tables" 23 | docker compose exec "$mysql_name" mysql testdb -e " 24 | CREATE TABLE users ( 25 | user_id INT AUTO_INCREMENT PRIMARY KEY, 26 | username VARCHAR(50) NOT NULL, 27 | email VARCHAR(100) NOT NULL 28 | ); 29 | 30 | CREATE TABLE orders ( 31 | order_id INT AUTO_INCREMENT PRIMARY KEY, 32 | user_id INT, 33 | order_date DATE, 34 | amount DECIMAL(10, 2), 35 | FOREIGN KEY (user_id) REFERENCES users(user_id) 36 | ); 37 | 38 | CREATE TABLE products ( 39 | product_id INT AUTO_INCREMENT PRIMARY KEY, 40 | product_name VARCHAR(100), 41 | price DECIMAL(10, 2) 42 | ); 43 | 44 | CREATE TABLE order_items ( 45 | order_item_id INT AUTO_INCREMENT PRIMARY KEY, 46 | order_id INT, 47 | product_id INT, 48 | quantity INT, 49 | FOREIGN KEY (order_id) REFERENCES orders(order_id), 50 | FOREIGN KEY (product_id) REFERENCES products(product_id) 51 | ); 52 | 53 | INSERT INTO users (username, email) VALUES 54 | ('user1', 'user1@example.com'), 55 | ('user2', 'user2@example.com'); 56 | 57 | INSERT INTO orders (user_id, order_date, amount) VALUES 58 | (1, '2023-06-01', 100.00), 59 | (2, '2023-06-02', 150.00); 60 | 61 | INSERT INTO products (product_name, price) VALUES 62 | ('Product A', 25.00), 63 | ('Product B', 50.00); 64 | 65 | INSERT INTO order_items (order_id, product_id, quantity) VALUES 66 | (1, 1, 2), 67 | (1, 2, 1), 68 | (2, 1, 1); 69 | " 70 | echo "[$mysql_name] Success to create tables" 71 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+' 6 | 7 | jobs: 8 | publish-maven-central: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 60 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v6 14 | - name: Set up JDK 15 | uses: actions/setup-java@v5 16 | with: 17 | distribution: temurin 18 | java-version: 17 19 | - name: Set up gradle 20 | uses: gradle/actions/setup-gradle@v5 21 | - name: Publish to maven central 22 | env: 23 | TAG_NAME: ${{ github.ref_name }} 24 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} 25 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} 26 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PGP_KEY }} 27 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PGP_PASSWORD }} 28 | run: | 29 | PUBLISH_VERSION=${TAG_NAME#v} 30 | ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache -PpublishVersion="$PUBLISH_VERSION" 31 | 32 | publish-gradle-plugin: 33 | runs-on: ubuntu-latest 34 | timeout-minutes: 60 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v6 38 | - name: Set up JDK 39 | uses: actions/setup-java@v5 40 | with: 41 | distribution: temurin 42 | java-version: 17 43 | - name: Set up gradle 44 | uses: gradle/actions/setup-gradle@v5 45 | - name: Publish to gradle plugin portal 46 | env: 47 | TAG_NAME: ${{ github.ref_name }} 48 | GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} 49 | GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} 50 | SIGNING_PGP_KEY: ${{ secrets.SIGNING_PGP_KEY }} 51 | SIGNING_PGP_PASSWORD: ${{ secrets.SIGNING_PGP_PASSWORD }} 52 | run: | 53 | PUBLISH_VERSION=${TAG_NAME#v} 54 | ./gradlew :kuery-client-gradle-plugin:publishPlugins -PpublishVersion="$PUBLISH_VERSION" 55 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/StringCaseTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import kotlinx.coroutines.test.runTest 7 | import org.junit.jupiter.api.AfterAll 8 | import org.junit.jupiter.api.AfterEach 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.r2dbc.core.awaitRowsUpdated 12 | 13 | class StringCaseTest { 14 | private val kueryClient = mysql.kueryClient() 15 | 16 | @BeforeEach 17 | fun beforeEach() = runTest { 18 | mysql.databaseClient.sql( 19 | """ 20 | CREATE TABLE `string_case` 21 | ( 22 | `id` BIGINT AUTO_INCREMENT, 23 | `hoge_bar` VARCHAR(255) DEFAULT NULL, 24 | `fugaPiyo` VARCHAR(255) DEFAULT NULL, 25 | PRIMARY KEY (`id`) 26 | ) ENGINE = InnoDB 27 | DEFAULT CHARSET = utf8mb4 28 | COLLATE = utf8mb4_bin; 29 | """.trimIndent(), 30 | ).fetch().awaitRowsUpdated() 31 | } 32 | 33 | @AfterEach 34 | fun afterEach() = runTest { 35 | mysql.databaseClient.sql("DROP TABLE string_case").fetch().awaitRowsUpdated() 36 | } 37 | 38 | data class Record( 39 | val hogeBar: String, 40 | val fugaPiyo: String, 41 | ) 42 | 43 | @Test 44 | fun test() = runTest { 45 | kueryClient.sql { 46 | +"INSERT INTO string_case (hoge_bar, fugaPiyo) VALUES ('a', 'b')" 47 | }.rowsUpdated() 48 | 49 | val record: Record = kueryClient.sql { 50 | +"SELECT * FROM string_case" 51 | }.single() 52 | println(record) 53 | 54 | assertThat(record.hogeBar).isEqualTo("a") 55 | assertThat(record.fugaPiyo).isEqualTo("b") 56 | } 57 | 58 | companion object { 59 | private val mysql = MySqlTestContainer() 60 | 61 | @AfterAll 62 | @JvmStatic 63 | fun afterAll() { 64 | mysql.close() 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/StringWrapperConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import org.junit.jupiter.api.AfterAll 7 | import org.junit.jupiter.api.AfterEach 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | import org.springframework.core.convert.converter.Converter 11 | import org.springframework.data.convert.ReadingConverter 12 | import org.springframework.data.convert.WritingConverter 13 | 14 | class StringWrapperConversionTest { 15 | private val kueryClient = mysql.kueryClient( 16 | listOf( 17 | StringWrapperToStringConverter(), 18 | StringToStringWrapperConverter(), 19 | ), 20 | ) 21 | 22 | data class StringWrapper(val value: String) 23 | 24 | data class Record(val text: StringWrapper) 25 | 26 | @WritingConverter 27 | class StringWrapperToStringConverter : Converter { 28 | override fun convert(source: StringWrapper): String = source.value 29 | } 30 | 31 | @ReadingConverter 32 | class StringToStringWrapperConverter : Converter { 33 | override fun convert(source: String): StringWrapper = StringWrapper(source) 34 | } 35 | 36 | @BeforeEach 37 | fun beforeEach() { 38 | mysql.setUpForConverterTest() 39 | } 40 | 41 | @AfterEach 42 | fun afterEach() { 43 | mysql.tearDownForConverterTest() 44 | } 45 | 46 | @Test 47 | fun test() { 48 | kueryClient.sql { 49 | +"INSERT INTO converter (text) VALUES (${StringWrapper("hoge")})" 50 | }.rowsUpdated() 51 | 52 | val record: Record = kueryClient.sql { 53 | +"SELECT * FROM converter" 54 | }.single() 55 | 56 | assertThat(record.text).isEqualTo(StringWrapper("hoge")) 57 | } 58 | 59 | companion object { 60 | private val mysql = MySqlTestContainer() 61 | 62 | @AfterAll 63 | @JvmStatic 64 | fun afterAll() { 65 | mysql.close() 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/StringWrapperConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import kotlinx.coroutines.test.runTest 7 | import org.junit.jupiter.api.AfterAll 8 | import org.junit.jupiter.api.AfterEach 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.core.convert.converter.Converter 12 | import org.springframework.data.convert.ReadingConverter 13 | import org.springframework.data.convert.WritingConverter 14 | 15 | class StringWrapperConversionTest { 16 | private val kueryClient = mysql.kueryClient( 17 | listOf( 18 | StringWrapperToStringConverter(), 19 | StringToStringWrapperConverter(), 20 | ), 21 | ) 22 | 23 | data class StringWrapper(val value: String) 24 | 25 | data class Record(val text: StringWrapper) 26 | 27 | @WritingConverter 28 | class StringWrapperToStringConverter : Converter { 29 | override fun convert(source: StringWrapper): String = source.value 30 | } 31 | 32 | @ReadingConverter 33 | class StringToStringWrapperConverter : Converter { 34 | override fun convert(source: String): StringWrapper = StringWrapper(source) 35 | } 36 | 37 | @BeforeEach 38 | fun beforeEach() = runTest { 39 | mysql.setUpForConverterTest() 40 | } 41 | 42 | @AfterEach 43 | fun afterEach() = runTest { 44 | mysql.tearDownForConverterTest() 45 | } 46 | 47 | @Test 48 | fun test() = runTest { 49 | kueryClient.sql { 50 | +"INSERT INTO converter (text) VALUES (${StringWrapper("hoge")})" 51 | }.rowsUpdated() 52 | 53 | val record: Record = kueryClient.sql { 54 | +"SELECT * FROM converter" 55 | }.single() 56 | 57 | assertThat(record.text).isEqualTo(StringWrapper("hoge")) 58 | } 59 | 60 | companion object { 61 | private val mysql = MySqlTestContainer() 62 | 63 | @AfterAll 64 | @JvmStatic 65 | fun afterAll() { 66 | mysql.close() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/CollectionConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import org.junit.jupiter.api.AfterAll 6 | import org.junit.jupiter.api.AfterEach 7 | import org.junit.jupiter.api.BeforeEach 8 | import org.junit.jupiter.api.Test 9 | import org.springframework.core.convert.converter.Converter 10 | import org.springframework.data.convert.ReadingConverter 11 | import org.springframework.data.convert.WritingConverter 12 | 13 | class CollectionConversionTest { 14 | private val kueryClient = mysql.kueryClient( 15 | listOf( 16 | StringWrapperToStringConverter(), 17 | StringToStringWrapperConverter(), 18 | ), 19 | ) 20 | 21 | data class StringWrapper(val value: String) 22 | 23 | @WritingConverter 24 | class StringWrapperToStringConverter : Converter { 25 | override fun convert(source: StringWrapper): String = source.value 26 | } 27 | 28 | @ReadingConverter 29 | class StringToStringWrapperConverter : Converter { 30 | override fun convert(source: String): StringWrapper = StringWrapper(source) 31 | } 32 | 33 | @BeforeEach 34 | fun beforeEach() { 35 | mysql.setUpForConverterTest() 36 | } 37 | 38 | @AfterEach 39 | fun afterEach() { 40 | mysql.tearDownForConverterTest() 41 | } 42 | 43 | @Test 44 | fun test() { 45 | kueryClient.sql { 46 | +""" 47 | INSERT INTO converter (text) VALUES 48 | ('text1'), 49 | ('text2'), 50 | ('text3'); 51 | """.trimIndent() 52 | }.rowsUpdated() 53 | 54 | val result = kueryClient.sql { 55 | val inList = listOf(StringWrapper("text1"), StringWrapper("text2")) 56 | +"SELECT * FROM converter WHERE text IN ($inList)" 57 | }.listMap() 58 | assertThat(result).isEqualTo(listOf(mapOf("id" to 1L, "text" to "text1"), mapOf("id" to 2L, "text" to "text2"))) 59 | } 60 | 61 | companion object { 62 | private val mysql = MySqlTestContainer() 63 | 64 | @AfterAll 65 | @JvmStatic 66 | fun afterAll() { 67 | mysql.close() 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/CollectionConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import kotlinx.coroutines.test.runTest 6 | import org.junit.jupiter.api.AfterAll 7 | import org.junit.jupiter.api.AfterEach 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | import org.springframework.core.convert.converter.Converter 11 | import org.springframework.data.convert.ReadingConverter 12 | import org.springframework.data.convert.WritingConverter 13 | 14 | class CollectionConversionTest { 15 | private val kueryClient = mysql.kueryClient( 16 | listOf( 17 | StringWrapperToStringConverter(), 18 | StringToStringWrapperConverter(), 19 | ), 20 | ) 21 | 22 | data class StringWrapper(val value: String) 23 | 24 | @WritingConverter 25 | class StringWrapperToStringConverter : Converter { 26 | override fun convert(source: StringWrapper): String = source.value 27 | } 28 | 29 | @ReadingConverter 30 | class StringToStringWrapperConverter : Converter { 31 | override fun convert(source: String): StringWrapper = StringWrapper(source) 32 | } 33 | 34 | @BeforeEach 35 | fun beforeEach() = runTest { 36 | mysql.setUpForConverterTest() 37 | } 38 | 39 | @AfterEach 40 | fun afterEach() = runTest { 41 | mysql.tearDownForConverterTest() 42 | } 43 | 44 | @Test 45 | fun test() = runTest { 46 | kueryClient.sql { 47 | +""" 48 | INSERT INTO converter (text) VALUES 49 | ('text1'), 50 | ('text2'), 51 | ('text3'); 52 | """.trimIndent() 53 | }.rowsUpdated() 54 | 55 | val result = kueryClient.sql { 56 | val inList = listOf(StringWrapper("text1"), StringWrapper("text2")) 57 | +"SELECT * FROM converter WHERE text IN ($inList)" 58 | }.listMap() 59 | assertThat(result).isEqualTo(listOf(mapOf("id" to 1L, "text" to "text1"), mapOf("id" to 2L, "text" to "text2"))) 60 | } 61 | 62 | companion object { 63 | private val mysql = MySqlTestContainer() 64 | 65 | @AfterAll 66 | @JvmStatic 67 | fun afterAll() { 68 | mysql.close() 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/test/kotlin/dev/hsbrysk/kuery/compiler/KueryClientCompilerTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.compiler 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import com.tschuchort.compiletesting.KotlinCompilation 6 | import com.tschuchort.compiletesting.SourceFile 7 | import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi 8 | import org.junit.jupiter.api.Disabled 9 | import org.junit.jupiter.api.Test 10 | 11 | /** 12 | * Here, we are simply testing whether the compilation succeeds. 13 | * For detailed behavior, we are testing with functional tests. 14 | */ 15 | @OptIn(ExperimentalCompilerApi::class) 16 | class KueryClientCompilerTest { 17 | // TODO: temporary disabled. 18 | // see: https://github.com/tschuchortdev/kotlin-compile-testing/issues 19 | @Disabled 20 | @Test 21 | fun test() { 22 | val source = SourceFile.kotlin( 23 | "Sample.kt", 24 | """ 25 | import dev.hsbrysk.kuery.core.Sql 26 | 27 | fun main() { 28 | val userId = 1 29 | val status = "active" 30 | 31 | Sql.create { 32 | +"SELECT * FROM users WHERE user_id = ${'$'}userId AND status = ${'$'}status" 33 | }.also { 34 | println(it) 35 | } 36 | 37 | Sql.create { 38 | add("SELECT * FROM users WHERE user_id = ${'$'}userId AND status = ${'$'}status") 39 | }.also { 40 | println(it) 41 | } 42 | 43 | Sql.create { 44 | val line2 = "L2=${'$'}{bind(2)}" 45 | val line1 = "L1=${'$'}{bind(1)}" 46 | val line0 = "L0=${'$'}{bind(0)}" 47 | 48 | addUnsafe(line0) 49 | addUnsafe(line1) 50 | addUnsafe(line2) 51 | }.also { 52 | println(it) 53 | } 54 | } 55 | """.trimIndent(), 56 | ) 57 | 58 | val result = compile(source) 59 | assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) 60 | } 61 | 62 | private fun compile(vararg sourceFiles: SourceFile): KotlinCompilation.Result = KotlinCompilation().apply { 63 | sources = sourceFiles.asList() 64 | commandLineProcessors = listOf(KueryClientCompilerCommandLineProcessor()) 65 | compilerPluginRegistrars = listOf(KueryClientCompilerPluginRegistrar()) 66 | inheritClassPath = true 67 | }.compile() 68 | } 69 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/MySqlTestContainer.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import dev.hsbrysk.kuery.core.KueryClient 4 | import io.micrometer.observation.ObservationRegistry 5 | import io.r2dbc.spi.ConnectionFactories 6 | import io.r2dbc.spi.ConnectionFactory 7 | import io.r2dbc.spi.ConnectionFactoryOptions 8 | import org.springframework.data.r2dbc.dialect.DialectResolver 9 | import org.springframework.r2dbc.core.DatabaseClient 10 | import org.springframework.r2dbc.core.awaitRowsUpdated 11 | import org.testcontainers.containers.MySQLContainer 12 | 13 | class MySqlTestContainer : AutoCloseable { 14 | private val mysqlContainer = MySQLContainer("mysql:8.0.37").also { it.start() } 15 | private val connectionFactory = connectionFactory() 16 | val databaseClient: DatabaseClient = DatabaseClient.builder() 17 | .connectionFactory(connectionFactory) 18 | .bindMarkers(DialectResolver.getDialect(connectionFactory).bindMarkersFactory) 19 | .build() 20 | 21 | fun kueryClient( 22 | converters: List = emptyList(), 23 | observationRegistry: ObservationRegistry? = null, 24 | ): KueryClient = SpringR2dbcKueryClient.builder() 25 | .connectionFactory(connectionFactory) 26 | .converters(converters) 27 | .apply { 28 | observationRegistry?.let { observationRegistry(it) } 29 | } 30 | .build() 31 | 32 | suspend fun setUpForConverterTest() { 33 | databaseClient.sql( 34 | """ 35 | CREATE TABLE `converter` 36 | ( 37 | `id` BIGINT AUTO_INCREMENT, 38 | `text` VARCHAR(255) DEFAULT NULL, 39 | PRIMARY KEY (`id`) 40 | ) ENGINE = InnoDB 41 | DEFAULT CHARSET = utf8mb4 42 | COLLATE = utf8mb4_bin; 43 | """.trimIndent(), 44 | ).fetch().awaitRowsUpdated() 45 | } 46 | 47 | suspend fun tearDownForConverterTest() { 48 | databaseClient.sql("DROP TABLE converter").fetch().awaitRowsUpdated() 49 | } 50 | 51 | private fun connectionFactory(): ConnectionFactory { 52 | val url = mysqlContainer.jdbcUrl.replace("jdbc", "r2dbc") 53 | val options = ConnectionFactoryOptions.parse(url).mutate() 54 | .option(ConnectionFactoryOptions.USER, mysqlContainer.username) 55 | .option(ConnectionFactoryOptions.PASSWORD, mysqlContainer.password) 56 | .build() 57 | return ConnectionFactories.get(options) 58 | } 59 | 60 | override fun close() { 61 | mysqlContainer.close() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/KueryBlockingClient.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import dev.hsbrysk.kuery.core.KueryBlockingClient.FetchSpec 4 | import kotlin.reflect.KClass 5 | 6 | interface KueryBlockingClient { 7 | /** 8 | * Returns a [FetchSpec] to obtain the execution results based on the received [SqlBuilder]. 9 | * 10 | * @param sqlId An ID that uniquely identifies the query. It is used for purposes such as metrics. 11 | * @param block [SqlBuilder] for constructing SQL. 12 | */ 13 | fun sql( 14 | sqlId: String, 15 | block: SqlBuilder.() -> Unit, 16 | ): FetchSpec 17 | 18 | /** 19 | * Returns a [FetchSpec] to obtain the execution results based on the received [SqlBuilder]. 20 | * 21 | * @param block [SqlBuilder] for constructing SQL. 22 | */ 23 | fun sql(block: SqlBuilder.() -> Unit): FetchSpec 24 | 25 | interface FetchSpec { 26 | /** 27 | * Receives the results as a map. 28 | */ 29 | fun singleMap(): Map 30 | 31 | /** 32 | * Receives the results as a map. 33 | */ 34 | fun singleMapOrNull(): Map? 35 | 36 | /** 37 | * Receives the results converted to the specified type. 38 | */ 39 | fun single(returnType: KClass): T 40 | 41 | /** 42 | * Receives the results converted to the specified type. 43 | */ 44 | fun singleOrNull(returnType: KClass): T? 45 | 46 | /** 47 | * Receives the results of multiple rows as a map. 48 | */ 49 | fun listMap(): List> 50 | 51 | /** 52 | * Receives the results of multiple rows converted to the specified type. 53 | */ 54 | fun list(returnType: KClass): List 55 | 56 | /** 57 | * Contract for fetching the number of affected rows 58 | */ 59 | fun rowsUpdated(): Long 60 | 61 | /** 62 | * Receives the values generated on the database side. 63 | * For example, an auto increment value. 64 | */ 65 | fun generatedValues(vararg columns: String): Map 66 | } 67 | } 68 | 69 | /** 70 | * Receives the results converted to the specified type. 71 | */ 72 | inline fun FetchSpec.single(): T = single(T::class) 73 | 74 | /** 75 | * Receives the results converted to the specified type. 76 | */ 77 | inline fun FetchSpec.singleOrNull(): T? = singleOrNull(T::class) 78 | 79 | /** 80 | * Receives the results of multiple rows converted to the specified type. 81 | */ 82 | inline fun FetchSpec.list(): List = list(T::class) 83 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | detekt = "1.23.8" 3 | kotlin-core = "2.2.21" 4 | kotlin-coroutines = "1.10.2" 5 | ktlint = "1.8.0" 6 | micrometer = "1.16.1" 7 | spring-boot = "3.5.9" 8 | spring-data = "3.5.7" 9 | 10 | [libraries] 11 | assertk = { module = "com.willowtreeapps.assertk:assertk-jvm", version = "0.28.1" } 12 | detekt-api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } 13 | detekt-test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } 14 | junit-bom = { module = "org.junit:junit-bom", version = "6.0.1" } 15 | kotlin-compile-testing = { module = "com.github.tschuchortdev:kotlin-compile-testing", version = "1.6.0" } 16 | kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" } 17 | kotlin-coroutines-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor", version.ref = "kotlin-coroutines" } 18 | kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" } 19 | ktlint-rule-engine-core = { module = "com.pinterest.ktlint:ktlint-rule-engine-core", version.ref = "ktlint" } 20 | micrometer-core = { module = "io.micrometer:micrometer-core", version.ref = "micrometer" } 21 | micrometer-observation-test = { module = "io.micrometer:micrometer-observation-test", version.ref = "micrometer" } 22 | mockk-core = { module = "io.mockk:mockk", version = "1.14.7" } 23 | spring-boot-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" } 24 | spring-data-jdbc = { module = "org.springframework.data:spring-data-jdbc", version.ref = "spring-data" } 25 | spring-data-r2dbc = { module = "org.springframework.data:spring-data-r2dbc", version.ref = "spring-data" } 26 | 27 | # gradle plugins for build-logic 28 | gradle-plugin-detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } 29 | gradle-plugin-dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "2.1.0" } 30 | gradle-plugin-jmh = { module = "me.champeau.jmh:jmh-gradle-plugin", version = "0.7.3" } 31 | gradle-plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin-core" } 32 | gradle-plugin-ktlint = { module = "org.jlleitschuh.gradle:ktlint-gradle", version = "14.0.1" } 33 | gradle-plugin-maven-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.35.0" } 34 | 35 | [plugins] 36 | buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.6" } 37 | kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin-core" } 38 | plugin-publish = { id = "com.gradle.plugin-publish", version = "2.0.0" } 39 | spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } 40 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/SingleBasicTypeTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import assertk.assertFailure 4 | import assertk.assertThat 5 | import assertk.assertions.isEqualTo 6 | import assertk.assertions.isInstanceOf 7 | import org.junit.jupiter.api.AfterAll 8 | import org.junit.jupiter.api.Test 9 | import org.junit.jupiter.params.ParameterizedTest 10 | import org.junit.jupiter.params.provider.Arguments 11 | import org.junit.jupiter.params.provider.MethodSource 12 | import org.springframework.core.convert.converter.Converter 13 | import org.springframework.data.convert.ReadingConverter 14 | import org.springframework.jdbc.BadSqlGrammarException 15 | import java.net.URI 16 | import kotlin.reflect.KClass 17 | 18 | class SingleBasicTypeTest { 19 | private val kueryClient = mysql.kueryClient( 20 | listOf( 21 | StringToStringWrapperConverter(), 22 | ), 23 | ) 24 | 25 | data class StringWrapper(val value: String) 26 | 27 | @ReadingConverter 28 | class StringToStringWrapperConverter : Converter { 29 | override fun convert(source: String): StringWrapper = StringWrapper(source) 30 | } 31 | 32 | @ParameterizedTest 33 | @MethodSource("singleValues") 34 | fun testSingleValues( 35 | query: String, 36 | expected: Any, 37 | type: KClass<*>, 38 | ) { 39 | val result = kueryClient.sql { 40 | +query 41 | }.single(type) 42 | 43 | assertThat(result).isEqualTo(expected) 44 | } 45 | 46 | @Test 47 | fun unSupportNotSimpleProperty() { 48 | assertFailure { 49 | kueryClient.sql { 50 | +"SELECT 'hoge'" 51 | }.single(StringWrapper::class) 52 | }.isInstanceOf(BadSqlGrammarException::class) 53 | } 54 | 55 | @Test 56 | fun testSingleColumnWithMultiValue() { 57 | val result = kueryClient.sql { 58 | +"SELECT 1 UNION SELECT 0" 59 | }.list(Int::class) 60 | 61 | assertThat(result).isEqualTo(listOf(1, 0)) 62 | } 63 | 64 | companion object { 65 | private val mysql = MySqlTestContainer() 66 | 67 | @AfterAll 68 | @JvmStatic 69 | fun afterAll() { 70 | mysql.close() 71 | } 72 | 73 | @JvmStatic 74 | fun singleValues(): List = listOf( 75 | Arguments.of("SELECT 1", 1.toShort(), Short::class), 76 | Arguments.of("SELECT 1", 1, Int::class), 77 | Arguments.of("SELECT 1", 1L, Long::class), 78 | Arguments.of("SELECT '1'", 1.toShort(), Short::class), 79 | Arguments.of("SELECT '1'", 1, Int::class), 80 | Arguments.of("SELECT '1'", 1L, Long::class), 81 | Arguments.of("SELECT 'hoge'", "hoge", String::class), 82 | Arguments.of("SELECT 'https://example.com'", URI("https://example.com"), URI::class), 83 | Arguments.of("SELECT 1", true, Boolean::class), 84 | Arguments.of("SELECT 0", false, Boolean::class), 85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/internal/DefaultSqlBuilder.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.internal 2 | 3 | import dev.hsbrysk.kuery.core.DelicateKueryClientApi 4 | import dev.hsbrysk.kuery.core.NamedSqlParameter 5 | import dev.hsbrysk.kuery.core.Sql 6 | import dev.hsbrysk.kuery.core.SqlBuilder 7 | 8 | internal class DefaultSqlBuilder : SqlBuilder { 9 | private val body = StringBuilder() 10 | private val parameters = mutableListOf() 11 | 12 | override fun add(sql: String): Unit = injectByPlugin() 13 | 14 | override fun String.unaryPlus(): Unit = injectByPlugin() 15 | 16 | // Only the developers of the kuery client will use the DefaultSqlBuilder, so we’ll make it opt-in here. 17 | @OptIn(DelicateKueryClientApi::class) 18 | override fun addUnsafe(sql: String) { 19 | body.appendLine(sql) 20 | } 21 | 22 | // Only the developers of the kuery client will use the DefaultSqlBuilder, so we’ll make it opt-in here. 23 | @OptIn(DelicateKueryClientApi::class) 24 | override fun bind(parameter: Any?): String { 25 | val currentIndex = parameters.size 26 | parameters.add(DefaultNamedSqlParameter(PARAMETER_NAME_PREFIX + currentIndex, parameter)) 27 | return PARAMETER_NAME_PREFIX_WITH_COLON + currentIndex 28 | } 29 | 30 | /** 31 | * @param fragments It refers to string parts. 32 | * @param values It refers to the values of string interpolation. 33 | * e.g. 34 | * ``` 35 | * """a${1}b""" -> fragments=["a", "b"], values=[1] 36 | * """${1}a""" -> fragments=["", "a"], values=[1] 37 | * """a${1}""" -> fragments=["a"], values=[1] 38 | * """a${1}${2}${3}""" -> fragments=["a", "", ""], values=[1, 2, 3] 39 | * ``` 40 | */ 41 | @Suppress("unused") // used by compiler plugin 42 | fun interpolate( 43 | fragments: List, 44 | values: List, 45 | ): String { 46 | val fragmentsSize = fragments.size 47 | val valuesSize = values.size 48 | // `StringConcatenationProcessor` should adhere to this assumption, but I'll double-check just to be sure. 49 | check(fragmentsSize == valuesSize || fragmentsSize == valuesSize + 1) { 50 | "The number of elements in `fragments`($fragmentsSize) and `values`($valuesSize) is incorrect. " + 51 | "There might be an issue with the Kotlin compiler plugin." 52 | } 53 | return buildString { 54 | fragments.forEachIndexed { index, fragment -> 55 | append(fragment) 56 | if (index < valuesSize) { 57 | append(bind(values[index])) 58 | } 59 | } 60 | } 61 | } 62 | 63 | fun build(): Sql = DefaultSql(body.toString().trim(), parameters) 64 | 65 | companion object { 66 | internal const val PARAMETER_NAME_PREFIX = "p" 67 | internal const val PARAMETER_NAME_PREFIX_WITH_COLON = ":$PARAMETER_NAME_PREFIX" 68 | 69 | fun injectByPlugin(): T = 70 | error("kuery-client-compiler plugin is not loaded or you are using an unsupported usage.") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/SingleBasicTypeTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import assertk.assertFailure 4 | import assertk.assertThat 5 | import assertk.assertions.isEqualTo 6 | import assertk.assertions.isInstanceOf 7 | import kotlinx.coroutines.test.runTest 8 | import org.junit.jupiter.api.AfterAll 9 | import org.junit.jupiter.api.Test 10 | import org.junit.jupiter.params.ParameterizedTest 11 | import org.junit.jupiter.params.provider.Arguments 12 | import org.junit.jupiter.params.provider.MethodSource 13 | import org.springframework.core.convert.converter.Converter 14 | import org.springframework.dao.DataRetrievalFailureException 15 | import org.springframework.data.convert.ReadingConverter 16 | import java.net.URI 17 | import kotlin.reflect.KClass 18 | 19 | class SingleBasicTypeTest { 20 | private val kueryClient = mysql.kueryClient( 21 | listOf( 22 | StringToStringWrapperConverter(), 23 | ), 24 | ) 25 | 26 | data class StringWrapper(val value: String) 27 | 28 | @ReadingConverter 29 | class StringToStringWrapperConverter : Converter { 30 | override fun convert(source: String): StringWrapper = StringWrapper(source) 31 | } 32 | 33 | @ParameterizedTest 34 | @MethodSource("singleValues") 35 | fun testSingleValues( 36 | query: String, 37 | expected: Any, 38 | type: KClass<*>, 39 | ) = runTest { 40 | val result = kueryClient.sql { 41 | +query 42 | }.single(type) 43 | 44 | assertThat(result).isEqualTo(expected) 45 | } 46 | 47 | @Test 48 | fun unSupportNotSimpleProperty() = runTest { 49 | assertFailure { 50 | kueryClient.sql { 51 | +"SELECT 'hoge'" 52 | }.single(StringWrapper::class) 53 | }.isInstanceOf(DataRetrievalFailureException::class) 54 | } 55 | 56 | @Test 57 | fun testSingleColumnWithMultiValue() = runTest { 58 | val result = kueryClient.sql { 59 | +"SELECT 1 UNION SELECT 0" 60 | }.list(Int::class) 61 | 62 | assertThat(result).isEqualTo(listOf(1, 0)) 63 | } 64 | 65 | companion object { 66 | private val mysql = MySqlTestContainer() 67 | 68 | @AfterAll 69 | @JvmStatic 70 | fun afterAll() { 71 | mysql.close() 72 | } 73 | 74 | @JvmStatic 75 | fun singleValues(): List = listOf( 76 | Arguments.of("SELECT 1", 1.toShort(), Short::class), 77 | Arguments.of("SELECT 1", 1, Int::class), 78 | Arguments.of("SELECT 1", 1L, Long::class), 79 | Arguments.of("SELECT '1'", 1.toShort(), Short::class), 80 | Arguments.of("SELECT '1'", 1, Int::class), 81 | Arguments.of("SELECT '1'", 1L, Long::class), 82 | Arguments.of("SELECT 'hoge'", "hoge", String::class), 83 | Arguments.of("SELECT 'https://example.com'", URI("https://example.com"), URI::class), 84 | // Unlike JDBC, this test case does not pass. 85 | // Arguments.of("SELECT 1", true, Boolean::class), 86 | // Arguments.of("SELECT 0", false, Boolean::class), 87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | 74 | 75 | @rem Execute Gradle 76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 | 78 | :end 79 | @rem End local scope for the variables with windows NT shell 80 | if %ERRORLEVEL% equ 0 goto mainEnd 81 | 82 | :fail 83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 | rem the _cmd.exe /c_ return code! 85 | set EXIT_CODE=%ERRORLEVEL% 86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 | exit /b %EXIT_CODE% 89 | 90 | :mainEnd 91 | if "%OS%"=="Windows_NT" endlocal 92 | 93 | :omega 94 | -------------------------------------------------------------------------------- /kuery-client-core/src/main/kotlin/dev/hsbrysk/kuery/core/KueryClient.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import dev.hsbrysk.kuery.core.KueryClient.FetchSpec 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlin.reflect.KClass 6 | 7 | interface KueryClient { 8 | /** 9 | * Returns a [FetchSpec] to obtain the execution results based on the received [SqlBuilder]. 10 | * 11 | * @param sqlId An ID that uniquely identifies the query. It is used for purposes such as metrics. 12 | * @param block [SqlBuilder] for constructing SQL. 13 | */ 14 | fun sql( 15 | sqlId: String, 16 | block: SqlBuilder.() -> Unit, 17 | ): FetchSpec 18 | 19 | /** 20 | * Returns a [FetchSpec] to obtain the execution results based on the received [SqlBuilder]. 21 | * 22 | * @param block [SqlBuilder] for constructing SQL. 23 | */ 24 | fun sql(block: SqlBuilder.() -> Unit): FetchSpec 25 | 26 | interface FetchSpec { 27 | /** 28 | * Receives the results as a map. 29 | */ 30 | suspend fun singleMap(): Map 31 | 32 | /** 33 | * Receives the results as a map. 34 | */ 35 | suspend fun singleMapOrNull(): Map? 36 | 37 | /** 38 | * Receives the results converted to the specified type. 39 | */ 40 | suspend fun single(returnType: KClass): T 41 | 42 | /** 43 | * Receives the results converted to the specified type. 44 | */ 45 | suspend fun singleOrNull(returnType: KClass): T? 46 | 47 | /** 48 | * Receives the results of multiple rows as a map. 49 | */ 50 | suspend fun listMap(): List> 51 | 52 | /** 53 | * Receives the results of multiple rows converted to the specified type. 54 | */ 55 | suspend fun list(returnType: KClass): List 56 | 57 | /** 58 | * Receives the results of multiple rows as a map. 59 | */ 60 | fun flowMap(): Flow> 61 | 62 | /** 63 | * Receives the results of multiple rows converted to the specified type. 64 | */ 65 | fun flow(returnType: KClass): Flow 66 | 67 | /** 68 | * Contract for fetching the number of affected rows 69 | */ 70 | suspend fun rowsUpdated(): Long 71 | 72 | /** 73 | * Receives the values generated on the database side. 74 | * For example, an auto increment value. 75 | */ 76 | suspend fun generatedValues(vararg columns: String): Map 77 | } 78 | } 79 | 80 | /** 81 | * Receives the results converted to the specified type. 82 | */ 83 | suspend inline fun FetchSpec.single(): T = single(T::class) 84 | 85 | /** 86 | * Receives the results converted to the specified type. 87 | */ 88 | suspend inline fun FetchSpec.singleOrNull(): T? = singleOrNull(T::class) 89 | 90 | /** 91 | * Receives the results of multiple rows converted to the specified type. 92 | */ 93 | suspend inline fun FetchSpec.list(): List = list(T::class) 94 | 95 | /** 96 | * Receives the results of multiple rows converted to the specified type. 97 | */ 98 | inline fun FetchSpec.flow(): Flow = flow(T::class) 99 | -------------------------------------------------------------------------------- /kuery-client-core/src/test/kotlin/dev/hsbrysk/kuery/core/internal/DefaultSqlBuilderTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core.internal 2 | 3 | import assertk.assertFailure 4 | import assertk.assertThat 5 | import assertk.assertions.isEqualTo 6 | import assertk.assertions.isInstanceOf 7 | import dev.hsbrysk.kuery.core.NamedSqlParameter 8 | import dev.hsbrysk.kuery.core.Sql 9 | import org.junit.jupiter.api.Test 10 | 11 | class DefaultSqlBuilderTest { 12 | @Test 13 | fun add() { 14 | assertFailure { 15 | DefaultSqlBuilder().add("") 16 | }.isInstanceOf(IllegalStateException::class) 17 | } 18 | 19 | @Test 20 | fun unaryPlus() { 21 | assertFailure { 22 | with(DefaultSqlBuilder()) { 23 | +"" 24 | } 25 | }.isInstanceOf(IllegalStateException::class) 26 | } 27 | 28 | @Test 29 | fun addUnsafe() { 30 | DefaultSqlBuilder() 31 | .apply { 32 | addUnsafe("") 33 | } 34 | .build() 35 | .let { 36 | assertThat(it).isEqualTo(Sql("")) 37 | } 38 | DefaultSqlBuilder() 39 | .apply { 40 | addUnsafe("") 41 | addUnsafe("") 42 | } 43 | .build() 44 | .let { 45 | assertThat(it).isEqualTo(Sql("")) 46 | } 47 | DefaultSqlBuilder() 48 | .apply { 49 | addUnsafe("hoge") 50 | } 51 | .build() 52 | .let { 53 | assertThat(it).isEqualTo(Sql("hoge")) 54 | } 55 | DefaultSqlBuilder() 56 | .apply { 57 | addUnsafe("hoge") 58 | addUnsafe("bar") 59 | } 60 | .build() 61 | .let { 62 | assertThat(it).isEqualTo(Sql("hoge\nbar")) 63 | } 64 | } 65 | 66 | @Test 67 | fun bind() { 68 | DefaultSqlBuilder() 69 | .apply { 70 | assertThat(bind(1)).isEqualTo(":p0") 71 | } 72 | .build() 73 | .let { 74 | assertThat(it).isEqualTo(Sql("", listOf(NamedSqlParameter("p0", 1)))) 75 | } 76 | DefaultSqlBuilder() 77 | .apply { 78 | assertThat(bind(1)).isEqualTo(":p0") 79 | assertThat(bind(2)).isEqualTo(":p1") 80 | } 81 | .build() 82 | .let { 83 | assertThat(it).isEqualTo(Sql("", listOf(NamedSqlParameter("p0", 1), NamedSqlParameter("p1", 2)))) 84 | } 85 | } 86 | 87 | @Test 88 | fun interpolate() { 89 | assertThat(DefaultSqlBuilder().interpolate(emptyList(), emptyList())).isEqualTo("") 90 | assertThat(DefaultSqlBuilder().interpolate(listOf("a"), emptyList())).isEqualTo("a") 91 | assertThat(DefaultSqlBuilder().interpolate(listOf("a"), listOf(1))).isEqualTo("a:p0") 92 | assertThat(DefaultSqlBuilder().interpolate(listOf("a", "b"), listOf(1))).isEqualTo("a:p0b") 93 | assertFailure { 94 | DefaultSqlBuilder().interpolate(listOf("a", "b"), emptyList()) 95 | }.isInstanceOf(IllegalStateException::class) 96 | assertFailure { 97 | DefaultSqlBuilder().interpolate(listOf("a"), listOf(1, 2)) 98 | }.isInstanceOf(IllegalStateException::class) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/ValuesHelperTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.list 6 | import dev.hsbrysk.kuery.core.values 7 | import org.junit.jupiter.api.AfterAll 8 | import org.junit.jupiter.api.AfterEach 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | 12 | class ValuesHelperTest { 13 | private val kueryClient = mysql.kueryClient() 14 | 15 | @BeforeEach 16 | fun setUp() { 17 | mysql.jdbcClient.sql( 18 | """ 19 | CREATE TABLE users ( 20 | user_id INT AUTO_INCREMENT PRIMARY KEY, 21 | username VARCHAR(50) NOT NULL, 22 | email VARCHAR(100), 23 | age INT NOT NULL 24 | ) 25 | """.trimIndent(), 26 | ).update() 27 | } 28 | 29 | @AfterEach 30 | fun testDown() { 31 | mysql.jdbcClient.sql( 32 | """ 33 | DROP TABLE users 34 | """.trimIndent(), 35 | ).update() 36 | } 37 | 38 | data class User( 39 | val userId: Int, 40 | val username: String, 41 | val email: String?, 42 | val age: Int, 43 | ) 44 | 45 | @Test 46 | fun test() { 47 | val input = listOf( 48 | listOf("user1", "user1@example.com", 1), 49 | listOf("user2", null, 2), 50 | listOf("user3", "user3@example.com", 3), 51 | ) 52 | 53 | val rowsUpdated = kueryClient.sql { 54 | +"INSERT INTO users (username, email, age)" 55 | values(input) 56 | }.rowsUpdated() 57 | assertThat(rowsUpdated).isEqualTo(3) 58 | 59 | val users: List = kueryClient.sql { 60 | +"SELECT * FROM users" 61 | }.list() 62 | assertThat(users).isEqualTo( 63 | listOf( 64 | User(1, "user1", "user1@example.com", 1), 65 | User(2, "user2", null, 2), 66 | User(3, "user3", "user3@example.com", 3), 67 | ), 68 | ) 69 | } 70 | 71 | @Test 72 | fun `test with transformer`() { 73 | data class UserParam( 74 | val username: String, 75 | val email: String?, 76 | val age: Int, 77 | ) 78 | 79 | val input = listOf( 80 | UserParam("user1", "user1@example.com", 1), 81 | UserParam("user2", null, 2), 82 | UserParam("user3", "user3@example.com", 3), 83 | ) 84 | 85 | val rowsUpdated = kueryClient.sql { 86 | +"INSERT INTO users (username, email, age)" 87 | values(input) { listOf(it.username, it.email, it.age) } 88 | }.rowsUpdated() 89 | assertThat(rowsUpdated).isEqualTo(3) 90 | 91 | val users: List = kueryClient.sql { 92 | +"SELECT * FROM users" 93 | }.list() 94 | assertThat(users).isEqualTo( 95 | listOf( 96 | User(1, "user1", "user1@example.com", 1), 97 | User(2, "user2", null, 2), 98 | User(3, "user3", "user3@example.com", 3), 99 | ), 100 | ) 101 | } 102 | 103 | companion object { 104 | private val mysql = MySqlTestContainer() 105 | 106 | @AfterAll 107 | @JvmStatic 108 | fun afterAll() { 109 | mysql.close() 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/main/kotlin/dev/hsbrysk/kuery/spring/r2dbc/internal/DefaultSpringR2dbcKueryClientBuilder.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc.internal 2 | 3 | import dev.hsbrysk.kuery.core.KueryClient 4 | import dev.hsbrysk.kuery.core.observation.KueryClientFetchObservationConvention 5 | import dev.hsbrysk.kuery.spring.r2dbc.SpringR2dbcKueryClientBuilder 6 | import io.micrometer.observation.ObservationRegistry 7 | import io.r2dbc.spi.ConnectionFactory 8 | import org.springframework.core.convert.support.DefaultConversionService 9 | import org.springframework.data.r2dbc.convert.R2dbcCustomConversions 10 | import org.springframework.data.r2dbc.dialect.DialectResolver 11 | import org.springframework.r2dbc.core.DatabaseClient 12 | 13 | internal class DefaultSpringR2dbcKueryClientBuilder : SpringR2dbcKueryClientBuilder { 14 | private var connectionFactory: ConnectionFactory? = null 15 | private var converters: List = emptyList() 16 | private var observationRegistry: ObservationRegistry? = null 17 | private var observationConvention: KueryClientFetchObservationConvention? = null 18 | private var enableAutoSqlIdGeneration: Boolean? = null 19 | 20 | override fun connectionFactory(connectionFactory: ConnectionFactory): SpringR2dbcKueryClientBuilder { 21 | this.connectionFactory = connectionFactory 22 | return this 23 | } 24 | 25 | override fun converters(converters: List): SpringR2dbcKueryClientBuilder { 26 | this.converters = converters 27 | return this 28 | } 29 | 30 | override fun observationRegistry(observationRegistry: ObservationRegistry): SpringR2dbcKueryClientBuilder { 31 | this.observationRegistry = observationRegistry 32 | return this 33 | } 34 | 35 | override fun observationConvention( 36 | observationConvention: KueryClientFetchObservationConvention, 37 | ): SpringR2dbcKueryClientBuilder { 38 | this.observationConvention = observationConvention 39 | return this 40 | } 41 | 42 | override fun enableAutoSqlIdGeneration(enableAutoSqlIdGeneration: Boolean): SpringR2dbcKueryClientBuilder { 43 | this.enableAutoSqlIdGeneration = enableAutoSqlIdGeneration 44 | return this 45 | } 46 | 47 | override fun build(): KueryClient { 48 | val connectionFactory = requireNotNull(this.connectionFactory) { 49 | "Specify connectionFactory." 50 | } 51 | 52 | val databaseClient = databaseClient(connectionFactory) 53 | val conversionService = DefaultConversionService() 54 | val customConversions = r2dbcCustomConversions(connectionFactory).apply { 55 | registerConvertersIn(conversionService) 56 | } 57 | val enableAutoSqlIdGeneration = enableAutoSqlIdGeneration ?: (observationRegistry != null) 58 | 59 | return DefaultSpringR2dbcKueryClient( 60 | databaseClient, 61 | conversionService, 62 | customConversions, 63 | observationRegistry, 64 | observationConvention, 65 | enableAutoSqlIdGeneration, 66 | ) 67 | } 68 | 69 | private fun databaseClient(connectionFactory: ConnectionFactory): DatabaseClient = DatabaseClient.builder() 70 | .connectionFactory(connectionFactory) 71 | .bindMarkers(DialectResolver.getDialect(connectionFactory).bindMarkersFactory) 72 | .build() 73 | 74 | private fun r2dbcCustomConversions(connectionFactory: ConnectionFactory): R2dbcCustomConversions = 75 | R2dbcCustomConversions.of(DialectResolver.getDialect(connectionFactory), converters) 76 | } 77 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/ValuesHelperTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.list 6 | import dev.hsbrysk.kuery.core.values 7 | import kotlinx.coroutines.test.runTest 8 | import org.junit.jupiter.api.AfterAll 9 | import org.junit.jupiter.api.AfterEach 10 | import org.junit.jupiter.api.BeforeEach 11 | import org.junit.jupiter.api.Test 12 | import org.springframework.r2dbc.core.awaitRowsUpdated 13 | 14 | class ValuesHelperTest { 15 | private val kueryClient = mysql.kueryClient() 16 | 17 | @BeforeEach 18 | fun setUp() = runTest { 19 | mysql.databaseClient.sql( 20 | """ 21 | CREATE TABLE users ( 22 | user_id INT AUTO_INCREMENT PRIMARY KEY, 23 | username VARCHAR(50) NOT NULL, 24 | email VARCHAR(100), 25 | age INT NOT NULL 26 | ) 27 | """.trimIndent(), 28 | ).fetch().awaitRowsUpdated() 29 | } 30 | 31 | @AfterEach 32 | fun testDown() = runTest { 33 | mysql.databaseClient.sql( 34 | """ 35 | DROP TABLE users 36 | """.trimIndent(), 37 | ).fetch().awaitRowsUpdated() 38 | } 39 | 40 | data class User( 41 | val userId: Int, 42 | val username: String, 43 | val email: String?, 44 | val age: Int, 45 | ) 46 | 47 | @Test 48 | fun test() = runTest { 49 | val input = listOf( 50 | listOf("user1", "user1@example.com", 1), 51 | listOf("user2", null, 2), 52 | listOf("user3", "user3@example.com", 3), 53 | ) 54 | 55 | val rowsUpdated = kueryClient.sql { 56 | +"INSERT INTO users (username, email, age)" 57 | values(input) 58 | }.rowsUpdated() 59 | assertThat(rowsUpdated).isEqualTo(3) 60 | 61 | val users: List = kueryClient.sql { 62 | +"SELECT * FROM users" 63 | }.list() 64 | assertThat(users).isEqualTo( 65 | listOf( 66 | User(1, "user1", "user1@example.com", 1), 67 | User(2, "user2", null, 2), 68 | User(3, "user3", "user3@example.com", 3), 69 | ), 70 | ) 71 | } 72 | 73 | @Test 74 | fun `test with transformer`() = runTest { 75 | data class UserParam( 76 | val username: String, 77 | val email: String?, 78 | val age: Int, 79 | ) 80 | 81 | val input = listOf( 82 | UserParam("user1", "user1@example.com", 1), 83 | UserParam("user2", null, 2), 84 | UserParam("user3", "user3@example.com", 3), 85 | ) 86 | 87 | val rowsUpdated = kueryClient.sql { 88 | +"INSERT INTO users (username, email, age)" 89 | values(input) { listOf(it.username, it.email, it.age) } 90 | }.rowsUpdated() 91 | assertThat(rowsUpdated).isEqualTo(3) 92 | 93 | val users: List = kueryClient.sql { 94 | +"SELECT * FROM users" 95 | }.list() 96 | assertThat(users).isEqualTo( 97 | listOf( 98 | User(1, "user1", "user1@example.com", 1), 99 | User(2, "user2", null, 2), 100 | User(3, "user3", "user3@example.com", 3), 101 | ), 102 | ) 103 | } 104 | 105 | companion object { 106 | private val mysql = MySqlTestContainer() 107 | 108 | @AfterAll 109 | @JvmStatic 110 | fun afterAll() { 111 | mysql.close() 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /kuery-client-detekt/src/main/kotlin/dev/hsbrysk/kuery/detekt/rules/UseStringLiteralRule.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.detekt.rules 2 | 3 | import dev.hsbrysk.kuery.detekt.getLastReceiverExpression 4 | import dev.hsbrysk.kuery.detekt.isSqlBuilderAddExpression 5 | import dev.hsbrysk.kuery.detekt.isSqlBuilderUnaryExpression 6 | import io.gitlab.arturbosch.detekt.api.CodeSmell 7 | import io.gitlab.arturbosch.detekt.api.Config 8 | import io.gitlab.arturbosch.detekt.api.Debt 9 | import io.gitlab.arturbosch.detekt.api.Entity 10 | import io.gitlab.arturbosch.detekt.api.Issue 11 | import io.gitlab.arturbosch.detekt.api.Rule 12 | import io.gitlab.arturbosch.detekt.api.Severity 13 | import org.jetbrains.kotlin.psi.KtCallExpression 14 | import org.jetbrains.kotlin.psi.KtDotQualifiedExpression 15 | import org.jetbrains.kotlin.psi.KtExpression 16 | import org.jetbrains.kotlin.psi.KtStringTemplateExpression 17 | import org.jetbrains.kotlin.psi.KtUnaryExpression 18 | 19 | @Suppress("NestedBlockDepth") 20 | class UseStringLiteralRule(config: Config) : Rule(config) { 21 | override val issue = Issue( 22 | id = "UseStringLiteral", 23 | severity = Severity.Warning, 24 | description = "To keep it concise, use String Literal.", 25 | debt = Debt.FIVE_MINS, 26 | ) 27 | 28 | private val allowRegexes = buildList { 29 | addAll(valueOrNull>("allowRegexes")?.map { it.toRegex() }.orEmpty()) 30 | } 31 | 32 | override fun visitCallExpression(expression: KtCallExpression) { 33 | super.visitCallExpression(expression) 34 | 35 | if (isSqlBuilderAddExpression(expression, bindingContext)) { 36 | val argExpression = expression.valueArguments.first().getArgumentExpression() 37 | if (!isValidExpression(argExpression)) { 38 | if (!allowByRegexes(argExpression)) { 39 | report( 40 | CodeSmell( 41 | issue = issue, 42 | entity = Entity.from(expression), 43 | message = """ 44 | To keep it concise, use String Literal. 45 | """.trimIndent(), 46 | ), 47 | ) 48 | } 49 | } 50 | } 51 | } 52 | 53 | override fun visitUnaryExpression(expression: KtUnaryExpression) { 54 | super.visitUnaryExpression(expression) 55 | 56 | if (isSqlBuilderUnaryExpression(expression, bindingContext)) { 57 | val baseExpression = expression.baseExpression 58 | if (!isValidExpression(baseExpression)) { 59 | if (!allowByRegexes(baseExpression)) { 60 | report( 61 | CodeSmell( 62 | issue = issue, 63 | entity = Entity.from(expression), 64 | message = """ 65 | To keep it concise, use String Literal. 66 | """.trimIndent(), 67 | ), 68 | ) 69 | } 70 | } 71 | } 72 | } 73 | 74 | private fun isValidExpression(expression: KtExpression?): Boolean = if (expression is KtStringTemplateExpression) { 75 | true 76 | } else if ( 77 | expression is KtDotQualifiedExpression && 78 | getLastReceiverExpression(expression) is KtStringTemplateExpression 79 | ) { 80 | true 81 | } else { 82 | false 83 | } 84 | 85 | private fun allowByRegexes(expression: KtExpression?): Boolean { 86 | expression ?: return false 87 | if (allowRegexes.isEmpty()) { 88 | return false 89 | } 90 | return allowRegexes.any { expression.text.contains(it) } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/main/kotlin/dev/hsbrysk/kuery/spring/jdbc/internal/DefaultSpringJdbcKueryClientBuilder.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc.internal 2 | 3 | import dev.hsbrysk.kuery.core.KueryBlockingClient 4 | import dev.hsbrysk.kuery.core.observation.KueryClientFetchObservationConvention 5 | import dev.hsbrysk.kuery.spring.jdbc.SpringJdbcKueryClientBuilder 6 | import io.micrometer.observation.ObservationRegistry 7 | import org.springframework.core.convert.support.DefaultConversionService 8 | import org.springframework.data.convert.CustomConversions 9 | import org.springframework.data.jdbc.core.convert.JdbcCustomConversions 10 | import org.springframework.data.jdbc.core.dialect.DialectResolver 11 | import org.springframework.data.jdbc.core.mapping.JdbcSimpleTypes 12 | import org.springframework.data.mapping.model.SimpleTypeHolder 13 | import org.springframework.data.relational.core.dialect.Dialect 14 | import org.springframework.jdbc.core.JdbcTemplate 15 | import org.springframework.jdbc.core.simple.JdbcClient 16 | import javax.sql.DataSource 17 | 18 | internal class DefaultSpringJdbcKueryClientBuilder : SpringJdbcKueryClientBuilder { 19 | private var dataSource: DataSource? = null 20 | private var converters: List = emptyList() 21 | private var observationRegistry: ObservationRegistry? = null 22 | private var observationConvention: KueryClientFetchObservationConvention? = null 23 | private var enableAutoSqlIdGeneration: Boolean? = null 24 | 25 | override fun dataSource(dataSource: DataSource): SpringJdbcKueryClientBuilder { 26 | this.dataSource = dataSource 27 | return this 28 | } 29 | 30 | override fun converters(converters: List): SpringJdbcKueryClientBuilder { 31 | this.converters = converters 32 | return this 33 | } 34 | 35 | override fun observationRegistry(observationRegistry: ObservationRegistry): SpringJdbcKueryClientBuilder { 36 | this.observationRegistry = observationRegistry 37 | return this 38 | } 39 | 40 | override fun observationConvention( 41 | observationConvention: KueryClientFetchObservationConvention, 42 | ): SpringJdbcKueryClientBuilder { 43 | this.observationConvention = observationConvention 44 | return this 45 | } 46 | 47 | override fun enableAutoSqlIdGeneration(enableAutoSqlIdGeneration: Boolean): SpringJdbcKueryClientBuilder { 48 | this.enableAutoSqlIdGeneration = enableAutoSqlIdGeneration 49 | return this 50 | } 51 | 52 | override fun build(): KueryBlockingClient { 53 | val dataSource = requireNotNull(this.dataSource) { 54 | "Specify dataSource." 55 | } 56 | 57 | val jdbcClient = jdbcClient(dataSource) 58 | val conversionService = DefaultConversionService() 59 | val customConversions = jdbcCustomConversions(dataSource).apply { 60 | registerConvertersIn(conversionService) 61 | } 62 | val enableAutoSqlIdGeneration = enableAutoSqlIdGeneration ?: (observationRegistry != null) 63 | 64 | return DefaultSpringJdbcKueryClient( 65 | jdbcClient, 66 | conversionService, 67 | customConversions, 68 | observationRegistry, 69 | observationConvention, 70 | enableAutoSqlIdGeneration, 71 | ) 72 | } 73 | 74 | private fun jdbcClient(dataSource: DataSource): JdbcClient = JdbcClient.create(dataSource) 75 | 76 | private fun jdbcCustomConversions(dataSource: DataSource): JdbcCustomConversions { 77 | val dialect = dialect(dataSource) 78 | val simpleTypeHolder = if (dialect.simpleTypes().isEmpty()) { 79 | JdbcSimpleTypes.HOLDER 80 | } else { 81 | SimpleTypeHolder(dialect.simpleTypes(), JdbcSimpleTypes.HOLDER) 82 | } 83 | 84 | return JdbcCustomConversions( 85 | CustomConversions.StoreConversions.of(simpleTypeHolder, storeConverters(dialect)), 86 | converters, 87 | ) 88 | } 89 | 90 | private fun dialect(dataSource: DataSource): Dialect = DialectResolver.getDialect(JdbcTemplate(dataSource)) 91 | 92 | private fun storeConverters(dialect: Dialect): List = buildList { 93 | addAll(dialect.converters) 94 | addAll(JdbcCustomConversions.storeConverters()) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/CodeEnumConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import org.junit.jupiter.api.AfterAll 7 | import org.junit.jupiter.api.AfterEach 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | import org.springframework.core.convert.converter.Converter 11 | import org.springframework.core.convert.converter.ConverterFactory 12 | import org.springframework.data.convert.ReadingConverter 13 | import org.springframework.data.convert.WritingConverter 14 | 15 | class CodeEnumConversionTest { 16 | private val kueryClient = mysql.kueryClient( 17 | listOf( 18 | IntCodeEnumWritingConverter(), 19 | IntCodeEnumReadingConverter(), 20 | StringCodeEnumWritingConverter(), 21 | StringCodeEnumReadingConverter(), 22 | ), 23 | ) 24 | 25 | @BeforeEach 26 | fun beforeEach() { 27 | mysql.jdbcClient.sql( 28 | """ 29 | CREATE TABLE `code_enum` 30 | ( 31 | `id` BIGINT AUTO_INCREMENT, 32 | `int_enum` INT DEFAULT NULL, 33 | `string_enum` VARCHAR(255) DEFAULT NULL, 34 | PRIMARY KEY (`id`) 35 | ) ENGINE = InnoDB 36 | DEFAULT CHARSET = utf8mb4 37 | COLLATE = utf8mb4_bin; 38 | """.trimIndent(), 39 | ).update() 40 | } 41 | 42 | @AfterEach 43 | fun afterEach() { 44 | mysql.jdbcClient.sql("DROP TABLE code_enum").update() 45 | } 46 | 47 | interface CodeEnum { 48 | val code: T 49 | 50 | companion object { 51 | fun > getByCode( 52 | code: T, 53 | clazz: Class, 54 | ): E? = clazz.enumConstants.firstOrNull { code == it.code } 55 | } 56 | } 57 | 58 | interface IntCodeEnum : CodeEnum 59 | 60 | interface StringCodeEnum : CodeEnum 61 | 62 | enum class SampleIntCodeEnum(override val code: Int) : IntCodeEnum { 63 | HOGE(10), 64 | } 65 | 66 | enum class SampleStringCodeEnum(override val code: String) : StringCodeEnum { 67 | BAR("hoge"), 68 | } 69 | 70 | data class Record( 71 | val intEnum: SampleIntCodeEnum, 72 | val stringEnum: SampleStringCodeEnum, 73 | ) 74 | 75 | @WritingConverter 76 | class IntCodeEnumWritingConverter : Converter { 77 | override fun convert(source: IntCodeEnum): Int = source.code 78 | } 79 | 80 | @ReadingConverter 81 | class IntCodeEnumReadingConverter : ConverterFactory { 82 | override fun getConverter(targetType: Class): Converter = Converter { 83 | CodeEnum.getByCode(it, targetType) 84 | } 85 | } 86 | 87 | @WritingConverter 88 | class StringCodeEnumWritingConverter : Converter { 89 | override fun convert(source: StringCodeEnum): String = source.code 90 | } 91 | 92 | @ReadingConverter 93 | class StringCodeEnumReadingConverter : ConverterFactory { 94 | override fun getConverter(targetType: Class): Converter = Converter { 95 | CodeEnum.getByCode(it, targetType) 96 | } 97 | } 98 | 99 | @Test 100 | fun test() { 101 | kueryClient.sql { 102 | +"INSERT INTO code_enum (int_enum, string_enum)" 103 | +"VALUES (${SampleIntCodeEnum.HOGE}, ${SampleStringCodeEnum.BAR})" 104 | }.rowsUpdated() 105 | 106 | val record: Record = kueryClient.sql { 107 | +"SELECT * FROM code_enum" 108 | }.single() 109 | 110 | assertThat(record.intEnum).isEqualTo(SampleIntCodeEnum.HOGE) 111 | assertThat(record.stringEnum).isEqualTo(SampleStringCodeEnum.BAR) 112 | } 113 | 114 | companion object { 115 | private val mysql = MySqlTestContainer() 116 | 117 | @AfterAll 118 | @JvmStatic 119 | fun afterAll() { 120 | mysql.close() 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/CodeEnumConversionTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import dev.hsbrysk.kuery.core.single 6 | import kotlinx.coroutines.test.runTest 7 | import org.junit.jupiter.api.AfterAll 8 | import org.junit.jupiter.api.AfterEach 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.core.convert.converter.Converter 12 | import org.springframework.core.convert.converter.ConverterFactory 13 | import org.springframework.data.convert.ReadingConverter 14 | import org.springframework.data.convert.WritingConverter 15 | import org.springframework.r2dbc.core.awaitRowsUpdated 16 | 17 | class CodeEnumConversionTest { 18 | private val kueryClient = mysql.kueryClient( 19 | listOf( 20 | IntCodeEnumWritingConverter(), 21 | IntCodeEnumReadingConverter(), 22 | StringCodeEnumWritingConverter(), 23 | StringCodeEnumReadingConverter(), 24 | ), 25 | ) 26 | 27 | @BeforeEach 28 | fun beforeEach() = runTest { 29 | mysql.databaseClient.sql( 30 | """ 31 | CREATE TABLE `code_enum` 32 | ( 33 | `id` BIGINT AUTO_INCREMENT, 34 | `int_enum` INT DEFAULT NULL, 35 | `string_enum` VARCHAR(255) DEFAULT NULL, 36 | PRIMARY KEY (`id`) 37 | ) ENGINE = InnoDB 38 | DEFAULT CHARSET = utf8mb4 39 | COLLATE = utf8mb4_bin; 40 | """.trimIndent(), 41 | ).fetch().awaitRowsUpdated() 42 | } 43 | 44 | @AfterEach 45 | fun afterEach() = runTest { 46 | mysql.databaseClient.sql("DROP TABLE code_enum").fetch().awaitRowsUpdated() 47 | } 48 | 49 | interface CodeEnum { 50 | val code: T 51 | 52 | companion object { 53 | fun > getByCode( 54 | code: T, 55 | clazz: Class, 56 | ): E? = clazz.enumConstants.firstOrNull { code == it.code } 57 | } 58 | } 59 | 60 | interface IntCodeEnum : CodeEnum 61 | 62 | interface StringCodeEnum : CodeEnum 63 | 64 | enum class SampleIntCodeEnum(override val code: Int) : IntCodeEnum { 65 | HOGE(10), 66 | } 67 | 68 | enum class SampleStringCodeEnum(override val code: String) : StringCodeEnum { 69 | BAR("hoge"), 70 | } 71 | 72 | data class Record( 73 | val intEnum: SampleIntCodeEnum, 74 | val stringEnum: SampleStringCodeEnum, 75 | ) 76 | 77 | @WritingConverter 78 | class IntCodeEnumWritingConverter : Converter { 79 | override fun convert(source: IntCodeEnum): Int = source.code 80 | } 81 | 82 | @ReadingConverter 83 | class IntCodeEnumReadingConverter : ConverterFactory { 84 | override fun getConverter(targetType: Class): Converter = Converter { 85 | CodeEnum.getByCode(it, targetType) 86 | } 87 | } 88 | 89 | @WritingConverter 90 | class StringCodeEnumWritingConverter : Converter { 91 | override fun convert(source: StringCodeEnum): String = source.code 92 | } 93 | 94 | @ReadingConverter 95 | class StringCodeEnumReadingConverter : ConverterFactory { 96 | override fun getConverter(targetType: Class): Converter = Converter { 97 | CodeEnum.getByCode(it, targetType) 98 | } 99 | } 100 | 101 | @Test 102 | fun test() = runTest { 103 | kueryClient.sql { 104 | +"INSERT INTO code_enum (int_enum, string_enum)" 105 | +"VALUES (${SampleIntCodeEnum.HOGE}, ${SampleStringCodeEnum.BAR})" 106 | }.rowsUpdated() 107 | 108 | val record: Record = kueryClient.sql { 109 | +"SELECT * FROM code_enum" 110 | }.single() 111 | 112 | assertThat(record.intEnum).isEqualTo(SampleIntCodeEnum.HOGE) 113 | assertThat(record.stringEnum).isEqualTo(SampleStringCodeEnum.BAR) 114 | } 115 | 116 | companion object { 117 | private val mysql = MySqlTestContainer() 118 | 119 | @AfterAll 120 | @JvmStatic 121 | fun afterAll() { 122 | mysql.close() 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /docs/transaction.md: -------------------------------------------------------------------------------- 1 | # Transaction 2 | 3 | You can use the transaction mechanisms provided by Spring. 4 | 5 | This document provides a brief explanation. For details, please refer to the Spring documentation. 6 | 7 | ## R2DBC (kuery-client-spring-data-r2dbc) 8 | 9 | ### Programmatic Transaction Management 10 | 11 | When using R2DBC, you can use `TransactionalOperator` to programmatically manage transactions. 12 | 13 | When using Spring Boot, it is registered as a bean by default, so you can use it as is. 14 | 15 | (On the other hand, if you are using multiple databases, for example, you will need to provide it yourself. In such 16 | cases, please refer to the Spring documentation and set it up accordingly.) 17 | 18 | #### Example 19 | 20 | ```kotlin 21 | @Service 22 | class UserService( 23 | private val userRepository: UserRepository, 24 | private val transaction: TransactionalOperator, // registered as a bean 25 | ) { 26 | suspend fun addUser( 27 | username: String, 28 | email: Email, 29 | ): Int { 30 | // Programmatically apply transactions 31 | return transaction.executeAndAwait { 32 | userRepository.insert(username, email) 33 | } 34 | } 35 | } 36 | 37 | @Repository 38 | class UserRepository(private val kueryClient: KueryClient) { 39 | suspend fun insert( 40 | username: String, 41 | email: Email, 42 | ): Int { 43 | // ... 44 | } 45 | } 46 | ``` 47 | 48 | ### AOP(`@Transactional`) Transaction Management 49 | 50 | Of course, you can also use the AOP-based approach. In this case, add `@Transactional` to the methods where you want to 51 | apply the transaction. 52 | 53 | ```kotlin 54 | @Service 55 | class UserService( 56 | private val userRepository: UserRepository, 57 | ) { 58 | // Apply transactions using AOP 59 | @Transactional 60 | suspend fun addUser( 61 | username: String, 62 | email: Email, 63 | ): Int { 64 | return userRepository.insert(username, email) 65 | } 66 | } 67 | 68 | @Repository 69 | class UserRepository(private val kueryClient: KueryClient) { 70 | suspend fun insert( 71 | username: String, 72 | email: Email, 73 | ): Int { 74 | // ... 75 | } 76 | } 77 | ``` 78 | 79 | ## JDBC (kuery-client-spring-data-jdbc) 80 | 81 | When using JDBC, you can use `TransactionTemplate` to programmatically manage transactions. 82 | 83 | When using Spring Boot, it is registered as a bean by default, so you can use it as is. 84 | 85 | (On the other hand, if you are using multiple databases, for example, you will need to provide it yourself. In such 86 | cases, please refer to the Spring documentation and set it up accordingly.) 87 | 88 | ### Programmatic Transaction Management 89 | 90 | #### Example 91 | 92 | ```kotlin 93 | @Service 94 | class UserService( 95 | private val userRepository: UserRepository, 96 | private val transaction: TransactionTemplate, // registered as a bean 97 | ) { 98 | fun addUser( 99 | username: String, 100 | email: Email, 101 | ): Int { 102 | // Programmatically apply transactions 103 | return transaction.execute { 104 | userRepository.insert(username, email) 105 | }!! 106 | } 107 | } 108 | 109 | @Repository 110 | class UserRepository(private val kueryClient: KueryClient) { 111 | fun insert( 112 | username: String, 113 | email: Email, 114 | ): Int { 115 | // ... 116 | } 117 | } 118 | ``` 119 | 120 | ### AOP(@Transactional) Transaction Management 121 | 122 | Of course, you can also use the AOP-based approach. In this case, add `@Transactional` to the methods where you want to 123 | apply the transaction. 124 | 125 | ```kotlin 126 | @Service 127 | class UserService( 128 | private val userRepository: UserRepository, 129 | ) { 130 | // Apply transactions using AOP 131 | @Transactional 132 | fun addUser( 133 | username: String, 134 | email: Email, 135 | ): Int { 136 | return userRepository.insert(username, email) 137 | } 138 | } 139 | 140 | @Repository 141 | class UserRepository(private val kueryClient: KueryClient) { 142 | fun insert( 143 | username: String, 144 | email: Email, 145 | ): Int { 146 | // ... 147 | } 148 | } 149 | ``` 150 | 151 | ## Details 152 | 153 | For more details, please refer to the Spring documentation. 154 | 155 | https://docs.spring.io/spring-framework/reference/data-access/transaction.html 156 | -------------------------------------------------------------------------------- /docs/basics.md: -------------------------------------------------------------------------------- 1 | # Basics 2 | 3 | ## Building SQL 4 | 5 | ### `+`(unaryPlus) 6 | 7 | Concatenate SQL strings using the + operator. 8 | 9 | ```kotlin 10 | kueryClient 11 | .sql { 12 | +"SELECT * FROM users" 13 | +"WHERE user_id = 1" 14 | } 15 | ``` 16 | 17 | Of course, if there is no need to concatenate, you don't have to. 18 | 19 | ```kotlin 20 | kueryClient 21 | .sql { 22 | +""" 23 | SELECT * FROM users 24 | WHERE user_id = 1 25 | """ 26 | } 27 | ``` 28 | 29 | ### `fun add(sql: String)` 30 | 31 | It is an alias for `+`(unaryPlus). However, since the argument is annotated 32 | with `org.intellij.lang.annotations.Language`, if you are using a JetBrains IDE, you will get syntax assistance. 33 | 34 | ## Binding Parameters 35 | 36 | When you want to bind parameters, use string interpolation. 37 | 38 | ```kotlin 39 | val userId = "..." 40 | kueryClient 41 | .sql { 42 | +""" 43 | SELECT * FROM users 44 | WHERE user_id = $userId 45 | """ 46 | } 47 | ``` 48 | 49 | ## Logic such as `if` and `for` ...etc 50 | 51 | Just write using Kotlin syntax. There is no need to learn special syntax. 52 | 53 | ```kotlin 54 | kueryClient 55 | .sql { 56 | +"SELECT * FROM users" 57 | +"WHERE" 58 | +"status = $status" 59 | if (vip != null) { 60 | +"AND vip = $vip" 61 | } 62 | } 63 | ``` 64 | 65 | ## Fetch Result 66 | 67 | `kuery-client-spring-data-r2dbc/jdbc` both have a minimal interface. In the case of `kuery-client-spring-data-r2dbc`, it 68 | will be a suspend function. 69 | 70 | ### (suspend) fun singleMap(): Map 71 | 72 | Receives the results as a map. 73 | 74 | ```kotlin 75 | val map: Map = kueyClient 76 | .sql { +"SELECT * FROM users WHERE user_id = 1" } 77 | .singleMap() 78 | ``` 79 | 80 | ### `(suspend) fun singleMapOrNull(): Map?` 81 | 82 | Receives the results as a map. 83 | 84 | ```kotlin 85 | val map: Map? = kueyClient 86 | .sql { +"SELECT * FROM users WHERE user_id = 1" } 87 | .singleMapOrNull() 88 | ``` 89 | 90 | ### `(suspend) fun single(returnType: KClass): T` 91 | 92 | Receives the results converted to the specified type. 93 | 94 | ```kotlin 95 | val user: User = kueyClient 96 | .sql { +"SELECT * FROM users WHERE user_id = 1" } 97 | .single() 98 | ``` 99 | 100 | ### `(suspend) fun singleOrNull(returnType: KClass): T?` 101 | 102 | Receives the results converted to the specified type. 103 | 104 | ```kotlin 105 | val user: User? = kueyClient 106 | .sql { +"SELECT * FROM users WHERE user_id = 1" } 107 | .singleOrNull() 108 | ``` 109 | 110 | ### `(suspend) fun listMap(): List>` 111 | 112 | Receives the results of multiple rows as a map. 113 | 114 | ```kotlin 115 | val result: List> = kueyClient 116 | .sql { +"SELECT * FROM users WHERE user_id = 1" } 117 | .listMap() 118 | ``` 119 | 120 | ### `(suspend) fun list(returnType: KClass): List` 121 | 122 | Receives the results of multiple rows converted to the specified type. 123 | 124 | ```kotlin 125 | val users: List = kueyClient 126 | .sql { +"SELECT * FROM users WHERE user_id = 1" } 127 | .list() 128 | ``` 129 | 130 | ### [`kuery-client-spring-data-r2dbc` only] `fun flowMap(): Flow>` 131 | 132 | Receives the results of multiple rows as a map. 133 | 134 | ```kotlin 135 | val result: Flow> = kueyClient 136 | .sql { +"SELECT * FROM users WHERE user_id = 1" } 137 | .flowMap() 138 | ``` 139 | 140 | ### [`kuery-client-spring-data-r2dbc` only] `fun flow(returnType: KClass): Flow` 141 | 142 | Receives the results of multiple rows converted to the specified type. 143 | 144 | ```kotlin 145 | val users: Flow = kueyClient 146 | .sql { +"SELECT * FROM users WHERE user_id = 1" } 147 | .flow() 148 | ``` 149 | 150 | ### `(suspend) fun rowsUpdated(): Long` 151 | 152 | Contract for fetching the number of affected rows 153 | 154 | ```kotlin 155 | val result: Long = kueyClient 156 | .sql {+"INSERT INTO users (username, email) VALUES ('username1', 'email1')"} 157 | .rowsUpdated() 158 | ``` 159 | 160 | ### `(suspend) fun generatedValues(vararg columns: String): Map` 161 | 162 | Receives the values generated on the database side. For example, an auto increment value. 163 | 164 | ```kotlin 165 | val result: Map = kueyClient 166 | .sql {+"INSERT INTO users (username, email) VALUES ('username1', 'email1')"} 167 | .generatedValues("user_id") 168 | ``` 169 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/test/kotlin/dev/hsbrysk/kuery/spring/jdbc/ObservationTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc 2 | 3 | import com.example.spring.jdbc.UserRepository 4 | import io.micrometer.observation.tck.TestObservationRegistry 5 | import io.micrometer.observation.tck.TestObservationRegistryAssert 6 | import org.junit.jupiter.api.AfterAll 7 | import org.junit.jupiter.api.AfterEach 8 | import org.junit.jupiter.api.BeforeEach 9 | import org.junit.jupiter.api.Test 10 | 11 | class ObservationTest { 12 | private val registry = TestObservationRegistry.create() 13 | private val kueryClient = mysql.kueryClient(observationRegistry = registry) 14 | private val userRepository = UserRepository(kueryClient) 15 | 16 | @BeforeEach 17 | fun setUp() { 18 | mysql.jdbcClient.sql( 19 | """ 20 | CREATE TABLE users ( 21 | user_id INT AUTO_INCREMENT PRIMARY KEY, 22 | username VARCHAR(50) NOT NULL, 23 | email VARCHAR(100) NOT NULL 24 | ) 25 | """.trimIndent(), 26 | ).update() 27 | mysql.jdbcClient.sql( 28 | """ 29 | INSERT INTO users (username, email) VALUES 30 | ('user1', 'user1@example.com'), 31 | ('user2', 'user2@example.com') 32 | """.trimIndent(), 33 | ).update() 34 | } 35 | 36 | @AfterEach 37 | fun testDown() { 38 | mysql.jdbcClient.sql( 39 | """ 40 | DROP TABLE users; 41 | """.trimIndent(), 42 | ).update() 43 | } 44 | 45 | @Test 46 | fun singleMap() { 47 | userRepository.singleMap(1) 48 | assertObservation( 49 | sqlId = "com.example.spring.jdbc.UserRepository.singleMap", 50 | sql = "SELECT * FROM users WHERE user_id = :p0", 51 | ) 52 | } 53 | 54 | @Test 55 | fun singleMapOrNull() { 56 | userRepository.singleMapOrNull(1) 57 | assertObservation( 58 | sqlId = "com.example.spring.jdbc.UserRepository.singleMapOrNull", 59 | sql = "SELECT * FROM users WHERE user_id = :p0", 60 | ) 61 | } 62 | 63 | @Test 64 | fun single() { 65 | userRepository.single(1) 66 | assertObservation( 67 | sqlId = "com.example.spring.jdbc.UserRepository.single", 68 | sql = "SELECT * FROM users WHERE user_id = :p0", 69 | ) 70 | } 71 | 72 | @Test 73 | fun singleOrNull() { 74 | userRepository.singleOrNull(1) 75 | assertObservation( 76 | sqlId = "com.example.spring.jdbc.UserRepository.singleOrNull", 77 | sql = "SELECT * FROM users WHERE user_id = :p0", 78 | ) 79 | } 80 | 81 | @Test 82 | fun listMap() { 83 | userRepository.listMap() 84 | assertObservation( 85 | sqlId = "com.example.spring.jdbc.UserRepository.listMap", 86 | sql = "SELECT * FROM users", 87 | ) 88 | } 89 | 90 | @Test 91 | fun list() { 92 | userRepository.list() 93 | assertObservation( 94 | sqlId = "com.example.spring.jdbc.UserRepository.list", 95 | sql = "SELECT * FROM users", 96 | ) 97 | } 98 | 99 | @Test 100 | fun rowUpdated() { 101 | userRepository.rowUpdated("user3", "user3@example.com") 102 | assertObservation( 103 | sqlId = "com.example.spring.jdbc.UserRepository.rowUpdated", 104 | sql = "INSERT INTO users (username, email) VALUES (:p0, :p1)", 105 | ) 106 | } 107 | 108 | @Test 109 | fun generatedValues() { 110 | userRepository.generatedValues("user3", "user3@example.com") 111 | assertObservation( 112 | sqlId = "com.example.spring.jdbc.UserRepository.generatedValues", 113 | sql = "INSERT INTO users (username, email) VALUES (:p0, :p1)", 114 | ) 115 | } 116 | 117 | private fun assertObservation( 118 | sqlId: String, 119 | sql: String, 120 | ) { 121 | TestObservationRegistryAssert.assertThat(registry) 122 | .doesNotHaveAnyRemainingCurrentObservation() 123 | .hasObservationWithNameEqualTo("kuery.client.fetches") 124 | .that() 125 | .hasLowCardinalityKeyValue("sql.id", sqlId) 126 | .hasHighCardinalityKeyValue("sql", sql) 127 | .hasBeenStarted() 128 | .hasBeenStopped() 129 | } 130 | 131 | companion object { 132 | private val mysql = MySqlTestContainer() 133 | 134 | @AfterAll 135 | @JvmStatic 136 | fun afterAll() { 137 | mysql.close() 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## Features 4 | 5 | - **Love SQL ♥** 6 | - While ORM libraries in the world are convenient, they often require learning their own DSL, which We believe has a 7 | high learning cost. Kuery Client emphasizes writing SQL as it is. 8 | - **Based on spring-data-r2dbc and spring-data-jdbc** 9 | - Kuery Client is implemented based on spring-data-r2dbc and spring-data-jdbc. Use whichever you prefer. You can use 10 | Spring's ecosystem as it is, such as `@Transactional`. 11 | - **Observability** 12 | - It supports Micrometer Observation, so Metrics/Tracing/Logging can also be customized. 13 | - **Extensible** 14 | - When dealing with complex data schemas, there are often cases where you want to write common query logic. Thanks 15 | to Kotlin's extension functions, this becomes easier. 16 | 17 | ## Motivation 18 | 19 | We have used numerous ORM libraries, but in the end, we preferred libraries 20 | like [MyBatis](https://github.com/mybatis/mybatis-3) that allow writing SQL directly. 21 | 22 | To construct SQL dynamically, custom template syntax (such as if/foreach) is often used, but we prefer to write logic 23 | using the syntax provided by the programming language as much as possible. 24 | We want to write dynamic SQL using Kotlin syntax, similar to [kotlinx.html](https://github.com/Kotlin/kotlinx.html). 25 | 26 | To meet these needs, we implemented `Kuery Client`. 27 | 28 | ## Overview 29 | 30 | By using the following SQL builder, you can easily build and execute SQL. Whether using R2DBC or JDBC, the way of 31 | writing is almost the same. 32 | 33 | By providing a Kotlin compiler plugin, we achieve binding parameters using string interpolation. 34 | 35 | ::: code-group 36 | 37 | ```kotlin [kuery-client-spring-data-r2dbc] 38 | data class User(...) 39 | 40 | class UserRepository(private val kueryClient: KueryClient) { 41 | suspend fun findById(userId: Int): User? = kueryClient 42 | .sql { +"SELECT * FROM users WHERE user_id = $userId" } 43 | .singleOrNull() 44 | 45 | suspend fun search(status: String, vip: Boolean?): List = kueryClient 46 | .sql { 47 | +""" 48 | SELECT * FROM users 49 | WHERE 50 | status = $status 51 | """ 52 | if (vip != null) { 53 | +"AND vip = $vip" 54 | } 55 | } 56 | .list() 57 | 58 | suspend fun insertMany(users: List): Long = kueryClient 59 | .sql { 60 | +"INSERT INTO users (username, email)" 61 | // useful helper function 62 | values(users) { listOf(it.username, it.email) } 63 | } 64 | .rowsUpdated() 65 | } 66 | ``` 67 | 68 | ```kotlin [kuery-client-spring-data-jdbc] 69 | data class User(...) 70 | 71 | class UserRepository(private val kueryClient: KueryBlockingClient) { 72 | fun findById(userId: Int): User? = kueryClient 73 | .sql { +"SELECT * FROM users WHERE user_id = $userId" } 74 | .singleOrNull() 75 | 76 | fun search(status: String, vip: Boolean?): List = kueryClient 77 | .sql { 78 | +""" 79 | SELECT * FROM users 80 | WHERE 81 | status = $status 82 | """ 83 | if (vip != null) { 84 | +"AND vip = $vip" 85 | } 86 | } 87 | .list() 88 | 89 | fun insertMany(users: List): Long = kueryClient 90 | .sql { 91 | +"INSERT INTO users (username, email)" 92 | // useful helper function 93 | values(users) { listOf(it.username, it.email) } 94 | } 95 | .rowsUpdated() 96 | } 97 | ``` 98 | 99 | ::: 100 | 101 | This SQL builder is very simple. There are only two things you need to remember: 102 | 103 | - You can concatenate SQL strings using `+`(unaryPlus). 104 | - You can also directly express logic such as if statements in Kotlin. 105 | - By using string interpolation, it is possible to bind parameters. 106 | 107 | ## Based on spring-data-r2dbc and spring-data-jdbc 108 | 109 | Currently, it is implemented based on the well-established `spring-data-r2dbc` and `spring-data-jdbc` in the Java 110 | community. Kuery Client simply provides the aforementioned SQL builder on this foundation. 111 | 112 | It is designed to be used alongside both `spring-data-r2dbc` and `spring-data-jdbc`, allowing you to start small. 113 | 114 | In the future, we may add a different foundation or possibly create a new one from scratch. 115 | 116 | ## Preface 117 | 118 | This document does not explain Spring or Spring Boot. It is written assuming you are already familiar with them. For 119 | those without this knowledge, the document may be difficult to understand. 120 | -------------------------------------------------------------------------------- /kuery-client-spring-data-r2dbc/src/test/kotlin/dev/hsbrysk/kuery/spring/r2dbc/ObservationTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.r2dbc 2 | 3 | import com.example.spring.r2dbc.UserRepository 4 | import io.micrometer.observation.tck.TestObservationRegistry 5 | import io.micrometer.observation.tck.TestObservationRegistryAssert 6 | import kotlinx.coroutines.test.runTest 7 | import org.junit.jupiter.api.AfterAll 8 | import org.junit.jupiter.api.AfterEach 9 | import org.junit.jupiter.api.BeforeEach 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.r2dbc.core.awaitRowsUpdated 12 | 13 | class ObservationTest { 14 | private val registry = TestObservationRegistry.create() 15 | private val kueryClient = mysql.kueryClient(observationRegistry = registry) 16 | private val userRepository = UserRepository(kueryClient) 17 | 18 | @BeforeEach 19 | fun setUp() = runTest { 20 | mysql.databaseClient.sql( 21 | """ 22 | CREATE TABLE users ( 23 | user_id INT AUTO_INCREMENT PRIMARY KEY, 24 | username VARCHAR(50) NOT NULL, 25 | email VARCHAR(100) NOT NULL 26 | ); 27 | 28 | INSERT INTO users (username, email) VALUES 29 | ('user1', 'user1@example.com'), 30 | ('user2', 'user2@example.com'); 31 | """.trimIndent(), 32 | ).fetch().awaitRowsUpdated() 33 | } 34 | 35 | @AfterEach 36 | fun testDown() = runTest { 37 | mysql.databaseClient.sql( 38 | """ 39 | DROP TABLE users; 40 | """.trimIndent(), 41 | ).fetch().awaitRowsUpdated() 42 | } 43 | 44 | @Test 45 | fun singleMap() = runTest { 46 | userRepository.singleMap(1) 47 | assertObservation( 48 | sqlId = "com.example.spring.r2dbc.UserRepository.singleMap", 49 | sql = "SELECT * FROM users WHERE user_id = :p0", 50 | ) 51 | } 52 | 53 | @Test 54 | fun singleMapOrNull() = runTest { 55 | userRepository.singleMapOrNull(1) 56 | assertObservation( 57 | sqlId = "com.example.spring.r2dbc.UserRepository.singleMapOrNull", 58 | sql = "SELECT * FROM users WHERE user_id = :p0", 59 | ) 60 | } 61 | 62 | @Test 63 | fun single() = runTest { 64 | userRepository.single(1) 65 | assertObservation( 66 | sqlId = "com.example.spring.r2dbc.UserRepository.single", 67 | sql = "SELECT * FROM users WHERE user_id = :p0", 68 | ) 69 | } 70 | 71 | @Test 72 | fun singleOrNull() = runTest { 73 | userRepository.singleOrNull(1) 74 | assertObservation( 75 | sqlId = "com.example.spring.r2dbc.UserRepository.singleOrNull", 76 | sql = "SELECT * FROM users WHERE user_id = :p0", 77 | ) 78 | } 79 | 80 | @Test 81 | fun listMap() = runTest { 82 | userRepository.listMap() 83 | assertObservation( 84 | sqlId = "com.example.spring.r2dbc.UserRepository.listMap", 85 | sql = "SELECT * FROM users", 86 | ) 87 | } 88 | 89 | @Test 90 | fun list() = runTest { 91 | userRepository.list() 92 | assertObservation( 93 | sqlId = "com.example.spring.r2dbc.UserRepository.list", 94 | sql = "SELECT * FROM users", 95 | ) 96 | } 97 | 98 | @Test 99 | fun rowUpdated() = runTest { 100 | userRepository.rowUpdated("user3", "user3@example.com") 101 | assertObservation( 102 | sqlId = "com.example.spring.r2dbc.UserRepository.rowUpdated", 103 | sql = "INSERT INTO users (username, email) VALUES (:p0, :p1)", 104 | ) 105 | } 106 | 107 | @Test 108 | fun generatedValues() = runTest { 109 | userRepository.generatedValues("user3", "user3@example.com") 110 | assertObservation( 111 | sqlId = "com.example.spring.r2dbc.UserRepository.generatedValues", 112 | sql = "INSERT INTO users (username, email) VALUES (:p0, :p1)", 113 | ) 114 | } 115 | 116 | private fun assertObservation( 117 | sqlId: String, 118 | sql: String, 119 | ) { 120 | TestObservationRegistryAssert.assertThat(registry) 121 | .doesNotHaveAnyRemainingCurrentObservation() 122 | .hasObservationWithNameEqualTo("kuery.client.fetches") 123 | .that() 124 | .hasLowCardinalityKeyValue("sql.id", sqlId) 125 | .hasHighCardinalityKeyValue("sql", sql) 126 | .hasBeenStarted() 127 | .hasBeenStopped() 128 | } 129 | 130 | companion object { 131 | private val mysql = MySqlTestContainer() 132 | 133 | @AfterAll 134 | @JvmStatic 135 | fun afterAll() { 136 | mysql.close() 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /kuery-client-core/src/test/kotlin/dev/hsbrysk/kuery/core/SqlBuilderHelpersTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import assertk.assertFailure 4 | import assertk.assertThat 5 | import assertk.assertions.isEqualTo 6 | import assertk.assertions.isInstanceOf 7 | import org.junit.jupiter.api.Test 8 | 9 | @OptIn(DelicateKueryClientApi::class) 10 | class SqlBuilderHelpersTest { 11 | @Test 12 | fun `values single`() { 13 | val input = listOf( 14 | listOf("user0", "user0@example.com", 1), 15 | ) 16 | val result = Sql { 17 | addUnsafe("INSERT INTO users (userid, email, age)") 18 | values(input) 19 | } 20 | 21 | assertThat(result.body) 22 | .isEqualTo("INSERT INTO users (userid, email, age)\nVALUES (:p0, :p1, :p2)") 23 | assertThat(result.parameters).isEqualTo( 24 | listOf( 25 | NamedSqlParameter("p0", "user0"), 26 | NamedSqlParameter("p1", "user0@example.com"), 27 | NamedSqlParameter("p2", 1), 28 | ), 29 | ) 30 | } 31 | 32 | @Test 33 | fun `values multi`() { 34 | val input = listOf( 35 | listOf("user0", "user0@example.com", 1), 36 | listOf("user1", null, 2), 37 | listOf("user2", "user2@example.com", 3), 38 | ) 39 | val result = Sql { 40 | addUnsafe("INSERT INTO users (userid, email, age)") 41 | values(input) 42 | } 43 | 44 | assertThat(result.body) 45 | .isEqualTo( 46 | "INSERT INTO users (userid, email, age)\nVALUES (:p0, :p1, :p2), (:p3, :p4, :p5), (:p6, :p7, :p8)", 47 | ) 48 | assertThat(result.parameters).isEqualTo( 49 | listOf( 50 | NamedSqlParameter("p0", "user0"), 51 | NamedSqlParameter("p1", "user0@example.com"), 52 | NamedSqlParameter("p2", 1), 53 | NamedSqlParameter("p3", "user1"), 54 | NamedSqlParameter("p4", null), 55 | NamedSqlParameter("p5", 2), 56 | NamedSqlParameter("p6", "user2"), 57 | NamedSqlParameter("p7", "user2@example.com"), 58 | NamedSqlParameter("p8", 3), 59 | ), 60 | ) 61 | } 62 | 63 | @Test 64 | fun `values empty`() { 65 | assertFailure { 66 | Sql { 67 | addUnsafe("INSERT INTO users (userid, email, age)") 68 | values(emptyList>()) 69 | } 70 | }.isInstanceOf(IllegalArgumentException::class) 71 | } 72 | 73 | @Test 74 | fun `values child list empty`() { 75 | val input = listOf( 76 | listOf(), 77 | ) 78 | assertFailure { 79 | Sql { 80 | addUnsafe("INSERT INTO users (userid, email, age)") 81 | values(input) 82 | } 83 | }.isInstanceOf(IllegalArgumentException::class) 84 | } 85 | 86 | @Test 87 | fun `values child list size is different`() { 88 | val input = listOf( 89 | listOf("user0", "user0@example.com", 1), 90 | listOf("user1", null), 91 | listOf("user2", "user2@example.com", 3), 92 | ) 93 | assertFailure { 94 | Sql { 95 | addUnsafe("INSERT INTO users (userid, email, age)") 96 | values(input) 97 | } 98 | }.isInstanceOf(IllegalArgumentException::class) 99 | } 100 | 101 | @Test 102 | fun `values multi with transformer`() { 103 | data class UserParam( 104 | val userid: String, 105 | val email: String?, 106 | val age: Int, 107 | ) 108 | 109 | val input = listOf( 110 | UserParam("user0", "user0@example.com", 1), 111 | UserParam("user1", null, 2), 112 | UserParam("user2", "user2@example.com", 3), 113 | ) 114 | val result = Sql { 115 | addUnsafe("INSERT INTO users (userid, email, age)") 116 | values(input) { listOf(it.userid, it.email, it.age) } 117 | } 118 | 119 | assertThat(result.body) 120 | .isEqualTo( 121 | "INSERT INTO users (userid, email, age)\nVALUES (:p0, :p1, :p2), (:p3, :p4, :p5), (:p6, :p7, :p8)", 122 | ) 123 | assertThat(result.parameters).isEqualTo( 124 | listOf( 125 | NamedSqlParameter("p0", "user0"), 126 | NamedSqlParameter("p1", "user0@example.com"), 127 | NamedSqlParameter("p2", 1), 128 | NamedSqlParameter("p3", "user1"), 129 | NamedSqlParameter("p4", null), 130 | NamedSqlParameter("p5", 2), 131 | NamedSqlParameter("p6", "user2"), 132 | NamedSqlParameter("p7", "user2@example.com"), 133 | NamedSqlParameter("p8", 3), 134 | ), 135 | ) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | kuery-client-logo 4 |

5 | Maven Central Version 6 |
7 | Document Site 8 |
9 | 10 | ## Introduction 11 | 12 | ### Features 13 | 14 | - **Love SQL ♥** 15 | - While ORM libraries in the world are convenient, they often require learning their own DSL, which we believe has a 16 | high learning cost. Kuery Client emphasizes writing SQL as it is. 17 | - **Based on spring-data-r2dbc and spring-data-jdbc** 18 | - Kuery Client is implemented based on spring-data-r2dbc and spring-data-jdbc. Use whichever you prefer. You can use 19 | Spring's ecosystem as it is, such as `@Transactional`. 20 | - **Observability** 21 | - It supports Micrometer Observation, so Metrics/Tracing/Logging can also be customized. 22 | - **Extensible** 23 | - When dealing with complex data schemas, there are often cases where you want to write common query logic. Thanks 24 | to Kotlin's extension functions, this becomes easier. 25 | 26 | ### Motivation 27 | 28 | We have used numerous ORM libraries, but in the end, we preferred libraries 29 | like [MyBatis](https://github.com/mybatis/mybatis-3) that allow writing SQL directly. 30 | 31 | To construct SQL dynamically, custom template syntax (such as if/foreach) is often used, but we prefer to write logic 32 | using the syntax provided by the programming language as much as possible. 33 | we want to write dynamic SQL using Kotlin syntax, similar to [kotlinx.html](https://github.com/Kotlin/kotlinx.html). 34 | 35 | To meet these needs, we implemented `Kuery Client`. 36 | 37 | ### Overview 38 | 39 | By using the following SQL builder, you can easily build and execute SQL. Whether using R2DBC or JDBC, the way of 40 | writing is almost the same. 41 | 42 | By providing a Kotlin compiler plugin, we achieve binding parameters using string interpolation. 43 | 44 | ```kotlin 45 | data class User(...) 46 | 47 | class UserRepository(private val kueryClient: KueryClient) { 48 | suspend fun findById(userId: Int): User? = kueryClient 49 | .sql { +"SELECT * FROM users WHERE user_id = $userId" } 50 | .singleOrNull() 51 | 52 | suspend fun search(status: String, vip: Boolean?): List = kueryClient 53 | .sql { 54 | +""" 55 | SELECT * FROM users 56 | WHERE 57 | status = $status 58 | """ 59 | if (vip != null) { 60 | +"AND vip = $vip" 61 | } 62 | } 63 | .list() 64 | 65 | suspend fun insertMany(users: List): Long = kueryClient 66 | .sql { 67 | +"INSERT INTO users (username, email)" 68 | // useful helper function 69 | values(users) { listOf(it.username, it.email) } 70 | } 71 | .rowsUpdated() 72 | } 73 | ``` 74 | 75 | This SQL builder is very simple. There are only two things you need to remember: 76 | 77 | - You can concatenate SQL strings using `+`(unaryPlus). 78 | - You can also directly express logic such as if statements in Kotlin. 79 | - By using string interpolation, it is possible to bind parameters. 80 | 81 | ### Based on spring-data-r2dbc and spring-data-jdbc 82 | 83 | Currently, it is implemented based on the well-established `spring-data-r2dbc` and `spring-data-jdbc` in the Java 84 | community. Kuery Client simply provides the aforementioned SQL builder on this foundation. 85 | 86 | It is designed to be used alongside both `spring-data-r2dbc` and `spring-data-jdbc`, allowing you to start small. 87 | 88 | In the future, we may add a different foundation or possibly create a new one from scratch. 89 | 90 | ## Getting Started 91 | 92 | ### Install 93 | 94 | #### Gradle 95 | 96 | ```kotlin 97 | plugins { 98 | id("dev.hsbrysk.kuery-client") version "{{version}}" 99 | } 100 | 101 | implementation("dev.hsbrysk.kuery-client:kuery-client-spring-data-r2dbc:{{version}}") 102 | // or, implementation("dev.hsbrysk.kuery-client:kuery-client-spring-data-jdbc:{{version}}") 103 | ``` 104 | 105 | ### Build KueryClient 106 | 107 | #### for `kuery-client-spring-data-r2dbc` 108 | 109 | ```kotlin 110 | val connectionFactory: ConnectionFactory = ... 111 | 112 | val kueryClient = SpringR2dbcKueryClient.builder() 113 | .connectionFactory(connectionFactory) 114 | .build() 115 | ``` 116 | 117 | #### for `kuery-client-spring-data-jdbc` 118 | 119 | ```kotlin 120 | val dataSource: DataSource = ... 121 | 122 | val kueryClient = SpringJdbcKueryClient.builder() 123 | .dataSource(dataSource) 124 | .build() 125 | ``` 126 | 127 | ### Let's Use It 128 | 129 | ```kotlin 130 | val userId = "..." 131 | val user: User = kueryClient 132 | .sql { +"SELECT * FROM users WHERE user_id = $userId" } 133 | .singleOrNull() 134 | ``` 135 | -------------------------------------------------------------------------------- /docs/observation.md: -------------------------------------------------------------------------------- 1 | # Observation 2 | 3 | Kuery Client supports [Micrometer Observation](https://micrometer.io/). 4 | 5 | If you want to use this feature, please specify the `ObservationRegistry` when creating the `KueryClient`. 6 | 7 | ```kotlin {4} 8 | // e.g. In the case of kuery-client-spring-data-r2dbc 9 | val kueryClient = SpringR2dbcKueryClient.builder() 10 | .connectionFactory(connectionFactory) 11 | .observationRegistry(...) 12 | .build() 13 | ``` 14 | 15 | If you want to customize the metrics name or other settings, please implement and specify the `ObservationConvention` 16 | also. 17 | 18 | ```kotlin {4-5} 19 | // e.g. In the case of kuery-client-spring-data-r2dbc 20 | val kueryClient = SpringR2dbcKueryClient.builder() 21 | .connectionFactory(connectionFactory) 22 | .observationRegistry(...) 23 | .observationConvention(...) 24 | .build() 25 | ``` 26 | 27 | ## Example: spring-boot-starter-actuator & Prometheus 28 | 29 | ::: info 30 | We won't go into detail about Spring Boot, Micrometer, and Prometheus here. 31 | The documentation is written concisely, assuming you are familiar with these. 32 | ::: 33 | 34 | First, add `org.springframework.boot:spring-boot-starter-actuator` and `io.micrometer:micrometer-registry-prometheus` as 35 | dependencies. 36 | 37 | ```kotlin 38 | // ... 39 | // other dependencies 40 | // ... 41 | implementation("org.springframework.boot:spring-boot-starter-actuator:{{version}}") 42 | implementation("io.micrometer:micrometer-registry-prometheus:{{version}}") 43 | ``` 44 | 45 | Then, write the following and register KueryClient as a Bean: 46 | 47 | ```kotlin 48 | @Configuration(proxyBeanMethods = false) 49 | class ExampleConfiguration { 50 | @Bean 51 | fun kueryClient(connectionFactory: ConnectionFactory, observationRegistry: ObservationRegistry): KueryClient { 52 | return SpringR2dbcKueryClient.builder() 53 | .connectionFactory(connectionFactory) 54 | .observationRegistry(observationRegistry) 55 | .build() 56 | } 57 | } 58 | ``` 59 | 60 | Suppose you are implementing a repository like the following. 61 | 62 | ```kotlin 63 | package com.example.spring.data.r2dbc 64 | 65 | // ... 66 | 67 | @Repository 68 | class UserRepository(private val kueryClient: KueryClient) { 69 | suspend fun selectByUserId(userId: Int): User? = kueryClient 70 | .sql { 71 | +"SELECT * FROM users WHERE user_id = $userId" 72 | } 73 | .singleOrNull() 74 | } 75 | ``` 76 | 77 | With these assumptions, you can obtain Prometheus metrics as follows: 78 | 79 | ```shell 80 | curl {host}/actuator/prometheus | grep kuery 81 | 82 | # HELP kuery_client_fetches_active_seconds 83 | # TYPE kuery_client_fetches_active_seconds summary 84 | kuery_client_fetches_active_seconds_count{sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0 85 | kuery_client_fetches_active_seconds_sum{sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0.0 86 | # HELP kuery_client_fetches_active_seconds_max 87 | # TYPE kuery_client_fetches_active_seconds_max gauge 88 | kuery_client_fetches_active_seconds_max{sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0.0 89 | # HELP kuery_client_fetches_seconds 90 | # TYPE kuery_client_fetches_seconds summary 91 | kuery_client_fetches_seconds_count{error="none",sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 14 92 | kuery_client_fetches_seconds_sum{error="none",sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0.13953154 93 | # HELP kuery_client_fetches_seconds_max 94 | # TYPE kuery_client_fetches_seconds_max gauge 95 | kuery_client_fetches_seconds_max{error="none",sql_id="com.example.spring.data.r2dbc.UserRepository.selectByUserId"} 0.026267833 96 | ``` 97 | 98 | Metrics are recorded along with the controller/method where the repository implementing sql_id is used. 99 | 100 | ## Constraints on `sql_id` 101 | 102 | There is a constraint that if you have multiple `kueryClient.sql {...}` calls within the same method, the same `sql_id` 103 | will be used. Therefore, it is recommended to implement one SQL per method in the repository. 104 | 105 | ```kotlin 106 | @Repository 107 | class UserRepository(private val kueryClient: KueryClient) { 108 | suspend fun selectByUserId(userId: Int): UserAndDetail { 109 | val user: User = kueryClient 110 | .sql { 111 | +"SELECT * FROM users WHERE user_id = $userId" 112 | } 113 | .single() 114 | val userDetail: UserDetail = kueryClient 115 | .sql { 116 | +"SELECT * FROM user_details WHERE user_id = $userId" 117 | } 118 | .single() 119 | return UserAndDetail(user, userDetail) 120 | } 121 | } 122 | ``` 123 | 124 | If you absolutely need to make multiple calls in a repository method, you can avoid this by specifying the `sql_id` 125 | yourself. 126 | 127 | ```kotlin 128 | @Repository 129 | class UserRepository(private val kueryClient: KueryClient) { 130 | suspend fun selectByUserId(userId: Int): UserAndDetail { 131 | val user: User = kueryClient 132 | .sql("my_sql_id_1") { 133 | +"SELECT * FROM users WHERE user_id = $userId" 134 | } 135 | .single() 136 | val userDetail: UserDetail = kueryClient 137 | .sql("my_sql_id_2") { 138 | +"SELECT * FROM user_details WHERE user_id = $userId" 139 | } 140 | .single() 141 | return UserAndDetail(user, userDetail) 142 | } 143 | } 144 | ``` 145 | -------------------------------------------------------------------------------- /kuery-client-compiler/functional-test/src/test/kotlin/dev/hsbrysk/kuery/core/StringInterpolationTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import io.mockk.InternalPlatformDsl.toStr 6 | import org.junit.jupiter.api.Test 7 | 8 | class StringInterpolationTest { 9 | @Test 10 | fun none() { 11 | val sql = Sql { 12 | } 13 | assertThat(sql).isEqualTo(Sql("")) 14 | } 15 | 16 | @Test 17 | fun `empty string`() { 18 | val sql1 = Sql { 19 | +"" 20 | } 21 | assertThat(sql1).isEqualTo(Sql("")) 22 | 23 | // Unnecessary line breaks are trimmed 24 | // see DefaultSqlBuilder.build() 25 | val sql2 = Sql { 26 | +"" 27 | +"" 28 | +"" 29 | } 30 | assertThat(sql2).isEqualTo(Sql("")) 31 | } 32 | 33 | @Test 34 | fun `only string interpolation`() { 35 | val sql1 = Sql { 36 | +"${1}" 37 | } 38 | assertThat(sql1).isEqualTo( 39 | Sql( 40 | ":p0", 41 | listOf(NamedSqlParameter("p0", 1)), 42 | ), 43 | ) 44 | 45 | val sql2 = Sql { 46 | +"${1}${2}" 47 | } 48 | assertThat(sql2).isEqualTo( 49 | Sql( 50 | ":p0:p1", 51 | listOf(NamedSqlParameter("p0", 1), NamedSqlParameter("p1", 2)), 52 | ), 53 | ) 54 | } 55 | 56 | @Test 57 | fun `only fragments`() { 58 | val sql1 = Sql { 59 | +"hoge" 60 | } 61 | assertThat(sql1).isEqualTo( 62 | Sql("hoge"), 63 | ) 64 | 65 | val sql2 = Sql { 66 | +"h" 67 | +"o" 68 | +"g" 69 | +"e" 70 | } 71 | assertThat(sql2).isEqualTo( 72 | Sql("h\no\ng\ne"), 73 | ) 74 | } 75 | 76 | @Test 77 | fun mixed() { 78 | val sql1 = Sql { 79 | +"a${1}b" 80 | } 81 | assertThat(sql1).isEqualTo( 82 | Sql("a:p0b", listOf(NamedSqlParameter("p0", 1))), 83 | ) 84 | 85 | val sql2 = Sql { 86 | +"${1}a" 87 | } 88 | assertThat(sql2).isEqualTo( 89 | Sql(":p0a", listOf(NamedSqlParameter("p0", 1))), 90 | ) 91 | 92 | val sql3 = Sql { 93 | +"a${1}" 94 | } 95 | assertThat(sql3).isEqualTo( 96 | Sql("a:p0", listOf(NamedSqlParameter("p0", 1))), 97 | ) 98 | 99 | val sql4 = Sql { 100 | +"a${1}${2}${3}" 101 | } 102 | assertThat(sql4).isEqualTo( 103 | Sql( 104 | "a:p0:p1:p2", 105 | listOf(NamedSqlParameter("p0", 1), NamedSqlParameter("p1", 2), NamedSqlParameter("p2", 3)), 106 | ), 107 | ) 108 | 109 | val sql5 = Sql { 110 | +"a${1}" 111 | +"b${2}" 112 | } 113 | assertThat(sql5).isEqualTo( 114 | Sql("a:p0\nb:p1", listOf(NamedSqlParameter("p0", 1), NamedSqlParameter("p1", 2))), 115 | ) 116 | } 117 | 118 | @Test 119 | fun `int string interpolation`() { 120 | val sql1 = Sql { 121 | +"a ${1}" 122 | } 123 | assertThat(sql1).isEqualTo( 124 | Sql( 125 | "a :p0", 126 | listOf(NamedSqlParameter("p0", 1)), 127 | ), 128 | ) 129 | 130 | val sql2 = Sql { 131 | +"a ${1 + 1}" 132 | } 133 | assertThat(sql2).isEqualTo( 134 | Sql( 135 | "a :p0", 136 | listOf(NamedSqlParameter("p0", 2)), 137 | ), 138 | ) 139 | } 140 | 141 | @Test 142 | fun `string string interpolation`() { 143 | // In such cases, string interpolation will not be executed. 144 | val sql1 = Sql { 145 | +"a ${"hoge"}" 146 | } 147 | assertThat(sql1).isEqualTo( 148 | Sql( 149 | "a hoge", 150 | ), 151 | ) 152 | 153 | // On the other hand, in such cases, it will be executed. 154 | val sql2 = Sql { 155 | +"a ${"hoge".removePrefix("h").removePrefix("o")}" 156 | } 157 | assertThat(sql2).isEqualTo( 158 | Sql( 159 | "a :p0", 160 | listOf(NamedSqlParameter("p0", "ge")), 161 | ), 162 | ) 163 | } 164 | 165 | @Test 166 | fun `boolean string interpolation`() { 167 | // In such cases, string interpolation will not be executed. 168 | val sql1 = Sql { 169 | +"a ${true}" 170 | } 171 | assertThat(sql1).isEqualTo( 172 | Sql( 173 | "a :p0", 174 | listOf(NamedSqlParameter("p0", true)), 175 | ), 176 | ) 177 | 178 | // On the other hand, in such cases, it will be executed. 179 | val sql2 = Sql { 180 | +"a ${true && true}" 181 | } 182 | assertThat(sql2).isEqualTo( 183 | Sql( 184 | "a :p0", 185 | listOf(NamedSqlParameter("p0", true)), 186 | ), 187 | ) 188 | } 189 | 190 | @Test 191 | fun `null string interpolation`() { 192 | // In such cases, string interpolation will not be executed. 193 | val sql1 = Sql { 194 | +"a ${null}" 195 | } 196 | assertThat(sql1).isEqualTo( 197 | Sql( 198 | "a :p0", 199 | listOf(NamedSqlParameter("p0", null)), 200 | ), 201 | ) 202 | 203 | // On the other hand, in such cases, it will be executed. 204 | val sql2 = Sql { 205 | +"a ${null.toStr()}" 206 | } 207 | assertThat(sql2).isEqualTo( 208 | Sql( 209 | "a :p0", 210 | listOf(NamedSqlParameter("p0", "null")), 211 | ), 212 | ) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /kuery-client-compiler/src/main/kotlin/dev/hsbrysk/kuery/compiler/ir/StringInterpolationTransformer.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.compiler.ir 2 | 3 | import dev.hsbrysk.kuery.compiler.ir.misc.CallableIds 4 | import dev.hsbrysk.kuery.compiler.ir.misc.ClassIds 5 | import dev.hsbrysk.kuery.compiler.ir.misc.ClassNames 6 | import dev.hsbrysk.kuery.compiler.ir.misc.StringConcatenationProcessor 7 | import org.jetbrains.kotlin.DeprecatedForRemovalCompilerApi 8 | import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext 9 | import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder 10 | import org.jetbrains.kotlin.ir.builders.IrBuilderWithScope 11 | import org.jetbrains.kotlin.ir.builders.irCall 12 | import org.jetbrains.kotlin.ir.builders.irVararg 13 | import org.jetbrains.kotlin.ir.expressions.IrCall 14 | import org.jetbrains.kotlin.ir.expressions.IrExpression 15 | import org.jetbrains.kotlin.ir.expressions.IrStringConcatenation 16 | import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol 17 | import org.jetbrains.kotlin.ir.types.IrType 18 | import org.jetbrains.kotlin.ir.types.classFqName 19 | import org.jetbrains.kotlin.ir.types.classOrFail 20 | import org.jetbrains.kotlin.ir.types.defaultType 21 | import org.jetbrains.kotlin.ir.types.typeWith 22 | import org.jetbrains.kotlin.ir.util.functions 23 | import org.jetbrains.kotlin.ir.util.irCastIfNeeded 24 | import org.jetbrains.kotlin.ir.util.isVararg 25 | import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid 26 | 27 | @OptIn(DeprecatedForRemovalCompilerApi::class) 28 | @Suppress("OPT_IN_USAGE") 29 | class StringInterpolationTransformer(private val pluginContext: IrPluginContext) : IrElementTransformerVoid() { 30 | private var current: IrCall? = null 31 | 32 | override fun visitCall(expression: IrCall): IrExpression { 33 | if (expression.isAddOrUnaryPlus()) { 34 | return try { 35 | current = expression 36 | super.visitCall(expression) 37 | 38 | when (expression.symbol.owner.name.asString()) { 39 | "add" -> transformAddCall(expression) 40 | "unaryPlus" -> transformUnaryPlusCall(expression) 41 | else -> error("Unexpected error") // not happened 42 | } 43 | } finally { 44 | current = null 45 | } 46 | } 47 | 48 | return super.visitCall(expression) 49 | } 50 | 51 | private fun transformAddCall(expression: IrCall): IrCall { 52 | val builder = irBuilder(expression) 53 | val sqlBuilder = checkNotNull(expression.dispatchReceiver) 54 | val sqlBuilderClass = sqlBuilder.type.classOrFail 55 | val addUnsafe = sqlBuilderClass.functions.first { it.owner.name.asString() == "addUnsafe" } 56 | return builder.irCall(addUnsafe, pluginContext.symbols.unit.defaultType).apply { 57 | dispatchReceiver = sqlBuilder 58 | putValueArgument( 59 | 0, 60 | List(expression.valueArgumentsCount) { expression.getValueArgument(it) } 61 | .first(), 62 | ) 63 | } 64 | } 65 | 66 | private fun transformUnaryPlusCall(expression: IrCall): IrCall { 67 | val builder = irBuilder(expression) 68 | val sqlBuilder = checkNotNull(expression.dispatchReceiver) 69 | val sqlBuilderClass = sqlBuilder.type.classOrFail 70 | val addUnsafe = sqlBuilderClass.functions.first { it.owner.name.asString() == "addUnsafe" } 71 | return builder.irCall(addUnsafe, pluginContext.symbols.unit.defaultType).apply { 72 | dispatchReceiver = sqlBuilder 73 | putValueArgument(0, expression.extensionReceiver) 74 | } 75 | } 76 | 77 | override fun visitStringConcatenation(expression: IrStringConcatenation): IrExpression { 78 | val current = current ?: return super.visitStringConcatenation(expression) 79 | val builder = irBuilder(current) 80 | 81 | val (fragments, values) = StringConcatenationProcessor(builder).process(expression.arguments).let { 82 | Pair( 83 | builder.irListOf(pluginContext.symbols.string.defaultType, it.first), 84 | builder.irListOf(pluginContext.symbols.any.defaultType, it.second), 85 | ) 86 | } 87 | 88 | val defaultSqlBuilderClass = checkNotNull(pluginContext.referenceClass(ClassIds.DEFAULT_SQL_BUILDER)) 89 | val interpolate = defaultSqlBuilderClass.functions.first { it.owner.name.asString() == "interpolate" } 90 | 91 | return builder.irCall(interpolate, pluginContext.symbols.string.defaultType).apply { 92 | dispatchReceiver = builder.irCastIfNeeded( 93 | checkNotNull(current.dispatchReceiver), 94 | defaultSqlBuilderClass.typeWith(), 95 | ) 96 | putValueArgument(0, fragments) 97 | putValueArgument(1, values) 98 | } 99 | } 100 | 101 | private fun irBuilder(expression: IrCall): DeclarationIrBuilder = DeclarationIrBuilder( 102 | pluginContext, 103 | expression.symbol, 104 | expression.startOffset, 105 | expression.endOffset, 106 | ) 107 | 108 | private fun IrBuilderWithScope.irListOf( 109 | type: IrType, 110 | values: List, 111 | ): IrExpression { 112 | val vararg = irVararg(type, values) 113 | return irCall(pluginContext.listOfRef(), pluginContext.symbols.list.typeWith(type)).apply { 114 | putTypeArgument(0, type) 115 | putValueArgument(0, vararg) 116 | } 117 | } 118 | 119 | companion object { 120 | private fun IrCall.isAddOrUnaryPlus(): Boolean { 121 | if (dispatchReceiver?.type?.classFqName?.asString() != ClassNames.SQL_BUILDER) { 122 | return false 123 | } 124 | when (symbol.owner.name.asString()) { 125 | "add", "unaryPlus" -> return true 126 | } 127 | return false 128 | } 129 | 130 | private fun IrPluginContext.listOfRef(): IrSimpleFunctionSymbol = referenceFunctions(CallableIds.LIST_OF) 131 | .first { it.owner.valueParameters.firstOrNull()?.isVararg ?: false } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /kuery-client-compiler/functional-test/src/test/kotlin/dev/hsbrysk/kuery/core/SqlTest.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.core 2 | 3 | import assertk.assertThat 4 | import assertk.assertions.isEqualTo 5 | import org.junit.jupiter.api.Test 6 | 7 | @OptIn(DelicateKueryClientApi::class) 8 | class SqlTest { 9 | @Test 10 | fun `simple create`() { 11 | val sql = Sql { 12 | +"SELECT * FROM user" 13 | } 14 | assertThat(sql).isEqualTo(Sql("SELECT * FROM user")) 15 | } 16 | 17 | @Test 18 | fun `simple insert`() { 19 | data class User( 20 | val name: String, 21 | val age: Int, 22 | val address: String, 23 | ) 24 | 25 | val user = User(name = "name", age = 18, address = "address") 26 | val sql = Sql { 27 | +"INSERT INTO user (name, age, address)" 28 | +"VALUES (${user.name}, ${user.age}, ${user.address})" 29 | } 30 | assertThat(sql).isEqualTo( 31 | Sql( 32 | """ 33 | INSERT INTO user (name, age, address) 34 | VALUES (:p0, :p1, :p2) 35 | """.trimIndent(), 36 | listOf( 37 | NamedSqlParameter("p0", user.name), 38 | NamedSqlParameter("p1", user.age), 39 | NamedSqlParameter("p2", user.address), 40 | ), 41 | ), 42 | ) 43 | } 44 | 45 | @Test 46 | fun `simple update`() { 47 | data class User( 48 | val id: String, 49 | val name: String, 50 | val age: Int, 51 | val address: String, 52 | ) 53 | 54 | val user = User(id = "id", name = "name", age = 18, address = "address") 55 | val sql = Sql { 56 | add( 57 | """ 58 | UPDATE user 59 | SET 60 | name = ${user.name}, 61 | age = ${user.age}, 62 | address = ${user.address} 63 | WHERE 64 | id = ${user.id} 65 | """.trimIndent(), 66 | ) 67 | } 68 | assertThat(sql).isEqualTo( 69 | Sql( 70 | """ 71 | UPDATE user 72 | SET 73 | name = :p0, 74 | age = :p1, 75 | address = :p2 76 | WHERE 77 | id = :p3 78 | """.trimIndent(), 79 | listOf( 80 | NamedSqlParameter("p0", user.name), 81 | NamedSqlParameter("p1", user.age), 82 | NamedSqlParameter("p2", user.address), 83 | NamedSqlParameter("p3", user.id), 84 | ), 85 | ), 86 | ) 87 | } 88 | 89 | @Test 90 | fun `simple delete`() { 91 | val id = "id" 92 | val sql = Sql { 93 | +"DELETE FROM user WHERE id = $id" 94 | } 95 | assertThat(sql).isEqualTo( 96 | Sql( 97 | """ 98 | DELETE FROM user WHERE id = :p0 99 | """.trimIndent(), 100 | listOf( 101 | NamedSqlParameter("p0", "id"), 102 | ), 103 | ), 104 | ) 105 | } 106 | 107 | @Test 108 | fun `select in`() { 109 | val ids = listOf(1, 2, 3, 4, 5) 110 | val sql = Sql { 111 | +"SELECT *" 112 | +"FROM user" 113 | +"WHERE id IN ($ids)" 114 | } 115 | assertThat(sql).isEqualTo( 116 | Sql( 117 | """ 118 | SELECT * 119 | FROM user 120 | WHERE id IN (:p0) 121 | """.trimIndent(), 122 | listOf( 123 | NamedSqlParameter("p0", ids), 124 | ), 125 | ), 126 | ) 127 | } 128 | 129 | @Test 130 | fun `insert multi`() { 131 | data class User( 132 | val name: String, 133 | val age: Int, 134 | val address: String, 135 | ) 136 | 137 | val users = listOf( 138 | User(name = "name1", age = 1, address = "address1"), 139 | User(name = "name2", age = 2, address = "address2"), 140 | User(name = "name3", age = 3, address = "address3"), 141 | ) 142 | val sql = Sql { 143 | +"INSERT INTO user (name, age, address)" 144 | values(users) { listOf(it.name, it.age, it.address) } 145 | } 146 | assertThat(sql).isEqualTo( 147 | Sql( 148 | """ 149 | INSERT INTO user (name, age, address) 150 | VALUES (:p0, :p1, :p2), (:p3, :p4, :p5), (:p6, :p7, :p8) 151 | """.trimIndent(), 152 | users.flatMapIndexed { i, user -> 153 | listOf( 154 | NamedSqlParameter("p${(i * 3)}", user.name), 155 | NamedSqlParameter("p${(i * 3 + 1)}", user.age), 156 | NamedSqlParameter("p${(i * 3) + 2}", user.address), 157 | ) 158 | }, 159 | ), 160 | ) 161 | } 162 | 163 | @Test 164 | fun `select complex condition`() { 165 | data class UserFilter( 166 | val id: String, 167 | val name: String?, 168 | val age: Int?, 169 | val address: String?, 170 | ) 171 | 172 | val filter = UserFilter(id = "id", name = null, age = 18, address = null) 173 | val sql = Sql { 174 | +"SELECT *" 175 | +"FROM user" 176 | +"WHERE" 177 | +"id = ${filter.id}" 178 | filter.name?.let { +"AND name = $it" } 179 | filter.age?.let { +"AND age = $it" } 180 | filter.address?.let { +"AND address = $it" } 181 | } 182 | assertThat(sql).isEqualTo( 183 | Sql( 184 | """ 185 | SELECT * 186 | FROM user 187 | WHERE 188 | id = :p0 189 | AND age = :p1 190 | """.trimIndent(), 191 | listOf( 192 | NamedSqlParameter("p0", filter.id), 193 | NamedSqlParameter("p1", filter.age), 194 | ), 195 | ), 196 | ) 197 | } 198 | 199 | @Test 200 | fun mixedOrder() { 201 | val sql = Sql { 202 | val line2 = "L2=${bind(2)}" 203 | val line1 = "L1=${bind(1)}" 204 | val line0 = "L0=${bind(0)}" 205 | 206 | +line0 207 | +line1 208 | +line2 209 | } 210 | assertThat(sql).isEqualTo( 211 | Sql( 212 | """ 213 | L0=:p2 214 | L1=:p1 215 | L2=:p0 216 | """.trimIndent(), 217 | listOf( 218 | NamedSqlParameter("p0", 2), 219 | NamedSqlParameter("p1", 1), 220 | NamedSqlParameter("p2", 0), 221 | ), 222 | ), 223 | ) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /kuery-client-spring-data-jdbc/src/main/kotlin/dev/hsbrysk/kuery/spring/jdbc/internal/DefaultSpringJdbcKueryClient.kt: -------------------------------------------------------------------------------- 1 | package dev.hsbrysk.kuery.spring.jdbc.internal 2 | 3 | import dev.hsbrysk.kuery.core.KueryBlockingClient 4 | import dev.hsbrysk.kuery.core.NamedSqlParameter 5 | import dev.hsbrysk.kuery.core.Sql 6 | import dev.hsbrysk.kuery.core.SqlBuilder 7 | import dev.hsbrysk.kuery.core.internal.SqlIds.id 8 | import dev.hsbrysk.kuery.core.observation.KueryClientFetchContext 9 | import dev.hsbrysk.kuery.core.observation.KueryClientFetchObservationConvention 10 | import dev.hsbrysk.kuery.core.observation.KueryClientObservationDocumentation 11 | import dev.hsbrysk.kuery.spring.jdbc.SqlIdInjector 12 | import io.micrometer.observation.Observation 13 | import io.micrometer.observation.ObservationRegistry 14 | import org.springframework.beans.BeanUtils 15 | import org.springframework.core.convert.ConversionService 16 | import org.springframework.dao.support.DataAccessUtils 17 | import org.springframework.data.jdbc.core.convert.JdbcCustomConversions 18 | import org.springframework.jdbc.core.DataClassRowMapper 19 | import org.springframework.jdbc.core.RowMapper 20 | import org.springframework.jdbc.core.SingleColumnRowMapper 21 | import org.springframework.jdbc.core.simple.JdbcClient 22 | import org.springframework.jdbc.core.simple.JdbcClient.MappedQuerySpec 23 | import org.springframework.jdbc.core.simple.JdbcClient.StatementSpec 24 | import org.springframework.jdbc.support.GeneratedKeyHolder 25 | import java.util.concurrent.ConcurrentHashMap 26 | import kotlin.reflect.KClass 27 | 28 | internal class DefaultSpringJdbcKueryClient( 29 | private val jdbcClient: JdbcClient, 30 | private val conversionService: ConversionService, 31 | private val customConversions: JdbcCustomConversions, 32 | private val observationRegistry: ObservationRegistry?, 33 | private val observationConvention: KueryClientFetchObservationConvention?, 34 | private val enableAutoSqlIdGeneration: Boolean, 35 | ) : KueryBlockingClient { 36 | private val defaultObservationConvention = KueryClientFetchObservationConvention.default() 37 | private val rowMapperCache = ConcurrentHashMap, RowMapper<*>>() 38 | 39 | override fun sql( 40 | sqlId: String, 41 | block: SqlBuilder.() -> Unit, 42 | ): KueryBlockingClient.FetchSpec { 43 | val sql = Sql(block) 44 | return FetchSpec(sqlId, sql, jdbcClient.sql(sql)) 45 | } 46 | 47 | override fun sql(block: SqlBuilder.() -> Unit): KueryBlockingClient.FetchSpec { 48 | val sqlId = if (enableAutoSqlIdGeneration) block.id() else "NONE" 49 | return sql(sqlId, block) 50 | } 51 | 52 | private fun JdbcClient.sql(sql: Sql): StatementSpec = sql.parameters.fold(this.sql(sql.body)) { acc, parameter -> 53 | if (parameter.value != null) { 54 | acc.bind(parameter) 55 | } else { 56 | acc.param(parameter.name, null) 57 | } 58 | } 59 | 60 | private fun StatementSpec.bind(parameter: NamedSqlParameter): StatementSpec { 61 | val value = checkNotNull(parameter.value) 62 | 63 | val targetType = customConversions.getCustomWriteTarget(value::class.java) 64 | if (targetType.isPresent) { 65 | return param(parameter.name, checkNotNull(conversionService.convert(value, targetType.get()))) 66 | } 67 | 68 | return when (value) { 69 | is Collection<*> -> param(parameter.name, convertCollection(value)) 70 | is Array<*> -> param(parameter.name, convertArray(value)) 71 | is Enum<*> -> param(parameter.name, value.name) 72 | else -> param(parameter.name, value) 73 | } 74 | } 75 | 76 | private fun convertCollection(collection: Collection<*>): Collection<*> = collection.map { 77 | if (it == null) { 78 | null 79 | } else { 80 | val targetType = customConversions.getCustomWriteTarget(it::class.java) 81 | if (targetType.isPresent) { 82 | conversionService.convert(it, targetType.get()) 83 | } else { 84 | it 85 | } 86 | } 87 | } 88 | 89 | private fun convertArray(array: Array<*>): Array<*> = array.map { 90 | if (it == null) { 91 | null 92 | } else { 93 | val targetType = customConversions.getCustomWriteTarget(it::class.java) 94 | if (targetType.isPresent) { 95 | conversionService.convert(it, targetType.get()) 96 | } else { 97 | it 98 | } 99 | } 100 | }.toTypedArray() 101 | 102 | @Suppress("TooManyFunctions") 103 | inner class FetchSpec( 104 | private val sqlId: String, 105 | private val sql: Sql, 106 | private val spec: StatementSpec, 107 | ) : KueryBlockingClient.FetchSpec { 108 | override fun singleMap(): Map = observe { 109 | spec.query().singleRow() 110 | } 111 | 112 | override fun singleMapOrNull(): Map? = observe { 113 | DataAccessUtils.singleResult(spec.query().listOfRows()) 114 | } 115 | 116 | override fun single(returnType: KClass): T = observe { 117 | spec.queryType(returnType).single() 118 | } 119 | 120 | override fun singleOrNull(returnType: KClass): T? = observe { 121 | DataAccessUtils.singleResult(spec.queryType(returnType).list()) 122 | } 123 | 124 | override fun listMap(): List> = observe { 125 | spec.query().listOfRows() 126 | } 127 | 128 | override fun list(returnType: KClass): List = observe { 129 | spec.queryType(returnType).list() 130 | } 131 | 132 | override fun rowsUpdated(): Long = observe { 133 | spec.update().toLong() 134 | } 135 | 136 | override fun generatedValues(vararg columns: String): Map = observe { 137 | val keyHolder = GeneratedKeyHolder() 138 | if (columns.isEmpty()) { 139 | spec.update(keyHolder) 140 | } else { 141 | spec.update(keyHolder, *columns) 142 | } 143 | checkNotNull(keyHolder.keys) 144 | } 145 | 146 | private fun observe(block: () -> T): T { 147 | return SqlIdInjector(sqlId).use { 148 | val observation = observationOrNull() ?: return block() 149 | 150 | observation.start() 151 | observation.openScope().use { 152 | try { 153 | block() 154 | } catch (@Suppress("TooGenericExceptionCaught") e: Throwable) { 155 | observation.error(e) 156 | throw e 157 | } finally { 158 | observation.stop() 159 | } 160 | } 161 | } 162 | } 163 | 164 | private fun observationOrNull(): Observation? { 165 | if (observationRegistry == null) { 166 | return null 167 | } 168 | return KueryClientObservationDocumentation.FETCH.observation( 169 | observationConvention, 170 | defaultObservationConvention, 171 | { KueryClientFetchContext(sqlId, sql) }, 172 | observationRegistry, 173 | ) 174 | } 175 | 176 | private fun StatementSpec.queryType(returnType: KClass): MappedQuerySpec { 177 | val cs = conversionService 178 | 179 | @Suppress("UNCHECKED_CAST") 180 | val mapper = rowMapperCache.computeIfAbsent(returnType) { 181 | // Align with Spring Data JDBC's [DefaultJdbcClient] behavior 182 | if (BeanUtils.isSimpleProperty(returnType.java)) { 183 | SingleColumnRowMapper(returnType.java).apply { 184 | setConversionService(cs) 185 | } 186 | } else { 187 | DataClassRowMapper(returnType.java).apply { 188 | setConversionService(cs) 189 | } 190 | } 191 | } as RowMapper 192 | return this.query(mapper) 193 | } 194 | } 195 | } 196 | --------------------------------------------------------------------------------