Add DB decode annotations and preserved declaration metadata

This commit is contained in:
Sergey Chernov 2026-04-25 13:36:33 +03:00
parent 2abe7e2f96
commit 50e34e520e
23 changed files with 1690 additions and 55 deletions

View File

@ -13,6 +13,7 @@
- Prefer defining Lyng entities (enums/classes/type shapes) in `.lyng` files; only define them in Kotlin when there is Kotlin/platform-specific implementation detail that cannot be expressed in Lyng. - Prefer defining Lyng entities (enums/classes/type shapes) in `.lyng` files; only define them in Kotlin when there is Kotlin/platform-specific implementation detail that cannot be expressed in Lyng.
- Avoid hardcoding Lyng API documentation in Kotlin registrars when it can be declared in `.lyng`; Kotlin-side docs should be fallback/bridge only. - Avoid hardcoding Lyng API documentation in Kotlin registrars when it can be declared in `.lyng`; Kotlin-side docs should be fallback/bridge only.
- For mixed pluggable modules (Lyng + Kotlin), embed module `.lyng` sources as generated Kotlin string literals, evaluate them into module scope during registration, then attach Kotlin implementations/bindings. - For mixed pluggable modules (Lyng + Kotlin), embed module `.lyng` sources as generated Kotlin string literals, evaluate them into module scope during registration, then attach Kotlin implementations/bindings.
- When a change adds or changes Lyng-visible runtime/module behavior, update the corresponding `.lyng` declaration in the same change, including declaration-level docs/comments for new API surface.
## Kotlin/Wasm generation guardrails ## Kotlin/Wasm generation guardrails
- Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`. - Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`.

View File

@ -207,14 +207,27 @@ assertThrows(RollbackException) {
- `isEmpty()` — fast emptiness check where possible. - `isEmpty()` — fast emptiness check where possible.
- `iterator()` — normal row iteration while the transaction is active. - `iterator()` — normal row iteration while the transaction is active.
- `toList()` — materialize detached `SqlRow` snapshots that may be used after the transaction ends. - `toList()` — materialize detached `SqlRow` snapshots that may be used after the transaction ends.
- `decodeAs<T>()` — transaction-scoped iterable view that decodes each row into `T`.
##### `SqlRow` ##### `SqlRow`
- `row[index]` — zero-based positional access. - `row[index]` — zero-based positional access.
- `row["columnName"]` — case-insensitive lookup by output column label. - `row["columnName"]` — case-insensitive lookup by output column label.
- `row.decodeAs<T>()` — decode one row into a typed Lyng value.
Name-based access fails with `SqlUsageException` if the name is missing or ambiguous. Name-based access fails with `SqlUsageException` if the name is missing or ambiguous.
##### `DbFieldAdapter`
Custom DB field projection hook used by `@DbDecodeWith(...)`.
- `decode(rawValue, column, row, targetType)` — adapt one raw DB field value to a Lyng value for the requested target type.
- `encode(value, targetType)` — future symmetric hook for SQL parameter encoding.
Use `@DbDecodeWith(adapter)` on class constructor parameters and class-body fields/properties that participate in `decodeAs<T>()`.
Annotation arguments are evaluated once when the declaration is created, and the resulting adapter instance is retained in declaration metadata.
##### `ExecutionResult` ##### `ExecutionResult`
- `affectedRowsCount` - `affectedRowsCount`
@ -249,6 +262,22 @@ Portable result metadata categories:
- `DateTime` - `DateTime`
- `Instant` - `Instant`
Typed row decode rules:
- object/class targets map constructor parameters by column label, case-insensitively
- remaining matching serializable mutable fields are assigned after constructor call
- `@DbDecodeWith(adapter)` on a constructor parameter or class-body field/property takes precedence over built-in JSON/Lynon decoding
- `@DbDecodeWith(adapter)` must receive exactly one adapter instance implementing `DbFieldAdapter`
- adapter output must match the target member type or decoding fails with `SqlUsageException`
- missing required non-null constructor fields fail
- defaulted or nullable constructor fields may be omitted from the result
- extra result columns currently fail in strict mode
- if a row has exactly one column, that value may be decoded directly as the requested target type
- JSON-like native column types (`json`, `jsonb`) are decoded through typed canonical `Json` when the target type is not `String`
- binary columns are decoded through `Lynon` when the target type is not `Buffer`
- `Buffer` targets keep the raw binary payload without Lynon decoding
- plain text columns are not implicitly treated as JSON
For temporal types, see [time functions](time.md). For temporal types, see [time functions](time.md).
--- ---
@ -387,6 +416,8 @@ This means:
- do not keep `ResultSet` objects after the transaction block returns - do not keep `ResultSet` objects after the transaction block returns
- materialize rows with `toList()` inside the transaction when they must outlive it - materialize rows with `toList()` inside the transaction when they must outlive it
- the iterable returned by `decodeAs<T>()` is also transaction-scoped
- decoded objects produced while iterating `decodeAs<T>()` are detached ordinary Lyng values
The same rule applies to generated keys from `ExecutionResult.getGeneratedKeys()`: the `ResultSet` is transaction-scoped, but rows returned by `toList()` are detached. The same rule applies to generated keys from `ExecutionResult.getGeneratedKeys()`: the `ResultSet` is transaction-scoped, but rows returned by `toList()` are detached.

View File

@ -18,21 +18,33 @@
package net.sergeych.lyng.io.db package net.sergeych.lyng.io.db
import net.sergeych.lyng.Arguments import net.sergeych.lyng.Arguments
import net.sergeych.lyng.DeclAnnotation
import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.TypeDecl
import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjBitBuffer
import net.sergeych.lyng.obj.ObjClass import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjEnumClass import net.sergeych.lyng.obj.ObjEnumClass
import net.sergeych.lyng.obj.ObjEnumEntry import net.sergeych.lyng.obj.ObjEnumEntry
import net.sergeych.lyng.obj.ObjException import net.sergeych.lyng.obj.ObjException
import net.sergeych.lyng.obj.ObjImmutableList import net.sergeych.lyng.obj.ObjImmutableList
import net.sergeych.lyng.obj.ObjInstance
import net.sergeych.lyng.obj.ObjInt import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjNull import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjRecord
import net.sergeych.lyng.obj.ObjString import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjTypeExpr
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.thisAs import net.sergeych.lyng.obj.thisAs
import net.sergeych.lyng.requireScope import net.sergeych.lyng.requireScope
import net.sergeych.lyng.serialization.ObjJsonClass
import net.sergeych.lynon.BitArray
import net.sergeych.lynon.ObjLynonClass
import kotlinx.serialization.json.Json
import kotlin.collections.List import kotlin.collections.List
import kotlin.collections.Map import kotlin.collections.Map
import kotlin.collections.MutableList import kotlin.collections.MutableList
@ -43,11 +55,14 @@ import kotlin.collections.forEachIndexed
import kotlin.collections.getOrNull import kotlin.collections.getOrNull
import kotlin.collections.getOrPut import kotlin.collections.getOrPut
import kotlin.collections.indices import kotlin.collections.indices
import kotlin.collections.LinkedHashMap
import kotlin.collections.linkedMapOf import kotlin.collections.linkedMapOf
import kotlin.collections.listOf import kotlin.collections.listOf
import kotlin.collections.map import kotlin.collections.map
import kotlin.collections.mutableListOf import kotlin.collections.mutableListOf
import kotlin.collections.set
import kotlin.text.lowercase import kotlin.text.lowercase
import kotlin.text.substringAfterLast
internal data class SqlColumnMeta( internal data class SqlColumnMeta(
val name: String, val name: String,
@ -90,6 +105,9 @@ internal class SqlCoreModule private constructor(
val sqlConstraintException: ObjException.Companion.ExceptionClass, val sqlConstraintException: ObjException.Companion.ExceptionClass,
val sqlUsageException: ObjException.Companion.ExceptionClass, val sqlUsageException: ObjException.Companion.ExceptionClass,
val rollbackException: ObjException.Companion.ExceptionClass, val rollbackException: ObjException.Companion.ExceptionClass,
val iterableClass: ObjClass,
val iteratorClass: ObjClass,
val dbFieldAdapterClass: ObjClass,
val sqlTypes: SqlTypeEntries, val sqlTypes: SqlTypeEntries,
) { ) {
companion object { companion object {
@ -106,6 +124,11 @@ internal class SqlCoreModule private constructor(
sqlConstraintException = module.requireClass("SqlConstraintException") as ObjException.Companion.ExceptionClass, sqlConstraintException = module.requireClass("SqlConstraintException") as ObjException.Companion.ExceptionClass,
sqlUsageException = module.requireClass("SqlUsageException") as ObjException.Companion.ExceptionClass, sqlUsageException = module.requireClass("SqlUsageException") as ObjException.Companion.ExceptionClass,
rollbackException = module.requireClass("RollbackException") as ObjException.Companion.ExceptionClass, rollbackException = module.requireClass("RollbackException") as ObjException.Companion.ExceptionClass,
iterableClass = module.importProvider.rootScope.get("Iterable")?.value as? ObjClass
?: error("lyng.stdlib.Iterable declaration is missing"),
iteratorClass = module.importProvider.rootScope.get("Iterator")?.value as? ObjClass
?: error("lyng.stdlib.Iterator declaration is missing"),
dbFieldAdapterClass = module.requireClass("DbFieldAdapter"),
sqlTypes = SqlTypeEntries.resolve(module), sqlTypes = SqlTypeEntries.resolve(module),
) )
} }
@ -148,6 +171,8 @@ internal class SqlRuntimeTypes private constructor(
val rowClass: ObjClass, val rowClass: ObjClass,
val columnClass: ObjClass, val columnClass: ObjClass,
val executionResultClass: ObjClass, val executionResultClass: ObjClass,
val decodedIterableClass: ObjClass,
val decodedIteratorClass: ObjClass,
) { ) {
companion object { companion object {
fun create(prefix: String, core: SqlCoreModule): SqlRuntimeTypes { fun create(prefix: String, core: SqlCoreModule): SqlRuntimeTypes {
@ -157,6 +182,8 @@ internal class SqlRuntimeTypes private constructor(
val rowClass = object : ObjClass("${prefix}Row", core.rowClass) {} val rowClass = object : ObjClass("${prefix}Row", core.rowClass) {}
val columnClass = object : ObjClass("${prefix}Column", core.columnClass) {} val columnClass = object : ObjClass("${prefix}Column", core.columnClass) {}
val executionResultClass = object : ObjClass("${prefix}ExecutionResult", core.executionResultClass) {} val executionResultClass = object : ObjClass("${prefix}ExecutionResult", core.executionResultClass) {}
val decodedIterableClass = object : ObjClass("${prefix}DecodedIterable", core.iterableClass) {}
val decodedIteratorClass = object : ObjClass("${prefix}DecodedIterator", core.iteratorClass) {}
val runtime = SqlRuntimeTypes( val runtime = SqlRuntimeTypes(
core = core, core = core,
databaseClass = databaseClass, databaseClass = databaseClass,
@ -165,6 +192,8 @@ internal class SqlRuntimeTypes private constructor(
rowClass = rowClass, rowClass = rowClass,
columnClass = columnClass, columnClass = columnClass,
executionResultClass = executionResultClass, executionResultClass = executionResultClass,
decodedIterableClass = decodedIterableClass,
decodedIteratorClass = decodedIteratorClass,
) )
runtime.bind() runtime.bind()
return runtime return runtime
@ -251,6 +280,19 @@ internal class SqlRuntimeTypes private constructor(
self.lifetime.ensureActive(this) self.lifetime.ensureActive(this)
ObjImmutableList(self.rows) ObjImmutableList(self.rows)
} }
resultSetClass.addFn(
"decodeAs",
callSignature = core.resultSetClass.getInstanceMemberOrNull("decodeAs")?.callSignature
) {
val self = thisAs<SqlResultSetObj>()
self.lifetime.ensureActive(this)
SqlDecodedIterableObj(
self.types,
self.lifetime,
self.rows.map { it as SqlRowObj },
resolveDecodeTargetType(requireScope())
)
}
rowClass.addProperty("size", getter = { rowClass.addProperty("size", getter = {
val self = thisAs<SqlRowObj>() val self = thisAs<SqlRowObj>()
@ -260,6 +302,12 @@ internal class SqlRuntimeTypes private constructor(
val self = thisAs<SqlRowObj>() val self = thisAs<SqlRowObj>()
ObjImmutableList(self.values) ObjImmutableList(self.values)
}) })
rowClass.addFn(
"decodeAs",
callSignature = core.rowClass.getInstanceMemberOrNull("decodeAs")?.callSignature
) {
decodeSqlRow(requireScope(), thisAs(), resolveDecodeTargetType(requireScope()))
}
columnClass.addProperty("name", getter = { ObjString(thisAs<SqlColumnObj>().meta.name) }) columnClass.addProperty("name", getter = { ObjString(thisAs<SqlColumnObj>().meta.name) })
columnClass.addProperty("sqlType", getter = { thisAs<SqlColumnObj>().meta.sqlType }) columnClass.addProperty("sqlType", getter = { thisAs<SqlColumnObj>().meta.sqlType })
@ -276,6 +324,27 @@ internal class SqlRuntimeTypes private constructor(
self.lifetime.ensureActive(this) self.lifetime.ensureActive(this)
SqlResultSetObj(self.types, self.lifetime, self.result.generatedKeys) SqlResultSetObj(self.types, self.lifetime, self.result.generatedKeys)
} }
decodedIterableClass.addFn("iterator") {
val self = thisAs<SqlDecodedIterableObj>()
self.lifetime.ensureActive(this)
SqlDecodedIteratorObj(self.types, self.lifetime, self.rows.iterator(), self.targetType)
}
decodedIteratorClass.addFn("hasNext") {
val self = thisAs<SqlDecodedIteratorObj>()
self.lifetime.ensureActive(this)
ObjBool(self.rows.hasNext())
}
decodedIteratorClass.addFn("next") {
val self = thisAs<SqlDecodedIteratorObj>()
self.lifetime.ensureActive(this)
decodeSqlRow(requireScope(), self.rows.next(), self.targetType)
}
decodedIteratorClass.addFn("cancelIteration") {
thisAs<SqlDecodedIteratorObj>().lifetime.ensureActive(this)
ObjVoid
}
} }
} }
@ -319,6 +388,7 @@ internal class SqlResultSetObj(
val lifetime: SqlTransactionLifetime, val lifetime: SqlTransactionLifetime,
data: SqlResultSetData, data: SqlResultSetData,
) : Obj() { ) : Obj() {
val columnMeta: List<SqlColumnMeta> = data.columns
val columns: List<Obj> = data.columns.map { SqlColumnObj(types, it) } val columns: List<Obj> = data.columns.map { SqlColumnObj(types, it) }
val rows: List<Obj> = buildRows(types, data) val rows: List<Obj> = buildRows(types, data)
@ -334,13 +404,14 @@ internal class SqlResultSetObj(
indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index) indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index)
} }
return data.rows.map { rowValues -> return data.rows.map { rowValues ->
SqlRowObj(types, rowValues, indexByName) SqlRowObj(types, data.columns, rowValues, indexByName)
} }
} }
} }
internal class SqlRowObj( internal class SqlRowObj(
val types: SqlRuntimeTypes, val types: SqlRuntimeTypes,
val columns: List<SqlColumnMeta>,
val values: List<Obj>, val values: List<Obj>,
private val indexByName: Map<String, List<Int>>, private val indexByName: Map<String, List<Int>>,
) : Obj() { ) : Obj() {
@ -381,6 +452,26 @@ internal class SqlRowObj(
} }
} }
internal class SqlDecodedIterableObj(
val types: SqlRuntimeTypes,
val lifetime: SqlTransactionLifetime,
val rows: List<SqlRowObj>,
val targetType: TypeDecl,
) : Obj() {
override val objClass: ObjClass
get() = types.decodedIterableClass
}
internal class SqlDecodedIteratorObj(
val types: SqlRuntimeTypes,
val lifetime: SqlTransactionLifetime,
val rows: Iterator<SqlRowObj>,
val targetType: TypeDecl,
) : Obj() {
override val objClass: ObjClass
get() = types.decodedIteratorClass
}
internal class SqlColumnObj( internal class SqlColumnObj(
val types: SqlRuntimeTypes, val types: SqlRuntimeTypes,
val meta: SqlColumnMeta, val meta: SqlColumnMeta,
@ -397,3 +488,339 @@ internal class SqlExecutionResultObj(
override val objClass: ObjClass override val objClass: ObjClass
get() = types.executionResultClass get() = types.executionResultClass
} }
private fun resolveDecodeTargetType(scope: Scope): TypeDecl {
val explicit = scope.args.explicitTypeArgs.singleOrNull()
if (explicit != null) return explicit
val bound = scope["T"]?.value
return when (bound) {
is ObjTypeExpr -> bound.typeDecl
is ObjClass -> TypeDecl.Simple(bound.className, false)
else -> scope.raiseIllegalArgument("decodeAs requires exactly one type argument")
}
}
private suspend fun decodeSqlRow(scope: Scope, row: SqlRowObj, targetType: TypeDecl): Obj {
val targetClass = resolveTypeDeclClass(scope, targetType)
if (targetClass != null && shouldUseStructuredRowDecoding(row, targetClass)) {
return decodeStructuredRow(scope, row, targetType, targetClass)
}
if (row.values.size == 1) {
return decodeSqlValue(scope, row.types, row, row.columns[0], row.values[0], targetType)
}
scope.raiseError(
ObjException(
row.types.core.sqlUsageException,
scope,
ObjString("Can't decode SQL row with ${row.values.size} columns as ${renderTypeName(targetType)}")
)
)
}
private fun shouldUseStructuredRowDecoding(row: SqlRowObj, targetClass: ObjClass): Boolean {
if (row.values.size > 1) return true
val memberNames = linkedMapOf<String, Int>()
targetClass.constructorMeta?.params?.forEach { memberNames[it.name.lowercase()] = 1 }
row.columns.forEach { column ->
if (column.name.lowercase() in memberNames) return true
}
return false
}
private suspend fun decodeStructuredRow(
scope: Scope,
row: SqlRowObj,
targetType: TypeDecl,
targetClass: ObjClass,
): Obj {
val meta = targetClass.constructorMeta
?: raiseSqlUsage(scope, row.types, "Can't decode SQL row as ${targetClass.className}: target class has no constructor metadata")
val normalizedColumns = buildColumnLookup(scope, row)
val consumed = mutableSetOf<String>()
val namedArgs = LinkedHashMap<String, Obj>()
for (param in meta.params) {
if (param.isTransient) continue
val lowered = param.name.lowercase()
val column = normalizedColumns[lowered]
if (column == null) {
if (param.defaultValue == null && !param.type.isNullable) {
raiseSqlUsage(scope, row.types, "Missing SQL column '${param.name}' for ${targetClass.className}")
}
continue
}
namedArgs[param.name] = decodeSqlValue(
scope,
row.types,
row,
column.first,
row.values[column.second],
param.type,
param.annotations
)
consumed += lowered
}
val callScope = scope.createChildScope(args = Arguments(list = emptyList(), named = namedArgs))
val instance = targetClass.callOn(callScope)
if (instance !is ObjInstance) {
return instance
}
val knownFields = collectSerializableFieldTargets(scope, row, targetClass, instance)
for ((lowered, target) in knownFields) {
if (consumed.contains(lowered)) continue
val column = normalizedColumns[lowered] ?: continue
val targetTypeDecl = target.record.typeDecl ?: TypeDecl.TypeAny
target.record.value = decodeSqlValue(
scope,
row.types,
row,
column.first,
row.values[column.second],
targetTypeDecl,
target.annotations
)
consumed += lowered
}
val allowedNames = meta.params.filter { !it.isTransient }.map { it.name.lowercase() }.toMutableSet()
allowedNames += knownFields.keys
for (column in row.columns) {
val lowered = column.name.lowercase()
if (lowered !in allowedNames) {
raiseSqlUsage(scope, row.types, "Unknown SQL result column '${column.name}' while decoding ${renderTypeName(targetType)}")
}
}
instance.invokeInstanceMethod(scope, "onDeserialized", Arguments.EMPTY) { ObjVoid }
return instance
}
private data class FieldDecodeTarget(
val record: ObjRecord,
val annotations: List<DeclAnnotation>,
)
private fun collectSerializableFieldTargets(
scope: Scope,
row: SqlRowObj,
targetClass: ObjClass,
instance: ObjInstance,
): Map<String, FieldDecodeTarget> {
val result = linkedMapOf<String, FieldDecodeTarget>()
for ((name, record) in instance.serializingVars) {
val simpleName = name.substringAfterLast("::")
val lowered = simpleName.lowercase()
val classAnnotations = targetClass.getInstanceMemberOrNull(simpleName)?.annotations ?: emptyList()
val previous = result.put(lowered, FieldDecodeTarget(record, classAnnotations))
if (previous != null) {
raiseSqlUsage(scope, row.types, "Ambiguous serializable target field '$lowered' in ${targetClass.className}")
}
}
return result
}
private fun buildColumnLookup(scope: Scope, row: SqlRowObj): Map<String, Pair<SqlColumnMeta, Int>> {
val result = linkedMapOf<String, Pair<SqlColumnMeta, Int>>()
row.columns.forEachIndexed { index, column ->
val lowered = column.name.lowercase()
if (result.containsKey(lowered)) {
raiseSqlUsage(scope, row.types, "Ambiguous SQL result column: ${column.name}")
}
result[lowered] = column to index
}
return result
}
private suspend fun decodeSqlValue(
scope: Scope,
types: SqlRuntimeTypes,
row: SqlRowObj,
column: SqlColumnMeta,
value: Obj,
targetType: TypeDecl,
annotations: List<DeclAnnotation> = emptyList(),
): Obj {
val adapterAnnotation = findDbDecodeWithAnnotation(scope, types, annotations)
if (adapterAnnotation != null) {
val adapted = applyDbFieldAdapter(scope, types, row, column, value, targetType, adapterAnnotation)
if (adapted === ObjNull) {
if (targetType.isNullable || targetType == TypeDecl.TypeNullableAny) return ObjNull
raiseSqlUsage(scope, types, "SQL column '${column.name}' is null but target type ${renderTypeName(targetType)} is non-null")
}
if (!matchesTypeDeclCompat(scope, adapted, targetType)) {
raiseSqlUsage(
scope,
types,
"DB adapter result for column '${column.name}' does not match target type ${renderTypeName(targetType)}"
)
}
return adapted
}
if (value === ObjNull) {
if (targetType.isNullable || targetType == TypeDecl.TypeNullableAny) return ObjNull
raiseSqlUsage(scope, types, "SQL column '${column.name}' is null but target type ${renderTypeName(targetType)} is non-null")
}
if (matchesTypeDeclCompat(scope, value, targetType)) {
return value
}
if (value is ObjBuffer) {
return try {
val decoded = ObjLynonClass.decodeAny(scope, ObjBitBuffer(BitArray(value.byteArray, 8)))
if (!matchesTypeDeclCompat(scope, decoded, targetType)) {
raiseSqlUsage(
scope,
types,
"Lynon-decoded SQL column '${column.name}' does not match target type ${renderTypeName(targetType)}"
)
}
decoded
} catch (e: Throwable) {
raiseSqlUsage(
scope,
types,
"Failed to decode Lynon column '${column.name}' as ${renderTypeName(targetType)}: ${e.message ?: e::class.simpleName}"
)
}
}
if (isJsonLikeNativeType(column.nativeType) && value is ObjString) {
return try {
ObjJsonClass.decodeFromJsonElement(scope, Json.parseToJsonElement(value.value), targetType)
} catch (e: Throwable) {
raiseSqlUsage(
scope,
types,
"Failed to decode JSON column '${column.name}' as ${renderTypeName(targetType)}: ${e.message ?: e::class.simpleName}"
)
}
}
raiseSqlUsage(
scope,
types,
"SQL column '${column.name}' of native type ${column.nativeType} can't be decoded as ${renderTypeName(targetType)}"
)
}
private fun findDbDecodeWithAnnotation(
scope: Scope,
types: SqlRuntimeTypes,
annotations: List<DeclAnnotation>,
): DeclAnnotation? {
val matches = annotations.filter { it.name == "DbDecodeWith" }
if (matches.size > 1) {
raiseSqlUsage(scope, types, "Only one @DbDecodeWith(...) annotation is allowed per declaration")
}
return matches.singleOrNull()
}
private suspend fun applyDbFieldAdapter(
scope: Scope,
types: SqlRuntimeTypes,
row: SqlRowObj,
column: SqlColumnMeta,
value: Obj,
targetType: TypeDecl,
annotation: DeclAnnotation,
): Obj {
if (annotation.named.isNotEmpty() || annotation.positional.size != 1) {
raiseSqlUsage(scope, types, "@DbDecodeWith(...) expects exactly one adapter instance argument")
}
val adapter = annotation.positional.first()
if (!adapter.isInstanceOf(types.core.dbFieldAdapterClass)) {
raiseSqlUsage(scope, types, "@DbDecodeWith(...) argument must implement DbFieldAdapter")
}
return try {
adapter.invokeInstanceMethod(
scope,
"decode",
Arguments(
value,
SqlColumnObj(types, column),
row,
runtimeTargetTypeObject(scope, targetType)
)
)
} catch (e: Throwable) {
raiseSqlUsage(
scope,
types,
"Failed to decode SQL column '${column.name}' with @DbDecodeWith(...): ${e.message ?: e::class.simpleName}"
)
}
}
private fun runtimeTargetTypeObject(scope: Scope, targetType: TypeDecl): Obj {
return resolveTypeDeclClass(scope, targetType) ?: ObjTypeExpr(targetType)
}
private fun isJsonLikeNativeType(nativeType: String): Boolean {
val normalized = nativeType.trim().substringBefore('(').lowercase()
return normalized == "json" || normalized == "jsonb"
}
private fun resolveTypeDeclClass(scope: Scope, type: TypeDecl): ObjClass? = when (type) {
is TypeDecl.Simple -> {
val direct = scope[type.name]?.value as? ObjClass
direct ?: scope[type.name.substringAfterLast('.')]?.value as? ObjClass
}
is TypeDecl.Generic -> {
val direct = scope[type.name]?.value as? ObjClass
direct ?: scope[type.name.substringAfterLast('.')]?.value as? ObjClass
}
is TypeDecl.Ellipsis -> resolveTypeDeclClass(scope, type.elementType)
is TypeDecl.TypeVar -> when (val bound = scope[type.name]?.value) {
is ObjClass -> bound
is ObjTypeExpr -> resolveTypeDeclClass(scope, bound.typeDecl)
else -> null
}
else -> null
}
private fun matchesTypeDeclCompat(scope: Scope, value: Obj, typeDecl: TypeDecl): Boolean {
if (value === ObjNull) return typeDecl.isNullable || typeDecl == TypeDecl.TypeNullableAny
fun resolve(typeName: String): ObjClass? {
val direct = scope[typeName]?.value as? ObjClass
return direct ?: scope[typeName.substringAfterLast('.')]?.value as? ObjClass
}
return when (typeDecl) {
TypeDecl.TypeAny, TypeDecl.TypeNullableAny -> true
is TypeDecl.TypeVar -> {
val cls = resolve(typeDecl.name)
if (cls != null) value.isInstanceOf(cls) else value.isInstanceOf(typeDecl.name)
}
is TypeDecl.Simple -> {
val cls = resolve(typeDecl.name)
if (cls != null) value.isInstanceOf(cls) else value.isInstanceOf(typeDecl.name.substringAfterLast('.'))
}
is TypeDecl.Generic -> {
val cls = resolve(typeDecl.name)
if (cls != null) value.isInstanceOf(cls) else value.isInstanceOf(typeDecl.name.substringAfterLast('.'))
}
is TypeDecl.Function -> value.isInstanceOf("Callable")
is TypeDecl.Ellipsis -> matchesTypeDeclCompat(scope, value, typeDecl.elementType)
is TypeDecl.Union -> typeDecl.options.any { matchesTypeDeclCompat(scope, value, it) }
is TypeDecl.Intersection -> typeDecl.options.all { matchesTypeDeclCompat(scope, value, it) }
}
}
private fun renderTypeName(type: TypeDecl): String = when (type) {
TypeDecl.TypeAny -> "Object"
TypeDecl.TypeNullableAny -> "Object?"
is TypeDecl.Simple -> type.name + if (type.isNullable) "?" else ""
is TypeDecl.Generic -> type.name + "<" + type.args.joinToString(",") { renderTypeName(it) } + ">" + if (type.isNullable) "?" else ""
is TypeDecl.Function -> "Callable"
is TypeDecl.Ellipsis -> renderTypeName(type.elementType) + "..."
is TypeDecl.TypeVar -> type.name + if (type.isNullable) "?" else ""
is TypeDecl.Union -> type.options.joinToString(" | ") { renderTypeName(it) } + if (type.isNullable) "?" else ""
is TypeDecl.Intersection -> type.options.joinToString(" & ") { renderTypeName(it) } + if (type.isNullable) "?" else ""
}
private fun raiseSqlUsage(scope: Scope, types: SqlRuntimeTypes?, message: String): Nothing {
val exClass = types?.core?.sqlUsageException
if (exClass != null) {
scope.raiseError(ObjException(exClass, scope, ObjString(message)))
}
scope.raiseIllegalArgument(message)
}

View File

@ -137,6 +137,230 @@ class LyngSqliteModuleTest {
assertEquals(2L, result.value) assertEquals(2L, result.value)
} }
@Test
fun testDecodeAsProjectsJsonColumnIntoObjectField() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
class Row(id: Int, payload: Point)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(id integer not null, payload json not null)")
tx.execute("insert into data(id, payload) values(?, ?)", 7, "{\"x\":4,\"y\":5}")
val row = tx.select("select id, payload from data").decodeAs<Row>().first
assertEquals(7, row.id)
assertEquals(4, row.payload.x)
assertEquals(5, row.payload.y)
row.payload.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-json-field>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testDecodeAsSupportsSingleJsonColumnProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload json not null)")
tx.execute("insert into data(payload) values(?)", "{\"x\":9,\"y\":11}")
val point = tx.select("select payload from data").decodeAs<Point>().first
assertEquals(9, point.x)
assertEquals(11, point.y)
point.x + point.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-json-single>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(20L, result.value)
}
@Test
fun testDecodeAsDoesNotAutoDecodePlainTextAsJson() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload text not null)")
tx.execute("insert into data(payload) values(?)", "{\"x\":1,\"y\":2}")
tx.select("select payload from data").decodeAs<Point>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-decode-json-text-guard>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testDecodeAsSupportsSingleLynonBinaryProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.serialization
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
tx.execute("insert into data(payload) values(?)", Lynon.encode(Point(6, 8)).toBuffer())
val point = tx.select("select payload from data").decodeAs<Point>().first
assertEquals(6, point.x)
assertEquals(8, point.y)
point.x + point.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-lynon-single>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(14L, result.value)
}
@Test
fun testDecodeAsSupportsDbDecodeWithOnConstructorParamsAndFields() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object TrimmedStringAdapter: DbFieldAdapter {
override fun decode(rawValue, column, row, targetType) =
when(rawValue) {
null -> null
else -> rawValue.toString().trim()
}
}
class User(
id: Int,
@DbDecodeWith(TrimmedStringAdapter) name: String
) {
@DbDecodeWith(TrimmedStringAdapter)
var note: String = ""
}
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(id integer not null, name text not null, note text not null)")
tx.execute("insert into data(id, name, note) values(?, ?, ?)", 10, " Alice ", " hello ")
val user = tx.select("select id, name, note from data").decodeAs<User>().first
assertEquals(10, user.id)
assertEquals("Alice", user.name)
assertEquals("hello", user.note)
user.note.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-dbdecodewith>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testDecodeAsFailsWhenDbDecodeWithReturnsWrongType() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object BadAdapter: DbFieldAdapter {
override fun decode(rawValue, column, row, targetType) = 42
}
class User(@DbDecodeWith(BadAdapter) name: String)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(name text not null)")
tx.execute("insert into data(name) values(?)", "Alice")
tx.select("select name from data").decodeAs<User>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-decode-dbdecodewith-bad-type>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testDecodeAsKeepsRawBufferForBufferTarget() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.buffer
import lyng.serialization
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
val encoded = Lynon.encode(Point(1, 2)).toBuffer()
tx.execute("insert into data(payload) values(?)", encoded)
val payload = tx.select("select payload from data").decodeAs<Buffer>().first
assertEquals(encoded.size, payload.size)
payload.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-decode-buffer-raw>", code), scope.importManager).execute(scope) as ObjInt
assertTrue(result.value > 0)
}
@Test
fun testDecodeAsFailsForNonLynonBinaryTypedProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.buffer
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
tx.execute("insert into data(payload) values(?)", "hello".encodeUtf8())
tx.select("select payload from data").decodeAs<Point>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-decode-lynon-binary-guard>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test @Test
fun testNestedTransactionRollbackUsesSavepoint() = runTest { fun testNestedTransactionRollbackUsesSavepoint() = runTest {
val scope = Script.newScope() val scope = Script.newScope()

View File

@ -19,11 +19,13 @@ package net.sergeych.lyng.io.db.sqlite
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Pos import net.sergeych.lyng.Pos
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.Script import net.sergeych.lyng.Script
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer import net.sergeych.lyng.obj.ObjBuffer
@ -231,6 +233,230 @@ class LyngSqliteModuleNativeTest {
assertEquals("beta", stringValue(scope, rows.getAt(scope, ObjInt.of(1)).getAt(scope, ObjString("name")))) assertEquals("beta", stringValue(scope, rows.getAt(scope, ObjInt.of(1)).getAt(scope, ObjString("name"))))
} }
@Test
fun testDecodeAsProjectsJsonColumnIntoObjectField() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
class Row(id: Int, payload: Point)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(id integer not null, payload json not null)")
tx.execute("insert into data(id, payload) values(?, ?)", 7, "{\"x\":4,\"y\":5}")
val row = tx.select("select id, payload from data").decodeAs<Row>().first
assertEquals(7, row.id)
assertEquals(4, row.payload.x)
assertEquals(5, row.payload.y)
row.payload.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-json-field>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testDecodeAsSupportsSingleJsonColumnProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload json not null)")
tx.execute("insert into data(payload) values(?)", "{\"x\":9,\"y\":11}")
val point = tx.select("select payload from data").decodeAs<Point>().first
assertEquals(9, point.x)
assertEquals(11, point.y)
point.x + point.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-json-single>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(20L, result.value)
}
@Test
fun testDecodeAsDoesNotAutoDecodePlainTextAsJson() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload text not null)")
tx.execute("insert into data(payload) values(?)", "{\"x\":1,\"y\":2}")
tx.select("select payload from data").decodeAs<Point>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-native-decode-json-text-guard>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testDecodeAsSupportsSingleLynonBinaryProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.serialization
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
tx.execute("insert into data(payload) values(?)", Lynon.encode(Point(6, 8)).toBuffer())
val point = tx.select("select payload from data").decodeAs<Point>().first
assertEquals(6, point.x)
assertEquals(8, point.y)
point.x + point.y
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-lynon-single>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(14L, result.value)
}
@Test
fun testDecodeAsSupportsDbDecodeWithOnConstructorParamsAndFields() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object TrimmedStringAdapter: DbFieldAdapter {
override fun decode(rawValue, column, row, targetType) =
when(rawValue) {
null -> null
else -> rawValue.toString().trim()
}
}
class User(
id: Int,
@DbDecodeWith(TrimmedStringAdapter) name: String
) {
@DbDecodeWith(TrimmedStringAdapter)
var note: String = ""
}
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(id integer not null, name text not null, note text not null)")
tx.execute("insert into data(id, name, note) values(?, ?, ?)", 10, " Alice ", " hello ")
val user = tx.select("select id, name, note from data").decodeAs<User>().first
assertEquals(10, user.id)
assertEquals("Alice", user.name)
assertEquals("hello", user.note)
user.note.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-dbdecodewith>", code), scope.importManager).execute(scope) as ObjInt
assertEquals(5L, result.value)
}
@Test
fun testDecodeAsFailsWhenDbDecodeWithReturnsWrongType() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db
import lyng.io.db.sqlite
object BadAdapter: DbFieldAdapter {
override fun decode(rawValue, column, row, targetType) = 42
}
class User(@DbDecodeWith(BadAdapter) name: String)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(name text not null)")
tx.execute("insert into data(name) values(?)", "Alice")
tx.select("select name from data").decodeAs<User>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-native-decode-dbdecodewith-bad-type>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test
fun testDecodeAsKeepsRawBufferForBufferTarget() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.buffer
import lyng.serialization
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
val encoded = Lynon.encode(Point(1, 2)).toBuffer()
tx.execute("insert into data(payload) values(?)", encoded)
val payload = tx.select("select payload from data").decodeAs<Buffer>().first
assertEquals(encoded.size, payload.size)
payload.size
}
""".trimIndent()
val result = Compiler.compile(Source("<sqlite-native-decode-buffer-raw>", code), scope.importManager).execute(scope) as ObjInt
assertTrue(result.value > 0)
}
@Test
fun testDecodeAsFailsForNonLynonBinaryTypedProjection() = runTest {
val scope = Script.newScope()
createSqliteModule(scope.importManager)
val code = """
import lyng.io.db.sqlite
import lyng.buffer
class Point(x: Int, y: Int)
val db = openSqlite(":memory:")
db.transaction { tx ->
tx.execute("create table data(payload blob not null)")
tx.execute("insert into data(payload) values(?)", "hello".encodeUtf8())
tx.select("select payload from data").decodeAs<Point>().first
}
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(Source("<sqlite-native-decode-lynon-binary-guard>", code), scope.importManager).execute(scope)
}
assertEquals("SqlUsageException", error.errorObject.objClass.className)
}
@Test @Test
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest { fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
val scope = Script.newScope() val scope = Script.newScope()

View File

@ -20,6 +20,32 @@ extern class SqlColumn {
val nativeType: String val nativeType: String
} }
/*
Adapter interface for custom DB field projection.
Use it with `@DbDecodeWith(adapter)` on class constructor parameters or
class-body fields/properties participating in `decodeAs<T>()` projection.
`targetType` is the requested Lyng target type represented as a runtime
type object, such as a class or a type expression.
Default methods throw `NotImplementedException`.
*/
interface DbFieldAdapter {
/*
Decode one raw database field value into a Lyng value suitable for the
requested target type.
*/
fun decode(rawValue: Object?, column: SqlColumn, row: SqlRow, targetType: Object): Object? =
throw NotImplementedException("DB field adapter decode is not implemented")
/*
Encode one Lyng value into a database field representation.
*/
fun encode(value: Object?, targetType: Object): Object? =
throw NotImplementedException("DB field adapter encode is not implemented")
}
extern class SqlRow { extern class SqlRow {
/* Number of columns in the row */ /* Number of columns in the row */
val size: Int val size: Int
@ -34,6 +60,22 @@ extern class SqlRow {
names and invalid indexes should also fail. names and invalid indexes should also fail.
*/ */
override fun getAt(indexOrName: String | Int): Object? override fun getAt(indexOrName: String | Int): Object?
/*
Decode this row into a typed Lyng value.
For object/class targets, constructor parameters are matched by column
label first and then remaining matching serializable mutable fields are
assigned.
If a constructor parameter or class-body field/property has
`@DbDecodeWith(adapter)`, the adapter is applied first and its result
must match the target member type.
For single-column rows, the column value may also be decoded directly to
the requested target type.
*/
fun decodeAs<T>(): T
} }
/* /*
@ -69,6 +111,16 @@ extern class ResultSet : Iterable<SqlRow> {
internally, but this must not change visible later iteration behavior. internally, but this must not change visible later iteration behavior.
*/ */
override fun isEmpty(): Bool override fun isEmpty(): Bool
/*
Return a transaction-scoped iterable view that decodes each row with
`SqlRow.decodeAs<T>()`.
The returned iterable itself must not be used after the owning
transaction ends. Materialized decoded objects may outlive the
transaction.
*/
fun decodeAs<T>(): Iterable<T>
} }
extern class ExecutionResult { extern class ExecutionResult {

View File

@ -88,7 +88,8 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
a.visibility ?: defaultVisibility, a.visibility ?: defaultVisibility,
recordType = recordType, recordType = recordType,
declaringClass = declaringClass, declaringClass = declaringClass,
isTransient = a.isTransient isTransient = a.isTransient,
annotations = a.annotations
) )
} }
return return
@ -108,7 +109,8 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
a.visibility ?: defaultVisibility, a.visibility ?: defaultVisibility,
recordType = recordType, recordType = recordType,
declaringClass = declaringClass, declaringClass = declaringClass,
isTransient = a.isTransient isTransient = a.isTransient,
annotations = a.annotations
) )
} }
@ -505,5 +507,7 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
val accessType: AccessType? = null, val accessType: AccessType? = null,
val visibility: Visibility? = null, val visibility: Visibility? = null,
val isTransient: Boolean = false, val isTransient: Boolean = false,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
val annotations: List<DeclAnnotation> = emptyList(),
) )
} }

View File

@ -41,6 +41,17 @@ data class ClassDeclSpec(
val initScope: List<Statement>, val initScope: List<Statement>,
) )
private suspend fun evaluateConstructorAnnotations(scope: Scope, args: ArgsDeclaration?): ArgsDeclaration? {
if (args == null) return null
if (args.params.none { it.annotationSpecs.isNotEmpty() }) return args
return args.copy(
params = args.params.map { item ->
if (item.annotationSpecs.isEmpty()) item
else item.copy(annotations = item.annotationSpecs.evaluateDeclAnnotations(scope))
}
)
}
internal suspend fun executeClassDecl( internal suspend fun executeClassDecl(
scope: Scope, scope: Scope,
spec: ClassDeclSpec, spec: ClassDeclSpec,
@ -61,7 +72,7 @@ internal suspend fun executeClassDecl(
val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray()) val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray())
newClass.isAnonymous = spec.isAnonymous newClass.isAnonymous = spec.isAnonymous
newClass.isSingletonObject = true newClass.isSingletonObject = true
newClass.constructorMeta = ArgsDeclaration(emptyList(), Token.Type.RPAREN) newClass.constructorMeta = evaluateConstructorAnnotations(scope, ArgsDeclaration(emptyList(), Token.Type.RPAREN))
for (i in parentClasses.indices) { for (i in parentClasses.indices) {
val argsList = spec.baseSpecs[i].args val argsList = spec.baseSpecs[i].args
if (argsList != null) newClass.directParentArgs[parentClasses[i]] = argsList if (argsList != null) newClass.directParentArgs[parentClasses[i]] = argsList
@ -86,6 +97,7 @@ internal suspend fun executeClassDecl(
} }
if (spec.isExtern) { if (spec.isExtern) {
val evaluatedConstructorArgs = evaluateConstructorAnnotations(scope, spec.constructorArgs)
val parentClasses = spec.baseSpecs.mapNotNull { baseSpec -> val parentClasses = spec.baseSpecs.mapNotNull { baseSpec ->
val rec = scope[baseSpec.name] val rec = scope[baseSpec.name]
val cls = rec?.value as? ObjClass val cls = rec?.value as? ObjClass
@ -106,8 +118,8 @@ internal suspend fun executeClassDecl(
} }
val stub = resolved ?: ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).apply { val stub = resolved ?: ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).apply {
this.isAbstract = true this.isAbstract = true
constructorMeta = spec.constructorArgs constructorMeta = evaluatedConstructorArgs
spec.constructorArgs?.params?.forEach { p -> evaluatedConstructorArgs?.params?.forEach { p ->
if (p.accessType != null) { if (p.accessType != null) {
createField( createField(
p.name, p.name,
@ -118,6 +130,7 @@ internal suspend fun executeClassDecl(
pos = Pos.builtIn, pos = Pos.builtIn,
isTransient = p.isTransient, isTransient = p.isTransient,
type = ObjRecord.Type.ConstructorField, type = ObjRecord.Type.ConstructorField,
annotations = p.annotations,
fieldId = spec.constructorFieldIds?.get(p.name) fieldId = spec.constructorFieldIds?.get(p.name)
) )
} }
@ -161,16 +174,17 @@ internal suspend fun executeClassDecl(
} }
} }
val evaluatedConstructorArgs = evaluateConstructorAnnotations(scope, spec.constructorArgs)
val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).also { val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).also {
it.isAbstract = spec.isAbstract it.isAbstract = spec.isAbstract
it.isClosed = spec.isClosed it.isClosed = spec.isClosed
it.instanceConstructor = constructorCode it.instanceConstructor = constructorCode
it.constructorMeta = spec.constructorArgs it.constructorMeta = evaluatedConstructorArgs
for (i in parentClasses.indices) { for (i in parentClasses.indices) {
val argsList = spec.baseSpecs[i].args val argsList = spec.baseSpecs[i].args
if (argsList != null) it.directParentArgs[parentClasses[i]] = argsList if (argsList != null) it.directParentArgs[parentClasses[i]] = argsList
} }
spec.constructorArgs?.params?.forEach { p -> evaluatedConstructorArgs?.params?.forEach { p ->
if (p.accessType != null) { if (p.accessType != null) {
it.createField( it.createField(
p.name, p.name,
@ -181,6 +195,7 @@ internal suspend fun executeClassDecl(
pos = Pos.builtIn, pos = Pos.builtIn,
isTransient = p.isTransient, isTransient = p.isTransient,
type = ObjRecord.Type.ConstructorField, type = ObjRecord.Type.ConstructorField,
annotations = p.annotations,
fieldId = spec.constructorFieldIds?.get(p.name) fieldId = spec.constructorFieldIds?.get(p.name)
) )
} }

View File

@ -38,6 +38,7 @@ class ClassInstanceFieldDeclStatement(
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
val isTransient: Boolean, val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
val fieldId: Int?, val fieldId: Int?,
val initStatement: Statement?, val initStatement: Statement?,
override val pos: Pos, override val pos: Pos,
@ -56,6 +57,7 @@ class ClassInstancePropertyDeclStatement(
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
val isTransient: Boolean, val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
val prop: ObjProperty, val prop: ObjProperty,
val methodId: Int?, val methodId: Int?,
val initStatement: Statement?, val initStatement: Statement?,
@ -75,6 +77,7 @@ class ClassInstanceDelegatedDeclStatement(
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
val isTransient: Boolean, val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
val methodId: Int?, val methodId: Int?,
val initStatement: Statement?, val initStatement: Statement?,
override val pos: Pos, override val pos: Pos,

View File

@ -32,6 +32,7 @@ class ClassStaticFieldInitStatement(
val initializer: Statement?, val initializer: Statement?,
val isDelegated: Boolean, val isDelegated: Boolean,
val isTransient: Boolean, val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
private val startPos: Pos, private val startPos: Pos,
) : Statement() { ) : Statement() {
override val pos: Pos = startPos override val pos: Pos = startPos
@ -39,6 +40,7 @@ class ClassStaticFieldInitStatement(
override suspend fun execute(scope: Scope): Obj { override suspend fun execute(scope: Scope): Obj {
val initValue = initializer?.let { execBytecodeOnly(scope, it, "class static field init") }?.byValueCopy() val initValue = initializer?.let { execBytecodeOnly(scope, it, "class static field init") }?.byValueCopy()
?: ObjNull ?: ObjNull
val annotations = annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("static field init requires class scope") ?: scope.raiseIllegalState("static field init requires class scope")
return if (isDelegated) { return if (isDelegated) {
@ -61,7 +63,8 @@ class ClassStaticFieldInitStatement(
writeVisibility, writeVisibility,
startPos, startPos,
isTransient = isTransient, isTransient = isTransient,
type = ObjRecord.Type.Delegated type = ObjRecord.Type.Delegated,
annotations = annotations
).apply { ).apply {
delegate = finalDelegate delegate = finalDelegate
} }
@ -72,7 +75,8 @@ class ClassStaticFieldInitStatement(
visibility, visibility,
writeVisibility, writeVisibility,
recordType = ObjRecord.Type.Delegated, recordType = ObjRecord.Type.Delegated,
isTransient = isTransient isTransient = isTransient,
annotations = annotations
).apply { ).apply {
delegate = finalDelegate delegate = finalDelegate
} }
@ -85,7 +89,8 @@ class ClassStaticFieldInitStatement(
visibility, visibility,
writeVisibility, writeVisibility,
startPos, startPos,
isTransient = isTransient isTransient = isTransient,
annotations = annotations
) )
scope.addItem( scope.addItem(
name, name,
@ -94,7 +99,8 @@ class ClassStaticFieldInitStatement(
visibility, visibility,
writeVisibility, writeVisibility,
recordType = ObjRecord.Type.Field, recordType = ObjRecord.Type.Field,
isTransient = isTransient isTransient = isTransient,
annotations = annotations
) )
initValue initValue
} }

View File

@ -2014,6 +2014,7 @@ class Compiler(
private var lastAnnotation: (suspend (Scope, ObjString, Statement) -> Statement)? = null private var lastAnnotation: (suspend (Scope, ObjString, Statement) -> Statement)? = null
private var isTransientFlag: Boolean = false private var isTransientFlag: Boolean = false
private val pendingDeclAnnotations: MutableList<ParsedDeclAnnotation> = mutableListOf()
private var lastLabel: String? = null private var lastLabel: String? = null
private val strictSlotRefs: Boolean = settings.strictSlotRefs private val strictSlotRefs: Boolean = settings.strictSlotRefs
private val allowUnresolvedRefs: Boolean = settings.allowUnresolvedRefs private val allowUnresolvedRefs: Boolean = settings.allowUnresolvedRefs
@ -2689,6 +2690,7 @@ class Compiler(
lastAnnotation = null lastAnnotation = null
lastLabel = null lastLabel = null
isTransientFlag = false isTransientFlag = false
pendingDeclAnnotations.clear()
while (true) { while (true) {
val t = cc.next() val t = cc.next()
return when (t.type) { return when (t.type) {
@ -2706,15 +2708,17 @@ class Compiler(
} }
Token.Type.ATLABEL -> { Token.Type.ATLABEL -> {
val label = t.value val parsedAnnotation = parseDeclAnnotation(t)
if (label == "Transient") { if (parsedAnnotation.name == "Transient") {
isTransientFlag = true isTransientFlag = true
continue
} }
if (cc.peekNextNonWhitespace().type == Token.Type.LBRACE) { if (cc.peekNextNonWhitespace().type == Token.Type.LBRACE) {
lastLabel = label lastLabel = parsedAnnotation.name
}
pendingDeclAnnotations += parsedAnnotation
if (parsedAnnotation.name != "Transient") {
lastAnnotation = parsedAnnotation.toStatementAnnotation()
} }
lastAnnotation = parseAnnotation(t)
continue continue
} }
@ -4033,11 +4037,17 @@ class Compiler(
Token.Type.ID, Token.Type.ATLABEL -> { Token.Type.ID, Token.Type.ATLABEL -> {
var isTransient = false var isTransient = false
if (t.type == Token.Type.ATLABEL) { val annotationSpecs = mutableListOf<ParsedDeclAnnotation>()
if (t.value == "Transient") { while (t.type == Token.Type.ATLABEL) {
val spec = parseDeclAnnotation(t)
annotationSpecs += spec
if (spec.name == "Transient") {
isTransient = true isTransient = true
}
t = cc.next() t = cc.next()
} else throw ScriptError(t.pos, "Unexpected label in argument list") }
if (annotationSpecs.isNotEmpty() && !isClassDeclaration) {
throw ScriptError(t.pos, "parameter annotations are currently supported only on class constructor parameters")
} }
// visibility // visibility
@ -4114,7 +4124,8 @@ class Compiler(
defaultValue, defaultValue,
effectiveAccess, effectiveAccess,
visibility, visibility,
isTransient isTransient,
annotationSpecs = annotationSpecs
) )
// important: valid argument list continues with ',' and ends with '->' or ')' // important: valid argument list continues with ',' and ends with '->' or ')'
@ -7120,19 +7131,25 @@ class Compiler(
return parseNumberOrNull(isPlus) ?: throw ScriptError(cc.currentPos(), "Expecting number") return parseNumberOrNull(isPlus) ?: throw ScriptError(cc.currentPos(), "Expecting number")
} }
suspend fun parseAnnotation(t: Token): (suspend (Scope, ObjString, Statement) -> Statement) { private suspend fun parseDeclAnnotation(t: Token): ParsedDeclAnnotation {
val extraArgs = parseArgsOrNull() val extraArgs = parseArgsOrNull()
resolutionSink?.reference(t.value, t.pos) resolutionSink?.reference(t.value, t.pos)
// println("annotation ${t.value}: args: $extraArgs") val compiledArgs = extraArgs?.first?.map { arg ->
return { scope, name, body -> val value = arg.value
val extras = extraArgs?.first?.toArguments(scope, extraArgs.second)?.list arg.copy(
val required = listOf(name, body) value = if (value is Statement) wrapBytecode(value) else value
val args = extras?.let { required + it } ?: required )
val fn = scope.get(t.value)?.value ?: scope.raiseSymbolNotFound("annotation not found: ${t.value}") } ?: emptyList()
if (fn !is Statement) scope.raiseIllegalArgument("annotation must be callable, got ${fn.objClass}") return ParsedDeclAnnotation(
(fn.execute(scope.createChildScope(Arguments(args))) as? Statement) name = t.value,
?: scope.raiseClassCastError("function annotation must return callable") args = compiledArgs,
tailBlockMode = extraArgs?.second ?: false,
pos = t.pos
)
} }
suspend fun parseAnnotation(t: Token): (suspend (Scope, ObjString, Statement) -> Statement) {
return parseDeclAnnotation(t).toStatementAnnotation()
} }
suspend fun parseArgsOrNull(): Pair<List<ParsedArgument>, Boolean>? = suspend fun parseArgsOrNull(): Pair<List<ParsedArgument>, Boolean>? =
@ -9232,6 +9249,8 @@ class Compiler(
isTransient: Boolean = isTransientFlag isTransient: Boolean = isTransientFlag
): Statement { ): Statement {
isTransientFlag = false isTransientFlag = false
val declarationAnnotationSpecs = pendingDeclAnnotations.toList()
pendingDeclAnnotations.clear()
val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true
var start = cc.currentPos() var start = cc.currentPos()
var extTypeName: String? = null var extTypeName: String? = null
@ -9700,6 +9719,7 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient, isTransient = isTransient,
annotations = emptyList(),
accessTypeLabel = "Callable", accessTypeLabel = "Callable",
initializer = initExpr, initializer = initExpr,
pos = start pos = start
@ -10251,6 +10271,8 @@ class Compiler(
isTransient: Boolean = isTransientFlag isTransient: Boolean = isTransientFlag
): Statement { ): Statement {
isTransientFlag = false isTransientFlag = false
val declarationAnnotationSpecs = pendingDeclAnnotations.toList()
pendingDeclAnnotations.clear()
val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true
val markStart = cc.savePos() val markStart = cc.savePos()
val nextToken = cc.next() val nextToken = cc.next()
@ -10643,6 +10665,9 @@ class Compiler(
!isStatic && !isStatic &&
!isProperty !isProperty
) { ) {
if (declarationAnnotationSpecs.isNotEmpty()) {
throw ScriptError(start, "declaration annotations are currently supported only on class members")
}
if (isDelegate) { if (isDelegate) {
val initExpr = initialExpression ?: throw ScriptError(start, "Delegate must be initialized") val initExpr = initialExpression ?: throw ScriptError(start, "Delegate must be initialized")
val slotPlan = slotPlanStack.lastOrNull() val slotPlan = slotPlanStack.lastOrNull()
@ -10663,6 +10688,9 @@ class Compiler(
} }
if (isStatic) { if (isStatic) {
if (extTypeName != null && declarationAnnotationSpecs.isNotEmpty()) {
throw ScriptError(start, "declaration annotations are not supported on extension properties")
}
if (declaringClassNameCaptured != null) { if (declaringClassNameCaptured != null) {
val directRef = unwrapDirectRef(initialExpression) val directRef = unwrapDirectRef(initialExpression)
val declClass = resolveTypeDeclObjClass(varTypeDecl) val declClass = resolveTypeDeclObjClass(varTypeDecl)
@ -10685,6 +10713,7 @@ class Compiler(
initializer = initialExpression, initializer = initialExpression,
isDelegated = isDelegate, isDelegated = isDelegate,
isTransient = isTransient, isTransient = isTransient,
annotationSpecs = declarationAnnotationSpecs,
startPos = start startPos = start
) )
return NopStatement return NopStatement
@ -10847,6 +10876,9 @@ class Compiler(
} }
if (extTypeName != null) { if (extTypeName != null) {
if (declarationAnnotationSpecs.isNotEmpty()) {
throw ScriptError(start, "declaration annotations are not supported on extension properties")
}
declareLocalName(extensionPropertyGetterName(extTypeName, name), isMutable = false) declareLocalName(extensionPropertyGetterName(extTypeName, name), isMutable = false)
if (setter != null) { if (setter != null) {
declareLocalName(extensionPropertySetterName(extTypeName, name), isMutable = false) declareLocalName(extensionPropertySetterName(extTypeName, name), isMutable = false)
@ -10883,6 +10915,7 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient, isTransient = isTransient,
annotations = emptyList(),
accessTypeLabel = accessType, accessTypeLabel = accessType,
initializer = initExpr, initializer = initExpr,
pos = start pos = start
@ -10898,6 +10931,7 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient, isTransient = isTransient,
annotationSpecs = declarationAnnotationSpecs,
methodId = memberMethodId, methodId = memberMethodId,
initStatement = initStmt, initStatement = initStmt,
pos = start pos = start
@ -10919,6 +10953,7 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient, isTransient = isTransient,
annotations = emptyList(),
prop = prop, prop = prop,
pos = start pos = start
) )
@ -10933,6 +10968,7 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient, isTransient = isTransient,
annotationSpecs = declarationAnnotationSpecs,
prop = prop, prop = prop,
methodId = memberMethodId, methodId = memberMethodId,
initStatement = initStmt, initStatement = initStmt,
@ -10950,6 +10986,7 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient, isTransient = isTransient,
annotations = emptyList(),
isLateInitVal = isLateInitVal, isLateInitVal = isLateInitVal,
initializer = initialExpression, initializer = initialExpression,
pos = start pos = start
@ -10966,6 +11003,7 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient, isTransient = isTransient,
annotationSpecs = declarationAnnotationSpecs,
fieldId = memberFieldId, fieldId = memberFieldId,
initStatement = initStmt, initStatement = initStmt,
pos = start pos = start

View File

@ -0,0 +1,129 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng
import net.sergeych.lyng.bytecode.BytecodeStatement
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjIterable
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjMap
import net.sergeych.lyng.obj.ObjString
/**
* Preserved declaration annotation evaluated at declaration-creation time.
*/
data class DeclAnnotation(
val name: String,
val positional: List<Obj> = emptyList(),
val named: Map<String, Obj> = emptyMap(),
)
/**
* Parsed declaration annotation awaiting declaration-time evaluation.
*/
data class ParsedDeclAnnotation(
val name: String,
val args: List<ParsedArgument> = emptyList(),
val tailBlockMode: Boolean = false,
val pos: Pos = Pos.builtIn,
) {
suspend fun evaluate(scope: Scope): DeclAnnotation {
val resolved = evaluateDeclAnnotationArguments(scope, args, tailBlockMode)
return DeclAnnotation(name, resolved.list, resolved.named)
}
fun toStatementAnnotation(): suspend (Scope, ObjString, Statement) -> Statement = { scope, declName, body ->
val extras = args.toArguments(scope, tailBlockMode).list
val required = listOf(declName, body)
val callArgs = if (extras.isEmpty()) required else required + extras
val fn = scope.get(name)?.value ?: scope.raiseSymbolNotFound("annotation not found: $name")
if (fn !is Statement) scope.raiseIllegalArgument("annotation must be callable, got ${fn.objClass}")
(fn.execute(scope.createChildScope(Arguments(callArgs))) as? Statement)
?: scope.raiseClassCastError("function annotation must return callable")
}
}
suspend fun Iterable<ParsedDeclAnnotation>.evaluateDeclAnnotations(scope: Scope): List<DeclAnnotation> {
val result = mutableListOf<DeclAnnotation>()
for (spec in this) {
result += spec.evaluate(scope)
}
return result
}
private suspend fun evaluateDeclAnnotationArguments(
scope: Scope,
args: List<ParsedArgument>,
tailBlockMode: Boolean,
): Arguments {
suspend fun eval(value: Obj): Obj = when (value) {
is BytecodeBodyProvider -> (value.bytecodeBody() ?: scope.raiseIllegalState("annotation argument requires bytecode body")).execute(scope)
is Statement -> BytecodeStatement.wrap(value, "@annotation", allowLocalSlots = true).execute(scope)
else -> value.callOn(scope)
}
val resolved = ArrayList<ParsedArgument>(args.size)
for (arg in args) {
resolved += arg.copy(value = eval(arg.value))
}
val positional: MutableList<Obj> = mutableListOf()
var named: MutableMap<String, Obj>? = null
var namedSeen = false
for ((idx, x) in resolved.withIndex()) {
if (x.name != null) {
if (named == null) named = linkedMapOf()
if (named.containsKey(x.name)) scope.raiseIllegalArgument("argument '${x.name}' is already set")
named[x.name] = x.value
namedSeen = true
continue
}
val value = x.value
if (x.isSplat) {
when {
value is ObjMap -> {
if (named == null) named = linkedMapOf()
for ((k, v) in value.map) {
if (k !is ObjString) scope.raiseIllegalArgument("named splat expects a Map with string keys")
val key = k.value
if (named.containsKey(key)) scope.raiseIllegalArgument("argument '$key' is already set")
named[key] = v
}
namedSeen = true
}
value is ObjList -> {
if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments")
positional.addAll(value.list)
}
value.isInstanceOf(ObjIterable) -> {
if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments")
val iterable = value.invokeInstanceMethod(scope, "toList") as ObjList
positional.addAll(iterable.list)
}
else -> scope.raiseClassCastError("expected list of objects for splat argument")
}
} else {
val isLast = idx == resolved.size - 1
if (namedSeen && !(isLast && tailBlockMode)) {
scope.raiseIllegalArgument("positional argument cannot follow named arguments")
}
positional.add(value)
}
}
return Arguments(positional, tailBlockMode, named ?: emptyMap())
}

View File

@ -33,6 +33,7 @@ class InstanceFieldInitStatement(
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
val isTransient: Boolean, val isTransient: Boolean,
val annotations: List<DeclAnnotation> = emptyList(),
val isLateInitVal: Boolean, val isLateInitVal: Boolean,
val initializer: Statement?, val initializer: Statement?,
override val pos: Pos, override val pos: Pos,
@ -50,7 +51,8 @@ class InstanceFieldInitStatement(
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient isTransient = isTransient,
annotations = annotations
) )
return ObjVoid return ObjVoid
} }
@ -74,6 +76,7 @@ class InstancePropertyInitStatement(
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
val isTransient: Boolean, val isTransient: Boolean,
val annotations: List<DeclAnnotation> = emptyList(),
val prop: ObjProperty, val prop: ObjProperty,
override val pos: Pos, override val pos: Pos,
) : Statement() { ) : Statement() {
@ -88,7 +91,8 @@ class InstancePropertyInitStatement(
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient isTransient = isTransient,
annotations = annotations
) )
return ObjVoid return ObjVoid
} }
@ -104,6 +108,7 @@ class InstanceDelegatedInitStatement(
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
val isTransient: Boolean, val isTransient: Boolean,
val annotations: List<DeclAnnotation> = emptyList(),
val accessTypeLabel: String, val accessTypeLabel: String,
val initializer: Statement, val initializer: Statement,
override val pos: Pos, override val pos: Pos,
@ -130,7 +135,8 @@ class InstanceDelegatedInitStatement(
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient isTransient = isTransient,
annotations = annotations
).apply { ).apply {
delegate = finalDelegate delegate = finalDelegate
} }

View File

@ -718,6 +718,7 @@ open class Scope(
isTransient: Boolean = false, isTransient: Boolean = false,
callSignature: CallSignature? = null, callSignature: CallSignature? = null,
typeDecl: TypeDecl? = null, typeDecl: TypeDecl? = null,
annotations: List<DeclAnnotation> = emptyList(),
fieldId: Int? = null, fieldId: Int? = null,
methodId: Int? = null methodId: Int? = null
): ObjRecord { ): ObjRecord {
@ -731,6 +732,7 @@ open class Scope(
isTransient = isTransient, isTransient = isTransient,
callSignature = callSignature, callSignature = callSignature,
typeDecl = typeDecl, typeDecl = typeDecl,
annotations = annotations,
memberName = name, memberName = name,
fieldId = fieldId, fieldId = fieldId,
methodId = methodId methodId = methodId

View File

@ -6360,7 +6360,8 @@ class BytecodeCompiler(
isMutable = stmt.isMutable, isMutable = stmt.isMutable,
visibility = stmt.visibility, visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility, writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs
) )
) )
} else { } else {
@ -6370,7 +6371,8 @@ class BytecodeCompiler(
isMutable = stmt.isMutable, isMutable = stmt.isMutable,
visibility = stmt.visibility, visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility, writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs
) )
) )
} }
@ -6397,6 +6399,7 @@ class BytecodeCompiler(
writeVisibility = stmt.writeVisibility, writeVisibility = stmt.writeVisibility,
typeDecl = stmt.typeDecl, typeDecl = stmt.typeDecl,
isTransient = stmt.isTransient, isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs,
isAbstract = stmt.isAbstract, isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed, isClosed = stmt.isClosed,
isOverride = stmt.isOverride, isOverride = stmt.isOverride,
@ -6419,6 +6422,7 @@ class BytecodeCompiler(
visibility = stmt.visibility, visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility, writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient, isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs,
isAbstract = stmt.isAbstract, isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed, isClosed = stmt.isClosed,
isOverride = stmt.isOverride, isOverride = stmt.isOverride,
@ -6442,6 +6446,7 @@ class BytecodeCompiler(
visibility = stmt.visibility, visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility, writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient, isTransient = stmt.isTransient,
annotationSpecs = stmt.annotationSpecs,
isAbstract = stmt.isAbstract, isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed, isClosed = stmt.isClosed,
isOverride = stmt.isOverride, isOverride = stmt.isOverride,
@ -6475,6 +6480,7 @@ class BytecodeCompiler(
visibility = stmt.visibility, visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility, writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient, isTransient = stmt.isTransient,
annotations = stmt.annotations,
isAbstract = stmt.isAbstract, isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed, isClosed = stmt.isClosed,
isOverride = stmt.isOverride isOverride = stmt.isOverride
@ -6497,6 +6503,7 @@ class BytecodeCompiler(
visibility = stmt.visibility, visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility, writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient, isTransient = stmt.isTransient,
annotations = stmt.annotations,
isAbstract = stmt.isAbstract, isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed, isClosed = stmt.isClosed,
isOverride = stmt.isOverride isOverride = stmt.isOverride
@ -6517,6 +6524,7 @@ class BytecodeCompiler(
visibility = stmt.visibility, visibility = stmt.visibility,
writeVisibility = stmt.writeVisibility, writeVisibility = stmt.writeVisibility,
isTransient = stmt.isTransient, isTransient = stmt.isTransient,
annotations = stmt.annotations,
isAbstract = stmt.isAbstract, isAbstract = stmt.isAbstract,
isClosed = stmt.isClosed, isClosed = stmt.isClosed,
isOverride = stmt.isOverride, isOverride = stmt.isOverride,

View File

@ -18,6 +18,7 @@
package net.sergeych.lyng.bytecode package net.sergeych.lyng.bytecode
import net.sergeych.lyng.ArgsDeclaration import net.sergeych.lyng.ArgsDeclaration
import net.sergeych.lyng.ParsedDeclAnnotation
import net.sergeych.lyng.Pos import net.sergeych.lyng.Pos
import net.sergeych.lyng.TypeDecl import net.sergeych.lyng.TypeDecl
import net.sergeych.lyng.Visibility import net.sergeych.lyng.Visibility
@ -85,6 +86,7 @@ sealed class BytecodeConst {
val visibility: Visibility, val visibility: Visibility,
val writeVisibility: Visibility?, val writeVisibility: Visibility?,
val isTransient: Boolean, val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
) : BytecodeConst() ) : BytecodeConst()
data class ClassDelegatedDecl( data class ClassDelegatedDecl(
val name: String, val name: String,
@ -92,6 +94,7 @@ sealed class BytecodeConst {
val visibility: Visibility, val visibility: Visibility,
val writeVisibility: Visibility?, val writeVisibility: Visibility?,
val isTransient: Boolean, val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
) : BytecodeConst() ) : BytecodeConst()
data class ClassInstanceInitDecl( data class ClassInstanceInitDecl(
val initStatement: Obj, val initStatement: Obj,
@ -103,6 +106,7 @@ sealed class BytecodeConst {
val writeVisibility: Visibility?, val writeVisibility: Visibility?,
val typeDecl: TypeDecl?, val typeDecl: TypeDecl?,
val isTransient: Boolean, val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
val isAbstract: Boolean, val isAbstract: Boolean,
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
@ -116,6 +120,7 @@ sealed class BytecodeConst {
val visibility: Visibility, val visibility: Visibility,
val writeVisibility: Visibility?, val writeVisibility: Visibility?,
val isTransient: Boolean, val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
val isAbstract: Boolean, val isAbstract: Boolean,
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
@ -130,6 +135,7 @@ sealed class BytecodeConst {
val visibility: Visibility, val visibility: Visibility,
val writeVisibility: Visibility?, val writeVisibility: Visibility?,
val isTransient: Boolean, val isTransient: Boolean,
val annotationSpecs: List<ParsedDeclAnnotation>,
val isAbstract: Boolean, val isAbstract: Boolean,
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
@ -143,6 +149,7 @@ sealed class BytecodeConst {
val visibility: Visibility, val visibility: Visibility,
val writeVisibility: Visibility?, val writeVisibility: Visibility?,
val isTransient: Boolean, val isTransient: Boolean,
val annotations: List<net.sergeych.lyng.DeclAnnotation>,
val isAbstract: Boolean, val isAbstract: Boolean,
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
@ -153,6 +160,7 @@ sealed class BytecodeConst {
val visibility: Visibility, val visibility: Visibility,
val writeVisibility: Visibility?, val writeVisibility: Visibility?,
val isTransient: Boolean, val isTransient: Boolean,
val annotations: List<net.sergeych.lyng.DeclAnnotation>,
val isAbstract: Boolean, val isAbstract: Boolean,
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,
@ -164,6 +172,7 @@ sealed class BytecodeConst {
val visibility: Visibility, val visibility: Visibility,
val writeVisibility: Visibility?, val writeVisibility: Visibility?,
val isTransient: Boolean, val isTransient: Boolean,
val annotations: List<net.sergeych.lyng.DeclAnnotation>,
val isAbstract: Boolean, val isAbstract: Boolean,
val isClosed: Boolean, val isClosed: Boolean,
val isOverride: Boolean, val isOverride: Boolean,

View File

@ -365,6 +365,7 @@ class BytecodeStatement private constructor(
stmt.initializer?.let { unwrapDeep(it) }, stmt.initializer?.let { unwrapDeep(it) },
stmt.isDelegated, stmt.isDelegated,
stmt.isTransient, stmt.isTransient,
stmt.annotationSpecs,
stmt.pos stmt.pos
) )
} }
@ -385,6 +386,7 @@ class BytecodeStatement private constructor(
stmt.isClosed, stmt.isClosed,
stmt.isOverride, stmt.isOverride,
stmt.isTransient, stmt.isTransient,
stmt.annotationSpecs,
stmt.fieldId, stmt.fieldId,
stmt.initStatement?.let { unwrapDeep(it) }, stmt.initStatement?.let { unwrapDeep(it) },
stmt.pos stmt.pos
@ -400,6 +402,7 @@ class BytecodeStatement private constructor(
stmt.isClosed, stmt.isClosed,
stmt.isOverride, stmt.isOverride,
stmt.isTransient, stmt.isTransient,
stmt.annotationSpecs,
stmt.prop, stmt.prop,
stmt.methodId, stmt.methodId,
stmt.initStatement?.let { unwrapDeep(it) }, stmt.initStatement?.let { unwrapDeep(it) },
@ -416,6 +419,7 @@ class BytecodeStatement private constructor(
stmt.isClosed, stmt.isClosed,
stmt.isOverride, stmt.isOverride,
stmt.isTransient, stmt.isTransient,
stmt.annotationSpecs,
stmt.methodId, stmt.methodId,
stmt.initStatement?.let { unwrapDeep(it) }, stmt.initStatement?.let { unwrapDeep(it) },
stmt.pos stmt.pos
@ -431,6 +435,7 @@ class BytecodeStatement private constructor(
stmt.isClosed, stmt.isClosed,
stmt.isOverride, stmt.isOverride,
stmt.isTransient, stmt.isTransient,
stmt.annotations,
stmt.isLateInitVal, stmt.isLateInitVal,
stmt.initializer?.let { unwrapDeep(it) }, stmt.initializer?.let { unwrapDeep(it) },
stmt.pos stmt.pos
@ -446,6 +451,7 @@ class BytecodeStatement private constructor(
stmt.isClosed, stmt.isClosed,
stmt.isOverride, stmt.isOverride,
stmt.isTransient, stmt.isTransient,
stmt.annotations,
stmt.prop, stmt.prop,
stmt.pos stmt.pos
) )
@ -461,6 +467,7 @@ class BytecodeStatement private constructor(
stmt.isClosed, stmt.isClosed,
stmt.isOverride, stmt.isOverride,
stmt.isTransient, stmt.isTransient,
stmt.annotations,
stmt.accessTypeLabel, stmt.accessTypeLabel,
unwrapDeep(stmt.initializer), unwrapDeep(stmt.initializer),
stmt.pos stmt.pos

View File

@ -2805,6 +2805,7 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassFieldDecl val decl = frame.fn.constants[constId] as? BytecodeConst.ClassFieldDecl
?: error("DECL_CLASS_FIELD expects ClassFieldDecl at $constId") ?: error("DECL_CLASS_FIELD expects ClassFieldDecl at $constId")
val scope = frame.ensureScope() val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class field init requires class scope") ?: scope.raiseIllegalState("class field init requires class scope")
val value = frame.slotToObj(slot).byValueCopy() val value = frame.slotToObj(slot).byValueCopy()
@ -2815,7 +2816,8 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd
decl.visibility, decl.visibility,
decl.writeVisibility, decl.writeVisibility,
Pos.builtIn, Pos.builtIn,
isTransient = decl.isTransient isTransient = decl.isTransient,
annotations = annotations
) )
scope.addItem( scope.addItem(
decl.name, decl.name,
@ -2824,7 +2826,8 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd
decl.visibility, decl.visibility,
decl.writeVisibility, decl.writeVisibility,
recordType = ObjRecord.Type.Field, recordType = ObjRecord.Type.Field,
isTransient = decl.isTransient isTransient = decl.isTransient,
annotations = annotations
) )
return return
} }
@ -2835,6 +2838,7 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) :
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassDelegatedDecl val decl = frame.fn.constants[constId] as? BytecodeConst.ClassDelegatedDecl
?: error("DECL_CLASS_DELEGATED expects ClassDelegatedDecl at $constId") ?: error("DECL_CLASS_DELEGATED expects ClassDelegatedDecl at $constId")
val scope = frame.ensureScope() val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class delegated init requires class scope") ?: scope.raiseIllegalState("class delegated init requires class scope")
val initValue = frame.slotToObj(slot) val initValue = frame.slotToObj(slot)
@ -2857,7 +2861,8 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) :
decl.writeVisibility, decl.writeVisibility,
Pos.builtIn, Pos.builtIn,
isTransient = decl.isTransient, isTransient = decl.isTransient,
type = ObjRecord.Type.Delegated type = ObjRecord.Type.Delegated,
annotations = annotations
).apply { ).apply {
delegate = finalDelegate delegate = finalDelegate
} }
@ -2868,7 +2873,8 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) :
decl.visibility, decl.visibility,
decl.writeVisibility, decl.writeVisibility,
recordType = ObjRecord.Type.Delegated, recordType = ObjRecord.Type.Delegated,
isTransient = decl.isTransient isTransient = decl.isTransient,
annotations = annotations
).apply { ).apply {
delegate = finalDelegate delegate = finalDelegate
} }
@ -2895,6 +2901,7 @@ class CmdDeclClassInstanceField(internal val constId: Int, internal val slot: In
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstanceFieldDecl val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstanceFieldDecl
?: error("DECL_CLASS_INSTANCE_FIELD expects ClassInstanceFieldDecl at $constId") ?: error("DECL_CLASS_INSTANCE_FIELD expects ClassInstanceFieldDecl at $constId")
val scope = frame.ensureScope() val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class instance field requires class scope") ?: scope.raiseIllegalState("class instance field requires class scope")
cls.createField( cls.createField(
@ -2911,7 +2918,8 @@ class CmdDeclClassInstanceField(internal val constId: Int, internal val slot: In
isTransient = decl.isTransient, isTransient = decl.isTransient,
typeDecl = decl.typeDecl, typeDecl = decl.typeDecl,
type = ObjRecord.Type.Field, type = ObjRecord.Type.Field,
fieldId = decl.fieldId fieldId = decl.fieldId,
annotations = annotations
) )
if (!decl.isAbstract) { if (!decl.isAbstract) {
decl.initStatement?.let { cls.instanceInitializers += it } decl.initStatement?.let { cls.instanceInitializers += it }
@ -2926,6 +2934,7 @@ class CmdDeclClassInstanceProperty(internal val constId: Int, internal val slot:
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstancePropertyDecl val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstancePropertyDecl
?: error("DECL_CLASS_INSTANCE_PROPERTY expects ClassInstancePropertyDecl at $constId") ?: error("DECL_CLASS_INSTANCE_PROPERTY expects ClassInstancePropertyDecl at $constId")
val scope = frame.ensureScope() val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class instance property requires class scope") ?: scope.raiseIllegalState("class instance property requires class scope")
cls.addProperty( cls.addProperty(
@ -2938,7 +2947,8 @@ class CmdDeclClassInstanceProperty(internal val constId: Int, internal val slot:
isOverride = decl.isOverride, isOverride = decl.isOverride,
pos = decl.pos, pos = decl.pos,
prop = decl.prop, prop = decl.prop,
methodId = decl.methodId methodId = decl.methodId,
annotations = annotations
) )
if (!decl.isAbstract) { if (!decl.isAbstract) {
decl.initStatement?.let { cls.instanceInitializers += it } decl.initStatement?.let { cls.instanceInitializers += it }
@ -2953,6 +2963,7 @@ class CmdDeclClassInstanceDelegated(internal val constId: Int, internal val slot
val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstanceDelegatedDecl val decl = frame.fn.constants[constId] as? BytecodeConst.ClassInstanceDelegatedDecl
?: error("DECL_CLASS_INSTANCE_DELEGATED expects ClassInstanceDelegatedDecl at $constId") ?: error("DECL_CLASS_INSTANCE_DELEGATED expects ClassInstanceDelegatedDecl at $constId")
val scope = frame.ensureScope() val scope = frame.ensureScope()
val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope)
val cls = scope.thisObj as? ObjClass val cls = scope.thisObj as? ObjClass
?: scope.raiseIllegalState("class instance delegated requires class scope") ?: scope.raiseIllegalState("class instance delegated requires class scope")
cls.createField( cls.createField(
@ -2968,7 +2979,8 @@ class CmdDeclClassInstanceDelegated(internal val constId: Int, internal val slot
isOverride = decl.isOverride, isOverride = decl.isOverride,
isTransient = decl.isTransient, isTransient = decl.isTransient,
type = ObjRecord.Type.Delegated, type = ObjRecord.Type.Delegated,
methodId = decl.methodId methodId = decl.methodId,
annotations = annotations
) )
if (!decl.isAbstract) { if (!decl.isAbstract) {
decl.initStatement?.let { cls.instanceInitializers += it } decl.initStatement?.let { cls.instanceInitializers += it }
@ -2994,7 +3006,8 @@ class CmdDeclInstanceField(internal val constId: Int, internal val slot: Int) :
isAbstract = decl.isAbstract, isAbstract = decl.isAbstract,
isClosed = decl.isClosed, isClosed = decl.isClosed,
isOverride = decl.isOverride, isOverride = decl.isOverride,
isTransient = decl.isTransient isTransient = decl.isTransient,
annotations = decl.annotations
) )
if (slot >= frame.fn.scopeSlotCount) { if (slot >= frame.fn.scopeSlotCount) {
val localIndex = slot - frame.fn.scopeSlotCount val localIndex = slot - frame.fn.scopeSlotCount
@ -3023,7 +3036,8 @@ class CmdDeclInstanceProperty(internal val constId: Int, internal val slot: Int)
isAbstract = decl.isAbstract, isAbstract = decl.isAbstract,
isClosed = decl.isClosed, isClosed = decl.isClosed,
isOverride = decl.isOverride, isOverride = decl.isOverride,
isTransient = decl.isTransient isTransient = decl.isTransient,
annotations = decl.annotations
) )
if (slot >= frame.fn.scopeSlotCount) { if (slot >= frame.fn.scopeSlotCount) {
val localIndex = slot - frame.fn.scopeSlotCount val localIndex = slot - frame.fn.scopeSlotCount
@ -3076,7 +3090,8 @@ class CmdDeclInstanceDelegated(internal val constId: Int, internal val slot: Int
isAbstract = decl.isAbstract, isAbstract = decl.isAbstract,
isClosed = decl.isClosed, isClosed = decl.isClosed,
isOverride = decl.isOverride, isOverride = decl.isOverride,
isTransient = decl.isTransient isTransient = decl.isTransient,
annotations = decl.annotations
).apply { ).apply {
delegate = finalDelegate delegate = finalDelegate
} }
@ -3705,9 +3720,26 @@ class CmdGetClassScope(
decl = declared decl = declared
break break
} }
val resolved = rec ?: scope.raiseSymbolNotFound(name) val resolvedRec = if (rec != null) {
val declClass = decl ?: cls val declClass = decl ?: cls
val resolvedRec = cls.resolveRecord(scope, resolved, name, declClass) cls.resolveRecord(scope, rec, name, declClass)
} else {
val metaRec = cls.objClass.getInstanceMemberOrNull(name)
if (metaRec == null || metaRec.isAbstract) {
scope.raiseSymbolNotFound(name)
}
val declClass = metaRec.declaringClass ?: cls.objClass
val resolved = cls.resolveRecord(scope, metaRec, name, declClass)
if (resolved.type == ObjRecord.Type.Fun) {
resolved.copy(
value = ObjExternCallable.fromBridge {
resolved.value.invoke(scope, cls, args, declClass)
}
)
} else {
resolved
}
}
val value = resolvedRec.value val value = resolvedRec.value
frame.storeObjResult(dst, value) frame.storeObjResult(dst, value)
return return

View File

@ -28,6 +28,23 @@ import net.sergeych.lynon.LynonType
// Simple id generator for class identities (not thread-safe; fine for scripts) // Simple id generator for class identities (not thread-safe; fine for scripts)
private object ClassIdGen { var c: Long = 1L; fun nextId(): Long = c++ } private object ClassIdGen { var c: Long = 1L; fun nextId(): Long = c++ }
private fun DeclAnnotation.toObj(): Obj {
val namedArgs = linkedMapOf<Obj, Obj>()
for ((k, v) in named) {
namedArgs[ObjString(k)] = v
}
return ObjMap(
linkedMapOf(
ObjString("name") to ObjString(name),
ObjString("positional") to ObjImmutableList(positional),
ObjString("named") to ObjMap(namedArgs)
)
)
}
private fun annotationListObj(annotations: List<DeclAnnotation>): Obj =
ObjImmutableList(annotations.map { it.toObj() })
val ObjClassType by lazy { val ObjClassType by lazy {
object : ObjClass("Class") { object : ObjClass("Class") {
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj { override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
@ -98,6 +115,30 @@ val ObjClassType by lazy {
val rec = cls.getInstanceMemberOrNull(name) val rec = cls.getInstanceMemberOrNull(name)
rec?.value ?: ObjNull rec?.value ?: ObjNull
} }
addFnDoc(
name = "getConstructorAnnotations",
doc = "Return preserved annotations for a constructor parameter by name as descriptor maps with fields `name`, `positional`, and `named`.",
params = listOf(ParamDoc("name", type("lyng.String"))),
returns = TypeGenericDoc(type("lyng.List"), listOf(type("lyng.Map"))),
moduleName = "lyng.stdlib"
) {
val cls = thisAs<ObjClass>()
val name = requiredArg<ObjString>(0).value
val param = cls.constructorMeta?.params?.firstOrNull { it.name == name }
annotationListObj(param?.annotations ?: emptyList())
}
addFnDoc(
name = "getMemberAnnotations",
doc = "Return preserved annotations for a member by name as descriptor maps with fields `name`, `positional`, and `named`.",
params = listOf(ParamDoc("name", type("lyng.String"))),
returns = TypeGenericDoc(type("lyng.List"), listOf(type("lyng.Map"))),
moduleName = "lyng.stdlib"
) {
val cls = thisAs<ObjClass>()
val name = requiredArg<ObjString>(0).value
val rec = cls.getInstanceMemberOrNull(name) ?: cls.classScope?.objects?.get(name)
annotationListObj(rec?.annotations ?: emptyList())
}
} }
} }
@ -851,6 +892,7 @@ open class ObjClass(
methodId: Int? = null, methodId: Int? = null,
typeDecl: net.sergeych.lyng.TypeDecl? = null, typeDecl: net.sergeych.lyng.TypeDecl? = null,
callSignature: net.sergeych.lyng.CallSignature? = null, callSignature: net.sergeych.lyng.CallSignature? = null,
annotations: List<DeclAnnotation> = emptyList(),
): ObjRecord { ): ObjRecord {
// Validation of override rules: only for non-system declarations // Validation of override rules: only for non-system declarations
var existing: ObjRecord? = null var existing: ObjRecord? = null
@ -953,6 +995,7 @@ open class ObjClass(
type = type, type = type,
callSignature = callSignature, callSignature = callSignature,
typeDecl = typeDecl, typeDecl = typeDecl,
annotations = annotations,
memberName = name, memberName = name,
fieldId = effectiveFieldId, fieldId = effectiveFieldId,
methodId = effectiveMethodId methodId = effectiveMethodId
@ -979,7 +1022,8 @@ open class ObjClass(
type: ObjRecord.Type = ObjRecord.Type.Field, type: ObjRecord.Type = ObjRecord.Type.Field,
fieldId: Int? = null, fieldId: Int? = null,
methodId: Int? = null, methodId: Int? = null,
callSignature: net.sergeych.lyng.CallSignature? = null callSignature: net.sergeych.lyng.CallSignature? = null,
annotations: List<DeclAnnotation> = emptyList()
): ObjRecord { ): ObjRecord {
initClassScope() initClassScope()
val existing = classScope!!.objects[name] val existing = classScope!!.objects[name]
@ -1021,6 +1065,7 @@ open class ObjClass(
recordType = type, recordType = type,
isTransient = isTransient, isTransient = isTransient,
callSignature = callSignature, callSignature = callSignature,
annotations = annotations,
fieldId = effectiveFieldId, fieldId = effectiveFieldId,
methodId = effectiveMethodId methodId = effectiveMethodId
) )
@ -1067,7 +1112,8 @@ open class ObjClass(
isOverride: Boolean = false, isOverride: Boolean = false,
pos: Pos = Pos.builtIn, pos: Pos = Pos.builtIn,
prop: ObjProperty? = null, prop: ObjProperty? = null,
methodId: Int? = null methodId: Int? = null,
annotations: List<DeclAnnotation> = emptyList()
) { ) {
val g = getter?.let { ObjExternCallable.fromBridge { it() } } val g = getter?.let { ObjExternCallable.fromBridge { it() } }
val s = setter?.let { ObjExternCallable.fromBridge { it(requiredArg(0)); ObjVoid } } val s = setter?.let { ObjExternCallable.fromBridge { it(requiredArg(0)); ObjVoid } }
@ -1076,7 +1122,8 @@ open class ObjClass(
name, finalProp, false, visibility, writeVisibility, pos, declaringClass, name, finalProp, false, visibility, writeVisibility, pos, declaringClass,
isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride,
type = ObjRecord.Type.Property, type = ObjRecord.Type.Property,
methodId = methodId methodId = methodId,
annotations = annotations
) )
} }

View File

@ -16,6 +16,7 @@
*/ */
package net.sergeych.lyng.obj package net.sergeych.lyng.obj
import net.sergeych.lyng.DeclAnnotation
import net.sergeych.lyng.Scope import net.sergeych.lyng.Scope
import net.sergeych.lyng.Visibility import net.sergeych.lyng.Visibility
@ -40,6 +41,7 @@ data class ObjRecord(
var receiver: Obj? = null, var receiver: Obj? = null,
val callSignature: net.sergeych.lyng.CallSignature? = null, val callSignature: net.sergeych.lyng.CallSignature? = null,
val typeDecl: net.sergeych.lyng.TypeDecl? = null, val typeDecl: net.sergeych.lyng.TypeDecl? = null,
val annotations: List<DeclAnnotation> = emptyList(),
val memberName: String? = null, val memberName: String? = null,
val fieldId: Int? = null, val fieldId: Int? = null,
val methodId: Int? = null, val methodId: Int? = null,

View File

@ -0,0 +1,68 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.obj.ObjInt
import kotlin.test.Test
import kotlin.test.assertEquals
class DeclAnnotationIntrospectionTest {
@Test
fun classAnnotationQueriesExposeConstructorAndMemberAnnotations() = runTest {
val scope = Scope()
val result = scope.eval(
"""
val suffix = "!"
object Marker
class Sample(
@Transient @Tag(1, label: "ctor", extra: suffix) val x: Int
) {
@Transient @DbDecodeWith(Marker)
var y: Int = 10
}
val ctorAnnotations: ImmutableList<Map<String, Object>> = Sample.getConstructorAnnotations("x")
val ctorTag: Map<String, Object> = ctorAnnotations[1]
val ctorPositional: ImmutableList<Object> = ctorTag["positional"] as ImmutableList<Object>
val ctorNamed: Map<String, Object> = ctorTag["named"] as Map<String, Object>
assertEquals(2, ctorAnnotations.size)
assertEquals("Transient", ctorAnnotations[0]["name"])
assertEquals("Tag", ctorTag["name"])
assertEquals(1, ctorPositional[0])
assertEquals("ctor", ctorNamed["label"])
assertEquals("!", ctorNamed["extra"])
val memberAnnotations: ImmutableList<Map<String, Object>> = Sample.getMemberAnnotations("y")
val memberDecodeWith: Map<String, Object> = memberAnnotations[1]
val memberPositional: ImmutableList<Object> = memberDecodeWith["positional"] as ImmutableList<Object>
assertEquals(2, memberAnnotations.size)
assertEquals("Transient", memberAnnotations[0]["name"])
assertEquals("DbDecodeWith", memberDecodeWith["name"])
assertEquals(Marker, memberPositional[0])
memberAnnotations.size + ctorAnnotations.size
""".trimIndent()
) as ObjInt
assertEquals(4L, result.value)
}
}

View File

@ -14,6 +14,24 @@ extern class NotImplementedException
/* Raised when an awaited asynchronous task was cancelled before producing a value. */ /* Raised when an awaited asynchronous task was cancelled before producing a value. */
extern class CancellationException : Exception extern class CancellationException : Exception
/* Runtime metaobject describing a class. */
extern class Class {
/* Full name of this class including package if available. */
val className: String
/* Simple name of this class (without package). */
val name: String
/* Declared instance fields of this class and its ancestors (C3 order), without duplicates. */
val fields: List<String>
/* Declared instance methods of this class and its ancestors (C3 order), without duplicates. */
val methods: List<String>
/* Lookup a member by name in this class (including ancestors) and return it, or null if absent. */
fun get(name: String): Object?
/* Preserved annotations for a constructor parameter as descriptor maps with keys `name`, `positional`, and `named`. */
fun getConstructorAnnotations(name: String): ImmutableList<Map<String, Object>>
/* Preserved annotations for a member as descriptor maps with keys `name`, `positional`, and `named`. */
fun getMemberAnnotations(name: String): ImmutableList<Map<String, Object>>
}
/* A handle to a running asynchronous task. */ /* A handle to a running asynchronous task. */
extern class Deferred { extern class Deferred {
/* Cancel the task if it is still active. Safe to call multiple times. */ /* Cancel the task if it is still active. Safe to call multiple times. */

View File

@ -0,0 +1,280 @@
# ResultSet typed decode API
Status: draft design note
## Goal
Extend `lyng.io.db` with row deserialization into ordinary Lyng objects using the new typed serialization-style API naming.
Primary use case:
```lyng
class Point(x: Real, y: Real)
val point = db.transaction { tx ->
tx.select(
"select row as x, col as y from data where not is_deleted"
).decodeAs<Point>().first
}
```
## Agreed API
Use `decodeAs<T>()` as the only public API form in v1.
Rationale:
- matches the new typed serialization naming (`Json.decodeAs(...)`)
- communicates decoding/materialization, not casting
- keeps the common case strongly typed and chain-friendly
- avoids adding a second runtime-type overload before it is needed
Planned Lyng-facing declarations:
```lyng
extern class SqlRow {
fun decodeAs<T>(): T
}
extern class ResultSet : Iterable<SqlRow> {
fun decodeAs<T>(): Iterable<T>
}
```
## Lifetime semantics
`ResultSet.decodeAs<T>()` returns a transaction-scoped iterable view over the underlying result set.
Rules:
- the returned iterable must not be used after the owning transaction ends
- decoded objects created during iteration are detached ordinary Lyng objects
- to keep decoded values after the transaction, materialize them inside the transaction
- normal materialization forms are `toList()`, `first`, `findFirst`, or manual iteration
Valid:
```lyng
val points = db.transaction { tx ->
tx.select("select x, y from point")
.decodeAs<Point>()
.toList()
}
```
Invalid:
```lyng
val decoded = db.transaction { tx ->
tx.select("select x, y from point").decodeAs<Point>()
}
decoded.first
```
## ResultSet shape
`ResultSet.decodeAs<T>()` should preserve the current `ResultSet` paradigm:
- `ResultSet` stays the row-producing source
- `decodeAs<T>()` is a projection from `Iterable<SqlRow>` to `Iterable<T>`
- no new DB-specific collection type is introduced in v1
Implementation-wise, `ResultSet.decodeAs<T>()` can be defined as a lazy iterable that decodes each row via `SqlRow.decodeAs<T>()`.
## Mapping discussion to finalize
The following mapping behavior still needs explicit design decisions:
- how constructor parameters are matched from columns
- whether matching is case-insensitive
- whether mutable serializable fields are populated after constructor call
- treatment of default constructor values
- treatment of nullable vs non-nullable targets
- behavior for missing columns
- behavior for extra columns
- behavior for duplicate/ambiguous column labels
- whether `onDeserialized()` is called after row decode
- whether v1 supports only flat object decode or also nested shapes
## Current direction for mapping
Current likely direction, not finalized yet:
- constructor parameters map by column label
- matching is case-insensitive, consistent with `SqlRow["name"]`
- after constructor call, remaining matching serializable mutable fields may be assigned
- missing required non-null constructor values fail
- missing nullable constructor parameters become `null`
- defaulted constructor parameters use their defaults when the column is absent
- ambiguous duplicate column labels fail
- extra columns likely fail in strict mode for v1
- `onDeserialized()` likely should run after the object is fully populated
- v1 should likely stay flat and avoid nested/prefix-based mapping
## Projection/conversion rules
### General principle
Row decoding should be strict and predictable.
It should not globally treat every SQL string column as serialized JSON or every binary column as Lynon.
That would be too implicit:
- ordinary text columns are common and must stay ordinary text by default
- ordinary binary/blob columns are common and must stay raw binary by default
- automatic format decoding should happen only when there is a clear signal
### Proposed conversion precedence
For each constructor parameter or serializable mutable field:
1. resolve the source column by name
2. if the source value already matches the target type, use it directly
3. if an explicit DB decoding attribute is present on the target member, apply that decoding rule
4. otherwise, if the column metadata clearly indicates a special encoded DB type and the target is not the raw DB carrier type, apply the built-in format rule
5. otherwise fail with a decode/type mismatch error
### Direct match
Direct match means the row value is already assignable to the target type after the normal SQL backend conversion.
Examples:
- SQL numeric column already surfaced as `Int`/`Real`/`Decimal`
- SQL bool column surfaced as `Bool`
- SQL date/time column surfaced as `Date`, `DateTime`, `Instant`
- SQL text column surfaced as `String`
- SQL binary column surfaced as `Buffer`
These should not trigger any extra JSON/Lynon decoding.
### Built-in encoded-column rules
Current likely direction:
- JSON/JSONB-like columns should decode through typed canonical `Json` when the target is not `String`
- binary columns should decode through `Lynon` when the target is not `Buffer`
This implies the current default:
- string -> non-string is eligible for automatic typed `Json` decode only when the column metadata says the DB column is JSON-like
- binary -> non-binary is decoded through `Lynon`
- binary -> `Buffer` stays raw `Buffer`
Examples:
- PostgreSQL `json` / `jsonb` column into `Point` -> use typed `Json` decode
- PostgreSQL `jsonb` column into `Map<String, Object?>` -> use typed `Json` decode
- plain `text` / `varchar` column into `Point` -> fail unless explicitly annotated
- `bytea` / `blob` column into `Buffer` -> direct match, no Lynon decode
- `bytea` / `blob` column into `Point` -> decode with `Lynon`
### Attribute-based explicit decoding
Common explicit attributes look useful:
- `@DbJson`
- `@DbLynon`
Applied to constructor parameters and serializable mutable fields.
Meaning:
- `@DbJson` means decode the column value as typed canonical JSON into the target member type
- `@DbLynon` means decode the column value as Lynon into the target member type
Example:
```lyng
class Record(
id: Int,
@DbJson payload: Payload,
@DbLynon cachedState: CacheEntry
)
```
This keeps the common DB formats easy to use without making plain `String` or `Buffer` columns magical.
Implementation note:
- declaration metadata now preserves evaluated constructor-parameter and class-member annotation arguments
- annotation arguments are evaluated once at declaration creation time and retained for the lifetime of the declaration
- `@DbDecodeWith(...)` now uses that preserved metadata path
### Generic custom decoder hook
A generic hook is useful too, but it should be adapter-based, not lambda-based.
Planned shape:
- `@DbDecodeWith(adapter)`
- `adapter` should be an instance of a dedicated interface such as `DbFieldAdapter`
Reason:
- a named adapter interface is easier to document and evolve than arbitrary callables
- it gives us room for richer decoding context without baking ad-hoc callable signatures into annotations
- it keeps the DB mapping API explicit and self-describing
Current design direction:
```lyng
interface DbFieldAdapter {
fun decode(rawValue: Object?, column: SqlColumn, row: SqlRow, targetType: Object): Object? =
throw NotImplementedException("DB field adapter decode is not implemented")
fun encode(value: Object?, targetType: Object): Object? =
throw NotImplementedException("DB field adapter encode is not implemented")
}
```
Decided:
- `decode(...)` should receive the target type
- adapters may be any ordinary instance, not only singleton objects
- the same abstraction should later support symmetric `encode(...)`
- adapter result must be checked against the target member type after decoding
Still open before full implementation:
- exact annotation shape for `@DbDecodeWith(...)`
- whether target member name should also be passed
- whether `targetType` should later get a more specific declaration type than plain `Object`
Implemented in the current design:
- `@DbDecodeWith(adapter)` on constructor parameters
- `@DbDecodeWith(adapter)` on class-body fields/properties participating in `decodeAs<T>()`
Future improvement:
- compiler warning when preserved annotation metadata captures runtime state/closures
- extend preserved annotation metadata beyond constructor parameters and class members to functions and top-level declarations
### Arrays and maps
Arrays and maps should not get DB-specific bespoke mapping in v1 unless they are coming through a recognized encoded format.
Reason:
- portable SQL array/map support is backend-specific and inconsistent
- JSON columns already give us a portable representation for `List` and `Map`
- adding DB-native array semantics now would complicate the contract too early
So in v1:
- if the backend already surfaces a value that directly matches the target type, use it
- otherwise `List` / `Map` reconstruction should happen via `@DbJson` or recognized JSON-like column metadata
### Recommended v1 policy
Current recommended projection policy:
- direct type match first
- then explicit member attribute (`@DbJson`, `@DbLynon`)
- then metadata-driven JSON decode for recognized JSON-like DB columns
- then Lynon decode for binary columns when the target is not `Buffer`
- no implicit JSON decode for arbitrary text columns
- fail on anything else