Add DB decode annotations and preserved declaration metadata

This commit is contained in:
sergeych committed 2026-04-25 13:36:33 +03:00
1 parent 2abe7e2f96
commit 50e34e520e
23 files changed
+1690 -55

No files matched your search

@@ -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()