@@ -16,6 +16,7 @@ import com.google.devtools.ksp.symbol.KSType
1616import com.google.devtools.ksp.symbol.KSValueArgument
1717import com.google.devtools.ksp.symbol.KSValueParameter
1818import com.google.devtools.ksp.validate
19+ import io.github.smyrgeorge.sqlx4k.Statement
1920import java.io.OutputStream
2021
2122class 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 + = " \n object $implName : ${iface .qualifiedName()} {\n "
89+ file + = " \n object $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
0 commit comments