Skip to content

feat(ksp): make ksp-codegen KSP2-compatible and refine Q-class rendering#1728

Open
soidisant wants to merge 2 commits into
OpenFeign:masterfrom
soidisant:feat/ksp2-and-java-entities
Open

feat(ksp): make ksp-codegen KSP2-compatible and refine Q-class rendering#1728
soidisant wants to merge 2 commits into
OpenFeign:masterfrom
soidisant:feat/ksp2-and-java-entities

Conversation

@soidisant
Copy link
Copy Markdown

@soidisant soidisant commented May 5, 2026

Motivation

I have a mixed Java/Kotlin project. The entities are Java, but I want to write the Querydsl queries from Kotlin code. With querydsl-apt this doesn't work cleanly: Gradle compiles Kotlin before Java, so the Java Q-classes APT generates aren't on the classpath when the Kotlin sources reference them — QFoo simply doesn't resolve from Kotlin. The standard workaround has been to write the queries in Java too, defeating the point of having Kotlin.

querydsl-ksp-codegen is the natural answer: KSP runs as part of kspKotlin, sees Java sources alongside Kotlin sources, and emits .kt Q-classes that the Kotlin code can use immediately. In practice it didn't work — the processor crashed under KSP2 (the default runtime for the KSP Gradle plugin since the 2.x line), Java entities were silently dropped, and a few rendering choices didn't survive contact with real-world entities. This PR makes the processor actually deliver on that promise.

Background

Running the existing querydsl-ksp-codegen under KSP2 crashes during kspKotlin with:

ksp.org.jetbrains.kotlin.analysis.api.lifetime.KaInvalidLifetimeOwnerAccessException:
Access to invalid KotlinAlwaysAccessibleLifetimeToken: PSI has changed since creation

Several KSP1-era assumptions in the processor break under KSP2's stricter analysis-API lifetime model. Once those are fixed, related rendering issues surface (Java entities silently dropped, Collection<E> falling through to SimplePath<MutableCollection>, KSType.toClassName() crashing on parameterised supertypes — fixes #1688). This PR addresses them as one coherent piece.

Supersedes #1690. That PR adds a one-line arguments.isEmpty() guard in fallbackType() to dodge the crash. It does fix #1688, but it also drops ComparablePath emission for parameterised Comparable<X> wrappers (custom typed-id classes etc.) which would silently downgrade to SimplePath. This PR uses declaration.qualifiedName comparison so the Comparable membership check works regardless of type arguments — see the customComparableWrapper_emitsComparablePath integration test.

Related to #1739. That PR adds APT-side compile-time detection for the classic bidirectional <clinit> deadlock. The KSP rendering shape produced here is structurally immune to that deadlock — direct object references are by lazy, so QFoo's static init never triggers QBar's static init transitively. The bidirectionalEntities_loadConcurrentlyWithoutDeadlock test pins this dynamically: it compiles two entities with mutual @ManyToOne refs and races two threads to touch QFoo.foo and QBar.bar simultaneously, asserting neither thread hangs. The edge case where the cycle is hidden behind an @MappedSuperclass (e.g. Auditable.createdBy : User with User extends Auditable) is also handled by rendering inherited object references lazily; see the inheritedObjectReference_doesNotRecurseAtConstruction test which reflectively loads the static field and asserts no StackOverflowError.

Changes

KSP2 lifecycle and reliability

  • Move all symbol-API work into process(); KSP2 invalidates the analysis-API lifetime at the process()/finish() boundary.
  • Allocate a fresh QueryModelExtractor per round (KSClassDeclaration references aren't reusable across rounds).
  • Validate each symbol and defer the unprocessable ones to the next round (canonical KSP pattern).
  • Match annotations via KSAnnotation.shortName + qualifiedName instead of resolving to ClassName/TypeName via kotlinpoet-ksp; the latter traverses the lifetime-bound KaFirTypeInformationProvider. The same change covers userType (used to crash for @Convert / @Type fields under KSP2) and ensures cross-reference annotation matching is FQN-strict (a user's own @com.example.Entity won't be mistaken for the JPA one).
  • In TypeExtractor.fallbackType(), look up Comparable via the supertype's declaration.qualifiedName rather than KSType.toClassName() — kotlinpoet-ksp throws IllegalStateException on any parameterised KSType (Comparable<X>, Collection<E>, …). Fixes KSP: TypeExtractor crashes on parameterized supertypes (ObjectId with MongoDB) #1688.

Java entity support

  • Java @Entity / @Embeddable / @MappedSuperclass are now first-class. KSP surfaces their fields as KSPropertyDeclaration; the extractor additionally excludes Java static and transient (keyword) modifiers.
  • Plain Collection<E> / Iterable<E> properties (a common Java JPA shape) now produce CollectionPath<E, QE> instead of falling through to fallbackType and emitting an invalid SimplePath<MutableCollection>.

Q-class rendering — hybrid @JvmField + lazy

  • _super, scalar paths, enums, collections, and inherited scalar properties render as eager @JvmField so Java consumers can field-access them (qFoo.active, qFoo.id) the same way they would Java-APT-generated Q-classes.
  • Object references — both direct (@ManyToOne) and inherited (a @ManyToOne on a @MappedSuperclass) — render as by lazy with a non-null type. Lazy avoids two construction-time hazards: self-referential entities (e.g. Foo.parent : Foo) stack-overflowing on eager init, and inherited bidirectional references (Auditable.createdBy : User, User extends Auditable) infinitely recursing when the child's eager _super.foo triggers the parent's per-instance lazy. The non-null type lets Kotlin queries write qFoo.bar.id with no ?. / !!. Each access creates one more level on demand, so depth is unbounded.
  • The synthesised JVM getter on lazy object refs is renamed via @get:JvmName so Java callers see qFoo.bar() instead of qFoo.getBar() — one () from the Java APT field shape.

Test harness

Adds dev.zacsweers.kctfork (0.12.1) for end-to-end KSP2 integration tests, with KSP runtime artifacts realigned to ${ksp.version}=2.3.7. Tests cover the Kotlin baseline, Java entities (with @Transient / static-final exclusion), Java-extending-Java @MappedSuperclass, Kotlin entity referencing a Java @Embeddable, parameterised supertypes (#1688 regression), a custom Comparable<X> wrapper, an entity with a Java Collection<E> field, the hybrid @JvmField + lazy self-reference shape, a @Convert-annotated field, cross-reference FQN strictness, an @MappedSuperclass with a @ManyToOne back to its subclass (verified by reflective static-field load — asserts no StackOverflowError), and a bidirectional @ManyToOne pair loaded concurrently from two threads (verified the <clinit> chain doesn't deadlock).

Example

  • Adds a Java Branch entity (self-referencing) alongside the Kotlin entities in querydsl-example-ksp-codegen, with a Kotlin test querying it. Demonstrates the motivating use case end-to-end.
  • Bumps that example to Gradle 9.5.0.

Docs

  • Adds a "Code Generation for Kotlin" section to docs/tutorials/kotlin.md covering the KSP path (Gradle) and the KAPT fallback (Maven).
  • Adds a "Mixed Java/Kotlin sources" section to the KSP module README.

Trade-offs

  • Object references are non-null with by lazy. Java callers go through qFoo.bar() (vs. qFoo.bar field access on Java-APT-generated Q-classes). In a project that writes queries from Kotlin, this is a non-issue; from Java, it's () instead of nothing.
  • No PathInits machinery. KSP2 + lazy makes it unnecessary — there's no construction-time depth limit because nothing is eagerly recursed. Java APT needs PathInits because it has no native lazy primitive.
  • @Access(PROPERTY) (JPA annotations on getters) is not yet supported — annotations need to be on fields. Same default as querydsl-apt.
  • Maven KSP integration is out of scope; kotlin-maven-plugin doesn't natively run KSP. Use Gradle for KSP, or fall back to KAPT + querydsl-kotlin-codegen on Maven.
  • Existing Java consumers of generated Q-classes: qFoo.getActived() becomes qFoo.actived (now a @JvmField); qFoo.getBar() for @ManyToOne becomes qFoo.bar() (renamed via @get:JvmName). Source-breaking on the Java side, transparent on the Kotlin side. Recompile required.

Validation

  • 22 unit + integration tests, all passing.
  • Existing querydsl-example-ksp-codegen builds and runs all tests (10 incl. the new Java-entity case) under Gradle 9.5.0 + JDK 21 + KSP 2.3.7.
  • Manually validated against a real downstream Spring Boot 4 / Kotlin 2.3.21 / KSP 2.3.7 / Hibernate 6 / JPA project — the original KaInvalidLifetimeOwnerAccessException is fixed, self-referential entities work, generated Q-classes compile cleanly. A long-standing internal workaround (manually constructing SimplePath from path metadata strings to dodge PathInits.DIRECT2's depth-2 ceiling) was replaced with the natural entityPath.parent().parent().parent().id chain.

Commits

Two commits, structured for review:

  1. Make querydsl-ksp-codegen KSP2-compatible and refine Q-class rendering — all the codegen changes.
  2. Demonstrate mixed Java/Kotlin codegen in querydsl-example-ksp-codegen — example Java entity + docs.

@soidisant soidisant force-pushed the feat/ksp2-and-java-entities branch 2 times, most recently from 271d3d3 to c0108f6 Compare May 13, 2026 15:49
soidisant added 2 commits May 13, 2026 18:02
Bundles the foundational fixes for running querydsl-ksp-codegen under
KSP2 (the default runtime for the KSP Gradle plugin 2.3.x) along with
the rendering tweaks that bring the generated Kotlin Q-classes close to
what querydsl-apt has long produced for Java.

KSP2 lifecycle and reliability:
 - Move all symbol-API work into process(); KSP2 invalidates the
   analysis-API lifetime at the process()/finish() boundary, so the
   previous deferred-extraction split crashed with
   KaInvalidLifetimeOwnerAccessException.
 - Allocate a fresh QueryModelExtractor per round so KSClassDeclaration
   references aren't reused across analysis sessions.
 - Validate each symbol and defer the unprocessable ones to a later
   round (canonical KSP pattern).
 - Match annotations via KSAnnotation.shortName / qualifiedName instead
   of resolving to ClassName/TypeName via kotlinpoet-ksp; the latter
   path traverses the lifetime-bound KaFirTypeInformationProvider.
 - Avoid KSType.toClassName() on parameterized supertypes during the
   Comparable lookup in TypeExtractor.fallbackType(); kotlinpoet-ksp
   throws IllegalStateException for any KSType with type arguments
   (Comparable<X>, Collection<E>, etc.). See OpenFeign#1688.

Java entity support:
 - Java @entity / @embeddable / @MappedSuperclass classes are now
   first-class. KSP surfaces their fields as KSPropertyDeclaration, so
   the existing extractor mostly works once we additionally exclude
   Java static and transient (keyword) modifiers. @transient is
   honoured via the shared shortName check.
 - Plain Collection<E> / Iterable<E> properties (a common Java JPA
   shape: `Collection<Bar> bars`) now produce CollectionPath<E,QE>
   instead of falling through to fallbackType and emitting an invalid
   `SimplePath<MutableCollection>` (no type arg).

Q-class rendering, hybrid @JvmField/lazy:
 - _super, inherited properties, scalar paths, enums, collections
   render as eager @JvmField — Java consumers can field-access them
   (qFoo.active, qFoo.id) the same way they would Java-APT-generated
   Q-classes.
 - @manytoone object references stay `by lazy` with non-null type:
   handles self-referential entities (Foo.parent : Foo) without
   stack overflow at construction; Kotlin queries can write
   `qFoo.bar.id` with no `?.` / `!!`. Lazy avoids the
   construction-time depth limit Java APT mitigates with PathInits.
 - The synthesised JVM getter on lazy object refs is renamed via
   @get:JvmName so Java callers see `qFoo.bar()` instead of
   `qFoo.getBar()` — one `()` from the Java APT field shape.

Test harness:
 - Adds dev.zacsweers.kctfork (0.12.1) for end-to-end KSP2 integration
   tests, with KSP runtime artifacts realigned to ksp.version=2.3.7 to
   avoid mismatched analysis-API behaviour.
 - New tests cover: the Kotlin baseline under KSP2, a Java entity
   (with @transient / static-final exclusion), a Java entity extending
   a Java @MappedSuperclass, a Kotlin entity referencing a Java
   @embeddable, an entity with parameterized Set/List supertypes
   (issue OpenFeign#1688 regression), a custom Comparable<X> wrapper, an entity
   with a Java Collection<E> field, and the hybrid @JvmField + lazy
   self-reference shape.
…ksp-codegen

Adds a Java `Branch` entity (self-referencing via `@ManyToOne`) and a
companion test alongside the existing Kotlin entities in
`querydsl-example-ksp-codegen`. Demonstrates two things at once: that
querydsl-ksp-codegen picks up Java sources during `kspKotlin` (the
typical reason to reach for it over `querydsl-apt`), and that the lazy
non-null rendering of object references handles self-references
without stack overflow.

Bumps the example to Gradle 9.5.0 and regenerates the wrapper.

Documents the mixed-language workflow:
 - Adds a "Code Generation for Kotlin" section to `docs/tutorials/kotlin.md`
   (previously only covered the runtime DSL extensions), pointing to this
   example for both pure-Kotlin and mixed Java/Kotlin scenarios. Notes the
   KAPT-based path for Maven users where KSP is unavailable.
 - Adds a "Mixed Java/Kotlin sources" section to the KSP module README.
@soidisant soidisant changed the title Make querydsl-ksp-codegen KSP2-compatible and refine Q-class rendering feat(ksp): make ksp-codegen KSP2-compatible and refine Q-class rendering May 13, 2026
@soidisant soidisant force-pushed the feat/ksp2-and-java-entities branch from c0108f6 to 65d1b70 Compare May 13, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

KSP: TypeExtractor crashes on parameterized supertypes (ObjectId with MongoDB)

1 participant