-
Notifications
You must be signed in to change notification settings - Fork 2
feat: Support value classes (#117) #118
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package me.kpavlov.kt.schema.integration.type | ||
|
|
||
| import com.fasterxml.jackson.annotation.JacksonAnnotation | ||
| import me.kpavlov.kt.schema.Description | ||
| import me.kpavlov.kt.schema.Schema | ||
|
|
||
| // Minimal inline value class to test flattening: no companion factory, no extra members. | ||
| @JvmInline | ||
| value class Age( | ||
| val value: Int, | ||
| ) | ||
|
|
||
| @Description("Distance in km") | ||
| @JvmInline | ||
| value class Distance( | ||
| val value: Double, | ||
| ) | ||
|
|
||
| @Schema | ||
| data class Trip( | ||
| @Description("Traveler's age") | ||
| val travelerAge: Age, | ||
| val distance: Distance, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package me.kpavlov.kt.schema.integration.type | ||
|
|
||
| import io.kotest.assertions.json.shouldEqualJson | ||
| import kotlin.test.Test | ||
|
|
||
| /** | ||
| * Tests for Trip schema generation - inline value class flattening. | ||
| */ | ||
| class TripSchemaTest { | ||
| @Test | ||
| fun `flattens inline value class properties to their wrapped primitive type`() { | ||
| val schema = Trip::class.jsonSchemaString | ||
|
|
||
| // language=json | ||
| schema shouldEqualJson | ||
| $$""" | ||
| { | ||
| "$schema": "https://json-schema.org/draft/2020-12/schema", | ||
| "$id": "me.kpavlov.kt.schema.integration.type.Trip", | ||
| "type": "object", | ||
| "properties": { | ||
| "travelerAge": { | ||
| "type": "integer", | ||
| "description": "Traveler's age" | ||
| }, | ||
| "distance": { | ||
| "type": "number", | ||
| "description": "Distance in km" | ||
| } | ||
| }, | ||
| "required": ["travelerAge", "distance"], | ||
| "additionalProperties": false | ||
| } | ||
| """.trimIndent() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,7 @@ internal class ReflectionIntrospectionContext : BaseIntrospectionContext<KType>( | |
| * - Nullability from descriptor.isNullable | ||
| * - Primitives (inlined) | ||
| * - Collections (List, Map) (inlined) | ||
| * - Inline value classes (flattened to their wrapped element's type) | ||
| * - Enums (referenced via TypeId) | ||
| * - Objects/Classes (referenced via TypeId) | ||
| * - Polymorphic types (referenced via TypeId) | ||
|
|
@@ -82,6 +83,11 @@ internal class ReflectionIntrospectionContext : BaseIntrospectionContext<KType>( | |
| return ref | ||
| } | ||
|
|
||
| // @JvmInline value classes serialize as their wrapped value, not as an object with one | ||
| // property, so flatten them to the wrapped type's schema (same idea as the kotlinx.serialization | ||
| // front end's descriptor.isInline handling, just reached via reflection here). | ||
| if (klass.isValue) return flattenInlineValueClass(type) | ||
|
|
||
| // Handle different kinds | ||
| return when { | ||
| isListLike(klass) -> handleListType(type) | ||
|
|
@@ -211,6 +217,49 @@ internal class ReflectionIntrospectionContext : BaseIntrospectionContext<KType>( | |
| return ref | ||
| } | ||
|
|
||
| /** | ||
| * Flattens an inline value class to the schema of its single wrapped property. | ||
| * | ||
| * On the JVM, a `@JvmInline value class` is erased to its wrapped value at the call site | ||
| * (e.g. a `Double`), never boxed as `{"value": 14.5}`, so the schema must follow suit. | ||
| * | ||
| * A class-level `@Description` on the value class is carried over onto the flattened | ||
| * primitive node, since there is no wrapper object left to attach it to. | ||
| * | ||
| * Falls back to [handleObjectType] when the wrapped property can't be determined, or for a | ||
| * value class that (transitively) wraps a collection of itself — flattening would otherwise | ||
| * recurse without end. | ||
| */ | ||
| private fun flattenInlineValueClass(type: KType): TypeRef { | ||
| val klass = type.klass | ||
| val wrappedType = findPrimaryConstructor(klass)?.parameters?.singleOrNull()?.type | ||
| if (wrappedType == null || type in visitingTypes) return handleObjectType(type) | ||
|
|
||
| val nullable = type.effectiveNullable() | ||
| visitingTypes += type | ||
| val wrappedRef = | ||
| try { | ||
| toRef(wrappedType) | ||
| } finally { | ||
| visitingTypes -= type | ||
| } | ||
|
|
||
| val classDescription = extractDescription(klass.java.annotations.toList()) | ||
| val resultRef = | ||
| if (classDescription != null && wrappedRef is TypeRef.Inline && wrappedRef.node is PrimitiveNode) { | ||
| TypeRef.Inline( | ||
| (wrappedRef.node as PrimitiveNode).copy(description = classDescription), | ||
| wrappedRef.nullable, | ||
| ) | ||
| } else { | ||
| wrappedRef | ||
| } | ||
|
|
||
| val ref = if (nullable && !resultRef.nullable) resultRef.withNullable(true) else resultRef | ||
| if (!nullable) typeRefCache[type] = ref | ||
| return ref | ||
|
Comment on lines
+233
to
+260
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- reflection context outline ---'
ast-grep outline kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt
printf '%s\n' '--- KSP context outline ---'
ast-grep outline kt-schema-ksp/src/main/kotlin/me/kpavlov/kt/schema/ksp/ir/KspIntrospectionContext.kt
printf '%s\n' '--- generic value-class and type-substitution references ---'
rg -n -S 'flattenInlineValueClass|resolveInlineValueClassOrNull|value class|VALUE|substitut|type\.arguments|KSTypeArgument|Wrapper' \
kt-schema-generator-core kt-schema-ksp \
-g '*.kt' -g '*.kts' -g '*.md' | head -300
printf '%s\n' '--- candidate tests ---'
git ls-files | rg '(^|/)(test|tests)/|Test\\.kt$|Spec\\.kt$' | head -300Repository: kpavlov/kt-schema Length of output: 14679 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- reflection implementation and nearby type handling ---'
sed -n '60,230p' kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt
sed -n '220,275p' kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt
printf '%s\n' '--- KSP implementation and nearby type handling ---'
sed -n '50,235p' kt-schema-ksp/src/main/kotlin/me/kpavlov/kt/schema/ksp/ir/KspIntrospectionContext.kt
printf '%s\n' '--- KSP type mappers ---'
sed -n '1,180p' kt-schema-ksp/src/main/kotlin/me/kpavlov/kt/schema/ksp/ir/KspTypeMappers.kt
printf '%s\n' '--- reflection tests around value classes ---'
sed -n '80,140p' kt-schema-generator-core/src/jvmTest/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectorTest.kt
sed -n '345,430p' kt-schema-generator-core/src/jvmTest/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectorTest.kt
printf '%s\n' '--- KSP test/model references to value classes ---'
rg -n -S 'value class|Modifier.VALUE|resolveInlineValueClassOrNull|Inline' \
kt-schema-ksp ksp-integration-tests -g '*.kt' | head -250Repository: kpavlov/kt-schema Length of output: 34144 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- full reflection entry and type helpers ---'
sed -n '1,75p' kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt
sed -n '275,430p' kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt
printf '%s\n' '--- full KSP inline tail and object handling ---'
sed -n '216,390p' kt-schema-ksp/src/main/kotlin/me/kpavlov/kt/schema/ksp/ir/KspIntrospectionContext.kt
printf '%s\n' '--- KSP integration value-class fixture and assertions ---'
cat -n ksp-integration-tests/src/main/kotlin/me/kpavlov/kt/schema/integration/type/Trip.kt
cat -n ksp-integration-tests/src/test/kotlin/me/kpavlov/kt/schema/integration/type/TripSchemaTest.kt
printf '%s\n' '--- all relevant KType/KSType construction and substitution APIs in repository ---'
rg -n -S 'createType|typeParameters|KTypeParameter|KSTypeParameter|replace|substitut|arguments.*map|declaration.*arguments|typeOf<' \
kt-schema-generator-core kt-schema-ksp ksp-integration-tests -g '*.kt' | head -300Repository: kpavlov/kt-schema Length of output: 29674 🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
import re
reflection = Path("kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt").read_text()
ksp = Path("kt-schema-ksp/src/main/kotlin/me/kpavlov/kt/schema/ksp/ir/KspIntrospectionContext.kt").read_text()
def function_body(source, signature):
start = source.index(signature)
brace = source.index("{", start)
depth = 0
for i in range(brace, len(source)):
if source[i] == "{":
depth += 1
elif source[i] == "}":
depth -= 1
if depth == 0:
return source[brace:i + 1]
raise AssertionError("function body not closed")
reflection_body = function_body(
reflection,
"private fun flattenInlineValueClass(type: KType): TypeRef",
)
ksp_body = function_body(
ksp,
"private fun resolveInlineValueClassOrNull(",
)
print("reflection_reads_constructor_parameter_directly=",
bool(re.search(r"parameters\\?\\.singleOrNull\\(\\)\\?\\.type", reflection_body)))
print("reflection_calls_toRef_with_declaration_type=",
"toRef(wrappedType)" in reflection_body)
print("reflection_uses_use_site_arguments_in_handler=",
"type.arguments" in reflection_body)
print("ksp_reads_constructor_parameter_directly=",
"wrappedParam.type.resolve()" in ksp_body)
print("ksp_calls_toRef_with_resolved_declaration_type=",
"toRef(wrappedParam.type.resolve())" in ksp_body)
print("ksp_uses_use_site_arguments_in_handler=",
"type.arguments" in ksp_body)
entry = function_body(ksp, "override fun toRef(type: KSType): TypeRef")
order = [
"resolveBasicTypeOrNull(type)",
"resolveJsonCollectionTypeOrNull(type)",
"resolvePrimitiveTypeKindOrNull(type)",
"resolveOpaqueTypeOrNull(type)",
"handleAnyFallback(type)",
"resolveInlineValueClassOrNull(type, nullable)",
]
positions = [entry.index(item) for item in order]
print("ksp_handler_order=", positions == sorted(positions))
# Deterministic symbolic execution of the KSP fallback path for Wrapper<Int>.
# A declaration-level constructor reference to T resolves to a KSTypeParameter,
# which is not a primitive or collection and is handled by handleAnyFallback.
handlers_for_type_parameter = [
("resolveBasicTypeOrNull", None),
("resolveJsonCollectionTypeOrNull", None),
("resolvePrimitiveTypeKindOrNull", None),
("resolveOpaqueTypeOrNull", None),
("handleAnyFallback", "AnyNode"),
]
print("symbolic_ksp_Wrapper_Int_wrapped_schema=",
next(result for _, result in handlers_for_type_parameter if result is not None))
PY
printf '%s\n' '--- generic-support declarations and version context ---'
rg -n -S 'generics are not supported|generic value|value class' \
README.md kt-schema-generator-core kt-schema-ksp ksp-integration-tests \
-g '*.md' -g '*.kt' -g '*.kts' | head -200
rg -n -S 'kotlin\\s*\\(|kotlin_version|kotlinVersion|ksp\\(' \
settings.gradle.kts build.gradle.kts gradle.properties gradle \
-g '*.gradle.kts' -g '*.properties' 2>/dev/null | head -100Repository: kpavlov/kt-schema Length of output: 5135 🌐 Web query:
💡 Result: Kotlin value classes (specifically inline value classes) support generic type parameters [1][2]. You can define a generic value class as Citations:
Substitute generic value-class parameters at the use site. Both handlers pass the declaration parameter 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** | ||
| * Handles enum types by creating an EnumNode and adding it to discovered nodes. | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -54,20 +54,35 @@ class ReflectionIntrospectorTest { | |
| @Suppress("unused") | ||
| sealed interface Vehicle { | ||
| sealed interface Motorized : Vehicle { | ||
| data class Car(val doors: Int ) : Motorized | ||
| data class Truck(val payload: Double ) : Motorized | ||
| data class Car( | ||
| val doors: Int, | ||
| ) : Motorized | ||
|
|
||
| data class Truck( | ||
| val payload: Double, | ||
| ) : Motorized | ||
| } | ||
|
|
||
| data class Bicycle(val gears: Int ) : Vehicle | ||
| data class Bicycle( | ||
| val gears: Int, | ||
| ) : Vehicle | ||
| } | ||
|
|
||
| @Suppress("unused") | ||
| sealed interface Event { | ||
| data class Click(val x: Int, val y: Int ) : Event | ||
| data class PageView(val url: String ) : Event | ||
| data class Click( | ||
| val x: Int, | ||
| val y: Int, | ||
| ) : Event | ||
|
|
||
| data class PageView( | ||
| val url: String, | ||
| ) : Event | ||
|
|
||
| @SchemaIgnore | ||
| data class Internal(val trace: String ) : Event | ||
| data class Internal( | ||
| val trace: String, | ||
| ) : Event | ||
| } | ||
|
|
||
| data class WithAny( | ||
|
|
@@ -82,6 +97,32 @@ class ReflectionIntrospectorTest { | |
| val mapping: Map<*, *>, | ||
| ) | ||
|
|
||
| @JvmInline | ||
| value class Age( | ||
| val value: Int, | ||
| ) | ||
|
|
||
| @Description("Distance in meters") | ||
| @JvmInline | ||
| value class DescribedDistance( | ||
| val value: Double, | ||
| ) | ||
|
|
||
| data class WithInlineValueClass( | ||
| val age: Age, | ||
| val nullableAge: Age?, | ||
| val distance: DescribedDistance, | ||
| ) | ||
|
|
||
| @JvmInline | ||
| value class RecursiveWrapper( | ||
| val items: List<RecursiveWrapper>, | ||
| ) | ||
|
|
||
| data class WithRecursiveInlineValueClass( | ||
| val wrapper: RecursiveWrapper, | ||
| ) | ||
|
|
||
| private val introspector = ReflectionClassIntrospector | ||
|
|
||
| @Test | ||
|
|
@@ -323,4 +364,44 @@ class ReflectionIntrospectorTest { | |
| } | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun `flattens inline value class to its wrapped primitive, carrying nullability and class description`() { | ||
| val graph = introspector.introspect(WithInlineValueClass::class) | ||
|
|
||
| val root = graph.root.shouldBeInstanceOf<TypeRef.Ref>() | ||
| val node = graph.nodes[root.id].shouldBeInstanceOf<ObjectNode>() | ||
| val props = node.properties.associateBy { it.name } | ||
|
|
||
| // Age(Int) flattens to a bare INT primitive — no {"value": ...} wrapper. | ||
| props.getValue("age").type.shouldBeInstanceOf<TypeRef.Inline> { inline -> | ||
| inline.node.shouldBeInstanceOf<PrimitiveNode> { prim -> | ||
| prim.kind shouldBe PrimitiveKind.INT | ||
| } | ||
| inline.nullable shouldBe false | ||
| } | ||
|
|
||
| // Age? propagates nullability onto the flattened primitive. | ||
| props.getValue("nullableAge").type.shouldBeInstanceOf<TypeRef.Inline> { inline -> | ||
| inline.nullable shouldBe true | ||
| } | ||
|
|
||
| // A class-level @Description on the value class lands on the flattened primitive. | ||
| props.getValue("distance").type.shouldBeInstanceOf<TypeRef.Inline> { inline -> | ||
| inline.node.shouldBeInstanceOf<PrimitiveNode> { prim -> | ||
| prim.kind shouldBe PrimitiveKind.DOUBLE | ||
| prim.description shouldBe "Distance in meters" | ||
| } | ||
| } | ||
|
|
||
| // Neither Age nor DescribedDistance should appear as a named node in the graph. | ||
| graph.nodes.keys.none { it.value.endsWith(".Age") || it.value.endsWith(".DescribedDistance") } shouldBe true | ||
| } | ||
|
|
||
| @Test | ||
| fun `inline value class wrapping a collection of itself falls back to a structural object instead of deadloop`() { | ||
| val graph = introspector.introspect(WithRecursiveInlineValueClass::class) | ||
|
|
||
| graph.root.shouldBeInstanceOf<TypeRef.Ref>() | ||
| } | ||
|
Comment on lines
+401
to
+406
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Assert the recursive fallback result. Line 405 only verifies the enclosing class root. Normal class introspection also returns Assert that As per coding guidelines, tests must use concrete inputs and outputs. 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
Remove test KDoc.
TripSchemaTestis test code. The KDoc is not needed.As per coding guidelines, "omit KDoc on tests".
🤖 Prompt for AI Agents
Source: Coding guidelines