diff --git a/AGENTS.md b/AGENTS.md index d87bff0..733fc7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - 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. +- 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 - Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`. diff --git a/docs/lyng.io.db.md b/docs/lyng.io.db.md index 7236a7e..a86917f 100644 --- a/docs/lyng.io.db.md +++ b/docs/lyng.io.db.md @@ -207,14 +207,27 @@ assertThrows(RollbackException) { - `isEmpty()` — fast emptiness check where possible. - `iterator()` — normal row iteration while the transaction is active. - `toList()` — materialize detached `SqlRow` snapshots that may be used after the transaction ends. +- `decodeAs()` — transaction-scoped iterable view that decodes each row into `T`. ##### `SqlRow` - `row[index]` — zero-based positional access. - `row["columnName"]` — case-insensitive lookup by output column label. +- `row.decodeAs()` — decode one row into a typed Lyng value. 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()`. + +Annotation arguments are evaluated once when the declaration is created, and the resulting adapter instance is retained in declaration metadata. + ##### `ExecutionResult` - `affectedRowsCount` @@ -249,6 +262,22 @@ Portable result metadata categories: - `DateTime` - `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). --- @@ -387,6 +416,8 @@ This means: - do not keep `ResultSet` objects after the transaction block returns - materialize rows with `toList()` inside the transaction when they must outlive it +- the iterable returned by `decodeAs()` is also transaction-scoped +- decoded objects produced while iterating `decodeAs()` 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. diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt index a3c0757..f9c0d10 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/db/SqlRuntimeSupport.kt @@ -18,21 +18,33 @@ package net.sergeych.lyng.io.db import net.sergeych.lyng.Arguments +import net.sergeych.lyng.DeclAnnotation import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.Scope import net.sergeych.lyng.ScopeFacade +import net.sergeych.lyng.TypeDecl import net.sergeych.lyng.obj.Obj 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.ObjEnumClass import net.sergeych.lyng.obj.ObjEnumEntry import net.sergeych.lyng.obj.ObjException import net.sergeych.lyng.obj.ObjImmutableList +import net.sergeych.lyng.obj.ObjInstance import net.sergeych.lyng.obj.ObjInt import net.sergeych.lyng.obj.ObjNull +import net.sergeych.lyng.obj.ObjRecord 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.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.Map import kotlin.collections.MutableList @@ -43,11 +55,14 @@ import kotlin.collections.forEachIndexed import kotlin.collections.getOrNull import kotlin.collections.getOrPut import kotlin.collections.indices +import kotlin.collections.LinkedHashMap import kotlin.collections.linkedMapOf import kotlin.collections.listOf import kotlin.collections.map import kotlin.collections.mutableListOf +import kotlin.collections.set import kotlin.text.lowercase +import kotlin.text.substringAfterLast internal data class SqlColumnMeta( val name: String, @@ -90,6 +105,9 @@ internal class SqlCoreModule private constructor( val sqlConstraintException: ObjException.Companion.ExceptionClass, val sqlUsageException: ObjException.Companion.ExceptionClass, val rollbackException: ObjException.Companion.ExceptionClass, + val iterableClass: ObjClass, + val iteratorClass: ObjClass, + val dbFieldAdapterClass: ObjClass, val sqlTypes: SqlTypeEntries, ) { companion object { @@ -106,6 +124,11 @@ internal class SqlCoreModule private constructor( sqlConstraintException = module.requireClass("SqlConstraintException") as ObjException.Companion.ExceptionClass, sqlUsageException = module.requireClass("SqlUsageException") 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), ) } @@ -148,6 +171,8 @@ internal class SqlRuntimeTypes private constructor( val rowClass: ObjClass, val columnClass: ObjClass, val executionResultClass: ObjClass, + val decodedIterableClass: ObjClass, + val decodedIteratorClass: ObjClass, ) { companion object { 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 columnClass = object : ObjClass("${prefix}Column", core.columnClass) {} 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( core = core, databaseClass = databaseClass, @@ -165,6 +192,8 @@ internal class SqlRuntimeTypes private constructor( rowClass = rowClass, columnClass = columnClass, executionResultClass = executionResultClass, + decodedIterableClass = decodedIterableClass, + decodedIteratorClass = decodedIteratorClass, ) runtime.bind() return runtime @@ -251,6 +280,19 @@ internal class SqlRuntimeTypes private constructor( self.lifetime.ensureActive(this) ObjImmutableList(self.rows) } + resultSetClass.addFn( + "decodeAs", + callSignature = core.resultSetClass.getInstanceMemberOrNull("decodeAs")?.callSignature + ) { + val self = thisAs() + self.lifetime.ensureActive(this) + SqlDecodedIterableObj( + self.types, + self.lifetime, + self.rows.map { it as SqlRowObj }, + resolveDecodeTargetType(requireScope()) + ) + } rowClass.addProperty("size", getter = { val self = thisAs() @@ -260,6 +302,12 @@ internal class SqlRuntimeTypes private constructor( val self = thisAs() ObjImmutableList(self.values) }) + rowClass.addFn( + "decodeAs", + callSignature = core.rowClass.getInstanceMemberOrNull("decodeAs")?.callSignature + ) { + decodeSqlRow(requireScope(), thisAs(), resolveDecodeTargetType(requireScope())) + } columnClass.addProperty("name", getter = { ObjString(thisAs().meta.name) }) columnClass.addProperty("sqlType", getter = { thisAs().meta.sqlType }) @@ -276,6 +324,27 @@ internal class SqlRuntimeTypes private constructor( self.lifetime.ensureActive(this) SqlResultSetObj(self.types, self.lifetime, self.result.generatedKeys) } + + decodedIterableClass.addFn("iterator") { + val self = thisAs() + self.lifetime.ensureActive(this) + SqlDecodedIteratorObj(self.types, self.lifetime, self.rows.iterator(), self.targetType) + } + + decodedIteratorClass.addFn("hasNext") { + val self = thisAs() + self.lifetime.ensureActive(this) + ObjBool(self.rows.hasNext()) + } + decodedIteratorClass.addFn("next") { + val self = thisAs() + self.lifetime.ensureActive(this) + decodeSqlRow(requireScope(), self.rows.next(), self.targetType) + } + decodedIteratorClass.addFn("cancelIteration") { + thisAs().lifetime.ensureActive(this) + ObjVoid + } } } @@ -319,6 +388,7 @@ internal class SqlResultSetObj( val lifetime: SqlTransactionLifetime, data: SqlResultSetData, ) : Obj() { + val columnMeta: List = data.columns val columns: List = data.columns.map { SqlColumnObj(types, it) } val rows: List = buildRows(types, data) @@ -334,13 +404,14 @@ internal class SqlResultSetObj( indexByName.getOrPut(column.name.lowercase()) { mutableListOf() }.add(index) } return data.rows.map { rowValues -> - SqlRowObj(types, rowValues, indexByName) + SqlRowObj(types, data.columns, rowValues, indexByName) } } } internal class SqlRowObj( val types: SqlRuntimeTypes, + val columns: List, val values: List, private val indexByName: Map>, ) : Obj() { @@ -381,6 +452,26 @@ internal class SqlRowObj( } } +internal class SqlDecodedIterableObj( + val types: SqlRuntimeTypes, + val lifetime: SqlTransactionLifetime, + val rows: List, + val targetType: TypeDecl, +) : Obj() { + override val objClass: ObjClass + get() = types.decodedIterableClass +} + +internal class SqlDecodedIteratorObj( + val types: SqlRuntimeTypes, + val lifetime: SqlTransactionLifetime, + val rows: Iterator, + val targetType: TypeDecl, +) : Obj() { + override val objClass: ObjClass + get() = types.decodedIteratorClass +} + internal class SqlColumnObj( val types: SqlRuntimeTypes, val meta: SqlColumnMeta, @@ -397,3 +488,339 @@ internal class SqlExecutionResultObj( override val objClass: ObjClass 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() + 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() + val namedArgs = LinkedHashMap() + + 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, +) + +private fun collectSerializableFieldTargets( + scope: Scope, + row: SqlRowObj, + targetClass: ObjClass, + instance: ObjInstance, +): Map { + val result = linkedMapOf() + 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> { + val result = linkedMapOf>() + 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 = 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? { + 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) +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt index 89f924c..e99fac4 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt @@ -137,6 +137,230 @@ class LyngSqliteModuleTest { 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().first + assertEquals(7, row.id) + assertEquals(4, row.payload.x) + assertEquals(5, row.payload.y) + row.payload.y + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + assertEquals(9, point.x) + assertEquals(11, point.y) + point.x + point.y + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + } + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(Source("", 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().first + assertEquals(6, point.x) + assertEquals(8, point.y) + point.x + point.y + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + assertEquals(10, user.id) + assertEquals("Alice", user.name) + assertEquals("hello", user.note) + user.note.size + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + } + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(Source("", 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().first + assertEquals(encoded.size, payload.size) + payload.size + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + } + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(Source("", code), scope.importManager).execute(scope) + } + assertEquals("SqlUsageException", error.errorObject.objClass.className) + } + @Test fun testNestedTransactionRollbackUsesSavepoint() = runTest { val scope = Script.newScope() diff --git a/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt b/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt index ac4ac41..587d922 100644 --- a/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt +++ b/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt @@ -19,11 +19,13 @@ package net.sergeych.lyng.io.db.sqlite import kotlinx.coroutines.test.runTest import kotlinx.datetime.TimeZone +import net.sergeych.lyng.Compiler import net.sergeych.lyng.ExecutionError import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.Pos import net.sergeych.lyng.Scope import net.sergeych.lyng.Script +import net.sergeych.lyng.Source import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.ObjBool 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")))) } + @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().first + assertEquals(7, row.id) + assertEquals(4, row.payload.x) + assertEquals(5, row.payload.y) + row.payload.y + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + assertEquals(9, point.x) + assertEquals(11, point.y) + point.x + point.y + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + } + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(Source("", 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().first + assertEquals(6, point.x) + assertEquals(8, point.y) + point.x + point.y + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + assertEquals(10, user.id) + assertEquals("Alice", user.name) + assertEquals("hello", user.note) + user.note.size + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + } + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(Source("", 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().first + assertEquals(encoded.size, payload.size) + payload.size + } + """.trimIndent() + + val result = Compiler.compile(Source("", 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().first + } + """.trimIndent() + + val error = assertFailsWith { + Compiler.compile(Source("", code), scope.importManager).execute(scope) + } + assertEquals("SqlUsageException", error.errorObject.objClass.className) + } + @Test fun testExecuteRejectsReturningButSelectSupportsIt() = runTest { val scope = Script.newScope() diff --git a/lyngio/stdlib/lyng/io/db.lyng b/lyngio/stdlib/lyng/io/db.lyng index 65f5410..ceeb55d 100644 --- a/lyngio/stdlib/lyng/io/db.lyng +++ b/lyngio/stdlib/lyng/io/db.lyng @@ -20,6 +20,32 @@ extern class SqlColumn { 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()` 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 { /* Number of columns in the row */ val size: Int @@ -34,6 +60,22 @@ extern class SqlRow { names and invalid indexes should also fail. */ 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 } /* @@ -69,6 +111,16 @@ extern class ResultSet : Iterable { internally, but this must not change visible later iteration behavior. */ override fun isEmpty(): Bool + + /* + Return a transaction-scoped iterable view that decodes each row with + `SqlRow.decodeAs()`. + + The returned iterable itself must not be used after the owning + transaction ends. Materialized decoded objects may outlive the + transaction. + */ + fun decodeAs(): Iterable } extern class ExecutionResult { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt index 3ac54e5..1151615 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt @@ -88,7 +88,8 @@ data class ArgsDeclaration(val params: List, val endTokenType: Token.Type) a.visibility ?: defaultVisibility, recordType = recordType, declaringClass = declaringClass, - isTransient = a.isTransient + isTransient = a.isTransient, + annotations = a.annotations ) } return @@ -108,7 +109,8 @@ data class ArgsDeclaration(val params: List, val endTokenType: Token.Type) a.visibility ?: defaultVisibility, recordType = recordType, declaringClass = declaringClass, - isTransient = a.isTransient + isTransient = a.isTransient, + annotations = a.annotations ) } @@ -505,5 +507,7 @@ data class ArgsDeclaration(val params: List, val endTokenType: Token.Type) val accessType: AccessType? = null, val visibility: Visibility? = null, val isTransient: Boolean = false, + val annotationSpecs: List = emptyList(), + val annotations: List = emptyList(), ) } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt index d0f48c1..57368f3 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt @@ -41,6 +41,17 @@ data class ClassDeclSpec( val initScope: List, ) +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( scope: Scope, spec: ClassDeclSpec, @@ -61,7 +72,7 @@ internal suspend fun executeClassDecl( val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray()) newClass.isAnonymous = spec.isAnonymous newClass.isSingletonObject = true - newClass.constructorMeta = ArgsDeclaration(emptyList(), Token.Type.RPAREN) + newClass.constructorMeta = evaluateConstructorAnnotations(scope, ArgsDeclaration(emptyList(), Token.Type.RPAREN)) for (i in parentClasses.indices) { val argsList = spec.baseSpecs[i].args if (argsList != null) newClass.directParentArgs[parentClasses[i]] = argsList @@ -86,6 +97,7 @@ internal suspend fun executeClassDecl( } if (spec.isExtern) { + val evaluatedConstructorArgs = evaluateConstructorAnnotations(scope, spec.constructorArgs) val parentClasses = spec.baseSpecs.mapNotNull { baseSpec -> val rec = scope[baseSpec.name] val cls = rec?.value as? ObjClass @@ -106,8 +118,8 @@ internal suspend fun executeClassDecl( } val stub = resolved ?: ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).apply { this.isAbstract = true - constructorMeta = spec.constructorArgs - spec.constructorArgs?.params?.forEach { p -> + constructorMeta = evaluatedConstructorArgs + evaluatedConstructorArgs?.params?.forEach { p -> if (p.accessType != null) { createField( p.name, @@ -118,6 +130,7 @@ internal suspend fun executeClassDecl( pos = Pos.builtIn, isTransient = p.isTransient, type = ObjRecord.Type.ConstructorField, + annotations = p.annotations, 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 { it.isAbstract = spec.isAbstract it.isClosed = spec.isClosed it.instanceConstructor = constructorCode - it.constructorMeta = spec.constructorArgs + it.constructorMeta = evaluatedConstructorArgs for (i in parentClasses.indices) { val argsList = spec.baseSpecs[i].args if (argsList != null) it.directParentArgs[parentClasses[i]] = argsList } - spec.constructorArgs?.params?.forEach { p -> + evaluatedConstructorArgs?.params?.forEach { p -> if (p.accessType != null) { it.createField( p.name, @@ -181,6 +195,7 @@ internal suspend fun executeClassDecl( pos = Pos.builtIn, isTransient = p.isTransient, type = ObjRecord.Type.ConstructorField, + annotations = p.annotations, fieldId = spec.constructorFieldIds?.get(p.name) ) } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassInstanceDeclStatements.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassInstanceDeclStatements.kt index 96c590f..de1d905 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassInstanceDeclStatements.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassInstanceDeclStatements.kt @@ -38,6 +38,7 @@ class ClassInstanceFieldDeclStatement( val isClosed: Boolean, val isOverride: Boolean, val isTransient: Boolean, + val annotationSpecs: List = emptyList(), val fieldId: Int?, val initStatement: Statement?, override val pos: Pos, @@ -56,6 +57,7 @@ class ClassInstancePropertyDeclStatement( val isClosed: Boolean, val isOverride: Boolean, val isTransient: Boolean, + val annotationSpecs: List = emptyList(), val prop: ObjProperty, val methodId: Int?, val initStatement: Statement?, @@ -75,6 +77,7 @@ class ClassInstanceDelegatedDeclStatement( val isClosed: Boolean, val isOverride: Boolean, val isTransient: Boolean, + val annotationSpecs: List = emptyList(), val methodId: Int?, val initStatement: Statement?, override val pos: Pos, diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassStaticFieldInitStatement.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassStaticFieldInitStatement.kt index 2a857a7..92bbabd 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassStaticFieldInitStatement.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassStaticFieldInitStatement.kt @@ -32,6 +32,7 @@ class ClassStaticFieldInitStatement( val initializer: Statement?, val isDelegated: Boolean, val isTransient: Boolean, + val annotationSpecs: List = emptyList(), private val startPos: Pos, ) : Statement() { override val pos: Pos = startPos @@ -39,6 +40,7 @@ class ClassStaticFieldInitStatement( override suspend fun execute(scope: Scope): Obj { val initValue = initializer?.let { execBytecodeOnly(scope, it, "class static field init") }?.byValueCopy() ?: ObjNull + val annotations = annotationSpecs.evaluateDeclAnnotations(scope) val cls = scope.thisObj as? ObjClass ?: scope.raiseIllegalState("static field init requires class scope") return if (isDelegated) { @@ -61,7 +63,8 @@ class ClassStaticFieldInitStatement( writeVisibility, startPos, isTransient = isTransient, - type = ObjRecord.Type.Delegated + type = ObjRecord.Type.Delegated, + annotations = annotations ).apply { delegate = finalDelegate } @@ -72,7 +75,8 @@ class ClassStaticFieldInitStatement( visibility, writeVisibility, recordType = ObjRecord.Type.Delegated, - isTransient = isTransient + isTransient = isTransient, + annotations = annotations ).apply { delegate = finalDelegate } @@ -85,7 +89,8 @@ class ClassStaticFieldInitStatement( visibility, writeVisibility, startPos, - isTransient = isTransient + isTransient = isTransient, + annotations = annotations ) scope.addItem( name, @@ -94,7 +99,8 @@ class ClassStaticFieldInitStatement( visibility, writeVisibility, recordType = ObjRecord.Type.Field, - isTransient = isTransient + isTransient = isTransient, + annotations = annotations ) initValue } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index dd936dc..65007f3 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -2014,6 +2014,7 @@ class Compiler( private var lastAnnotation: (suspend (Scope, ObjString, Statement) -> Statement)? = null private var isTransientFlag: Boolean = false + private val pendingDeclAnnotations: MutableList = mutableListOf() private var lastLabel: String? = null private val strictSlotRefs: Boolean = settings.strictSlotRefs private val allowUnresolvedRefs: Boolean = settings.allowUnresolvedRefs @@ -2689,6 +2690,7 @@ class Compiler( lastAnnotation = null lastLabel = null isTransientFlag = false + pendingDeclAnnotations.clear() while (true) { val t = cc.next() return when (t.type) { @@ -2706,15 +2708,17 @@ class Compiler( } Token.Type.ATLABEL -> { - val label = t.value - if (label == "Transient") { + val parsedAnnotation = parseDeclAnnotation(t) + if (parsedAnnotation.name == "Transient") { isTransientFlag = true - continue } 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 } @@ -4033,11 +4037,17 @@ class Compiler( Token.Type.ID, Token.Type.ATLABEL -> { var isTransient = false - if (t.type == Token.Type.ATLABEL) { - if (t.value == "Transient") { + val annotationSpecs = mutableListOf() + while (t.type == Token.Type.ATLABEL) { + val spec = parseDeclAnnotation(t) + annotationSpecs += spec + if (spec.name == "Transient") { isTransient = true - t = cc.next() - } else throw ScriptError(t.pos, "Unexpected label in argument list") + } + t = cc.next() + } + if (annotationSpecs.isNotEmpty() && !isClassDeclaration) { + throw ScriptError(t.pos, "parameter annotations are currently supported only on class constructor parameters") } // visibility @@ -4114,7 +4124,8 @@ class Compiler( defaultValue, effectiveAccess, visibility, - isTransient + isTransient, + annotationSpecs = annotationSpecs ) // 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") } - suspend fun parseAnnotation(t: Token): (suspend (Scope, ObjString, Statement) -> Statement) { + private suspend fun parseDeclAnnotation(t: Token): ParsedDeclAnnotation { val extraArgs = parseArgsOrNull() resolutionSink?.reference(t.value, t.pos) -// println("annotation ${t.value}: args: $extraArgs") - return { scope, name, body -> - val extras = extraArgs?.first?.toArguments(scope, extraArgs.second)?.list - val required = listOf(name, body) - val args = extras?.let { required + it } ?: required - val fn = scope.get(t.value)?.value ?: scope.raiseSymbolNotFound("annotation not found: ${t.value}") - if (fn !is Statement) scope.raiseIllegalArgument("annotation must be callable, got ${fn.objClass}") - (fn.execute(scope.createChildScope(Arguments(args))) as? Statement) - ?: scope.raiseClassCastError("function annotation must return callable") - } + val compiledArgs = extraArgs?.first?.map { arg -> + val value = arg.value + arg.copy( + value = if (value is Statement) wrapBytecode(value) else value + ) + } ?: emptyList() + return ParsedDeclAnnotation( + name = t.value, + 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, Boolean>? = @@ -9232,6 +9249,8 @@ class Compiler( isTransient: Boolean = isTransientFlag ): Statement { isTransientFlag = false + val declarationAnnotationSpecs = pendingDeclAnnotations.toList() + pendingDeclAnnotations.clear() val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true var start = cc.currentPos() var extTypeName: String? = null @@ -9700,6 +9719,7 @@ class Compiler( isClosed = isClosed, isOverride = isOverride, isTransient = isTransient, + annotations = emptyList(), accessTypeLabel = "Callable", initializer = initExpr, pos = start @@ -10251,6 +10271,8 @@ class Compiler( isTransient: Boolean = isTransientFlag ): Statement { isTransientFlag = false + val declarationAnnotationSpecs = pendingDeclAnnotations.toList() + pendingDeclAnnotations.clear() val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true val markStart = cc.savePos() val nextToken = cc.next() @@ -10643,6 +10665,9 @@ class Compiler( !isStatic && !isProperty ) { + if (declarationAnnotationSpecs.isNotEmpty()) { + throw ScriptError(start, "declaration annotations are currently supported only on class members") + } if (isDelegate) { val initExpr = initialExpression ?: throw ScriptError(start, "Delegate must be initialized") val slotPlan = slotPlanStack.lastOrNull() @@ -10663,6 +10688,9 @@ class Compiler( } if (isStatic) { + if (extTypeName != null && declarationAnnotationSpecs.isNotEmpty()) { + throw ScriptError(start, "declaration annotations are not supported on extension properties") + } if (declaringClassNameCaptured != null) { val directRef = unwrapDirectRef(initialExpression) val declClass = resolveTypeDeclObjClass(varTypeDecl) @@ -10685,6 +10713,7 @@ class Compiler( initializer = initialExpression, isDelegated = isDelegate, isTransient = isTransient, + annotationSpecs = declarationAnnotationSpecs, startPos = start ) return NopStatement @@ -10847,6 +10876,9 @@ class Compiler( } if (extTypeName != null) { + if (declarationAnnotationSpecs.isNotEmpty()) { + throw ScriptError(start, "declaration annotations are not supported on extension properties") + } declareLocalName(extensionPropertyGetterName(extTypeName, name), isMutable = false) if (setter != null) { declareLocalName(extensionPropertySetterName(extTypeName, name), isMutable = false) @@ -10883,6 +10915,7 @@ class Compiler( isClosed = isClosed, isOverride = isOverride, isTransient = isTransient, + annotations = emptyList(), accessTypeLabel = accessType, initializer = initExpr, pos = start @@ -10898,6 +10931,7 @@ class Compiler( isClosed = isClosed, isOverride = isOverride, isTransient = isTransient, + annotationSpecs = declarationAnnotationSpecs, methodId = memberMethodId, initStatement = initStmt, pos = start @@ -10919,6 +10953,7 @@ class Compiler( isClosed = isClosed, isOverride = isOverride, isTransient = isTransient, + annotations = emptyList(), prop = prop, pos = start ) @@ -10933,6 +10968,7 @@ class Compiler( isClosed = isClosed, isOverride = isOverride, isTransient = isTransient, + annotationSpecs = declarationAnnotationSpecs, prop = prop, methodId = memberMethodId, initStatement = initStmt, @@ -10950,6 +10986,7 @@ class Compiler( isClosed = isClosed, isOverride = isOverride, isTransient = isTransient, + annotations = emptyList(), isLateInitVal = isLateInitVal, initializer = initialExpression, pos = start @@ -10966,6 +11003,7 @@ class Compiler( isClosed = isClosed, isOverride = isOverride, isTransient = isTransient, + annotationSpecs = declarationAnnotationSpecs, fieldId = memberFieldId, initStatement = initStmt, pos = start diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/DeclAnnotation.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/DeclAnnotation.kt new file mode 100644 index 0000000..e19309d --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/DeclAnnotation.kt @@ -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 = emptyList(), + val named: Map = emptyMap(), +) + +/** + * Parsed declaration annotation awaiting declaration-time evaluation. + */ +data class ParsedDeclAnnotation( + val name: String, + val args: List = 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.evaluateDeclAnnotations(scope: Scope): List { + val result = mutableListOf() + for (spec in this) { + result += spec.evaluate(scope) + } + return result +} + +private suspend fun evaluateDeclAnnotationArguments( + scope: Scope, + args: List, + 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(args.size) + for (arg in args) { + resolved += arg.copy(value = eval(arg.value)) + } + + val positional: MutableList = mutableListOf() + var named: MutableMap? = 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()) +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/InstanceInitStatements.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/InstanceInitStatements.kt index 443fe51..96df651 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/InstanceInitStatements.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/InstanceInitStatements.kt @@ -33,6 +33,7 @@ class InstanceFieldInitStatement( val isClosed: Boolean, val isOverride: Boolean, val isTransient: Boolean, + val annotations: List = emptyList(), val isLateInitVal: Boolean, val initializer: Statement?, override val pos: Pos, @@ -50,7 +51,8 @@ class InstanceFieldInitStatement( isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, - isTransient = isTransient + isTransient = isTransient, + annotations = annotations ) return ObjVoid } @@ -74,6 +76,7 @@ class InstancePropertyInitStatement( val isClosed: Boolean, val isOverride: Boolean, val isTransient: Boolean, + val annotations: List = emptyList(), val prop: ObjProperty, override val pos: Pos, ) : Statement() { @@ -88,7 +91,8 @@ class InstancePropertyInitStatement( isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, - isTransient = isTransient + isTransient = isTransient, + annotations = annotations ) return ObjVoid } @@ -104,6 +108,7 @@ class InstanceDelegatedInitStatement( val isClosed: Boolean, val isOverride: Boolean, val isTransient: Boolean, + val annotations: List = emptyList(), val accessTypeLabel: String, val initializer: Statement, override val pos: Pos, @@ -130,7 +135,8 @@ class InstanceDelegatedInitStatement( isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, - isTransient = isTransient + isTransient = isTransient, + annotations = annotations ).apply { delegate = finalDelegate } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt index 40a8755..3852a2d 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt @@ -718,6 +718,7 @@ open class Scope( isTransient: Boolean = false, callSignature: CallSignature? = null, typeDecl: TypeDecl? = null, + annotations: List = emptyList(), fieldId: Int? = null, methodId: Int? = null ): ObjRecord { @@ -731,6 +732,7 @@ open class Scope( isTransient = isTransient, callSignature = callSignature, typeDecl = typeDecl, + annotations = annotations, memberName = name, fieldId = fieldId, methodId = methodId diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt index 7abef8d..7b3168d 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt @@ -6360,7 +6360,8 @@ class BytecodeCompiler( isMutable = stmt.isMutable, visibility = stmt.visibility, writeVisibility = stmt.writeVisibility, - isTransient = stmt.isTransient + isTransient = stmt.isTransient, + annotationSpecs = stmt.annotationSpecs ) ) } else { @@ -6370,7 +6371,8 @@ class BytecodeCompiler( isMutable = stmt.isMutable, visibility = stmt.visibility, writeVisibility = stmt.writeVisibility, - isTransient = stmt.isTransient + isTransient = stmt.isTransient, + annotationSpecs = stmt.annotationSpecs ) ) } @@ -6397,6 +6399,7 @@ class BytecodeCompiler( writeVisibility = stmt.writeVisibility, typeDecl = stmt.typeDecl, isTransient = stmt.isTransient, + annotationSpecs = stmt.annotationSpecs, isAbstract = stmt.isAbstract, isClosed = stmt.isClosed, isOverride = stmt.isOverride, @@ -6419,6 +6422,7 @@ class BytecodeCompiler( visibility = stmt.visibility, writeVisibility = stmt.writeVisibility, isTransient = stmt.isTransient, + annotationSpecs = stmt.annotationSpecs, isAbstract = stmt.isAbstract, isClosed = stmt.isClosed, isOverride = stmt.isOverride, @@ -6442,6 +6446,7 @@ class BytecodeCompiler( visibility = stmt.visibility, writeVisibility = stmt.writeVisibility, isTransient = stmt.isTransient, + annotationSpecs = stmt.annotationSpecs, isAbstract = stmt.isAbstract, isClosed = stmt.isClosed, isOverride = stmt.isOverride, @@ -6475,6 +6480,7 @@ class BytecodeCompiler( visibility = stmt.visibility, writeVisibility = stmt.writeVisibility, isTransient = stmt.isTransient, + annotations = stmt.annotations, isAbstract = stmt.isAbstract, isClosed = stmt.isClosed, isOverride = stmt.isOverride @@ -6497,6 +6503,7 @@ class BytecodeCompiler( visibility = stmt.visibility, writeVisibility = stmt.writeVisibility, isTransient = stmt.isTransient, + annotations = stmt.annotations, isAbstract = stmt.isAbstract, isClosed = stmt.isClosed, isOverride = stmt.isOverride @@ -6517,6 +6524,7 @@ class BytecodeCompiler( visibility = stmt.visibility, writeVisibility = stmt.writeVisibility, isTransient = stmt.isTransient, + annotations = stmt.annotations, isAbstract = stmt.isAbstract, isClosed = stmt.isClosed, isOverride = stmt.isOverride, diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeConst.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeConst.kt index bf8a7d5..7c9dbb1 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeConst.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeConst.kt @@ -18,6 +18,7 @@ package net.sergeych.lyng.bytecode import net.sergeych.lyng.ArgsDeclaration +import net.sergeych.lyng.ParsedDeclAnnotation import net.sergeych.lyng.Pos import net.sergeych.lyng.TypeDecl import net.sergeych.lyng.Visibility @@ -85,6 +86,7 @@ sealed class BytecodeConst { val visibility: Visibility, val writeVisibility: Visibility?, val isTransient: Boolean, + val annotationSpecs: List, ) : BytecodeConst() data class ClassDelegatedDecl( val name: String, @@ -92,6 +94,7 @@ sealed class BytecodeConst { val visibility: Visibility, val writeVisibility: Visibility?, val isTransient: Boolean, + val annotationSpecs: List, ) : BytecodeConst() data class ClassInstanceInitDecl( val initStatement: Obj, @@ -103,6 +106,7 @@ sealed class BytecodeConst { val writeVisibility: Visibility?, val typeDecl: TypeDecl?, val isTransient: Boolean, + val annotationSpecs: List, val isAbstract: Boolean, val isClosed: Boolean, val isOverride: Boolean, @@ -116,6 +120,7 @@ sealed class BytecodeConst { val visibility: Visibility, val writeVisibility: Visibility?, val isTransient: Boolean, + val annotationSpecs: List, val isAbstract: Boolean, val isClosed: Boolean, val isOverride: Boolean, @@ -130,6 +135,7 @@ sealed class BytecodeConst { val visibility: Visibility, val writeVisibility: Visibility?, val isTransient: Boolean, + val annotationSpecs: List, val isAbstract: Boolean, val isClosed: Boolean, val isOverride: Boolean, @@ -143,6 +149,7 @@ sealed class BytecodeConst { val visibility: Visibility, val writeVisibility: Visibility?, val isTransient: Boolean, + val annotations: List, val isAbstract: Boolean, val isClosed: Boolean, val isOverride: Boolean, @@ -153,6 +160,7 @@ sealed class BytecodeConst { val visibility: Visibility, val writeVisibility: Visibility?, val isTransient: Boolean, + val annotations: List, val isAbstract: Boolean, val isClosed: Boolean, val isOverride: Boolean, @@ -164,6 +172,7 @@ sealed class BytecodeConst { val visibility: Visibility, val writeVisibility: Visibility?, val isTransient: Boolean, + val annotations: List, val isAbstract: Boolean, val isClosed: Boolean, val isOverride: Boolean, diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeStatement.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeStatement.kt index e6b35a0..17312a9 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeStatement.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeStatement.kt @@ -365,6 +365,7 @@ class BytecodeStatement private constructor( stmt.initializer?.let { unwrapDeep(it) }, stmt.isDelegated, stmt.isTransient, + stmt.annotationSpecs, stmt.pos ) } @@ -385,6 +386,7 @@ class BytecodeStatement private constructor( stmt.isClosed, stmt.isOverride, stmt.isTransient, + stmt.annotationSpecs, stmt.fieldId, stmt.initStatement?.let { unwrapDeep(it) }, stmt.pos @@ -400,6 +402,7 @@ class BytecodeStatement private constructor( stmt.isClosed, stmt.isOverride, stmt.isTransient, + stmt.annotationSpecs, stmt.prop, stmt.methodId, stmt.initStatement?.let { unwrapDeep(it) }, @@ -416,6 +419,7 @@ class BytecodeStatement private constructor( stmt.isClosed, stmt.isOverride, stmt.isTransient, + stmt.annotationSpecs, stmt.methodId, stmt.initStatement?.let { unwrapDeep(it) }, stmt.pos @@ -431,6 +435,7 @@ class BytecodeStatement private constructor( stmt.isClosed, stmt.isOverride, stmt.isTransient, + stmt.annotations, stmt.isLateInitVal, stmt.initializer?.let { unwrapDeep(it) }, stmt.pos @@ -446,6 +451,7 @@ class BytecodeStatement private constructor( stmt.isClosed, stmt.isOverride, stmt.isTransient, + stmt.annotations, stmt.prop, stmt.pos ) @@ -461,6 +467,7 @@ class BytecodeStatement private constructor( stmt.isClosed, stmt.isOverride, stmt.isTransient, + stmt.annotations, stmt.accessTypeLabel, unwrapDeep(stmt.initializer), stmt.pos diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt index b84b9d9..b203a44 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/CmdRuntime.kt @@ -2805,6 +2805,7 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd val decl = frame.fn.constants[constId] as? BytecodeConst.ClassFieldDecl ?: error("DECL_CLASS_FIELD expects ClassFieldDecl at $constId") val scope = frame.ensureScope() + val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope) val cls = scope.thisObj as? ObjClass ?: scope.raiseIllegalState("class field init requires class scope") val value = frame.slotToObj(slot).byValueCopy() @@ -2815,7 +2816,8 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd decl.visibility, decl.writeVisibility, Pos.builtIn, - isTransient = decl.isTransient + isTransient = decl.isTransient, + annotations = annotations ) scope.addItem( decl.name, @@ -2824,7 +2826,8 @@ class CmdDeclClassField(internal val constId: Int, internal val slot: Int) : Cmd decl.visibility, decl.writeVisibility, recordType = ObjRecord.Type.Field, - isTransient = decl.isTransient + isTransient = decl.isTransient, + annotations = annotations ) return } @@ -2835,6 +2838,7 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) : val decl = frame.fn.constants[constId] as? BytecodeConst.ClassDelegatedDecl ?: error("DECL_CLASS_DELEGATED expects ClassDelegatedDecl at $constId") val scope = frame.ensureScope() + val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope) val cls = scope.thisObj as? ObjClass ?: scope.raiseIllegalState("class delegated init requires class scope") val initValue = frame.slotToObj(slot) @@ -2857,7 +2861,8 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) : decl.writeVisibility, Pos.builtIn, isTransient = decl.isTransient, - type = ObjRecord.Type.Delegated + type = ObjRecord.Type.Delegated, + annotations = annotations ).apply { delegate = finalDelegate } @@ -2868,7 +2873,8 @@ class CmdDeclClassDelegated(internal val constId: Int, internal val slot: Int) : decl.visibility, decl.writeVisibility, recordType = ObjRecord.Type.Delegated, - isTransient = decl.isTransient + isTransient = decl.isTransient, + annotations = annotations ).apply { 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 ?: error("DECL_CLASS_INSTANCE_FIELD expects ClassInstanceFieldDecl at $constId") val scope = frame.ensureScope() + val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope) val cls = scope.thisObj as? ObjClass ?: scope.raiseIllegalState("class instance field requires class scope") cls.createField( @@ -2911,7 +2918,8 @@ class CmdDeclClassInstanceField(internal val constId: Int, internal val slot: In isTransient = decl.isTransient, typeDecl = decl.typeDecl, type = ObjRecord.Type.Field, - fieldId = decl.fieldId + fieldId = decl.fieldId, + annotations = annotations ) if (!decl.isAbstract) { 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 ?: error("DECL_CLASS_INSTANCE_PROPERTY expects ClassInstancePropertyDecl at $constId") val scope = frame.ensureScope() + val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope) val cls = scope.thisObj as? ObjClass ?: scope.raiseIllegalState("class instance property requires class scope") cls.addProperty( @@ -2938,7 +2947,8 @@ class CmdDeclClassInstanceProperty(internal val constId: Int, internal val slot: isOverride = decl.isOverride, pos = decl.pos, prop = decl.prop, - methodId = decl.methodId + methodId = decl.methodId, + annotations = annotations ) if (!decl.isAbstract) { 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 ?: error("DECL_CLASS_INSTANCE_DELEGATED expects ClassInstanceDelegatedDecl at $constId") val scope = frame.ensureScope() + val annotations = decl.annotationSpecs.evaluateDeclAnnotations(scope) val cls = scope.thisObj as? ObjClass ?: scope.raiseIllegalState("class instance delegated requires class scope") cls.createField( @@ -2968,7 +2979,8 @@ class CmdDeclClassInstanceDelegated(internal val constId: Int, internal val slot isOverride = decl.isOverride, isTransient = decl.isTransient, type = ObjRecord.Type.Delegated, - methodId = decl.methodId + methodId = decl.methodId, + annotations = annotations ) if (!decl.isAbstract) { decl.initStatement?.let { cls.instanceInitializers += it } @@ -2994,7 +3006,8 @@ class CmdDeclInstanceField(internal val constId: Int, internal val slot: Int) : isAbstract = decl.isAbstract, isClosed = decl.isClosed, isOverride = decl.isOverride, - isTransient = decl.isTransient + isTransient = decl.isTransient, + annotations = decl.annotations ) if (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, isClosed = decl.isClosed, isOverride = decl.isOverride, - isTransient = decl.isTransient + isTransient = decl.isTransient, + annotations = decl.annotations ) if (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, isClosed = decl.isClosed, isOverride = decl.isOverride, - isTransient = decl.isTransient + isTransient = decl.isTransient, + annotations = decl.annotations ).apply { delegate = finalDelegate } @@ -3705,9 +3720,26 @@ class CmdGetClassScope( decl = declared break } - val resolved = rec ?: scope.raiseSymbolNotFound(name) - val declClass = decl ?: cls - val resolvedRec = cls.resolveRecord(scope, resolved, name, declClass) + val resolvedRec = if (rec != null) { + val declClass = decl ?: cls + 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 frame.storeObjResult(dst, value) return diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt index 97b043e..65b6139 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt @@ -28,6 +28,23 @@ import net.sergeych.lynon.LynonType // Simple id generator for class identities (not thread-safe; fine for scripts) private object ClassIdGen { var c: Long = 1L; fun nextId(): Long = c++ } +private fun DeclAnnotation.toObj(): Obj { + val namedArgs = linkedMapOf() + 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): Obj = + ObjImmutableList(annotations.map { it.toObj() }) + val ObjClassType by lazy { object : ObjClass("Class") { override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj { @@ -98,6 +115,30 @@ val ObjClassType by lazy { val rec = cls.getInstanceMemberOrNull(name) 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() + val name = requiredArg(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() + val name = requiredArg(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, typeDecl: net.sergeych.lyng.TypeDecl? = null, callSignature: net.sergeych.lyng.CallSignature? = null, + annotations: List = emptyList(), ): ObjRecord { // Validation of override rules: only for non-system declarations var existing: ObjRecord? = null @@ -953,6 +995,7 @@ open class ObjClass( type = type, callSignature = callSignature, typeDecl = typeDecl, + annotations = annotations, memberName = name, fieldId = effectiveFieldId, methodId = effectiveMethodId @@ -979,7 +1022,8 @@ open class ObjClass( type: ObjRecord.Type = ObjRecord.Type.Field, fieldId: Int? = null, methodId: Int? = null, - callSignature: net.sergeych.lyng.CallSignature? = null + callSignature: net.sergeych.lyng.CallSignature? = null, + annotations: List = emptyList() ): ObjRecord { initClassScope() val existing = classScope!!.objects[name] @@ -1021,6 +1065,7 @@ open class ObjClass( recordType = type, isTransient = isTransient, callSignature = callSignature, + annotations = annotations, fieldId = effectiveFieldId, methodId = effectiveMethodId ) @@ -1067,7 +1112,8 @@ open class ObjClass( isOverride: Boolean = false, pos: Pos = Pos.builtIn, prop: ObjProperty? = null, - methodId: Int? = null + methodId: Int? = null, + annotations: List = emptyList() ) { val g = getter?.let { ObjExternCallable.fromBridge { it() } } val s = setter?.let { ObjExternCallable.fromBridge { it(requiredArg(0)); ObjVoid } } @@ -1076,7 +1122,8 @@ open class ObjClass( name, finalProp, false, visibility, writeVisibility, pos, declaringClass, isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, type = ObjRecord.Type.Property, - methodId = methodId + methodId = methodId, + annotations = annotations ) } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt index e6f3216..3c6290e 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt @@ -16,6 +16,7 @@ */ package net.sergeych.lyng.obj +import net.sergeych.lyng.DeclAnnotation import net.sergeych.lyng.Scope import net.sergeych.lyng.Visibility @@ -40,6 +41,7 @@ data class ObjRecord( var receiver: Obj? = null, val callSignature: net.sergeych.lyng.CallSignature? = null, val typeDecl: net.sergeych.lyng.TypeDecl? = null, + val annotations: List = emptyList(), val memberName: String? = null, val fieldId: Int? = null, val methodId: Int? = null, diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/DeclAnnotationIntrospectionTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/DeclAnnotationIntrospectionTest.kt new file mode 100644 index 0000000..fd18faf --- /dev/null +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/DeclAnnotationIntrospectionTest.kt @@ -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> = Sample.getConstructorAnnotations("x") + val ctorTag: Map = ctorAnnotations[1] + val ctorPositional: ImmutableList = ctorTag["positional"] as ImmutableList + val ctorNamed: Map = ctorTag["named"] as Map + 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> = Sample.getMemberAnnotations("y") + val memberDecodeWith: Map = memberAnnotations[1] + val memberPositional: ImmutableList = memberDecodeWith["positional"] as ImmutableList + 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) + } +} diff --git a/lynglib/stdlib/lyng/root.lyng b/lynglib/stdlib/lyng/root.lyng index 455faa1..4f46e03 100644 --- a/lynglib/stdlib/lyng/root.lyng +++ b/lynglib/stdlib/lyng/root.lyng @@ -14,6 +14,24 @@ extern class NotImplementedException /* Raised when an awaited asynchronous task was cancelled before producing a value. */ 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 + /* Declared instance methods of this class and its ancestors (C3 order), without duplicates. */ + val methods: List + /* 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> + /* Preserved annotations for a member as descriptor maps with keys `name`, `positional`, and `named`. */ + fun getMemberAnnotations(name: String): ImmutableList> +} + /* A handle to a running asynchronous task. */ extern class Deferred { /* Cancel the task if it is still active. Safe to call multiple times. */ @@ -648,4 +666,4 @@ class LaunchPool(maxWorkers, maxQueueSize = Channel.UNLIMITED) { w.await() } } -} \ No newline at end of file +} diff --git a/notes/db/resultset_decode_api.md b/notes/db/resultset_decode_api.md new file mode 100644 index 0000000..9a3e990 --- /dev/null +++ b/notes/db/resultset_decode_api.md @@ -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().first +} +``` + +## Agreed API + +Use `decodeAs()` 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 +} + +extern class ResultSet : Iterable { + fun decodeAs(): Iterable +} +``` + +## Lifetime semantics + +`ResultSet.decodeAs()` 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() + .toList() +} +``` + +Invalid: + +```lyng +val decoded = db.transaction { tx -> + tx.select("select x, y from point").decodeAs() +} + +decoded.first +``` + +## ResultSet shape + +`ResultSet.decodeAs()` should preserve the current `ResultSet` paradigm: + +- `ResultSet` stays the row-producing source +- `decodeAs()` is a projection from `Iterable` to `Iterable` +- no new DB-specific collection type is introduced in v1 + +Implementation-wise, `ResultSet.decodeAs()` can be defined as a lazy iterable that decodes each row via `SqlRow.decodeAs()`. + +## 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` -> 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()` + +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