Add DB decode annotations and preserved declaration metadata
This commit is contained in:
parent
2abe7e2f96
commit
50e34e520e
@ -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(...)`.
|
||||
|
||||
@ -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<T>()` — 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<T>()` — 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<T>()`.
|
||||
|
||||
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<T>()` is also transaction-scoped
|
||||
- decoded objects produced while iterating `decodeAs<T>()` are detached ordinary Lyng values
|
||||
|
||||
The same rule applies to generated keys from `ExecutionResult.getGeneratedKeys()`: the `ResultSet` is transaction-scoped, but rows returned by `toList()` are detached.
|
||||
|
||||
|
||||
@ -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<SqlResultSetObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
SqlDecodedIterableObj(
|
||||
self.types,
|
||||
self.lifetime,
|
||||
self.rows.map { it as SqlRowObj },
|
||||
resolveDecodeTargetType(requireScope())
|
||||
)
|
||||
}
|
||||
|
||||
rowClass.addProperty("size", getter = {
|
||||
val self = thisAs<SqlRowObj>()
|
||||
@ -260,6 +302,12 @@ internal class SqlRuntimeTypes private constructor(
|
||||
val self = thisAs<SqlRowObj>()
|
||||
ObjImmutableList(self.values)
|
||||
})
|
||||
rowClass.addFn(
|
||||
"decodeAs",
|
||||
callSignature = core.rowClass.getInstanceMemberOrNull("decodeAs")?.callSignature
|
||||
) {
|
||||
decodeSqlRow(requireScope(), thisAs(), resolveDecodeTargetType(requireScope()))
|
||||
}
|
||||
|
||||
columnClass.addProperty("name", getter = { ObjString(thisAs<SqlColumnObj>().meta.name) })
|
||||
columnClass.addProperty("sqlType", getter = { thisAs<SqlColumnObj>().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<SqlDecodedIterableObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
SqlDecodedIteratorObj(self.types, self.lifetime, self.rows.iterator(), self.targetType)
|
||||
}
|
||||
|
||||
decodedIteratorClass.addFn("hasNext") {
|
||||
val self = thisAs<SqlDecodedIteratorObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
ObjBool(self.rows.hasNext())
|
||||
}
|
||||
decodedIteratorClass.addFn("next") {
|
||||
val self = thisAs<SqlDecodedIteratorObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
decodeSqlRow(requireScope(), self.rows.next(), self.targetType)
|
||||
}
|
||||
decodedIteratorClass.addFn("cancelIteration") {
|
||||
thisAs<SqlDecodedIteratorObj>().lifetime.ensureActive(this)
|
||||
ObjVoid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -319,6 +388,7 @@ internal class SqlResultSetObj(
|
||||
val lifetime: SqlTransactionLifetime,
|
||||
data: SqlResultSetData,
|
||||
) : Obj() {
|
||||
val columnMeta: List<SqlColumnMeta> = data.columns
|
||||
val columns: List<Obj> = data.columns.map { SqlColumnObj(types, it) }
|
||||
val rows: List<Obj> = 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<SqlColumnMeta>,
|
||||
val values: List<Obj>,
|
||||
private val indexByName: Map<String, List<Int>>,
|
||||
) : Obj() {
|
||||
@ -381,6 +452,26 @@ internal class SqlRowObj(
|
||||
}
|
||||
}
|
||||
|
||||
internal class SqlDecodedIterableObj(
|
||||
val types: SqlRuntimeTypes,
|
||||
val lifetime: SqlTransactionLifetime,
|
||||
val rows: List<SqlRowObj>,
|
||||
val targetType: TypeDecl,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.decodedIterableClass
|
||||
}
|
||||
|
||||
internal class SqlDecodedIteratorObj(
|
||||
val types: SqlRuntimeTypes,
|
||||
val lifetime: SqlTransactionLifetime,
|
||||
val rows: Iterator<SqlRowObj>,
|
||||
val targetType: TypeDecl,
|
||||
) : Obj() {
|
||||
override val objClass: ObjClass
|
||||
get() = types.decodedIteratorClass
|
||||
}
|
||||
|
||||
internal class SqlColumnObj(
|
||||
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<String, Int>()
|
||||
targetClass.constructorMeta?.params?.forEach { memberNames[it.name.lowercase()] = 1 }
|
||||
row.columns.forEach { column ->
|
||||
if (column.name.lowercase() in memberNames) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun decodeStructuredRow(
|
||||
scope: Scope,
|
||||
row: SqlRowObj,
|
||||
targetType: TypeDecl,
|
||||
targetClass: ObjClass,
|
||||
): Obj {
|
||||
val meta = targetClass.constructorMeta
|
||||
?: raiseSqlUsage(scope, row.types, "Can't decode SQL row as ${targetClass.className}: target class has no constructor metadata")
|
||||
|
||||
val normalizedColumns = buildColumnLookup(scope, row)
|
||||
val consumed = mutableSetOf<String>()
|
||||
val namedArgs = LinkedHashMap<String, Obj>()
|
||||
|
||||
for (param in meta.params) {
|
||||
if (param.isTransient) continue
|
||||
val lowered = param.name.lowercase()
|
||||
val column = normalizedColumns[lowered]
|
||||
if (column == null) {
|
||||
if (param.defaultValue == null && !param.type.isNullable) {
|
||||
raiseSqlUsage(scope, row.types, "Missing SQL column '${param.name}' for ${targetClass.className}")
|
||||
}
|
||||
continue
|
||||
}
|
||||
namedArgs[param.name] = decodeSqlValue(
|
||||
scope,
|
||||
row.types,
|
||||
row,
|
||||
column.first,
|
||||
row.values[column.second],
|
||||
param.type,
|
||||
param.annotations
|
||||
)
|
||||
consumed += lowered
|
||||
}
|
||||
|
||||
val callScope = scope.createChildScope(args = Arguments(list = emptyList(), named = namedArgs))
|
||||
val instance = targetClass.callOn(callScope)
|
||||
if (instance !is ObjInstance) {
|
||||
return instance
|
||||
}
|
||||
val knownFields = collectSerializableFieldTargets(scope, row, targetClass, instance)
|
||||
|
||||
for ((lowered, target) in knownFields) {
|
||||
if (consumed.contains(lowered)) continue
|
||||
val column = normalizedColumns[lowered] ?: continue
|
||||
val targetTypeDecl = target.record.typeDecl ?: TypeDecl.TypeAny
|
||||
target.record.value = decodeSqlValue(
|
||||
scope,
|
||||
row.types,
|
||||
row,
|
||||
column.first,
|
||||
row.values[column.second],
|
||||
targetTypeDecl,
|
||||
target.annotations
|
||||
)
|
||||
consumed += lowered
|
||||
}
|
||||
|
||||
val allowedNames = meta.params.filter { !it.isTransient }.map { it.name.lowercase() }.toMutableSet()
|
||||
allowedNames += knownFields.keys
|
||||
for (column in row.columns) {
|
||||
val lowered = column.name.lowercase()
|
||||
if (lowered !in allowedNames) {
|
||||
raiseSqlUsage(scope, row.types, "Unknown SQL result column '${column.name}' while decoding ${renderTypeName(targetType)}")
|
||||
}
|
||||
}
|
||||
|
||||
instance.invokeInstanceMethod(scope, "onDeserialized", Arguments.EMPTY) { ObjVoid }
|
||||
return instance
|
||||
}
|
||||
|
||||
private data class FieldDecodeTarget(
|
||||
val record: ObjRecord,
|
||||
val annotations: List<DeclAnnotation>,
|
||||
)
|
||||
|
||||
private fun collectSerializableFieldTargets(
|
||||
scope: Scope,
|
||||
row: SqlRowObj,
|
||||
targetClass: ObjClass,
|
||||
instance: ObjInstance,
|
||||
): Map<String, FieldDecodeTarget> {
|
||||
val result = linkedMapOf<String, FieldDecodeTarget>()
|
||||
for ((name, record) in instance.serializingVars) {
|
||||
val simpleName = name.substringAfterLast("::")
|
||||
val lowered = simpleName.lowercase()
|
||||
val classAnnotations = targetClass.getInstanceMemberOrNull(simpleName)?.annotations ?: emptyList()
|
||||
val previous = result.put(lowered, FieldDecodeTarget(record, classAnnotations))
|
||||
if (previous != null) {
|
||||
raiseSqlUsage(scope, row.types, "Ambiguous serializable target field '$lowered' in ${targetClass.className}")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun buildColumnLookup(scope: Scope, row: SqlRowObj): Map<String, Pair<SqlColumnMeta, Int>> {
|
||||
val result = linkedMapOf<String, Pair<SqlColumnMeta, Int>>()
|
||||
row.columns.forEachIndexed { index, column ->
|
||||
val lowered = column.name.lowercase()
|
||||
if (result.containsKey(lowered)) {
|
||||
raiseSqlUsage(scope, row.types, "Ambiguous SQL result column: ${column.name}")
|
||||
}
|
||||
result[lowered] = column to index
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun decodeSqlValue(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
row: SqlRowObj,
|
||||
column: SqlColumnMeta,
|
||||
value: Obj,
|
||||
targetType: TypeDecl,
|
||||
annotations: List<DeclAnnotation> = emptyList(),
|
||||
): Obj {
|
||||
val adapterAnnotation = findDbDecodeWithAnnotation(scope, types, annotations)
|
||||
if (adapterAnnotation != null) {
|
||||
val adapted = applyDbFieldAdapter(scope, types, row, column, value, targetType, adapterAnnotation)
|
||||
if (adapted === ObjNull) {
|
||||
if (targetType.isNullable || targetType == TypeDecl.TypeNullableAny) return ObjNull
|
||||
raiseSqlUsage(scope, types, "SQL column '${column.name}' is null but target type ${renderTypeName(targetType)} is non-null")
|
||||
}
|
||||
if (!matchesTypeDeclCompat(scope, adapted, targetType)) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"DB adapter result for column '${column.name}' does not match target type ${renderTypeName(targetType)}"
|
||||
)
|
||||
}
|
||||
return adapted
|
||||
}
|
||||
if (value === ObjNull) {
|
||||
if (targetType.isNullable || targetType == TypeDecl.TypeNullableAny) return ObjNull
|
||||
raiseSqlUsage(scope, types, "SQL column '${column.name}' is null but target type ${renderTypeName(targetType)} is non-null")
|
||||
}
|
||||
if (matchesTypeDeclCompat(scope, value, targetType)) {
|
||||
return value
|
||||
}
|
||||
if (value is ObjBuffer) {
|
||||
return try {
|
||||
val decoded = ObjLynonClass.decodeAny(scope, ObjBitBuffer(BitArray(value.byteArray, 8)))
|
||||
if (!matchesTypeDeclCompat(scope, decoded, targetType)) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"Lynon-decoded SQL column '${column.name}' does not match target type ${renderTypeName(targetType)}"
|
||||
)
|
||||
}
|
||||
decoded
|
||||
} catch (e: Throwable) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"Failed to decode Lynon column '${column.name}' as ${renderTypeName(targetType)}: ${e.message ?: e::class.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isJsonLikeNativeType(column.nativeType) && value is ObjString) {
|
||||
return try {
|
||||
ObjJsonClass.decodeFromJsonElement(scope, Json.parseToJsonElement(value.value), targetType)
|
||||
} catch (e: Throwable) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"Failed to decode JSON column '${column.name}' as ${renderTypeName(targetType)}: ${e.message ?: e::class.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"SQL column '${column.name}' of native type ${column.nativeType} can't be decoded as ${renderTypeName(targetType)}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun findDbDecodeWithAnnotation(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
annotations: List<DeclAnnotation>,
|
||||
): DeclAnnotation? {
|
||||
val matches = annotations.filter { it.name == "DbDecodeWith" }
|
||||
if (matches.size > 1) {
|
||||
raiseSqlUsage(scope, types, "Only one @DbDecodeWith(...) annotation is allowed per declaration")
|
||||
}
|
||||
return matches.singleOrNull()
|
||||
}
|
||||
|
||||
private suspend fun applyDbFieldAdapter(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
row: SqlRowObj,
|
||||
column: SqlColumnMeta,
|
||||
value: Obj,
|
||||
targetType: TypeDecl,
|
||||
annotation: DeclAnnotation,
|
||||
): Obj {
|
||||
if (annotation.named.isNotEmpty() || annotation.positional.size != 1) {
|
||||
raiseSqlUsage(scope, types, "@DbDecodeWith(...) expects exactly one adapter instance argument")
|
||||
}
|
||||
val adapter = annotation.positional.first()
|
||||
if (!adapter.isInstanceOf(types.core.dbFieldAdapterClass)) {
|
||||
raiseSqlUsage(scope, types, "@DbDecodeWith(...) argument must implement DbFieldAdapter")
|
||||
}
|
||||
return try {
|
||||
adapter.invokeInstanceMethod(
|
||||
scope,
|
||||
"decode",
|
||||
Arguments(
|
||||
value,
|
||||
SqlColumnObj(types, column),
|
||||
row,
|
||||
runtimeTargetTypeObject(scope, targetType)
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"Failed to decode SQL column '${column.name}' with @DbDecodeWith(...): ${e.message ?: e::class.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runtimeTargetTypeObject(scope: Scope, targetType: TypeDecl): Obj {
|
||||
return resolveTypeDeclClass(scope, targetType) ?: ObjTypeExpr(targetType)
|
||||
}
|
||||
|
||||
private fun isJsonLikeNativeType(nativeType: String): Boolean {
|
||||
val normalized = nativeType.trim().substringBefore('(').lowercase()
|
||||
return normalized == "json" || normalized == "jsonb"
|
||||
}
|
||||
|
||||
private fun resolveTypeDeclClass(scope: Scope, type: TypeDecl): ObjClass? = when (type) {
|
||||
is TypeDecl.Simple -> {
|
||||
val direct = scope[type.name]?.value as? ObjClass
|
||||
direct ?: scope[type.name.substringAfterLast('.')]?.value as? ObjClass
|
||||
}
|
||||
is TypeDecl.Generic -> {
|
||||
val direct = scope[type.name]?.value as? ObjClass
|
||||
direct ?: scope[type.name.substringAfterLast('.')]?.value as? ObjClass
|
||||
}
|
||||
is TypeDecl.Ellipsis -> resolveTypeDeclClass(scope, type.elementType)
|
||||
is TypeDecl.TypeVar -> when (val bound = scope[type.name]?.value) {
|
||||
is ObjClass -> bound
|
||||
is ObjTypeExpr -> resolveTypeDeclClass(scope, bound.typeDecl)
|
||||
else -> null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun matchesTypeDeclCompat(scope: Scope, value: Obj, typeDecl: TypeDecl): Boolean {
|
||||
if (value === ObjNull) return typeDecl.isNullable || typeDecl == TypeDecl.TypeNullableAny
|
||||
fun resolve(typeName: String): ObjClass? {
|
||||
val direct = scope[typeName]?.value as? ObjClass
|
||||
return direct ?: scope[typeName.substringAfterLast('.')]?.value as? ObjClass
|
||||
}
|
||||
return when (typeDecl) {
|
||||
TypeDecl.TypeAny, TypeDecl.TypeNullableAny -> true
|
||||
is TypeDecl.TypeVar -> {
|
||||
val cls = resolve(typeDecl.name)
|
||||
if (cls != null) value.isInstanceOf(cls) else value.isInstanceOf(typeDecl.name)
|
||||
}
|
||||
is TypeDecl.Simple -> {
|
||||
val cls = resolve(typeDecl.name)
|
||||
if (cls != null) value.isInstanceOf(cls) else value.isInstanceOf(typeDecl.name.substringAfterLast('.'))
|
||||
}
|
||||
is TypeDecl.Generic -> {
|
||||
val cls = resolve(typeDecl.name)
|
||||
if (cls != null) value.isInstanceOf(cls) else value.isInstanceOf(typeDecl.name.substringAfterLast('.'))
|
||||
}
|
||||
is TypeDecl.Function -> value.isInstanceOf("Callable")
|
||||
is TypeDecl.Ellipsis -> matchesTypeDeclCompat(scope, value, typeDecl.elementType)
|
||||
is TypeDecl.Union -> typeDecl.options.any { matchesTypeDeclCompat(scope, value, it) }
|
||||
is TypeDecl.Intersection -> typeDecl.options.all { matchesTypeDeclCompat(scope, value, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderTypeName(type: TypeDecl): String = when (type) {
|
||||
TypeDecl.TypeAny -> "Object"
|
||||
TypeDecl.TypeNullableAny -> "Object?"
|
||||
is TypeDecl.Simple -> type.name + if (type.isNullable) "?" else ""
|
||||
is TypeDecl.Generic -> type.name + "<" + type.args.joinToString(",") { renderTypeName(it) } + ">" + if (type.isNullable) "?" else ""
|
||||
is TypeDecl.Function -> "Callable"
|
||||
is TypeDecl.Ellipsis -> renderTypeName(type.elementType) + "..."
|
||||
is TypeDecl.TypeVar -> type.name + if (type.isNullable) "?" else ""
|
||||
is TypeDecl.Union -> type.options.joinToString(" | ") { renderTypeName(it) } + if (type.isNullable) "?" else ""
|
||||
is TypeDecl.Intersection -> type.options.joinToString(" & ") { renderTypeName(it) } + if (type.isNullable) "?" else ""
|
||||
}
|
||||
|
||||
private fun raiseSqlUsage(scope: Scope, types: SqlRuntimeTypes?, message: String): Nothing {
|
||||
val exClass = types?.core?.sqlUsageException
|
||||
if (exClass != null) {
|
||||
scope.raiseError(ObjException(exClass, scope, ObjString(message)))
|
||||
}
|
||||
scope.raiseIllegalArgument(message)
|
||||
}
|
||||
|
||||
@ -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<Row>().first
|
||||
assertEquals(7, row.id)
|
||||
assertEquals(4, row.payload.x)
|
||||
assertEquals(5, row.payload.y)
|
||||
row.payload.y
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-decode-json-field>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(5L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsSupportsSingleJsonColumnProjection() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload json not null)")
|
||||
tx.execute("insert into data(payload) values(?)", "{\"x\":9,\"y\":11}")
|
||||
val point = tx.select("select payload from data").decodeAs<Point>().first
|
||||
assertEquals(9, point.x)
|
||||
assertEquals(11, point.y)
|
||||
point.x + point.y
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-decode-json-single>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(20L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsDoesNotAutoDecodePlainTextAsJson() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload text not null)")
|
||||
tx.execute("insert into data(payload) values(?)", "{\"x\":1,\"y\":2}")
|
||||
tx.select("select payload from data").decodeAs<Point>().first
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
Compiler.compile(Source("<sqlite-decode-json-text-guard>", code), scope.importManager).execute(scope)
|
||||
}
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsSupportsSingleLynonBinaryProjection() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
import lyng.serialization
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload blob not null)")
|
||||
tx.execute("insert into data(payload) values(?)", Lynon.encode(Point(6, 8)).toBuffer())
|
||||
val point = tx.select("select payload from data").decodeAs<Point>().first
|
||||
assertEquals(6, point.x)
|
||||
assertEquals(8, point.y)
|
||||
point.x + point.y
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-decode-lynon-single>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(14L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsSupportsDbDecodeWithOnConstructorParamsAndFields() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
object TrimmedStringAdapter: DbFieldAdapter {
|
||||
override fun decode(rawValue, column, row, targetType) =
|
||||
when(rawValue) {
|
||||
null -> null
|
||||
else -> rawValue.toString().trim()
|
||||
}
|
||||
}
|
||||
|
||||
class User(
|
||||
id: Int,
|
||||
@DbDecodeWith(TrimmedStringAdapter) name: String
|
||||
) {
|
||||
@DbDecodeWith(TrimmedStringAdapter)
|
||||
var note: String = ""
|
||||
}
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(id integer not null, name text not null, note text not null)")
|
||||
tx.execute("insert into data(id, name, note) values(?, ?, ?)", 10, " Alice ", " hello ")
|
||||
val user = tx.select("select id, name, note from data").decodeAs<User>().first
|
||||
assertEquals(10, user.id)
|
||||
assertEquals("Alice", user.name)
|
||||
assertEquals("hello", user.note)
|
||||
user.note.size
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-decode-dbdecodewith>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(5L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsFailsWhenDbDecodeWithReturnsWrongType() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
object BadAdapter: DbFieldAdapter {
|
||||
override fun decode(rawValue, column, row, targetType) = 42
|
||||
}
|
||||
|
||||
class User(@DbDecodeWith(BadAdapter) name: String)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(name text not null)")
|
||||
tx.execute("insert into data(name) values(?)", "Alice")
|
||||
tx.select("select name from data").decodeAs<User>().first
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
Compiler.compile(Source("<sqlite-decode-dbdecodewith-bad-type>", code), scope.importManager).execute(scope)
|
||||
}
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsKeepsRawBufferForBufferTarget() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
import lyng.buffer
|
||||
import lyng.serialization
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload blob not null)")
|
||||
val encoded = Lynon.encode(Point(1, 2)).toBuffer()
|
||||
tx.execute("insert into data(payload) values(?)", encoded)
|
||||
val payload = tx.select("select payload from data").decodeAs<Buffer>().first
|
||||
assertEquals(encoded.size, payload.size)
|
||||
payload.size
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-decode-buffer-raw>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertTrue(result.value > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsFailsForNonLynonBinaryTypedProjection() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
import lyng.buffer
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload blob not null)")
|
||||
tx.execute("insert into data(payload) values(?)", "hello".encodeUtf8())
|
||||
tx.select("select payload from data").decodeAs<Point>().first
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
Compiler.compile(Source("<sqlite-decode-lynon-binary-guard>", code), scope.importManager).execute(scope)
|
||||
}
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNestedTransactionRollbackUsesSavepoint() = runTest {
|
||||
val scope = Script.newScope()
|
||||
|
||||
@ -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<Row>().first
|
||||
assertEquals(7, row.id)
|
||||
assertEquals(4, row.payload.x)
|
||||
assertEquals(5, row.payload.y)
|
||||
row.payload.y
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-native-decode-json-field>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(5L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsSupportsSingleJsonColumnProjection() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload json not null)")
|
||||
tx.execute("insert into data(payload) values(?)", "{\"x\":9,\"y\":11}")
|
||||
val point = tx.select("select payload from data").decodeAs<Point>().first
|
||||
assertEquals(9, point.x)
|
||||
assertEquals(11, point.y)
|
||||
point.x + point.y
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-native-decode-json-single>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(20L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsDoesNotAutoDecodePlainTextAsJson() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload text not null)")
|
||||
tx.execute("insert into data(payload) values(?)", "{\"x\":1,\"y\":2}")
|
||||
tx.select("select payload from data").decodeAs<Point>().first
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
Compiler.compile(Source("<sqlite-native-decode-json-text-guard>", code), scope.importManager).execute(scope)
|
||||
}
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsSupportsSingleLynonBinaryProjection() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
import lyng.serialization
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload blob not null)")
|
||||
tx.execute("insert into data(payload) values(?)", Lynon.encode(Point(6, 8)).toBuffer())
|
||||
val point = tx.select("select payload from data").decodeAs<Point>().first
|
||||
assertEquals(6, point.x)
|
||||
assertEquals(8, point.y)
|
||||
point.x + point.y
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-native-decode-lynon-single>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(14L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsSupportsDbDecodeWithOnConstructorParamsAndFields() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
object TrimmedStringAdapter: DbFieldAdapter {
|
||||
override fun decode(rawValue, column, row, targetType) =
|
||||
when(rawValue) {
|
||||
null -> null
|
||||
else -> rawValue.toString().trim()
|
||||
}
|
||||
}
|
||||
|
||||
class User(
|
||||
id: Int,
|
||||
@DbDecodeWith(TrimmedStringAdapter) name: String
|
||||
) {
|
||||
@DbDecodeWith(TrimmedStringAdapter)
|
||||
var note: String = ""
|
||||
}
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(id integer not null, name text not null, note text not null)")
|
||||
tx.execute("insert into data(id, name, note) values(?, ?, ?)", 10, " Alice ", " hello ")
|
||||
val user = tx.select("select id, name, note from data").decodeAs<User>().first
|
||||
assertEquals(10, user.id)
|
||||
assertEquals("Alice", user.name)
|
||||
assertEquals("hello", user.note)
|
||||
user.note.size
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-native-decode-dbdecodewith>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(5L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsFailsWhenDbDecodeWithReturnsWrongType() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
object BadAdapter: DbFieldAdapter {
|
||||
override fun decode(rawValue, column, row, targetType) = 42
|
||||
}
|
||||
|
||||
class User(@DbDecodeWith(BadAdapter) name: String)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(name text not null)")
|
||||
tx.execute("insert into data(name) values(?)", "Alice")
|
||||
tx.select("select name from data").decodeAs<User>().first
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
Compiler.compile(Source("<sqlite-native-decode-dbdecodewith-bad-type>", code), scope.importManager).execute(scope)
|
||||
}
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsKeepsRawBufferForBufferTarget() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
import lyng.buffer
|
||||
import lyng.serialization
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload blob not null)")
|
||||
val encoded = Lynon.encode(Point(1, 2)).toBuffer()
|
||||
tx.execute("insert into data(payload) values(?)", encoded)
|
||||
val payload = tx.select("select payload from data").decodeAs<Buffer>().first
|
||||
assertEquals(encoded.size, payload.size)
|
||||
payload.size
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-native-decode-buffer-raw>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertTrue(result.value > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecodeAsFailsForNonLynonBinaryTypedProjection() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
import lyng.buffer
|
||||
|
||||
class Point(x: Int, y: Int)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table data(payload blob not null)")
|
||||
tx.execute("insert into data(payload) values(?)", "hello".encodeUtf8())
|
||||
tx.select("select payload from data").decodeAs<Point>().first
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
Compiler.compile(Source("<sqlite-native-decode-lynon-binary-guard>", code), scope.importManager).execute(scope)
|
||||
}
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
|
||||
val scope = Script.newScope()
|
||||
|
||||
@ -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<T>()` projection.
|
||||
|
||||
`targetType` is the requested Lyng target type represented as a runtime
|
||||
type object, such as a class or a type expression.
|
||||
|
||||
Default methods throw `NotImplementedException`.
|
||||
*/
|
||||
interface DbFieldAdapter {
|
||||
/*
|
||||
Decode one raw database field value into a Lyng value suitable for the
|
||||
requested target type.
|
||||
*/
|
||||
fun decode(rawValue: Object?, column: SqlColumn, row: SqlRow, targetType: Object): Object? =
|
||||
throw NotImplementedException("DB field adapter decode is not implemented")
|
||||
|
||||
/*
|
||||
Encode one Lyng value into a database field representation.
|
||||
*/
|
||||
fun encode(value: Object?, targetType: Object): Object? =
|
||||
throw NotImplementedException("DB field adapter encode is not implemented")
|
||||
}
|
||||
|
||||
extern class SqlRow {
|
||||
/* 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>(): T
|
||||
}
|
||||
|
||||
/*
|
||||
@ -69,6 +111,16 @@ extern class ResultSet : Iterable<SqlRow> {
|
||||
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<T>()`.
|
||||
|
||||
The returned iterable itself must not be used after the owning
|
||||
transaction ends. Materialized decoded objects may outlive the
|
||||
transaction.
|
||||
*/
|
||||
fun decodeAs<T>(): Iterable<T>
|
||||
}
|
||||
|
||||
extern class ExecutionResult {
|
||||
|
||||
@ -88,7 +88,8 @@ data class ArgsDeclaration(val params: List<Item>, 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<Item>, 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<Item>, val endTokenType: Token.Type)
|
||||
val accessType: AccessType? = null,
|
||||
val visibility: Visibility? = null,
|
||||
val isTransient: Boolean = false,
|
||||
val annotationSpecs: List<ParsedDeclAnnotation> = emptyList(),
|
||||
val annotations: List<DeclAnnotation> = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -41,6 +41,17 @@ data class ClassDeclSpec(
|
||||
val initScope: List<Statement>,
|
||||
)
|
||||
|
||||
private suspend fun evaluateConstructorAnnotations(scope: Scope, args: ArgsDeclaration?): ArgsDeclaration? {
|
||||
if (args == null) return null
|
||||
if (args.params.none { it.annotationSpecs.isNotEmpty() }) return args
|
||||
return args.copy(
|
||||
params = args.params.map { item ->
|
||||
if (item.annotationSpecs.isEmpty()) item
|
||||
else item.copy(annotations = item.annotationSpecs.evaluateDeclAnnotations(scope))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
internal suspend fun executeClassDecl(
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@ class ClassInstanceFieldDeclStatement(
|
||||
val isClosed: Boolean,
|
||||
val isOverride: Boolean,
|
||||
val isTransient: Boolean,
|
||||
val annotationSpecs: List<ParsedDeclAnnotation> = 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<ParsedDeclAnnotation> = 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<ParsedDeclAnnotation> = emptyList(),
|
||||
val methodId: Int?,
|
||||
val initStatement: Statement?,
|
||||
override val pos: Pos,
|
||||
|
||||
@ -32,6 +32,7 @@ class ClassStaticFieldInitStatement(
|
||||
val initializer: Statement?,
|
||||
val isDelegated: Boolean,
|
||||
val isTransient: Boolean,
|
||||
val annotationSpecs: List<ParsedDeclAnnotation> = 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
|
||||
}
|
||||
|
||||
@ -2014,6 +2014,7 @@ class Compiler(
|
||||
|
||||
private var lastAnnotation: (suspend (Scope, ObjString, Statement) -> Statement)? = null
|
||||
private var isTransientFlag: Boolean = false
|
||||
private val pendingDeclAnnotations: MutableList<ParsedDeclAnnotation> = 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<ParsedDeclAnnotation>()
|
||||
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<List<ParsedArgument>, 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
|
||||
|
||||
@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng
|
||||
|
||||
import net.sergeych.lyng.bytecode.BytecodeStatement
|
||||
import net.sergeych.lyng.obj.Obj
|
||||
import net.sergeych.lyng.obj.ObjIterable
|
||||
import net.sergeych.lyng.obj.ObjList
|
||||
import net.sergeych.lyng.obj.ObjMap
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
|
||||
/**
|
||||
* Preserved declaration annotation evaluated at declaration-creation time.
|
||||
*/
|
||||
data class DeclAnnotation(
|
||||
val name: String,
|
||||
val positional: List<Obj> = emptyList(),
|
||||
val named: Map<String, Obj> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Parsed declaration annotation awaiting declaration-time evaluation.
|
||||
*/
|
||||
data class ParsedDeclAnnotation(
|
||||
val name: String,
|
||||
val args: List<ParsedArgument> = emptyList(),
|
||||
val tailBlockMode: Boolean = false,
|
||||
val pos: Pos = Pos.builtIn,
|
||||
) {
|
||||
suspend fun evaluate(scope: Scope): DeclAnnotation {
|
||||
val resolved = evaluateDeclAnnotationArguments(scope, args, tailBlockMode)
|
||||
return DeclAnnotation(name, resolved.list, resolved.named)
|
||||
}
|
||||
|
||||
fun toStatementAnnotation(): suspend (Scope, ObjString, Statement) -> Statement = { scope, declName, body ->
|
||||
val extras = args.toArguments(scope, tailBlockMode).list
|
||||
val required = listOf(declName, body)
|
||||
val callArgs = if (extras.isEmpty()) required else required + extras
|
||||
val fn = scope.get(name)?.value ?: scope.raiseSymbolNotFound("annotation not found: $name")
|
||||
if (fn !is Statement) scope.raiseIllegalArgument("annotation must be callable, got ${fn.objClass}")
|
||||
(fn.execute(scope.createChildScope(Arguments(callArgs))) as? Statement)
|
||||
?: scope.raiseClassCastError("function annotation must return callable")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Iterable<ParsedDeclAnnotation>.evaluateDeclAnnotations(scope: Scope): List<DeclAnnotation> {
|
||||
val result = mutableListOf<DeclAnnotation>()
|
||||
for (spec in this) {
|
||||
result += spec.evaluate(scope)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private suspend fun evaluateDeclAnnotationArguments(
|
||||
scope: Scope,
|
||||
args: List<ParsedArgument>,
|
||||
tailBlockMode: Boolean,
|
||||
): Arguments {
|
||||
suspend fun eval(value: Obj): Obj = when (value) {
|
||||
is BytecodeBodyProvider -> (value.bytecodeBody() ?: scope.raiseIllegalState("annotation argument requires bytecode body")).execute(scope)
|
||||
is Statement -> BytecodeStatement.wrap(value, "@annotation", allowLocalSlots = true).execute(scope)
|
||||
else -> value.callOn(scope)
|
||||
}
|
||||
|
||||
val resolved = ArrayList<ParsedArgument>(args.size)
|
||||
for (arg in args) {
|
||||
resolved += arg.copy(value = eval(arg.value))
|
||||
}
|
||||
|
||||
val positional: MutableList<Obj> = mutableListOf()
|
||||
var named: MutableMap<String, Obj>? = null
|
||||
var namedSeen = false
|
||||
for ((idx, x) in resolved.withIndex()) {
|
||||
if (x.name != null) {
|
||||
if (named == null) named = linkedMapOf()
|
||||
if (named.containsKey(x.name)) scope.raiseIllegalArgument("argument '${x.name}' is already set")
|
||||
named[x.name] = x.value
|
||||
namedSeen = true
|
||||
continue
|
||||
}
|
||||
val value = x.value
|
||||
if (x.isSplat) {
|
||||
when {
|
||||
value is ObjMap -> {
|
||||
if (named == null) named = linkedMapOf()
|
||||
for ((k, v) in value.map) {
|
||||
if (k !is ObjString) scope.raiseIllegalArgument("named splat expects a Map with string keys")
|
||||
val key = k.value
|
||||
if (named.containsKey(key)) scope.raiseIllegalArgument("argument '$key' is already set")
|
||||
named[key] = v
|
||||
}
|
||||
namedSeen = true
|
||||
}
|
||||
value is ObjList -> {
|
||||
if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments")
|
||||
positional.addAll(value.list)
|
||||
}
|
||||
value.isInstanceOf(ObjIterable) -> {
|
||||
if (namedSeen) scope.raiseIllegalArgument("positional splat cannot follow named arguments")
|
||||
val iterable = value.invokeInstanceMethod(scope, "toList") as ObjList
|
||||
positional.addAll(iterable.list)
|
||||
}
|
||||
else -> scope.raiseClassCastError("expected list of objects for splat argument")
|
||||
}
|
||||
} else {
|
||||
val isLast = idx == resolved.size - 1
|
||||
if (namedSeen && !(isLast && tailBlockMode)) {
|
||||
scope.raiseIllegalArgument("positional argument cannot follow named arguments")
|
||||
}
|
||||
positional.add(value)
|
||||
}
|
||||
}
|
||||
return Arguments(positional, tailBlockMode, named ?: emptyMap())
|
||||
}
|
||||
@ -33,6 +33,7 @@ class InstanceFieldInitStatement(
|
||||
val isClosed: Boolean,
|
||||
val isOverride: Boolean,
|
||||
val isTransient: Boolean,
|
||||
val annotations: List<DeclAnnotation> = 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<DeclAnnotation> = 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<DeclAnnotation> = 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
|
||||
}
|
||||
|
||||
@ -718,6 +718,7 @@ open class Scope(
|
||||
isTransient: Boolean = false,
|
||||
callSignature: CallSignature? = null,
|
||||
typeDecl: TypeDecl? = null,
|
||||
annotations: List<DeclAnnotation> = 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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<ParsedDeclAnnotation>,
|
||||
) : 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<ParsedDeclAnnotation>,
|
||||
) : 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<ParsedDeclAnnotation>,
|
||||
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<ParsedDeclAnnotation>,
|
||||
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<ParsedDeclAnnotation>,
|
||||
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<net.sergeych.lyng.DeclAnnotation>,
|
||||
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<net.sergeych.lyng.DeclAnnotation>,
|
||||
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<net.sergeych.lyng.DeclAnnotation>,
|
||||
val isAbstract: Boolean,
|
||||
val isClosed: Boolean,
|
||||
val isOverride: Boolean,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Obj, Obj>()
|
||||
for ((k, v) in named) {
|
||||
namedArgs[ObjString(k)] = v
|
||||
}
|
||||
return ObjMap(
|
||||
linkedMapOf(
|
||||
ObjString("name") to ObjString(name),
|
||||
ObjString("positional") to ObjImmutableList(positional),
|
||||
ObjString("named") to ObjMap(namedArgs)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun annotationListObj(annotations: List<DeclAnnotation>): Obj =
|
||||
ObjImmutableList(annotations.map { it.toObj() })
|
||||
|
||||
val ObjClassType by lazy {
|
||||
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<ObjClass>()
|
||||
val name = requiredArg<ObjString>(0).value
|
||||
val param = cls.constructorMeta?.params?.firstOrNull { it.name == name }
|
||||
annotationListObj(param?.annotations ?: emptyList())
|
||||
}
|
||||
addFnDoc(
|
||||
name = "getMemberAnnotations",
|
||||
doc = "Return preserved annotations for a member by name as descriptor maps with fields `name`, `positional`, and `named`.",
|
||||
params = listOf(ParamDoc("name", type("lyng.String"))),
|
||||
returns = TypeGenericDoc(type("lyng.List"), listOf(type("lyng.Map"))),
|
||||
moduleName = "lyng.stdlib"
|
||||
) {
|
||||
val cls = thisAs<ObjClass>()
|
||||
val name = requiredArg<ObjString>(0).value
|
||||
val rec = cls.getInstanceMemberOrNull(name) ?: cls.classScope?.objects?.get(name)
|
||||
annotationListObj(rec?.annotations ?: emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -851,6 +892,7 @@ open class ObjClass(
|
||||
methodId: Int? = null,
|
||||
typeDecl: net.sergeych.lyng.TypeDecl? = null,
|
||||
callSignature: net.sergeych.lyng.CallSignature? = null,
|
||||
annotations: List<DeclAnnotation> = 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<DeclAnnotation> = 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<DeclAnnotation> = 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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<DeclAnnotation> = emptyList(),
|
||||
val memberName: String? = null,
|
||||
val fieldId: Int? = null,
|
||||
val methodId: Int? = null,
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.sergeych.lyng.obj.ObjInt
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DeclAnnotationIntrospectionTest {
|
||||
|
||||
@Test
|
||||
fun classAnnotationQueriesExposeConstructorAndMemberAnnotations() = runTest {
|
||||
val scope = Scope()
|
||||
val result = scope.eval(
|
||||
"""
|
||||
val suffix = "!"
|
||||
|
||||
object Marker
|
||||
|
||||
class Sample(
|
||||
@Transient @Tag(1, label: "ctor", extra: suffix) val x: Int
|
||||
) {
|
||||
@Transient @DbDecodeWith(Marker)
|
||||
var y: Int = 10
|
||||
}
|
||||
|
||||
val ctorAnnotations: ImmutableList<Map<String, Object>> = Sample.getConstructorAnnotations("x")
|
||||
val ctorTag: Map<String, Object> = ctorAnnotations[1]
|
||||
val ctorPositional: ImmutableList<Object> = ctorTag["positional"] as ImmutableList<Object>
|
||||
val ctorNamed: Map<String, Object> = ctorTag["named"] as Map<String, Object>
|
||||
assertEquals(2, ctorAnnotations.size)
|
||||
assertEquals("Transient", ctorAnnotations[0]["name"])
|
||||
assertEquals("Tag", ctorTag["name"])
|
||||
assertEquals(1, ctorPositional[0])
|
||||
assertEquals("ctor", ctorNamed["label"])
|
||||
assertEquals("!", ctorNamed["extra"])
|
||||
|
||||
val memberAnnotations: ImmutableList<Map<String, Object>> = Sample.getMemberAnnotations("y")
|
||||
val memberDecodeWith: Map<String, Object> = memberAnnotations[1]
|
||||
val memberPositional: ImmutableList<Object> = memberDecodeWith["positional"] as ImmutableList<Object>
|
||||
assertEquals(2, memberAnnotations.size)
|
||||
assertEquals("Transient", memberAnnotations[0]["name"])
|
||||
assertEquals("DbDecodeWith", memberDecodeWith["name"])
|
||||
assertEquals(Marker, memberPositional[0])
|
||||
|
||||
memberAnnotations.size + ctorAnnotations.size
|
||||
""".trimIndent()
|
||||
) as ObjInt
|
||||
|
||||
assertEquals(4L, result.value)
|
||||
}
|
||||
}
|
||||
@ -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<String>
|
||||
/* Declared instance methods of this class and its ancestors (C3 order), without duplicates. */
|
||||
val methods: List<String>
|
||||
/* Lookup a member by name in this class (including ancestors) and return it, or null if absent. */
|
||||
fun get(name: String): Object?
|
||||
/* Preserved annotations for a constructor parameter as descriptor maps with keys `name`, `positional`, and `named`. */
|
||||
fun getConstructorAnnotations(name: String): ImmutableList<Map<String, Object>>
|
||||
/* Preserved annotations for a member as descriptor maps with keys `name`, `positional`, and `named`. */
|
||||
fun getMemberAnnotations(name: String): ImmutableList<Map<String, Object>>
|
||||
}
|
||||
|
||||
/* A handle to a running asynchronous task. */
|
||||
extern class Deferred {
|
||||
/* Cancel the task if it is still active. Safe to call multiple times. */
|
||||
|
||||
280
notes/db/resultset_decode_api.md
Normal file
280
notes/db/resultset_decode_api.md
Normal file
@ -0,0 +1,280 @@
|
||||
# ResultSet typed decode API
|
||||
|
||||
Status: draft design note
|
||||
|
||||
## Goal
|
||||
|
||||
Extend `lyng.io.db` with row deserialization into ordinary Lyng objects using the new typed serialization-style API naming.
|
||||
|
||||
Primary use case:
|
||||
|
||||
```lyng
|
||||
class Point(x: Real, y: Real)
|
||||
|
||||
val point = db.transaction { tx ->
|
||||
tx.select(
|
||||
"select row as x, col as y from data where not is_deleted"
|
||||
).decodeAs<Point>().first
|
||||
}
|
||||
```
|
||||
|
||||
## Agreed API
|
||||
|
||||
Use `decodeAs<T>()` as the only public API form in v1.
|
||||
|
||||
Rationale:
|
||||
|
||||
- matches the new typed serialization naming (`Json.decodeAs(...)`)
|
||||
- communicates decoding/materialization, not casting
|
||||
- keeps the common case strongly typed and chain-friendly
|
||||
- avoids adding a second runtime-type overload before it is needed
|
||||
|
||||
Planned Lyng-facing declarations:
|
||||
|
||||
```lyng
|
||||
extern class SqlRow {
|
||||
fun decodeAs<T>(): T
|
||||
}
|
||||
|
||||
extern class ResultSet : Iterable<SqlRow> {
|
||||
fun decodeAs<T>(): Iterable<T>
|
||||
}
|
||||
```
|
||||
|
||||
## Lifetime semantics
|
||||
|
||||
`ResultSet.decodeAs<T>()` returns a transaction-scoped iterable view over the underlying result set.
|
||||
|
||||
Rules:
|
||||
|
||||
- the returned iterable must not be used after the owning transaction ends
|
||||
- decoded objects created during iteration are detached ordinary Lyng objects
|
||||
- to keep decoded values after the transaction, materialize them inside the transaction
|
||||
- normal materialization forms are `toList()`, `first`, `findFirst`, or manual iteration
|
||||
|
||||
Valid:
|
||||
|
||||
```lyng
|
||||
val points = db.transaction { tx ->
|
||||
tx.select("select x, y from point")
|
||||
.decodeAs<Point>()
|
||||
.toList()
|
||||
}
|
||||
```
|
||||
|
||||
Invalid:
|
||||
|
||||
```lyng
|
||||
val decoded = db.transaction { tx ->
|
||||
tx.select("select x, y from point").decodeAs<Point>()
|
||||
}
|
||||
|
||||
decoded.first
|
||||
```
|
||||
|
||||
## ResultSet shape
|
||||
|
||||
`ResultSet.decodeAs<T>()` should preserve the current `ResultSet` paradigm:
|
||||
|
||||
- `ResultSet` stays the row-producing source
|
||||
- `decodeAs<T>()` is a projection from `Iterable<SqlRow>` to `Iterable<T>`
|
||||
- no new DB-specific collection type is introduced in v1
|
||||
|
||||
Implementation-wise, `ResultSet.decodeAs<T>()` can be defined as a lazy iterable that decodes each row via `SqlRow.decodeAs<T>()`.
|
||||
|
||||
## Mapping discussion to finalize
|
||||
|
||||
The following mapping behavior still needs explicit design decisions:
|
||||
|
||||
- how constructor parameters are matched from columns
|
||||
- whether matching is case-insensitive
|
||||
- whether mutable serializable fields are populated after constructor call
|
||||
- treatment of default constructor values
|
||||
- treatment of nullable vs non-nullable targets
|
||||
- behavior for missing columns
|
||||
- behavior for extra columns
|
||||
- behavior for duplicate/ambiguous column labels
|
||||
- whether `onDeserialized()` is called after row decode
|
||||
- whether v1 supports only flat object decode or also nested shapes
|
||||
|
||||
## Current direction for mapping
|
||||
|
||||
Current likely direction, not finalized yet:
|
||||
|
||||
- constructor parameters map by column label
|
||||
- matching is case-insensitive, consistent with `SqlRow["name"]`
|
||||
- after constructor call, remaining matching serializable mutable fields may be assigned
|
||||
- missing required non-null constructor values fail
|
||||
- missing nullable constructor parameters become `null`
|
||||
- defaulted constructor parameters use their defaults when the column is absent
|
||||
- ambiguous duplicate column labels fail
|
||||
- extra columns likely fail in strict mode for v1
|
||||
- `onDeserialized()` likely should run after the object is fully populated
|
||||
- v1 should likely stay flat and avoid nested/prefix-based mapping
|
||||
|
||||
## Projection/conversion rules
|
||||
|
||||
### General principle
|
||||
|
||||
Row decoding should be strict and predictable.
|
||||
|
||||
It should not globally treat every SQL string column as serialized JSON or every binary column as Lynon.
|
||||
|
||||
That would be too implicit:
|
||||
|
||||
- ordinary text columns are common and must stay ordinary text by default
|
||||
- ordinary binary/blob columns are common and must stay raw binary by default
|
||||
- automatic format decoding should happen only when there is a clear signal
|
||||
|
||||
### Proposed conversion precedence
|
||||
|
||||
For each constructor parameter or serializable mutable field:
|
||||
|
||||
1. resolve the source column by name
|
||||
2. if the source value already matches the target type, use it directly
|
||||
3. if an explicit DB decoding attribute is present on the target member, apply that decoding rule
|
||||
4. otherwise, if the column metadata clearly indicates a special encoded DB type and the target is not the raw DB carrier type, apply the built-in format rule
|
||||
5. otherwise fail with a decode/type mismatch error
|
||||
|
||||
### Direct match
|
||||
|
||||
Direct match means the row value is already assignable to the target type after the normal SQL backend conversion.
|
||||
|
||||
Examples:
|
||||
|
||||
- SQL numeric column already surfaced as `Int`/`Real`/`Decimal`
|
||||
- SQL bool column surfaced as `Bool`
|
||||
- SQL date/time column surfaced as `Date`, `DateTime`, `Instant`
|
||||
- SQL text column surfaced as `String`
|
||||
- SQL binary column surfaced as `Buffer`
|
||||
|
||||
These should not trigger any extra JSON/Lynon decoding.
|
||||
|
||||
### Built-in encoded-column rules
|
||||
|
||||
Current likely direction:
|
||||
|
||||
- JSON/JSONB-like columns should decode through typed canonical `Json` when the target is not `String`
|
||||
- binary columns should decode through `Lynon` when the target is not `Buffer`
|
||||
|
||||
This implies the current default:
|
||||
|
||||
- string -> non-string is eligible for automatic typed `Json` decode only when the column metadata says the DB column is JSON-like
|
||||
- binary -> non-binary is decoded through `Lynon`
|
||||
- binary -> `Buffer` stays raw `Buffer`
|
||||
|
||||
Examples:
|
||||
|
||||
- PostgreSQL `json` / `jsonb` column into `Point` -> use typed `Json` decode
|
||||
- PostgreSQL `jsonb` column into `Map<String, Object?>` -> use typed `Json` decode
|
||||
- plain `text` / `varchar` column into `Point` -> fail unless explicitly annotated
|
||||
- `bytea` / `blob` column into `Buffer` -> direct match, no Lynon decode
|
||||
- `bytea` / `blob` column into `Point` -> decode with `Lynon`
|
||||
|
||||
### Attribute-based explicit decoding
|
||||
|
||||
Common explicit attributes look useful:
|
||||
|
||||
- `@DbJson`
|
||||
- `@DbLynon`
|
||||
|
||||
Applied to constructor parameters and serializable mutable fields.
|
||||
|
||||
Meaning:
|
||||
|
||||
- `@DbJson` means decode the column value as typed canonical JSON into the target member type
|
||||
- `@DbLynon` means decode the column value as Lynon into the target member type
|
||||
|
||||
Example:
|
||||
|
||||
```lyng
|
||||
class Record(
|
||||
id: Int,
|
||||
@DbJson payload: Payload,
|
||||
@DbLynon cachedState: CacheEntry
|
||||
)
|
||||
```
|
||||
|
||||
This keeps the common DB formats easy to use without making plain `String` or `Buffer` columns magical.
|
||||
|
||||
Implementation note:
|
||||
|
||||
- declaration metadata now preserves evaluated constructor-parameter and class-member annotation arguments
|
||||
- annotation arguments are evaluated once at declaration creation time and retained for the lifetime of the declaration
|
||||
- `@DbDecodeWith(...)` now uses that preserved metadata path
|
||||
|
||||
### Generic custom decoder hook
|
||||
|
||||
A generic hook is useful too, but it should be adapter-based, not lambda-based.
|
||||
|
||||
Planned shape:
|
||||
|
||||
- `@DbDecodeWith(adapter)`
|
||||
- `adapter` should be an instance of a dedicated interface such as `DbFieldAdapter`
|
||||
|
||||
Reason:
|
||||
|
||||
- a named adapter interface is easier to document and evolve than arbitrary callables
|
||||
- it gives us room for richer decoding context without baking ad-hoc callable signatures into annotations
|
||||
- it keeps the DB mapping API explicit and self-describing
|
||||
|
||||
Current design direction:
|
||||
|
||||
```lyng
|
||||
interface DbFieldAdapter {
|
||||
fun decode(rawValue: Object?, column: SqlColumn, row: SqlRow, targetType: Object): Object? =
|
||||
throw NotImplementedException("DB field adapter decode is not implemented")
|
||||
|
||||
fun encode(value: Object?, targetType: Object): Object? =
|
||||
throw NotImplementedException("DB field adapter encode is not implemented")
|
||||
}
|
||||
```
|
||||
|
||||
Decided:
|
||||
|
||||
- `decode(...)` should receive the target type
|
||||
- adapters may be any ordinary instance, not only singleton objects
|
||||
- the same abstraction should later support symmetric `encode(...)`
|
||||
- adapter result must be checked against the target member type after decoding
|
||||
|
||||
Still open before full implementation:
|
||||
|
||||
- exact annotation shape for `@DbDecodeWith(...)`
|
||||
- whether target member name should also be passed
|
||||
- whether `targetType` should later get a more specific declaration type than plain `Object`
|
||||
|
||||
Implemented in the current design:
|
||||
|
||||
- `@DbDecodeWith(adapter)` on constructor parameters
|
||||
- `@DbDecodeWith(adapter)` on class-body fields/properties participating in `decodeAs<T>()`
|
||||
|
||||
Future improvement:
|
||||
|
||||
- compiler warning when preserved annotation metadata captures runtime state/closures
|
||||
- extend preserved annotation metadata beyond constructor parameters and class members to functions and top-level declarations
|
||||
|
||||
### Arrays and maps
|
||||
|
||||
Arrays and maps should not get DB-specific bespoke mapping in v1 unless they are coming through a recognized encoded format.
|
||||
|
||||
Reason:
|
||||
|
||||
- portable SQL array/map support is backend-specific and inconsistent
|
||||
- JSON columns already give us a portable representation for `List` and `Map`
|
||||
- adding DB-native array semantics now would complicate the contract too early
|
||||
|
||||
So in v1:
|
||||
|
||||
- if the backend already surfaces a value that directly matches the target type, use it
|
||||
- otherwise `List` / `Map` reconstruction should happen via `@DbJson` or recognized JSON-like column metadata
|
||||
|
||||
### Recommended v1 policy
|
||||
|
||||
Current recommended projection policy:
|
||||
|
||||
- direct type match first
|
||||
- then explicit member attribute (`@DbJson`, `@DbLynon`)
|
||||
- then metadata-driven JSON decode for recognized JSON-like DB columns
|
||||
- then Lynon decode for binary columns when the target is not `Buffer`
|
||||
- no implicit JSON decode for arbitrary text columns
|
||||
- fail on anything else
|
||||
Loading…
x
Reference in New Issue
Block a user