Skip to content

Commit 5e21e1d

Browse files
committed
Introduce extracted parameter properties in Statement interface, refactor RepositoryProcessor and TableProcessor, and replace QueryValidator with updated SqlValidator.
1 parent 095b688 commit 5e21e1d

File tree

7 files changed

+127
-60
lines changed

7 files changed

+127
-60
lines changed

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ jsqlparser = "5.3"
3232
# https://mvnrepository.com/artifact/org.apache.calcite/calcite-core/1.40.0
3333
# https://github.com/apache/calcite
3434
calcite = "1.40.0"
35+
# https://github.com/smyrgeorge/log4k
36+
log4k = "1.1.1"
3537

3638
[libraries]
3739
gradle-kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
@@ -49,6 +51,7 @@ r2dbc-mysql = { module = "io.asyncer:r2dbc-mysql", version.ref = "r2dbc-mysql" }
4951
r2dbc-postgresql = { module = "org.postgresql:r2dbc-postgresql", version.ref = "r2dbc-postgresql" }
5052
jsqlparser = { module = "com.github.jsqlparser:jsqlparser", version.ref = "jsqlparser" }
5153
calcite-core = { module = "org.apache.calcite:calcite-core", version.ref = "calcite" }
54+
log4k-slf4j = { module = "io.github.smyrgeorge:log4k-slf4j", version.ref = "log4k" }
5255

5356
[plugins]
5457
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }

sqlx4k-codegen/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ kotlin {
1212
}
1313
val jvmMain by getting {
1414
dependencies {
15+
api(project(":sqlx4k"))
1516
implementation(libs.ksp)
1617
implementation(libs.jsqlparser)
1718
implementation(libs.calcite.core)
19+
implementation(libs.log4k.slf4j)
1820
}
1921
}
2022
}

sqlx4k-codegen/src/jvmMain/kotlin/io/github/smyrgeorge/sqlx4k/processor/RepositoryProcessor.kt

Lines changed: 75 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.google.devtools.ksp.symbol.KSType
1616
import com.google.devtools.ksp.symbol.KSValueArgument
1717
import com.google.devtools.ksp.symbol.KSValueParameter
1818
import com.google.devtools.ksp.validate
19+
import io.github.smyrgeorge.sqlx4k.Statement
1920
import java.io.OutputStream
2021

2122
class RepositoryProcessor(
@@ -37,7 +38,6 @@ class RepositoryProcessor(
3738
val repoSymbols = resolver
3839
.getSymbolsWithAnnotation(TypeNames.REPOSITORY_ANNOTATION)
3940
.filterIsInstance<KSClassDeclaration>()
40-
4141
if (!repoSymbols.iterator().hasNext()) return emptyList()
4242

4343
val outputPackage = options[TableProcessor.PACKAGE_OPTION]
@@ -51,14 +51,16 @@ class RepositoryProcessor(
5151
logger.info("[RepositoryProcessor] Validate SQL schema: $globalCheckSqlSchema")
5252
val schemaMigrationsPath = options[SCHEMA_MIGRATIONS_PATH_OPTION] ?: "./set-the-path-to-schema-migrations"
5353

54-
if (globalCheckSqlSchema) QueryValidator.load(schemaMigrationsPath)
55-
56-
val outputFilename = "GeneratedRepositories"
54+
// Load schemas.
55+
if (globalCheckSqlSchema) SqlValidator.loadSchema(schemaMigrationsPath)
5756

5857
val file: OutputStream = codeGenerator.createNewFile(
58+
// Make sure to associate the generated file with sources to keep/maintain it across incremental builds.
59+
// Learn more about incremental processing in KSP from the official docs:
60+
// https://kotlinlang.org/docs/ksp-incremental.html
5961
dependencies = Dependencies(false, *resolver.getAllFiles().toList().toTypedArray()),
6062
packageName = outputPackage,
61-
fileName = outputFilename
63+
fileName = OUTPUT_FILENAME
6264
)
6365

6466
file += "// Generated by sqlx4k-codegen (RepositoryProcessor)\n"
@@ -69,22 +71,22 @@ class RepositoryProcessor(
6971

7072
// For each repository interface, find methods annotated with @Query
7173
val validatedRepos = repoSymbols.filter { it.validate() }
72-
validatedRepos.forEach { iface ->
73-
if (iface.classKind != ClassKind.INTERFACE)
74-
error("@Repository is only supported on interfaces (${iface.qualifiedName()}).")
74+
validatedRepos.forEach { repo ->
75+
if (repo.classKind != ClassKind.INTERFACE)
76+
error("@Repository is only supported on interfaces (${repo.qualifiedName()}).")
7577

7678
// Extract domain and mapper from @Repository annotation on the interface
77-
val (domainDecl, mapperTypeName) = parseRepositoryAnnotation(iface)
79+
val (domainDecl, mapperTypeName) = parseRepositoryAnnotation(repo)
7880

7981
// Find all methods declared in the interface
80-
val fnsAll = iface.declarations.filterIsInstance<KSFunctionDeclaration>().toList()
82+
val fnsAll = repo.declarations.filterIsInstance<KSFunctionDeclaration>().toList()
8183

8284
// Determine implementation class name
83-
val implName = iface.simpleName() + "Impl"
84-
logger.info("[RepositoryProcessor] Generating implementation object $implName for ${iface.qualifiedName()}")
85+
val implName = repo.simpleName() + "Impl"
86+
logger.info("[RepositoryProcessor] Generating implementation object $implName for ${repo.qualifiedName()}")
8587

8688
// Emit class header
87-
file += "\nobject $implName : ${iface.qualifiedName()} {\n"
89+
file += "\nobject $implName : ${repo.qualifiedName()} {\n"
8890

8991
// Generate @Query-based methods according to prefixes:
9092
// findAll/findAllBy/findOneBy/deleteBy/countBy/execute and also *All variants
@@ -172,21 +174,21 @@ class RepositoryProcessor(
172174
* Parses the `@Repository` annotation on a given interface and extracts the associated domain class
173175
* and mapper class information.
174176
*
175-
* @param iface The class declaration of the interface annotated with `@Repository`.
177+
* @param repo The class declaration of the interface annotated with `@Repository`.
176178
* @return A pair where the first element is the class declaration of the domain type and the second
177179
* element is the fully qualified name of the mapper class.
178180
* @throws IllegalStateException if the `@Repository` annotation is missing, incomplete, or improperly configured.
179181
*/
180-
private fun parseRepositoryAnnotation(iface: KSClassDeclaration): Pair<KSClassDeclaration, String> {
181-
fun implementsCrudRepository(iface: KSClassDeclaration): KSClassDeclaration {
182+
private fun parseRepositoryAnnotation(repo: KSClassDeclaration): Pair<KSClassDeclaration, String> {
183+
fun implementsCrudRepository(repo: KSClassDeclaration): KSClassDeclaration {
182184
// find CrudRepository<T>
183-
val st = iface.superTypes.map { it.resolve() }
185+
val st = repo.superTypes.map { it.resolve() }
184186
.firstOrNull { it.declaration.qualifiedName() == TypeNames.CRUD_REPOSITORY }
185-
?: error("@Repository interface ${iface.qualifiedName()} must extend ${TypeNames.CRUD_REPOSITORY}<T>")
187+
?: error("@Repository interface ${repo.qualifiedName()} must extend ${TypeNames.CRUD_REPOSITORY}<T>")
186188
val typeArg = st.arguments.firstOrNull()?.type?.resolve()
187-
?: error("${iface.qualifiedName()} implements CrudRepository without type argument; expected CrudRepository<T>")
189+
?: error("${repo.qualifiedName()} implements CrudRepository without type argument; expected CrudRepository<T>")
188190
val domainDecl = typeArg.declaration as? KSClassDeclaration
189-
?: error("CrudRepository type argument must be a class on ${iface.qualifiedName()}")
191+
?: error("CrudRepository type argument must be a class on ${repo.qualifiedName()}")
190192
// ensure @Table
191193
val hasTable = domainDecl.annotations.any {
192194
val qn = it.qualifiedName()
@@ -196,17 +198,17 @@ class RepositoryProcessor(
196198
return domainDecl
197199
}
198200

199-
val repoAnn = iface.annotations.firstOrNull { it.qualifiedName() == TypeNames.REPOSITORY_ANNOTATION }
200-
?: error("Missing @Repository annotation on interface ${iface.qualifiedName()}")
201+
val repoAnn = repo.annotations.firstOrNull { it.qualifiedName() == TypeNames.REPOSITORY_ANNOTATION }
202+
?: error("Missing @Repository annotation on interface ${repo.qualifiedName()}")
201203

202204
// Enforce that the interface extends CrudRepository<T> and derive domain from T
203-
val domainDecl = implementsCrudRepository(iface)
205+
val domainDecl = implementsCrudRepository(repo)
204206

205207
val mapperArg: KSValueArgument? = repoAnn.arguments.firstOrNull { it.name?.asString() == "mapper" }
206208
val mapperKSType = mapperArg?.value as? KSType
207-
?: error("@Repository must declare a mapper, e.g. @Repository(mapper = FooRowMapper::class) on ${iface.qualifiedName()}")
209+
?: error("@Repository must declare a mapper, e.g. @Repository(mapper = FooRowMapper::class) on ${repo.qualifiedName()}")
208210
val mapperTypeName = mapperKSType.declaration.qualifiedName()
209-
?: error("Unable to resolve mapper type for ${iface.qualifiedName()}")
211+
?: error("Unable to resolve mapper type for ${repo.qualifiedName()}")
210212
return domainDecl to mapperTypeName
211213
}
212214

@@ -234,14 +236,14 @@ class RepositoryProcessor(
234236
}
235237

236238
/**
237-
* Validates the return type of a repository method based on its prefix and ensures that it complies
239+
* Validates the return type of the repository method based on its prefix and ensures that it complies
238240
* with the expected return type structure and repository domain type.
239241
*
240242
* @param prefix The method prefix indicating the type of operation, such as FIND_ALL, DELETE_BY, etc.
241243
* @param fn The function declaration representing the repository method being validated.
242244
* @param domainDecl The class declaration of the repository's domain type to validate against.
243245
*/
244-
private fun validateReturnTypeForPrefix(prefix: Prefix, fn: KSFunctionDeclaration, domainDecl: KSClassDeclaration) {
246+
private fun validateReturnType(prefix: Prefix, fn: KSFunctionDeclaration, domainDecl: KSClassDeclaration) {
245247
val name = fn.simpleName()
246248
val returnType = fn.returnType?.resolve()
247249
?: error("Unable to resolve return type for method '$name'")
@@ -310,11 +312,35 @@ class RepositoryProcessor(
310312
}
311313
}
312314

315+
/**
316+
* Validates the parameters of a repository method against the SQL query and ensures consistency
317+
* between the method signature and the query's named parameters.
318+
*
319+
* @param sql The SQL query string provided in the `@Query` annotation.
320+
* @param fn The function declaration of the repository method being validated.
321+
* This includes information about the method name and its parameters.
322+
*/
323+
fun validateParameters(sql: String, fn: KSFunctionDeclaration) {
324+
val name = fn.simpleName()
325+
val statement = Statement.create(sql)
326+
if (statement.extractedPositionalParameters > 0)
327+
error("Method '$name' uses positional parameters in @Query (only named parameters are supported).")
328+
val parameters = fn.parameters.drop(1) // Exclude 'context' argument.
329+
if (parameters.size != statement.extractedNamedParameters.size)
330+
error("Method '$name' has ${parameters.size} parameters but @Query statement has ${statement.extractedNamedParameters.size} named parameters.")
331+
parameters.forEach { p ->
332+
val pName = p.name?.asString()
333+
?: error("All query parameters must be named when using namedParameters support")
334+
if (!statement.extractedNamedParameters.contains(pName))
335+
error("Method '$name' has parameter '$pName' but @Query statement does not contain a named parameter with that name.")
336+
}
337+
}
338+
313339
/**
314340
* Emits named parameter bindings for a given list of parameters into the specified output stream.
315341
* This method assumes that parameters after the first one must be named and generates binding statements accordingly.
316342
*
317-
* @param file The OutputStream where the named parameter binding statements will be written.
343+
* @param file The OutputStream where the named parameter-binding statements will be written.
318344
* @param params A list of KSValueParameter objects representing the parameters for which bindings are generated. The first parameter is excluded from processing.
319345
*/
320346
private fun emitNamedParameterBindings(file: OutputStream, params: List<KSValueParameter>) {
@@ -326,13 +352,15 @@ class RepositoryProcessor(
326352
}
327353

328354
/**
329-
* Emits a Kotlin method for executing a database query based on a function annotated with a custom `@Query` annotation.
330-
* This method generates the implementation for the annotated function, including SQL query execution and result handling.
355+
* Generates and writes the implementation for a repository method annotated with `@Query` into the provided output stream.
356+
* The method performs various validations such as syntax, schema, parameter consistency, and expected return type prior to generation.
331357
*
332-
* @param file The OutputStream where the method implementation will be written.
333-
* @param fn The function declaration representing the repository method annotated with `@Query`.
334-
* @param mapperTypeName The fully qualified name of the result mapper type used for mapping query results to domain objects.
335-
* @param domainDecl The class declaration of the domain object type associated with the repository method.
358+
* @param file The output stream where the generated method implementation will be written.
359+
* @param fn The function declaration of the repository method to be emitted. This includes the method name, parameters, and annotations.
360+
* @param validateSyntax A flag indicating whether the SQL query syntax should be validated before emitting the method.
361+
* @param validateSchema A flag indicating whether the SQL query schema consistency should be validated before emitting the method.
362+
* @param mapperTypeName The fully qualified name of the mapper type used for mapping query results to domain objects.
363+
* @param domainDecl The class declaration of the domain type associated with the repository being processed.
336364
*/
337365
private fun emitQueryMethod(
338366
file: OutputStream,
@@ -343,6 +371,7 @@ class RepositoryProcessor(
343371
domainDecl: KSClassDeclaration
344372
) {
345373
val name = fn.simpleName()
374+
val prefix: Prefix = parseMethodPrefix(name)
346375
logger.info("[RepositoryProcessor] Generating @Query method: $name")
347376

348377
val sql: String = fn.annotations.first { it.qualifiedName() == TypeNames.QUERY_ANNOTATION }
@@ -356,13 +385,13 @@ class RepositoryProcessor(
356385
val pType = p.type.toString()
357386
"$pName: $pType"
358387
}
359-
val prefix: Prefix = parseMethodPrefix(name)
388+
360389
validateContextParameter(name, params)
361390
validateParameterArity(prefix, name, params)
362-
validateReturnTypeForPrefix(prefix, fn, domainDecl)
363-
364-
if (validateSyntax) QueryValidator.validateQuerySyntax(fn.simpleName(), sql)
365-
if (validateSchema) QueryValidator.validateQuerySchema(fn.simpleName(), sql)
391+
validateParameters(sql, fn)
392+
validateReturnType(prefix, fn, domainDecl)
393+
if (validateSyntax) SqlValidator.validateQuerySyntax(fn.simpleName(), sql)
394+
if (validateSchema) SqlValidator.validateQuerySchema(fn.simpleName(), sql)
366395

367396
logger.info("[RepositoryProcessor] Emitting method '$name' with prefix ${prefix.name} in ${domainDecl.qualifiedName()} using mapper $mapperTypeName")
368397

@@ -472,6 +501,13 @@ class RepositoryProcessor(
472501
private fun KSAnnotation.qualifiedName(): String? = annotationType.resolve().declaration.qualifiedName?.asString()
473502

474503
companion object {
504+
/**
505+
* The default output file name used for generating repository implementations.
506+
* This constant is used in the code generation process to create output files
507+
* with a standardized and consistent naming convention.
508+
*/
509+
private const val OUTPUT_FILENAME = "GeneratedRepositories"
510+
475511
/**
476512
* Represents an option key used to enable or disable SQL syntax validation during the processing
477513
* of repository methods. Methods marked with the SQL validation flag will have their provided SQL

sqlx4k-codegen/src/jvmMain/kotlin/io/github/smyrgeorge/sqlx4k/processor/QueryValidator.kt renamed to sqlx4k-codegen/src/jvmMain/kotlin/io/github/smyrgeorge/sqlx4k/processor/SqlValidator.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import org.apache.calcite.sql.validate.SqlValidatorUtil
3030
import java.io.File
3131
import java.util.*
3232

33-
object QueryValidator {
33+
object SqlValidator {
3434
private lateinit var tables: List<TableDef>
3535
private lateinit var validator: SqlValidator
3636

@@ -63,7 +63,7 @@ object QueryValidator {
6363
}
6464
}
6565

66-
fun load(path: String) {
66+
fun loadSchema(path: String) {
6767
fun parseFileName(name: String): Long {
6868
val fileNamePattern = Regex("""^\s*(\d+)_([A-Za-z0-9._-]+)\.sql\s*$""")
6969
val name = name.trim()
@@ -129,8 +129,8 @@ object QueryValidator {
129129
}
130130
}
131131

132-
this.tables = schema.values.toList()
133-
this.validator = createCalciteValidator()
132+
tables = schema.values.toList()
133+
validator = createCalciteValidator()
134134
}
135135

136136
private fun createCalciteValidator(): SqlValidator {

sqlx4k-codegen/src/jvmMain/kotlin/io/github/smyrgeorge/sqlx4k/processor/TableProcessor.kt

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
package io.github.smyrgeorge.sqlx4k.processor
22

3-
import com.google.devtools.ksp.processing.*
4-
import com.google.devtools.ksp.symbol.*
3+
import com.google.devtools.ksp.processing.CodeGenerator
4+
import com.google.devtools.ksp.processing.Dependencies
5+
import com.google.devtools.ksp.processing.KSPLogger
6+
import com.google.devtools.ksp.processing.Resolver
7+
import com.google.devtools.ksp.processing.SymbolProcessor
8+
import com.google.devtools.ksp.symbol.KSAnnotated
9+
import com.google.devtools.ksp.symbol.KSAnnotation
10+
import com.google.devtools.ksp.symbol.KSClassDeclaration
11+
import com.google.devtools.ksp.symbol.KSPropertyDeclaration
12+
import com.google.devtools.ksp.symbol.KSValueArgument
13+
import com.google.devtools.ksp.symbol.KSVisitorVoid
514
import com.google.devtools.ksp.validate
615
import java.io.OutputStream
716

@@ -20,11 +29,9 @@ class TableProcessor(
2029
val symbols = resolver
2130
.getSymbolsWithAnnotation(TypeNames.TABLE_ANNOTATION)
2231
.filterIsInstance<KSClassDeclaration>()
23-
2432
if (!symbols.iterator().hasNext()) return emptyList()
2533

2634
val outputPackage = options[PACKAGE_OPTION] ?: error("Missing $PACKAGE_OPTION option")
27-
val outputFilename = "GeneratedQueries"
2835

2936
logger.info("sqlx4k-codegen: TableProcessor dialect = $dialect")
3037

@@ -34,7 +41,7 @@ class TableProcessor(
3441
// https://kotlinlang.org/docs/ksp-incremental.html
3542
dependencies = Dependencies(false, *resolver.getAllFiles().toList().toTypedArray()),
3643
packageName = outputPackage,
37-
fileName = outputFilename
44+
fileName = OUTPUT_FILENAME
3845
)
3946

4047
file += "// Generated by sqlx4k-codegen (TableProcessor)\n"
@@ -267,6 +274,13 @@ class TableProcessor(
267274
}
268275

269276
companion object {
277+
/**
278+
* The default filename used for storing or generating query-related output files.
279+
* Typically used within the code generation process to standardize the naming
280+
* of generated query files.
281+
*/
282+
private const val OUTPUT_FILENAME = "GeneratedQueries"
283+
270284
/**
271285
* Key used to specify the package name for the generated output classes.
272286
*/

sqlx4k/src/commonMain/kotlin/io/github/smyrgeorge/sqlx4k/Statement.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,18 @@ import kotlin.reflect.KClass
1010
*/
1111
interface Statement {
1212

13+
/**
14+
* A set containing the names of all named parameters extracted from the SQL statement.
15+
* It is populated using a parser that skips over string literals, comments, and
16+
* PostgreSQL dollar-quoted strings.
17+
*/
18+
val extractedNamedParameters: Set<String>
19+
20+
/**
21+
* The count of positional parameter placeholders ('?') extracted from the SQL query.
22+
*/
23+
val extractedPositionalParameters: Int
24+
1325
/**
1426
* Binds a value to a positional parameter in the statement based on the given index.
1527
*

0 commit comments

Comments
 (0)