diff --git a/docs/tutorials/kotlin.md b/docs/tutorials/kotlin.md index b419395253..2d9ca7217f 100644 --- a/docs/tutorials/kotlin.md +++ b/docs/tutorials/kotlin.md @@ -27,6 +27,62 @@ You also need the Querydsl module for your backend (e.g. `querydsl-jpa`, `querydsl-sql`). Code generation works the same as with Java — use the annotation processor for JPA or the Maven plugin for SQL. +## Code Generation for Kotlin + +There are two ways to generate Q-classes for a Kotlin codebase. Pick based on +your build system and how your entities are defined. + +### Pure Kotlin entities (Gradle, KSP) + +If your entities are Kotlin classes, use `querydsl-ksp-codegen` via the KSP +Gradle plugin. KSP runs as part of `kspKotlin` and emits `.kt` Q-classes: + +```kotlin +plugins { + kotlin("jvm") + id("com.google.devtools.ksp") version "" + kotlin("plugin.jpa") +} + +dependencies { + implementation("{{ site.group_id }}:querydsl-jpa:{{ site.querydsl_version }}") + ksp("{{ site.group_id }}:querydsl-ksp-codegen:{{ site.querydsl_version }}") +} +``` + +See the [`querydsl-ksp-codegen` README](https://github.com/OpenFeign/querydsl/blob/master/querydsl-tooling/querydsl-ksp-codegen/readme.md) +for the full list of `querydsl.*` settings (prefix, suffix, package suffix, +include/exclude filters). + +### Mixed Java/Kotlin entities (Gradle, KSP) + +When your project mixes Java entities with Kotlin queries, the standard +`querydsl-apt` Java processor doesn't work cleanly: Gradle compiles Kotlin +before Java, so Kotlin code can't see the Java Q-classes generated by APT. +`querydsl-ksp-codegen` handles this case — it picks up Java `@Entity` / +`@Embeddable` / `@MappedSuperclass` classes during `kspKotlin` and emits +Kotlin Q-classes that compile alongside your Kotlin sources. + +The Gradle setup is the same as for pure-Kotlin entities — no extra +configuration is needed beyond keeping your Java entities under `src/main/java`. +The runnable example +[`querydsl-examples/querydsl-example-ksp-codegen`](https://github.com/OpenFeign/querydsl/tree/master/querydsl-examples/querydsl-example-ksp-codegen) +mixes Kotlin entities (`Person`, `Cat`, …) with a Java entity (`Branch`, +self-referencing), all queried side-by-side from Kotlin tests. + +### Pure Kotlin entities (Maven, KAPT) + +KSP is not natively supported by `kotlin-maven-plugin`. On Maven, use KAPT +with `querydsl-kotlin-codegen` (which produces the same Kotlin Q-classes via +the legacy APT pipeline). See +[`querydsl-examples/querydsl-example-kotlin-codegen`](https://github.com/OpenFeign/querydsl/tree/master/querydsl-examples/querydsl-example-kotlin-codegen) +for a Maven-based reference. + +### Java entities only + +If your entities and queries are both Java, stick with `querydsl-apt` — +nothing on this page applies. + ## Kotlin Operator Extensions The `querydsl-kotlin` module provides operator overloads for Querydsl diff --git a/querydsl-examples/querydsl-example-ksp-codegen/gradle/wrapper/gradle-wrapper.jar b/querydsl-examples/querydsl-example-ksp-codegen/gradle/wrapper/gradle-wrapper.jar index d64cd49177..b1b8ef56b4 100755 Binary files a/querydsl-examples/querydsl-example-ksp-codegen/gradle/wrapper/gradle-wrapper.jar and b/querydsl-examples/querydsl-example-ksp-codegen/gradle/wrapper/gradle-wrapper.jar differ diff --git a/querydsl-examples/querydsl-example-ksp-codegen/gradle/wrapper/gradle-wrapper.properties b/querydsl-examples/querydsl-example-ksp-codegen/gradle/wrapper/gradle-wrapper.properties index 1af9e0930b..1a704683a0 100755 --- a/querydsl-examples/querydsl-example-ksp-codegen/gradle/wrapper/gradle-wrapper.properties +++ b/querydsl-examples/querydsl-example-ksp-codegen/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/querydsl-examples/querydsl-example-ksp-codegen/gradlew b/querydsl-examples/querydsl-example-ksp-codegen/gradlew index 1aa94a4269..b9bb139f79 100755 --- a/querydsl-examples/querydsl-example-ksp-codegen/gradlew +++ b/querydsl-examples/querydsl-example-ksp-codegen/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -170,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/querydsl-examples/querydsl-example-ksp-codegen/gradlew.bat b/querydsl-examples/querydsl-example-ksp-codegen/gradlew.bat index 93e3f59f13..24c62d56f2 100755 --- a/querydsl-examples/querydsl-example-ksp-codegen/gradlew.bat +++ b/querydsl-examples/querydsl-example-ksp-codegen/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -21,8 +23,8 @@ @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -43,13 +45,13 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -57,36 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/querydsl-examples/querydsl-example-ksp-codegen/src/main/java/com/querydsl/example/ksp/Branch.java b/querydsl-examples/querydsl-example-ksp-codegen/src/main/java/com/querydsl/example/ksp/Branch.java new file mode 100644 index 0000000000..0690f0ee85 --- /dev/null +++ b/querydsl-examples/querydsl-example-ksp-codegen/src/main/java/com/querydsl/example/ksp/Branch.java @@ -0,0 +1,62 @@ +package com.querydsl.example.ksp; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; + +/** + * A Java entity living alongside the Kotlin entities in this example. + * + *

Demonstrates the typical reason to reach for {@code querydsl-ksp-codegen}: + * with {@code querydsl-apt} a mixed Java/Kotlin Gradle project hits a chicken-and-egg + * compile order — Kotlin compiles before Java, so the Java Q-classes APT generates + * aren't on the classpath when Kotlin sources reference them. KSP runs as part of + * {@code kspKotlin}, sees Java sources alongside Kotlin sources, and emits {@code .kt} + * Q-classes that compile cleanly with the rest of the Kotlin code. + * + *

Also exercises the self-reference case: {@code parent} is itself a {@code Branch}, + * which under eager initialisation would stack-overflow at construction. The KSP + * processor emits the field as {@code by lazy} so each access creates one more level + * on demand. + */ +@Entity +public class Branch { + + @Id private Long id; + + private String name; + + @ManyToOne private Branch parent; + + public Branch() {} + + public Branch(Long id, String name, Branch parent) { + this.id = id; + this.name = name; + this.parent = parent; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Branch getParent() { + return parent; + } + + public void setParent(Branch parent) { + this.parent = parent; + } +} diff --git a/querydsl-examples/querydsl-example-ksp-codegen/src/test/kotlin/Tests.kt b/querydsl-examples/querydsl-example-ksp-codegen/src/test/kotlin/Tests.kt index f55cadb1b7..dcd0224572 100644 --- a/querydsl-examples/querydsl-example-ksp-codegen/src/test/kotlin/Tests.kt +++ b/querydsl-examples/querydsl-example-ksp-codegen/src/test/kotlin/Tests.kt @@ -1,5 +1,6 @@ import com.querydsl.example.ksp.Bear import com.querydsl.example.ksp.BearSpecies +import com.querydsl.example.ksp.Branch import com.querydsl.example.ksp.Cat import com.querydsl.example.ksp.CatType import com.querydsl.example.ksp.Dog @@ -7,6 +8,7 @@ import com.querydsl.example.ksp.Email import com.querydsl.example.ksp.Person import com.querydsl.example.ksp.QBear import com.querydsl.example.ksp.QBearSimplifiedProjection +import com.querydsl.example.ksp.QBranch import com.querydsl.example.ksp.QCat import com.querydsl.example.ksp.QDog import com.querydsl.example.ksp.QMyShape @@ -283,6 +285,36 @@ class Tests { } } + @Test + fun `select java entity from kotlin code`() { + // Branch is a Java @Entity sitting alongside the Kotlin entities. Demonstrates + // that querydsl-ksp-codegen picks up Java sources during kspKotlin and emits + // a .kt Q-class (QBranch) that Kotlin can use the same as QPerson, QCat, etc. + // Also exercises the self-reference shape: Branch.parent : Branch is rendered + // as `by lazy` non-null QBranch, so navigating arbitrary chains never overflows. + val emf = initialize() + val em = emf.createEntityManager() + em.transaction.begin() + val root = Branch(1L, "root", null) + em.persist(root) + em.persist(Branch(2L, "child", root)) + em.transaction.commit() + + val q = QBranch.branch + val child = JPAQueryFactory(em) + .selectFrom(q) + .where(q.name.eq("child")) + .fetchOne() + + if (child == null) { + fail("No child Branch was returned") + } else { + assertThat(child.id).isEqualTo(2L) + assertThat(child.parent.id).isEqualTo(1L) + } + em.close() + } + @Test fun ensureCorrectGeoType() { val departureProperty = QMyShape::class.memberProperties.single { it.name == "departureGeo" } @@ -300,6 +332,7 @@ class Tests { .addAnnotatedClass(Cat::class.java) .addAnnotatedClass(Dog::class.java) .addAnnotatedClass(Bear::class.java) + .addAnnotatedClass(Branch::class.java) return configuration .buildSessionFactory() diff --git a/querydsl-tooling/querydsl-ksp-codegen/pom.xml b/querydsl-tooling/querydsl-ksp-codegen/pom.xml index ea87bcb265..4e8ce4e63b 100644 --- a/querydsl-tooling/querydsl-ksp-codegen/pom.xml +++ b/querydsl-tooling/querydsl-ksp-codegen/pom.xml @@ -58,6 +58,45 @@ kotlin-script-runtime test + + dev.zacsweers.kctfork + core + 0.12.1 + test + + + dev.zacsweers.kctfork + ksp + 0.12.1 + test + + + + com.google.devtools.ksp + symbol-processing + + + + + + com.google.devtools.ksp + symbol-processing-aa-embeddable + ${ksp.version} + test + + + com.google.devtools.ksp + symbol-processing-common-deps + ${ksp.version} + test + diff --git a/querydsl-tooling/querydsl-ksp-codegen/readme.md b/querydsl-tooling/querydsl-ksp-codegen/readme.md index 9250e86aad..7f4a6015fa 100644 --- a/querydsl-tooling/querydsl-ksp-codegen/readme.md +++ b/querydsl-tooling/querydsl-ksp-codegen/readme.md @@ -4,6 +4,10 @@ Supports only jakarta annotations. Let us know if you want support for other annotations. +Both **pure-Kotlin** entities and **Java entities consumed from Kotlin** are +supported (see "Mixed Java/Kotlin sources" below). The processor runs under +KSP2, which is the default runtime for the KSP Gradle plugin since the 2.x line. + ## Setup ### Gradle @@ -66,3 +70,48 @@ ksp { arg("querydsl.excludedPackages", "com.example, com.sample") } ``` + +## Mixed Java/Kotlin sources + +A common reason to use this processor instead of `querydsl-apt` is when the +**entities are Java** but the queries are written in **Kotlin**. With Gradle's +default Java/Kotlin compile order, `querydsl-apt`'s Java Q-classes aren't on +the classpath when Kotlin sources compile, so Kotlin code can't reference them. +KSP runs as part of `kspKotlin`, sees Java sources alongside Kotlin sources, +and emits `.kt` Q-classes that Kotlin can use immediately. + +No extra plugin args are required — drop your `@Entity` Java classes under +`src/main/java` and they'll be picked up: + +```java +// src/main/java/com/example/Person.java +@Entity +public class Person { + @Id private Long id; + private String name; + @Embedded private Address address; + @Transient private String cachedDisplay; // skipped + public static final String CONSTANT = "x"; // skipped + // getters/setters... +} +``` + +```kotlin +// src/main/kotlin/com/example/Repository.kt +val q = QPerson.person +JPAQueryFactory(em).selectFrom(q).where(q.address.city.eq("London")).fetch() +``` + +A complete runnable Gradle project lives in +[querydsl-examples/querydsl-example-ksp-codegen](../../querydsl-examples/querydsl-example-ksp-codegen). +It carries Kotlin entities under `src/main/kotlin` and a Java entity (`Branch`, +self-referencing) under `src/main/java`, both queried side-by-side from Kotlin +tests — the same shape a typical mixed Java/Kotlin codebase has. + +### Limitations + +- JPA `@Access(PROPERTY)` (annotations on getters) is **not yet supported** — + put your JPA annotations on fields. This matches `querydsl-apt`'s default. +- Maven KSP integration is out of scope; `kotlin-maven-plugin` does not natively + run KSP processors. Use Gradle for KSP, or fall back to KAPT + + `querydsl-kotlin-codegen` on Maven. diff --git a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QPropertyType.kt b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QPropertyType.kt index 998db6e2e0..af6f06373a 100644 --- a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QPropertyType.kt +++ b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QPropertyType.kt @@ -1,5 +1,6 @@ package com.querydsl.ksp.codegen +import com.querydsl.core.types.dsl.CollectionPath import com.querydsl.core.types.dsl.EnumPath import com.querydsl.core.types.dsl.ListPath import com.querydsl.core.types.dsl.MapPath @@ -49,6 +50,22 @@ sealed interface QPropertyType { get() = SetPath::class.asClassName().parameterizedBy(innerType.originalTypeName, innerType.pathTypeName) } + class CollectionCollection( + val innerType: QPropertyType + ) : QPropertyType { + override val originalClassName: ClassName + get() = Collection::class.asClassName() + + override val originalTypeName: TypeName + get() = Collection::class.asTypeName().parameterizedBy(innerType.originalTypeName) + + override val pathClassName: ClassName + get() = CollectionPath::class.asClassName() + + override val pathTypeName: TypeName + get() = CollectionPath::class.asClassName().parameterizedBy(innerType.originalTypeName, innerType.pathTypeName) + } + class MapCollection( val keyType: QPropertyType, val valueType: QPropertyType diff --git a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryDslProcessor.kt b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryDslProcessor.kt index 8933601ef7..a5ccf9b22c 100644 --- a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryDslProcessor.kt +++ b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryDslProcessor.kt @@ -5,6 +5,7 @@ import com.google.devtools.ksp.processing.CodeGenerator import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.symbol.* +import com.google.devtools.ksp.validate import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ksp.writeTo @@ -12,61 +13,78 @@ class QueryDslProcessor( private val settings: KspSettings, private val codeGenerator: CodeGenerator ) : SymbolProcessor { - val typeProcessor = QueryModelExtractor(settings) + private val rendered = mutableSetOf() override fun process(resolver: Resolver): List { - if (settings.enable) { - QueryModelType.entries.forEach { type -> - type.associatedAnnotations.forEach { associatedAnnotation -> - resolver.getSymbolsWithAnnotation(associatedAnnotation) - .forEach { declaration -> - when { - type == QueryModelType.QUERY_PROJECTION -> { - val errorMessage = "$associatedAnnotation annotation" + - " must be declared on a constructor function or class" - when (declaration) { - is KSFunctionDeclaration -> { - if (!declaration.isConstructor()) error(errorMessage) - val parentDeclaration = declaration.parent as? KSClassDeclaration - ?: error(errorMessage) - if (isIncluded(parentDeclaration)) { - typeProcessor.addConstructor(parentDeclaration, declaration) - } - } - is KSClassDeclaration -> { - if (isIncluded(declaration)) { - typeProcessor.addClass(declaration, type) - } + if (!settings.enable) return emptyList() + // Fresh extractor per round: under KSP2 the analysis-API lifetime is + // scoped to one process() invocation, so KSClassDeclaration references + // captured in a previous round are invalid here. + val typeProcessor = QueryModelExtractor(settings) + + // KSP2 invalidates analysis-API lifetime tokens for symbols whose PSI is + // not yet stable; reading those throws KaInvalidLifetimeOwnerAccessException + // ("PSI has changed since creation"). The canonical fix is to call + // validate() on each symbol and defer the unprocessable ones to a later + // round. Deferred symbols are returned from process() and KSP re-invokes + // us once their PSI has settled. + val deferred = mutableListOf() + + QueryModelType.entries.forEach { type -> + type.associatedAnnotations.forEach { associatedAnnotation -> + resolver.getSymbolsWithAnnotation(associatedAnnotation) + .forEach { declaration -> + if (!declaration.validate()) { + deferred += declaration + return@forEach + } + when { + type == QueryModelType.QUERY_PROJECTION -> { + val errorMessage = "$associatedAnnotation annotation" + + " must be declared on a constructor function or class" + when (declaration) { + is KSFunctionDeclaration -> { + if (!declaration.isConstructor()) error(errorMessage) + val parentDeclaration = declaration.parent as? KSClassDeclaration + ?: error(errorMessage) + if (isIncluded(parentDeclaration)) { + typeProcessor.addConstructor(parentDeclaration, declaration) } - else -> error(errorMessage) } - } - declaration is KSClassDeclaration -> { - if (isIncluded(declaration)) { - typeProcessor.addClass(declaration, type) + is KSClassDeclaration -> { + if (isIncluded(declaration)) { + typeProcessor.addClass(declaration, type) + } } + else -> error(errorMessage) } - else -> { - error("Annotated element was expected to be class or constructor, instead got ${declaration}") + } + declaration is KSClassDeclaration -> { + if (isIncluded(declaration)) { + typeProcessor.addClass(declaration, type) } } + else -> { + error("Annotated element was expected to be class or constructor, instead got ${declaration}") + } } - } - - } + } + } } - return emptyList() - } - override fun finish() { + // Extract properties and render now, while the analysis-API session is still live. + // KSP2 invalidates symbol references after process() returns, so deferring this work + // to finish() throws KaInvalidLifetimeOwnerAccessException. val models = typeProcessor.process() models.forEach { model -> if (model.originatingFile == null) { - // skip models without originating file. This happens when the model is from a compiled dependency, - // so we don't have access to the source file. It is expected the Q class is packaged alongside the - // compiled dependency. + // Skip models without an originating file. This happens when the model comes + // from a compiled dependency rather than a source file in the current module. + // The Q-class is expected to be packaged alongside the compiled dependency. return@forEach } + val canonical = model.className.canonicalName + if (!rendered.add(canonical)) return@forEach val typeSpec = QueryModelRenderer.render(model) FileSpec.builder(model.className) @@ -79,6 +97,8 @@ class QueryDslProcessor( originatingKSFiles = listOf(model.originatingFile) ) } + + return deferred } private fun isIncluded(declaration: KSClassDeclaration): Boolean { diff --git a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelExtractor.kt b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelExtractor.kt index 071facea91..7d3cacd43e 100644 --- a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelExtractor.kt +++ b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelExtractor.kt @@ -2,10 +2,12 @@ package com.querydsl.ksp.codegen import com.google.devtools.ksp.getDeclaredProperties import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSFunction import com.google.devtools.ksp.symbol.KSFunctionDeclaration import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.Modifier import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.ksp.toClassName @@ -14,7 +16,7 @@ import jakarta.persistence.Transient class QueryModelExtractor( private val settings: KspSettings ) { - private val transientClassName = Transient::class.asClassName() + private val transientSimpleName = Transient::class.simpleName!! private val processed = mutableMapOf() fun addConstructor(classDeclaration: KSClassDeclaration, constructor: KSFunctionDeclaration): QueryModel { @@ -99,41 +101,65 @@ class QueryModelExtractor( } private fun extractPropertiesForClass(declaration: KSClassDeclaration): List { - return declaration - .getDeclaredProperties() - .filter { !it.isTransient() } - .filter { !it.isGetterTransient() } - .filter { it.hasBackingField } - .map { property -> - val propName = property.simpleName.asString() - val extractor = TypeExtractor( - settings, - property.simpleName.asString(), - property.annotations - ) - val type = extractor.extract(property.type.resolve()) - QProperty(propName, type) - } - .toList() + // Materialise the property sequence eagerly: KSP2's analysis-API + // lifetime tokens can advance between lazy steps, so the chained + // sequence form throws KaInvalidLifetimeOwnerAccessException at + // type.resolve() time. A list-based iteration keeps each property's + // resolution within a single live context. + val properties = declaration.getDeclaredProperties().toList() + val result = mutableListOf() + for (property in properties) { + if (property.isTransient() || property.isGetterTransient()) continue + // Exclude Java `static` and `transient` (the keyword, not the + // annotation) fields. KSP surfaces them as KSPropertyDeclaration + // alongside instance fields, but they're not persisted. + if (Modifier.JAVA_STATIC in property.modifiers) continue + if (Modifier.JAVA_TRANSIENT in property.modifiers) continue + if (!property.hasBackingField) continue + val propName = property.simpleName.asString() + val extractor = TypeExtractor( + settings, + propName, + property.annotations + ) + val type = extractor.extract(property.type.resolve()) + result += QProperty(propName, type) + } + return result } private fun KSPropertyDeclaration.isTransient(): Boolean { - return annotations.any { it.annotationType.resolve().toClassName() == transientClassName } + return annotations.any { it.hasSimpleName(transientSimpleName) } } private fun KSPropertyDeclaration.isGetterTransient(): Boolean { return this.getter?.let { getter -> - getter.annotations.any { it.annotationType.resolve().toClassName() == transientClassName } + getter.annotations.any { it.hasSimpleName(transientSimpleName) } } ?: false } + // Match by KSAnnotation.shortName (a KSName) instead of resolving the + // annotation type to a ClassName. Calling toClassName() / KSType.declaration + // under KSP2 traverses analysis-API lifetime tokens that can throw + // KaInvalidLifetimeOwnerAccessException ("PSI has changed since creation"). + // Simple-name match is accurate for the JPA annotations we recognise. + private fun KSAnnotation.hasSimpleName(name: String): Boolean { + return shortName.asString() == name + } + private fun KSClassDeclaration.superclassOrNull(): KSClassDeclaration? { for (superType in superTypes) { val resolvedType = superType.resolve() val declaration = resolvedType.declaration if (declaration is KSClassDeclaration) { - val superClassName = declaration.toClassName() - if (declaration.classKind == ClassKind.CLASS && superClassName != Any::class.asClassName()) { + // Compare against kotlin.Any via qualifiedName rather than + // KotlinPoet's toClassName(): the latter calls isError() on the + // resolved type, which invalidates KSP2 analysis-API lifetime + // tokens for any KSType resolved later in the same process() + // invocation (manifests as KaInvalidLifetimeOwnerAccessException + // when we later resolve property types). + val fqn = declaration.qualifiedName?.asString() + if (declaration.classKind == ClassKind.CLASS && fqn != "kotlin.Any") { return declaration } } @@ -143,7 +169,13 @@ class QueryModelExtractor( private fun toQueryModel(classDeclaration: KSClassDeclaration, type: QueryModelType, constructor: KSFunctionDeclaration?): QueryModel { return QueryModel( - originalClassName = classDeclaration.toClassName(), + // Build the ClassName from raw KSP names to avoid kotlinpoet-ksp's + // toClassName(), which invalidates KSP2 lifetime tokens (see + // superclassOrNull above for the same workaround). + originalClassName = ClassName( + classDeclaration.packageName.asString(), + classDeclaration.simpleName.asString() + ), typeParameterCount = classDeclaration.typeParameters.size, className = queryClassName(classDeclaration, settings), type = type, diff --git a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelRenderer.kt b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelRenderer.kt index 94950a2a0c..fc63d17401 100644 --- a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelRenderer.kt +++ b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelRenderer.kt @@ -54,15 +54,14 @@ object QueryModelRenderer { private fun TypeSpec.Builder.addSuperProperty(model: QueryModel): TypeSpec.Builder { model.superclass?.let { superclass -> + // Eager @JvmField: exposed as a Java field, and inherited + // @JvmField properties below can read `_super.x` directly. + // Construction recursion follows the inheritance chain (acyclic + // by language rule), so eager init is safe. val superProperty = PropertySpec .builder("_super", superclass.className) - .delegate( - CodeBlock.builder() - .beginControlFlow("lazy") - .addStatement("${superclass.className}(this)") - .endControlFlow() - .build() - ) + .initializer("${superclass.className}(this)") + .addAnnotation(JvmField::class) .build() addProperty(superProperty) } @@ -93,12 +92,40 @@ object QueryModelRenderer { } private fun renderInheritedProperty(property: QProperty): PropertySpec { + val type = property.type + if (type is QPropertyType.ObjectReference) { + // Defer inherited object references with `by lazy`. An eager + // `@JvmField val foo = _super.foo` reads the parent's per-instance + // `by lazy` during the child's construction, which for a + // mapped-superclass that `@ManyToOne`s back to a concrete + // subclass (e.g. `Auditable.createdBy : User` with `User extends + // Auditable`) triggers infinite construction recursion — every + // new child level allocates a fresh parent whose own lazy then + // allocates another child. Lazy here, paired with @get:JvmName + // to drop the `get` prefix, mirrors how we render direct object + // references and keeps the construction tree bounded to the + // depth the caller actually navigates. + val getterJvmName = AnnotationSpec.builder(JvmName::class) + .useSiteTarget(AnnotationSpec.UseSiteTarget.GET) + .addMember("%S", property.name) + .build() + return PropertySpec.builder(property.name, type.queryClassName) + .delegate( + CodeBlock.builder() + .beginControlFlow("lazy") + .addStatement("_super.${property.name}") + .endControlFlow() + .build() + ) + .addAnnotation(getterJvmName) + .build() + } + // Eager @JvmField for scalars / enums / collections — these don't + // recurse, so eager init is safe and Java consumers get field-style + // access (`qChild.actived` rather than `qChild.getActived()`). return PropertySpec.builder(property.name, property.type.pathTypeName) - .getter( - FunSpec.getterBuilder() - .addCode("return _super.${property.name}") - .build() - ) + .initializer("_super.${property.name}") + .addAnnotation(JvmField::class) .build() } @@ -132,6 +159,13 @@ object QueryModelRenderer { .initializer("createSet(\"$name\", ${inner.originalClassName}::class.java, ${inner.pathClassName}::class.java, null)") .build() } + is QPropertyType.CollectionCollection -> { + val inner = type.innerType + PropertySpec + .builder(name, CollectionPath::class.asClassName().parameterizedBy(inner.originalTypeName, inner.pathTypeName)) + .initializer("createCollection(\"$name\", ${inner.originalClassName}::class.java, ${inner.pathClassName}::class.java, null)") + .build() + } } } @@ -150,6 +184,17 @@ object QueryModelRenderer { } private fun renderObjectReference(name: String, type: QPropertyType.ObjectReference): PropertySpec { + // Object references are `by lazy` — defers construction so self-referential + // entities don't stack-overflow at init time, and the type stays non-null + // for ergonomic Kotlin queries (`q.headOffice.id` with no `?.`). + // + // Drop the synthesised `get` prefix on the JVM getter so Java consumers see + // `q.headOffice()` instead of `q.getHeadOffice()` — closer to the field-style + // access they get from querydsl-apt-generated Q-classes. + val getterJvmName = AnnotationSpec.builder(JvmName::class) + .useSiteTarget(AnnotationSpec.UseSiteTarget.GET) + .addMember("%S", name) + .build() return PropertySpec .builder(name, type.queryClassName) .delegate( @@ -159,6 +204,7 @@ object QueryModelRenderer { .endControlFlow() .build() ) + .addAnnotation(getterJvmName) .build() } diff --git a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelType.kt b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelType.kt index 8eed2f4c8d..9324551f59 100644 --- a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelType.kt +++ b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/QueryModelType.kt @@ -3,7 +3,6 @@ package com.querydsl.ksp.codegen import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration import com.querydsl.core.annotations.QueryProjection -import com.squareup.kotlinpoet.ksp.toTypeName import jakarta.persistence.Embeddable import jakarta.persistence.Entity import jakarta.persistence.MappedSuperclass @@ -26,7 +25,7 @@ enum class QueryModelType( fun autodetect(classDeclaration: KSClassDeclaration): QueryModelType? { for (annotation in classDeclaration.annotations) { for (type in QueryModelType.entries) { - if (type.associatedAnnotations.any { ann -> annotation.isEqualTo(ann) }) { + if (type.associatedAnnotations.any { ann -> annotation.matches(ann) }) { return type } } @@ -34,8 +33,22 @@ enum class QueryModelType( return null } - private fun KSAnnotation.isEqualTo(annotationQualifiedName: String): Boolean { - return annotationType.toTypeName().toString() == annotationQualifiedName + /** + * Match a [KSAnnotation] against a fully-qualified annotation name. This is + * called for *referenced* classes (during property type resolution and + * superclass lookup), where we have no FQN pre-filter — a user's own + * `@com.example.Entity` must not be mistaken for `@jakarta.persistence.Entity`. + * + * Uses the symbol API's qualifiedName rather than kotlinpoet-ksp's + * `toClassName()`/`toTypeName()`: those traverse analysis-API lifetime + * tokens and can throw `KaInvalidLifetimeOwnerAccessException` under KSP2. + * The shortName check is a cheap prefilter that avoids the resolve+lookup + * for the common case. + */ + private fun KSAnnotation.matches(annotationQualifiedName: String): Boolean { + val simpleName = annotationQualifiedName.substringAfterLast('.') + if (shortName.asString() != simpleName) return false + return annotationType.resolve().declaration.qualifiedName?.asString() == annotationQualifiedName } } } diff --git a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/TypeExtractor.kt b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/TypeExtractor.kt index 23e6722f9e..269f4a6cf3 100644 --- a/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/TypeExtractor.kt +++ b/querydsl-tooling/querydsl-ksp-codegen/src/main/kotlin/com/querydsl/ksp/codegen/TypeExtractor.kt @@ -65,8 +65,17 @@ class TypeExtractor( Comparable::class.java.canonicalName, java.lang.Comparable::class.qualifiedName ) - declaration.getAllSuperTypes().any { - comparableNames.contains(it.toClassName().canonicalName) + // Match supertypes by their declaration's qualified name. KSType.toClassName() + // (the kotlinpoet-ksp extension) throws IllegalStateException for parameterized + // types — getAllSuperTypes() routinely yields Comparable, Collection, + // Iterable, etc., so iterating with toClassName() blows up on any entity that + // implements a parameterized interface (Set, List, custom Comparable + // wrappers). The declaration's qualifiedName carries the same identity without + // the type-args check. + declaration.getAllSuperTypes().any { superType -> + val superDecl = superType.declaration + superDecl is KSClassDeclaration && + comparableNames.contains(superDecl.qualifiedName?.asString()) } } else { false @@ -110,6 +119,17 @@ class TypeExtractor( assertTypeArgCount(type, "set", 1) val innerType = extract(type.arguments.single().type!!.resolve()) return QPropertyType.SetCollection(innerType) + } else if (classNames.any { it.isCollection() }) { + // Plain Collection / Iterable (e.g. JPA `Collection` from Java). + // Checked AFTER List/Set so concrete sub-interfaces still produce ListPath/SetPath. + // Walk type.arguments first (the original use-site), then any supertype that + // carries the element type — handles cases where the entity field is declared + // as Iterable but resolves through a parameterized supertype chain. + val carrier = types.firstOrNull { it.arguments.size == 1 } ?: type + val arg = carrier.arguments.singleOrNull()?.type + ?: throwError("Unable to resolve element type for collection") + val innerType = extract(arg.resolve()) + QPropertyType.CollectionCollection(innerType) } else if (classNames.any { it.isArray() }) { throwError("Unable to process type Array, Consider using List or Set instead") } else { @@ -149,16 +169,25 @@ class TypeExtractor( return null } - val userTypeAnnotations = listOf( - ClassName("org.hibernate.annotations", "Type"), - ClassName("org.hibernate.annotations", "JdbcTypeCode"), - Convert::class.asClassName() + // Compare annotations by their fully-qualified name via the symbol API + // rather than kotlinpoet-ksp's toClassName(): the latter goes through + // KSType.declaration / isError() which traverses analysis-API lifetime + // tokens and can throw KaInvalidLifetimeOwnerAccessException under KSP2 + // for any property whose annotations include @Convert / @Type / etc. + // The shortName check is a cheap prefilter so the common (no annotation) + // case never resolves at all. + val userTypeFqns = listOf( + "org.hibernate.annotations.Type", + "org.hibernate.annotations.JdbcTypeCode", + Convert::class.qualifiedName!! ) - if (annotations.any { userTypeAnnotations.contains(it.annotationType.resolve().toClassName()) }) { - return QPropertyType.Unknown(type.toClassNameSimple(), type.toTypeName()) - } else { - return null + val userSimpleNames = userTypeFqns.mapTo(mutableSetOf()) { it.substringAfterLast('.') } + val match = annotations.any { ann -> + if (ann.shortName.asString() !in userSimpleNames) return@any false + val fqn = ann.annotationType.resolve().declaration.qualifiedName?.asString() + fqn in userTypeFqns } + return if (match) QPropertyType.Unknown(type.toClassNameSimple(), type.toTypeName()) else null } private fun assertTypeArgCount(parentType: KSType, collectionTypeName: String, count: Int) { @@ -193,6 +222,15 @@ private fun ClassName.isList(): Boolean { ).contains(this) } +private fun ClassName.isCollection(): Boolean { + return listOf( + Collection::class.asClassName(), + ClassName("kotlin.collections", "MutableCollection"), + Iterable::class.asClassName(), + ClassName("kotlin.collections", "MutableIterable") + ).contains(this) +} + private fun ClassName.isArray(): Boolean { return this == Array::class.asClassName() } diff --git a/querydsl-tooling/querydsl-ksp-codegen/src/test/kotlin/KspProcessorIntegrationTest.kt b/querydsl-tooling/querydsl-ksp-codegen/src/test/kotlin/KspProcessorIntegrationTest.kt new file mode 100644 index 0000000000..40f1fe0454 --- /dev/null +++ b/querydsl-tooling/querydsl-ksp-codegen/src/test/kotlin/KspProcessorIntegrationTest.kt @@ -0,0 +1,679 @@ +import com.querydsl.ksp.codegen.QueryDslProcessorProvider +import com.tschuchort.compiletesting.JvmCompilationResult +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.configureKsp +import com.tschuchort.compiletesting.kspSourcesDir +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.Test +import java.io.File +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicReference + +@OptIn(ExperimentalCompilerApi::class) +class KspProcessorIntegrationTest { + + @Test + fun kotlinEntity_generatesQClassUnderKsp2() { + val source = SourceFile.kotlin( + "User.kt", + """ + package test + + import jakarta.persistence.Entity + import jakarta.persistence.Id + + @Entity + class User { + @Id + var id: Long = 0 + var name: String = "" + var active: Boolean = false + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(source) + + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + assertThat(result.messages).doesNotContain("KaInvalidLifetimeOwnerAccessException") + + val qUser = generatedDir.findGenerated("QUser.kt") + assertThat(qUser.readText()) + .contains("class QUser") + .contains("createNumber(\"id\"") + .contains("createString(\"name\")") + .contains("createBoolean(\"active\")") + } + + @Test + fun javaEntity_generatesQClass() { + val source = SourceFile.java( + "User.java", + """ + package test; + + import jakarta.persistence.Entity; + import jakarta.persistence.Id; + import jakarta.persistence.Transient; + + @Entity + public class User { + @Id + public Long id; + public String name; + public boolean active; + @Transient + public String tempField; + public static final String CONSTANT = "x"; + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(source) + + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + + val qUser = generatedDir.findGenerated("QUser.kt").readText() + assertThat(qUser) + .contains("class QUser") + .contains("createNumber(\"id\"") + .contains("createString(\"name\")") + .contains("createBoolean(\"active\")") + // @Transient field excluded + .doesNotContain("\"tempField\"") + // static final excluded + .doesNotContain("\"CONSTANT\"") + } + + @Test + fun objectReferences_areLazyAndNonNull_otherFieldsAreJvmField() { + // Hybrid rendering: + // * scalar paths, _super, and inherited properties → eager @JvmField + // (Java consumers can do qFoo.actived as field-chained access) + // * @ManyToOne / object refs → `by lazy`, non-null type + // (handles self-references without StackOverflow at construction; + // Kotlin queries get non-null `qFoo.evaluation` without `?.`) + val parent = SourceFile.java( + "Quality.java", + """ + package test; + import jakarta.persistence.MappedSuperclass; + @MappedSuperclass + public class Quality { + public boolean actived; + } + """.trimIndent() + ) + val child = SourceFile.java( + "Branch.java", + """ + package test; + import jakarta.persistence.Entity; + import jakarta.persistence.Id; + import jakarta.persistence.ManyToOne; + @Entity + public class Branch extends Quality { + @Id public Long id; + public String name; + @ManyToOne private Branch parent; // self-reference + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(parent, child) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + + val qBranch = generatedDir.findGenerated("QBranch.kt").readText() + assertThat(qBranch) + // _super eager @JvmField + .contains("@JvmField") + .contains("public val _super: QQuality = test.QQuality(this)") + // Inherited property eager @JvmField + .contains("public val actived: BooleanPath = _super.actived") + // Scalar path eager @JvmField + .contains("public val name: StringPath = createString(\"name\")") + // Self-reference lazy + non-null (no `?` on the type, no + // construction-time recursion) + .contains("public val parent: QBranch by lazy") + .doesNotContain("public val parent: QBranch?") + // The synthesised JVM getter is renamed to drop the `get` prefix + // so Java callers can write `qBranch.parent()` instead of + // `qBranch.getParent()`. + .contains("@get:JvmName(\"parent\")") + } + + @Test + fun javaEntity_extendsJavaMappedSuperclass() { + val parent = SourceFile.java( + "Animal.java", + """ + package test; + import jakarta.persistence.MappedSuperclass; + + @MappedSuperclass + public class Animal { + public Long age; + } + """.trimIndent() + ) + val child = SourceFile.java( + "Cat.java", + """ + package test; + import jakarta.persistence.Entity; + import jakarta.persistence.Id; + + @Entity + public class Cat extends Animal { + @Id + public Long id; + public String name; + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(parent, child) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + + val qCat = generatedDir.findGenerated("QCat.kt").readText() + assertThat(qCat) + .contains("class QCat") + .contains("createString(\"name\")") + .contains("_super: QAnimal") + assertThat(generatedDir.findGenerated("QAnimal.kt").readText()) + .contains("createNumber(\"age\"") + } + + @Test + fun entityWithCollectionProperty_doesNotCrashOnParameterizedSupertype() { + // Regression: TypeExtractor.fallbackType used to call KSType.toClassName() + // on every supertype returned by getAllSuperTypes(). For an entity with a + // Set / List property whose element type falls through to + // fallbackType (e.g. an enum-like or custom wrapper), getAllSuperTypes() + // includes parameterized Collection / Iterable, and KotlinPoet rejects + // parameterized KSType with IllegalStateException("KSType '...' has type + // arguments"). See OpenFeign/querydsl#1688. + val source = SourceFile.kotlin( + "Foo.kt", + """ + package test + + import jakarta.persistence.ElementCollection + import jakarta.persistence.Entity + import jakarta.persistence.Id + + @Entity + class Foo { + @Id + var id: Long = 0 + @ElementCollection + var tags: MutableSet = mutableSetOf() + @ElementCollection + var labels: MutableList = mutableListOf() + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(source) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + assertThat(result.messages) + .doesNotContain("has type arguments, which are not supported for ClassName conversion") + + val qFoo = generatedDir.findGenerated("QFoo.kt").readText() + assertThat(qFoo) + .contains("class QFoo") + .contains("createSet(\"tags\"") + .contains("createList(\"labels\"") + } + + @Test + fun javaEntityWithCollectionField_emitsCollectionPath() { + // Java entities frequently declare @OneToMany fields as Collection or + // Iterable rather than Set/List. collectionType() previously only + // recognised Set/List/Map and fell through to fallbackType for plain + // Collection — which then emitted SimplePath (no type + // arg → invalid Kotlin). + val degree = SourceFile.java( + "Degree.java", + """ + package test; + import jakarta.persistence.Entity; + import jakarta.persistence.Id; + @Entity + public class Degree { + @Id public Long id; + public String name; + } + """.trimIndent() + ) + val learner = SourceFile.java( + "Learner.java", + """ + package test; + + import java.util.Collection; + import java.util.HashSet; + import jakarta.persistence.CascadeType; + import jakarta.persistence.Entity; + import jakarta.persistence.FetchType; + import jakarta.persistence.Id; + import jakarta.persistence.OneToMany; + + @Entity + public class Learner { + @Id + public Long id; + + @OneToMany(mappedBy = "learner", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + public Collection degrees = new HashSet<>(); + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(degree, learner) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + + val qLearner = generatedDir.findGenerated("QLearner.kt").readText() + assertThat(qLearner) + .contains("CollectionPath") + .contains("createCollection(\"degrees\", test.Degree::class.java, test.QDegree::class.java, null)") + // No raw MutableCollection without a type argument + .doesNotContain("SimplePath") + } + + @Test(timeout = 30_000) + fun bidirectionalEntities_loadConcurrentlyWithoutDeadlock() { + // Regression: two JPA entities with mutual @ManyToOne refs (Foo has + // a Bar, Bar has a Foo) are the canonical case where APT-generated + // Q-classes can deadlock when their static initialisers fire + // concurrently — each eagerly constructs the peer Q-class, + // and the two class-init locks are taken in opposite orders. See + // OpenFeign/querydsl#1739 for the APT-side compile-time detector. + // The KSP rendering emits object-reference fields as `by lazy`, so + // neither Q-class's touches the other transitively. This + // test pins that property — both statically (the generated source + // uses `by lazy`) and dynamically (force concurrent first access + // and assert neither thread hangs). + val foo = SourceFile.java( + "Foo.java", + """ + package test; + import jakarta.persistence.Entity; + import jakarta.persistence.Id; + import jakarta.persistence.ManyToOne; + @Entity + public class Foo { + @Id public Long id; + @ManyToOne public Bar bar; + } + """.trimIndent() + ) + val bar = SourceFile.java( + "Bar.java", + """ + package test; + import jakarta.persistence.Entity; + import jakarta.persistence.Id; + import jakarta.persistence.ManyToOne; + @Entity + public class Bar { + @Id public Long id; + @ManyToOne public Foo foo; + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(foo, bar) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + + // Static assertion: both peer references render lazy in the source. + assertThat(generatedDir.findGenerated("QFoo.kt").readText()) + .contains("public val bar: QBar by lazy") + assertThat(generatedDir.findGenerated("QBar.kt").readText()) + .contains("public val foo: QFoo by lazy") + + // Dynamic assertion: load the Class objects without initialising + // them (so hasn't run yet), then race two threads to touch + // QFoo.foo and QBar.bar at the same time. If either tried + // to construct the peer Q-class, the two threads would deadlock on + // the opposing class-init locks and the JUnit timeout would fire. + val cl = result.classLoader + val qFooClass = Class.forName("test.QFoo", false, cl) + val qBarClass = Class.forName("test.QBar", false, cl) + + val startLatch = CountDownLatch(1) + val fooResult = AtomicReference(null) + val barResult = AtomicReference(null) + val errors = ConcurrentLinkedQueue() + + val threadFoo = Thread({ + try { + startLatch.await() + fooResult.set(qFooClass.getField("foo").get(null)) + } catch (t: Throwable) { + errors.add(t) + } + }, "init-QFoo") + val threadBar = Thread({ + try { + startLatch.await() + barResult.set(qBarClass.getField("bar").get(null)) + } catch (t: Throwable) { + errors.add(t) + } + }, "init-QBar") + + threadFoo.start() + threadBar.start() + startLatch.countDown() + threadFoo.join(10_000) + threadBar.join(10_000) + + assertThat(threadFoo.isAlive) + .withFailMessage("init-QFoo never completed — likely deadlock with QBar") + .isFalse() + assertThat(threadBar.isAlive) + .withFailMessage("init-QBar never completed — likely deadlock with QFoo") + .isFalse() + assertThat(errors).isEmpty() + assertThat(fooResult.get()).isNotNull + assertThat(barResult.get()).isNotNull + } + + @Test + fun inheritedObjectReference_doesNotRecurseAtConstruction() { + // Regression: an `@MappedSuperclass` that `@ManyToOne`s back to a + // concrete entity which itself extends that superclass — e.g. an + // Auditable base with `createdBy : User` applied to `User` — used to + // stack-overflow at QUser.user construction. The eager + // `@JvmField val createdBy = _super.createdBy` on the child forced + // the parent's per-instance `by lazy` to evaluate during the child's + // ctor, which allocated another QUser, which allocated another + // QAuditable, ad infinitum. Inherited object references are now + // rendered as `by lazy` themselves, so first construction terminates + // and only user-driven navigation creates further levels. + val auditable = SourceFile.java( + "Auditable.java", + """ + package test; + import jakarta.persistence.ManyToOne; + import jakarta.persistence.MappedSuperclass; + @MappedSuperclass + public abstract class Auditable { + @ManyToOne public User createdBy; + } + """.trimIndent() + ) + val user = SourceFile.java( + "User.java", + """ + package test; + import jakarta.persistence.Entity; + import jakarta.persistence.Id; + @Entity + public class User extends Auditable { + @Id public Long id; + public String name; + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(auditable, user) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + + val qUser = generatedDir.findGenerated("QUser.kt").readText() + // The inherited createdBy must be a lazy delegate, not an eager + // @JvmField — otherwise QUser.user would StackOverflow on first access. + assertThat(qUser) + .contains("public val createdBy: QUser by lazy") + .contains("@get:JvmName(\"createdBy\")") + .doesNotContain("public val createdBy: QUser = _super.createdBy") + + // And — the real proof — load QUser.user from the compiled output + // and verify the static init does not StackOverflowError. + val qUserClass = result.classLoader.loadClass("test.QUser") + val userInstance = qUserClass.getField("user").get(null) + assertThat(userInstance).isNotNull + } + + @Test + fun convertAnnotatedField_doesNotCrashUnderKsp2() { + // Regression: TypeExtractor.userType used to call + // `it.annotationType.resolve().toClassName()` on every property's + // annotations, going through kotlinpoet-ksp's isError()/declaration + // path that traverses KSP2's analysis-API lifetime tokens. Any field + // carrying a JPA @Convert (or hibernate @Type / @JdbcTypeCode) + // annotation crashed kspKotlin with KaInvalidLifetimeOwnerAccessException. + val converter = SourceFile.kotlin( + "Converter.kt", + """ + package test + import jakarta.persistence.AttributeConverter + + class StringPair(val first: String, val second: String) + + class StringPairConverter : AttributeConverter { + override fun convertToDatabaseColumn(p: StringPair) = "${'$'}{p.first}|${'$'}{p.second}" + override fun convertToEntityAttribute(s: String): StringPair { + val parts = s.split("|") + return StringPair(parts[0], parts[1]) + } + } + """.trimIndent() + ) + val entity = SourceFile.kotlin( + "Bag.kt", + """ + package test + import jakarta.persistence.Convert + import jakarta.persistence.Entity + import jakarta.persistence.Id + + @Entity + class Bag { + @Id var id: Long = 0 + @Convert(converter = StringPairConverter::class) + var pair: StringPair = StringPair("", "") + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(converter, entity) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + assertThat(result.messages) + .doesNotContain("KaInvalidLifetimeOwnerAccessException") + + val qBag = generatedDir.findGenerated("QBag.kt").readText() + // @Convert-annotated field is rendered as a SimplePath. + assertThat(qBag) + .contains("class QBag") + .contains("createSimple(\"pair\"") + .contains("StringPair") + } + + @Test + fun referencedClassWithSameSimpleNameAnnotation_isNotMistakenForJpaEntity() { + // Regression: QueryModelType.autodetect used to compare KSAnnotation by + // simpleName only. That's safe for *discovered* symbols (KSP already + // FQN-filters via getSymbolsWithAnnotation), but it's also called on + // *referenced* classes during property type resolution. A user class + // annotated with their own @com.example.Entity (NOT JPA) referenced + // from a JPA entity would be misclassified as a Querydsl entity and + // emit a phantom QFooClass(...) reference that doesn't compile. + val customAnnotation = SourceFile.kotlin( + "CustomEntity.kt", + """ + package custom + // A user-defined annotation that happens to share the JPA simple name. + annotation class Entity + """.trimIndent() + ) + val referenced = SourceFile.kotlin( + "Helper.kt", + """ + package test + @custom.Entity + class Helper(val label: String) + """.trimIndent() + ) + val entity = SourceFile.kotlin( + "Holder.kt", + """ + package test + import jakarta.persistence.Entity + import jakarta.persistence.Id + import jakarta.persistence.Transient + + @Entity + class Holder { + @Id var id: Long = 0 + // Not persisted; we just want it as a Kotlin property type so + // that referenceType() examines its class. + @Transient var helper: Helper = Helper("x") + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(customAnnotation, referenced, entity) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + + val qHolder = generatedDir.findGenerated("QHolder.kt").readText() + // Helper must NOT be treated as a Querydsl entity (no QHelper reference). + assertThat(qHolder).doesNotContain("QHelper") + // No phantom Q-class generated for the non-JPA-annotated class either. + val generatedNames = generatedDir.walkTopDown().filter { it.isFile }.map { it.name }.toList() + assertThat(generatedNames).doesNotContain("QHelper.kt") + } + + @Test + fun customComparableWrapper_emitsComparablePath() { + // A custom Comparable wrapper (typed-value-querydsl-style) — declaration + // implements a parameterized Comparable in its supertype chain. Must still + // be detected as Comparable and produce ComparablePath rather than SimplePath, + // even though the supertype is parameterized. + val source = SourceFile.kotlin( + "Wrapped.kt", + """ + package test + + import jakarta.persistence.Entity + import jakarta.persistence.Id + + class TypedId(val value: Long) : Comparable { + override fun compareTo(other: TypedId) = value.compareTo(other.value) + } + + @Entity + class Wrapped { + @Id + var id: TypedId = TypedId(0) + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(source) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + + val qWrapped = generatedDir.findGenerated("QWrapped.kt").readText() + assertThat(qWrapped) + .contains("class QWrapped") + .contains("createComparable(\"id\"") + } + + @Test + fun mixedSources_kotlinEntityReferencesJavaEmbeddable() { + val javaEmbeddable = SourceFile.java( + "Address.java", + """ + package test; + import jakarta.persistence.Embeddable; + + @Embeddable + public class Address { + public String street; + public String city; + } + """.trimIndent() + ) + val kotlinEntity = SourceFile.kotlin( + "Person.kt", + """ + package test + + import jakarta.persistence.Embedded + import jakarta.persistence.Entity + import jakarta.persistence.Id + + @Entity + class Person { + @Id + var id: Long = 0 + var name: String = "" + @Embedded + var address: Address = Address() + } + """.trimIndent() + ) + + val (result, generatedDir) = compile(javaEmbeddable, kotlinEntity) + assertThat(result.exitCode) + .withFailMessage(result.messages) + .isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(generatedDir.findGenerated("QPerson.kt").readText()) + .contains("class QPerson") + .contains("createString(\"name\")") + assertThat(generatedDir.findGenerated("QAddress.kt").readText()) + .contains("createString(\"street\")") + .contains("createString(\"city\")") + } + + private fun compile(vararg sources: SourceFile): Pair { + val compilation = KotlinCompilation().apply { + this.sources = sources.toList() + inheritClassPath = true + // kctfork 0.10+ runs KSP2 only; no useKsp2 toggle exists. + configureKsp { + symbolProcessorProviders += QueryDslProcessorProvider() + incremental = false + } + } + return compilation.compile() to compilation.kspSourcesDir + } + + private fun File.findGenerated(name: String): File { + val match = walkTopDown().firstOrNull { it.isFile && it.name == name } + assertThat(match) + .withFailMessage { + val all = walkTopDown().filter { it.isFile }.joinToString("\n ") { it.relativeTo(this).path } + "Expected $name under $this; generated files were:\n $all" + } + .isNotNull + return match!! + } +} diff --git a/querydsl-tooling/querydsl-ksp-codegen/src/test/kotlin/RenderTest.kt b/querydsl-tooling/querydsl-ksp-codegen/src/test/kotlin/RenderTest.kt index fc60166f5b..364ddb6e7b 100644 --- a/querydsl-tooling/querydsl-ksp-codegen/src/test/kotlin/RenderTest.kt +++ b/querydsl-tooling/querydsl-ksp-codegen/src/test/kotlin/RenderTest.kt @@ -72,7 +72,7 @@ class RenderTest { val code = typeSpec.toString() code.assertCompiles() code.assertContains("class QCat : com.querydsl.core.types.dsl.EntityPathBase") - code.assertContainLines("val _super: QAnimal by lazy { QAnimal(this) }") + code.assertContainLines("@kotlin.jvm.JvmField public val _super: QAnimal = QAnimal(this)") } @Test @@ -98,7 +98,7 @@ class RenderTest { val code = typeSpec.toString() code.assertCompiles() code.assertContains("class QCat : com.querydsl.core.types.dsl.EntityPathBase") - code.assertContainLines("val _super: QAnimal by lazy { QAnimal(this) }") + code.assertContainLines("@kotlin.jvm.JvmField public val _super: QAnimal = QAnimal(this)") } @Test @@ -158,7 +158,7 @@ class RenderTest { val typeSpec = QueryModelRenderer.render(catModel) val code = typeSpec.toString() code.assertCompiles() - code.assertContainLines("val hasTail: com.querydsl.core.types.dsl.BooleanPath get() = _super.hasTail") + code.assertContainLines("@kotlin.jvm.JvmField public val hasTail: com.querydsl.core.types.dsl.BooleanPath = _super.hasTail") } @Test