Add SQL object expansion serialization support
This commit is contained in:
parent
50e34e520e
commit
92e9325f40
@ -200,6 +200,27 @@ assertThrows(RollbackException) {
|
||||
- `execute(clause, params...)` — execute a side-effect statement and return `ExecutionResult`.
|
||||
- `transaction(block)` — nested transaction with real savepoint semantics.
|
||||
|
||||
`select(...)` and `execute(...)` also support SQL object-expansion macros for declaration-driven writes:
|
||||
|
||||
- `@cols(?1)` — expand object argument `?1` to a comma-separated column list
|
||||
- `@vals(?1)` — expand object argument `?1` to matching placeholders and bind values
|
||||
- `@set(?1)` — expand object argument `?1` to `column = ?` pairs and bind values
|
||||
|
||||
Each macro also supports an optional clause-local exclusion list:
|
||||
|
||||
```lyng
|
||||
tx.execute("update item set @set(?1 except: \"id\", \"createdAt\") where id = ?2", item, item.id)
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```lyng
|
||||
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
|
||||
tx.execute("update item set @set(?1) where id = ?2", item, item.id)
|
||||
```
|
||||
|
||||
When a clause uses any of these macros, non-expanded scalar parameters in the same SQL string must use explicit indexed placeholders such as `?2`, `?3`, and so on.
|
||||
|
||||
##### `ResultSet`
|
||||
|
||||
- `columns` — positional `SqlColumn` metadata, available before iteration.
|
||||
@ -219,13 +240,15 @@ Name-based access fails with `SqlUsageException` if the name is missing or ambig
|
||||
|
||||
##### `DbFieldAdapter`
|
||||
|
||||
Custom DB field projection hook used by `@DbDecodeWith(...)`.
|
||||
Custom DB field projection hook used by `@DbDecodeWith(...)` and `@DbSerializeWith(...)`.
|
||||
|
||||
- `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.
|
||||
- `encode(value, targetType)` — adapt one Lyng value to a direct DB-bindable value for SQL object expansion.
|
||||
|
||||
Use `@DbDecodeWith(adapter)` on class constructor parameters and class-body fields/properties that participate in `decodeAs<T>()`.
|
||||
|
||||
Use `@DbSerializeWith(adapter)` on constructor parameters and class-body fields/properties that participate in `@cols(...)`, `@vals(...)`, and `@set(...)` object expansion.
|
||||
|
||||
Annotation arguments are evaluated once when the declaration is created, and the resulting adapter instance is retained in declaration metadata.
|
||||
|
||||
##### `ExecutionResult`
|
||||
@ -250,6 +273,88 @@ Portable bind values:
|
||||
|
||||
Unsupported parameter values fail with `SqlUsageException`.
|
||||
|
||||
SQL object-expansion write rules:
|
||||
|
||||
- constructor parameters participate in projection by declaration order
|
||||
- matching serializable class-body fields/properties also participate
|
||||
- `@Transient` fields are excluded automatically
|
||||
- `@DbExcept` fields are excluded automatically
|
||||
- `except:` excludes additional fields for one specific macro use
|
||||
- direct DB-bindable values are written as-is
|
||||
- `@DbJson` fields are encoded as canonical JSON text
|
||||
- `@DbLynon` fields are encoded as Lynon binary
|
||||
- `@DbSerializeWith(adapter)` fields are encoded through the adapter
|
||||
- unannotated non-bindable object fields fail with `SqlUsageException`
|
||||
|
||||
Write-side encoding is intentionally explicit. The runtime does not try to infer target DB column types from SQL text or backend metadata during statement preparation.
|
||||
|
||||
Example:
|
||||
|
||||
```lyng
|
||||
import lyng.io.db
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Payload(name: String, count: Int)
|
||||
|
||||
object TrimAdapter: DbFieldAdapter {
|
||||
override fun encode(value, targetType) =
|
||||
when(value) {
|
||||
null -> null
|
||||
else -> value.toString().trim()
|
||||
}
|
||||
}
|
||||
|
||||
class Item(
|
||||
id: Int,
|
||||
@DbSerializeWith(TrimAdapter) title: String,
|
||||
@DbJson meta: Payload,
|
||||
@DbLynon state: Payload
|
||||
) {
|
||||
var note: String = ""
|
||||
@DbExcept var cache: String = ""
|
||||
}
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
val restored = db.transaction { tx ->
|
||||
tx.execute(
|
||||
"create table item(id integer not null, title text not null, meta text not null, state blob not null, note text not null)"
|
||||
)
|
||||
|
||||
val item = Item(1, " first ", Payload("json", 10), Payload("bin", 20))
|
||||
item.note = "created"
|
||||
item.cache = "not stored"
|
||||
|
||||
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
|
||||
|
||||
item.title = " second "
|
||||
item.meta = Payload("json2", 11)
|
||||
item.state = Payload("bin2", 21)
|
||||
item.note = "updated"
|
||||
|
||||
tx.execute(
|
||||
"update item set @set(?1 except: \"id\") where id = ?2",
|
||||
item,
|
||||
item.id
|
||||
)
|
||||
|
||||
tx.select("select id, title, meta, state, note from item").decodeAs<Item>().first
|
||||
}
|
||||
|
||||
assertEquals("second", restored.title)
|
||||
assertEquals("json2", restored.meta.name)
|
||||
assertEquals(21, restored.state.count)
|
||||
assertEquals("updated", restored.note)
|
||||
```
|
||||
|
||||
This example shows:
|
||||
|
||||
- `@DbSerializeWith(...)` trimming a string before write
|
||||
- `@DbJson` storing structured data in a text column
|
||||
- `@DbLynon` storing structured data in a binary column
|
||||
- `@DbExcept` excluding a field from automatic projection
|
||||
- `@set(... except: "id")` skipping one field for an update clause
|
||||
- `decodeAs<Item>()` reconstructing the object on read
|
||||
|
||||
Portable result metadata categories:
|
||||
|
||||
- `Binary`
|
||||
|
||||
@ -36,6 +36,9 @@ 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.ObjDateTime
|
||||
import net.sergeych.lyng.obj.ObjInstant
|
||||
import net.sergeych.lyng.obj.ObjReal
|
||||
import net.sergeych.lyng.obj.ObjString
|
||||
import net.sergeych.lyng.obj.ObjTypeExpr
|
||||
import net.sergeych.lyng.obj.ObjVoid
|
||||
@ -227,16 +230,16 @@ internal class SqlRuntimeTypes private constructor(
|
||||
self.lifetime.ensureActive(this)
|
||||
val clause = (args.list.getOrNull(0) as? ObjString)?.value
|
||||
?: raiseClassCastError("query must be String")
|
||||
val params = args.list.drop(1)
|
||||
SqlResultSetObj(self.types, self.lifetime, self.backend.select(this, clause, params))
|
||||
val prepared = prepareSqlClause(requireScope(), self.types, clause, args.list.drop(1))
|
||||
SqlResultSetObj(self.types, self.lifetime, self.backend.select(this, prepared.clause, prepared.params))
|
||||
}
|
||||
transactionClass.addFn("execute") {
|
||||
val self = thisAs<SqlTransactionObj>()
|
||||
self.lifetime.ensureActive(this)
|
||||
val clause = (args.list.getOrNull(0) as? ObjString)?.value
|
||||
?: raiseClassCastError("query must be String")
|
||||
val params = args.list.drop(1)
|
||||
SqlExecutionResultObj(self.types, self.lifetime, self.backend.execute(this, clause, params))
|
||||
val prepared = prepareSqlClause(requireScope(), self.types, clause, args.list.drop(1))
|
||||
SqlExecutionResultObj(self.types, self.lifetime, self.backend.execute(this, prepared.clause, prepared.params))
|
||||
}
|
||||
transactionClass.addFn("transaction") {
|
||||
val self = thisAs<SqlTransactionObj>()
|
||||
@ -659,6 +662,10 @@ private suspend fun decodeSqlValue(
|
||||
}
|
||||
return adapted
|
||||
}
|
||||
val explicitEncoding = findExplicitDbEncodingAnnotation(scope, types, annotations)
|
||||
if (explicitEncoding != null) {
|
||||
return decodeExplicitDbEncoding(scope, types, row, column, value, targetType, explicitEncoding)
|
||||
}
|
||||
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")
|
||||
@ -715,6 +722,18 @@ private fun findDbDecodeWithAnnotation(
|
||||
return matches.singleOrNull()
|
||||
}
|
||||
|
||||
private fun findExplicitDbEncodingAnnotation(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
annotations: List<DeclAnnotation>,
|
||||
): DeclAnnotation? {
|
||||
val matches = annotations.filter { it.name == "DbJson" || it.name == "DbLynon" || it.name == "DbSerializeWith" }
|
||||
if (matches.size > 1) {
|
||||
raiseSqlUsage(scope, types, "Only one of @DbJson, @DbLynon, or @DbSerializeWith(...) is allowed per declaration")
|
||||
}
|
||||
return matches.singleOrNull()
|
||||
}
|
||||
|
||||
private suspend fun applyDbFieldAdapter(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
@ -723,13 +742,14 @@ private suspend fun applyDbFieldAdapter(
|
||||
value: Obj,
|
||||
targetType: TypeDecl,
|
||||
annotation: DeclAnnotation,
|
||||
annotationName: String = annotation.name,
|
||||
): Obj {
|
||||
if (annotation.named.isNotEmpty() || annotation.positional.size != 1) {
|
||||
raiseSqlUsage(scope, types, "@DbDecodeWith(...) expects exactly one adapter instance argument")
|
||||
raiseSqlUsage(scope, types, "@$annotationName(...) 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")
|
||||
raiseSqlUsage(scope, types, "@$annotationName(...) argument must implement DbFieldAdapter")
|
||||
}
|
||||
return try {
|
||||
adapter.invokeInstanceMethod(
|
||||
@ -746,11 +766,83 @@ private suspend fun applyDbFieldAdapter(
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"Failed to decode SQL column '${column.name}' with @DbDecodeWith(...): ${e.message ?: e::class.simpleName}"
|
||||
"Failed to decode SQL column '${column.name}' with @$annotationName(...): ${e.message ?: e::class.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decodeExplicitDbEncoding(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
row: SqlRowObj,
|
||||
column: SqlColumnMeta,
|
||||
value: Obj,
|
||||
targetType: TypeDecl,
|
||||
annotation: DeclAnnotation,
|
||||
): Obj {
|
||||
when (annotation.name) {
|
||||
"DbJson", "DbLynon" -> if (annotation.positional.isNotEmpty() || annotation.named.isNotEmpty()) {
|
||||
raiseSqlUsage(scope, types, "@${annotation.name} does not take arguments")
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
return when (annotation.name) {
|
||||
"DbJson" -> {
|
||||
val text = value as? ObjString
|
||||
?: raiseSqlUsage(scope, types, "@DbJson expects SQL column '${column.name}' to be String, got ${value.objClass.className}")
|
||||
try {
|
||||
ObjJsonClass.decodeFromJsonElement(scope, Json.parseToJsonElement(text.value), targetType)
|
||||
} catch (e: Throwable) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"Failed to decode JSON column '${column.name}' as ${renderTypeName(targetType)}: ${e.message ?: e::class.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
"DbLynon" -> {
|
||||
val payload = value as? ObjBuffer
|
||||
?: raiseSqlUsage(scope, types, "@DbLynon expects SQL column '${column.name}' to be Binary, got ${value.objClass.className}")
|
||||
try {
|
||||
val decoded = ObjLynonClass.decodeAny(scope, ObjBitBuffer(BitArray(payload.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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
"DbSerializeWith" -> {
|
||||
val adapted = applyDbFieldAdapter(scope, types, row, column, value, targetType, annotation, "DbSerializeWith")
|
||||
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)}"
|
||||
)
|
||||
}
|
||||
adapted
|
||||
}
|
||||
else -> raiseSqlUsage(scope, types, "Unsupported DB field decoding annotation: @${annotation.name}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun runtimeTargetTypeObject(scope: Scope, targetType: TypeDecl): Obj {
|
||||
return resolveTypeDeclClass(scope, targetType) ?: ObjTypeExpr(targetType)
|
||||
}
|
||||
@ -824,3 +916,500 @@ private fun raiseSqlUsage(scope: Scope, types: SqlRuntimeTypes?, message: String
|
||||
}
|
||||
scope.raiseIllegalArgument(message)
|
||||
}
|
||||
|
||||
private data class PreparedSqlClause(
|
||||
val clause: String,
|
||||
val params: List<Obj>,
|
||||
)
|
||||
|
||||
private data class SqlProjectionField(
|
||||
val name: String,
|
||||
val value: Obj,
|
||||
val targetType: TypeDecl,
|
||||
val annotations: List<DeclAnnotation>,
|
||||
)
|
||||
|
||||
private data class SqlProjectionFilter(
|
||||
val excludedFields: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
private enum class SqlMacroKind {
|
||||
Cols,
|
||||
Vals,
|
||||
Set
|
||||
}
|
||||
|
||||
private suspend fun prepareSqlClause(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
clause: String,
|
||||
params: List<Obj>,
|
||||
): PreparedSqlClause {
|
||||
if (!clause.contains("@")) return PreparedSqlClause(clause, params)
|
||||
|
||||
val explicitRefs = findExplicitSqlArgumentRefs(scope, types, clause)
|
||||
val output = StringBuilder(clause.length + 32)
|
||||
val boundParams = mutableListOf<Obj>()
|
||||
var cursor = 0
|
||||
var legacySequentialIndex = 0
|
||||
var sawMacro = false
|
||||
|
||||
while (cursor < clause.length) {
|
||||
val macro = parseSqlMacroAt(scope, types, clause, cursor)
|
||||
if (macro != null) {
|
||||
sawMacro = true
|
||||
val projection = buildSqlProjection(scope, types, params, macro.paramIndex, macro.filter)
|
||||
output.append(
|
||||
when (macro.kind) {
|
||||
SqlMacroKind.Cols -> projection.joinToString(", ") { it.name }
|
||||
SqlMacroKind.Vals -> projection.joinToString(", ") { "?" }
|
||||
SqlMacroKind.Set -> projection.joinToString(", ") { "${it.name} = ?" }
|
||||
}
|
||||
)
|
||||
if (macro.kind != SqlMacroKind.Cols) {
|
||||
for (field in projection) {
|
||||
boundParams += encodeProjectedDbField(scope, types, field)
|
||||
}
|
||||
}
|
||||
cursor = macro.endExclusive
|
||||
continue
|
||||
}
|
||||
|
||||
val indexed = parseIndexedPlaceholderAt(clause, cursor)
|
||||
if (indexed != null) {
|
||||
output.append('?')
|
||||
boundParams += resolveSqlArgument(scope, types, params, indexed.paramIndex)
|
||||
cursor = indexed.endExclusive
|
||||
continue
|
||||
}
|
||||
|
||||
val ch = clause[cursor]
|
||||
if (ch == '\'') {
|
||||
val end = skipSqlSingleQuotedString(clause, cursor)
|
||||
output.append(clause, cursor, end)
|
||||
cursor = end
|
||||
continue
|
||||
}
|
||||
if (ch == '"') {
|
||||
val end = skipSqlDoubleQuotedIdentifier(clause, cursor)
|
||||
output.append(clause, cursor, end)
|
||||
cursor = end
|
||||
continue
|
||||
}
|
||||
if (ch == '-' && cursor + 1 < clause.length && clause[cursor + 1] == '-') {
|
||||
val end = skipSqlLineComment(clause, cursor)
|
||||
output.append(clause, cursor, end)
|
||||
cursor = end
|
||||
continue
|
||||
}
|
||||
if (ch == '/' && cursor + 1 < clause.length && clause[cursor + 1] == '*') {
|
||||
val end = skipSqlBlockComment(clause, cursor)
|
||||
output.append(clause, cursor, end)
|
||||
cursor = end
|
||||
continue
|
||||
}
|
||||
if (ch == '?') {
|
||||
if (sawMacro) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"SQL clauses using @cols/@vals/@set must use explicit indexed placeholders like ?1, ?2 for non-expanded parameters"
|
||||
)
|
||||
}
|
||||
output.append('?')
|
||||
if (legacySequentialIndex >= params.size) {
|
||||
raiseSqlUsage(scope, types, "SQL parameter count mismatch: statement expects more values than provided")
|
||||
}
|
||||
boundParams += params[legacySequentialIndex++]
|
||||
cursor++
|
||||
continue
|
||||
}
|
||||
output.append(ch)
|
||||
cursor++
|
||||
}
|
||||
|
||||
if (!sawMacro) return PreparedSqlClause(clause, params)
|
||||
|
||||
val unreferencedIndexed = (1..params.size).filter { it !in explicitRefs }
|
||||
if (unreferencedIndexed.isNotEmpty()) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"Unused SQL argument(s) in macro clause: ${unreferencedIndexed.joinToString(", ") { "?$it" }}"
|
||||
)
|
||||
}
|
||||
return PreparedSqlClause(output.toString(), boundParams)
|
||||
}
|
||||
|
||||
private fun findExplicitSqlArgumentRefs(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
clause: String,
|
||||
): Set<Int> {
|
||||
val refs = linkedSetOf<Int>()
|
||||
var cursor = 0
|
||||
while (cursor < clause.length) {
|
||||
val macro = parseSqlMacroAt(scope, types, clause, cursor)
|
||||
if (macro != null) {
|
||||
refs += macro.paramIndex
|
||||
cursor = macro.endExclusive
|
||||
continue
|
||||
}
|
||||
val indexed = parseIndexedPlaceholderAt(clause, cursor)
|
||||
if (indexed != null) {
|
||||
refs += indexed.paramIndex
|
||||
cursor = indexed.endExclusive
|
||||
continue
|
||||
}
|
||||
val ch = clause[cursor]
|
||||
cursor = when {
|
||||
ch == '\'' -> skipSqlSingleQuotedString(clause, cursor)
|
||||
ch == '"' -> skipSqlDoubleQuotedIdentifier(clause, cursor)
|
||||
ch == '-' && cursor + 1 < clause.length && clause[cursor + 1] == '-' -> skipSqlLineComment(clause, cursor)
|
||||
ch == '/' && cursor + 1 < clause.length && clause[cursor + 1] == '*' -> skipSqlBlockComment(clause, cursor)
|
||||
else -> cursor + 1
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
private data class ParsedSqlMacro(
|
||||
val kind: SqlMacroKind,
|
||||
val paramIndex: Int,
|
||||
val filter: SqlProjectionFilter,
|
||||
val endExclusive: Int,
|
||||
)
|
||||
|
||||
private data class ParsedIndexedPlaceholder(
|
||||
val paramIndex: Int,
|
||||
val endExclusive: Int,
|
||||
)
|
||||
|
||||
private fun parseSqlMacroAt(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
clause: String,
|
||||
start: Int,
|
||||
): ParsedSqlMacro? {
|
||||
if (clause[start] != '@') return null
|
||||
val kinds = listOf(
|
||||
"cols" to SqlMacroKind.Cols,
|
||||
"vals" to SqlMacroKind.Vals,
|
||||
"set" to SqlMacroKind.Set,
|
||||
)
|
||||
for ((name, kind) in kinds) {
|
||||
if (!clause.startsWith("@$name", start)) continue
|
||||
var cursor = start + name.length + 1
|
||||
while (cursor < clause.length && clause[cursor].isWhitespace()) cursor++
|
||||
if (cursor >= clause.length || clause[cursor] != '(') {
|
||||
raiseSqlUsage(scope, types, "Malformed SQL macro @$name: expected '('")
|
||||
}
|
||||
cursor++
|
||||
while (cursor < clause.length && clause[cursor].isWhitespace()) cursor++
|
||||
if (cursor >= clause.length || clause[cursor] != '?') {
|
||||
raiseSqlUsage(scope, types, "Malformed SQL macro @$name(...): expected indexed argument like ?1")
|
||||
}
|
||||
cursor++
|
||||
val numberStart = cursor
|
||||
while (cursor < clause.length && clause[cursor].isDigit()) cursor++
|
||||
if (numberStart == cursor) {
|
||||
raiseSqlUsage(scope, types, "Malformed SQL macro @$name(...): expected indexed argument like ?1")
|
||||
}
|
||||
val paramIndex = clause.substring(numberStart, cursor).toInt()
|
||||
val excludedFields = linkedSetOf<String>()
|
||||
while (cursor < clause.length && clause[cursor].isWhitespace()) cursor++
|
||||
if (cursor < clause.length && clause.startsWith("except", cursor)) {
|
||||
val afterKeyword = cursor + "except".length
|
||||
if (afterKeyword < clause.length && isSqlMacroFilterIdentifierPart(clause[afterKeyword])) {
|
||||
raiseSqlUsage(scope, types, "Malformed SQL macro @$name(...): expected ')' or 'except:'")
|
||||
}
|
||||
cursor = afterKeyword
|
||||
while (cursor < clause.length && clause[cursor].isWhitespace()) cursor++
|
||||
if (cursor >= clause.length || clause[cursor] != ':') {
|
||||
raiseSqlUsage(scope, types, "Malformed SQL macro @$name(...): expected 'except:'")
|
||||
}
|
||||
cursor++
|
||||
while (cursor < clause.length && clause[cursor].isWhitespace()) cursor++
|
||||
var parsedAny = false
|
||||
while (cursor < clause.length) {
|
||||
val parsedField = parseSqlMacroFilterFieldName(clause, cursor) ?: break
|
||||
excludedFields += parsedField.name.lowercase()
|
||||
cursor = parsedField.endExclusive
|
||||
parsedAny = true
|
||||
while (cursor < clause.length && clause[cursor].isWhitespace()) cursor++
|
||||
if (cursor < clause.length && clause[cursor] == ',') {
|
||||
cursor++
|
||||
while (cursor < clause.length && clause[cursor].isWhitespace()) cursor++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if (!parsedAny) {
|
||||
raiseSqlUsage(scope, types, "Malformed SQL macro @$name(...): 'except:' must list at least one field name")
|
||||
}
|
||||
}
|
||||
while (cursor < clause.length && clause[cursor].isWhitespace()) cursor++
|
||||
if (cursor >= clause.length || clause[cursor] != ')') {
|
||||
raiseSqlUsage(scope, types, "Malformed SQL macro @$name(...): expected ')'")
|
||||
}
|
||||
return ParsedSqlMacro(kind, paramIndex, SqlProjectionFilter(excludedFields), cursor + 1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseIndexedPlaceholderAt(clause: String, start: Int): ParsedIndexedPlaceholder? {
|
||||
if (clause[start] != '?') return null
|
||||
if (start + 1 >= clause.length || !clause[start + 1].isDigit()) return null
|
||||
var cursor = start + 1
|
||||
while (cursor < clause.length && clause[cursor].isDigit()) cursor++
|
||||
return ParsedIndexedPlaceholder(clause.substring(start + 1, cursor).toInt(), cursor)
|
||||
}
|
||||
|
||||
private fun isSqlMacroFilterIdentifierStart(ch: Char): Boolean = ch == '_' || ch.isLetter()
|
||||
|
||||
private fun isSqlMacroFilterIdentifierPart(ch: Char): Boolean =
|
||||
ch == '_' || ch.isLetterOrDigit()
|
||||
|
||||
private data class ParsedSqlMacroFilterField(
|
||||
val name: String,
|
||||
val endExclusive: Int,
|
||||
)
|
||||
|
||||
private fun parseSqlMacroFilterFieldName(clause: String, start: Int): ParsedSqlMacroFilterField? {
|
||||
if (start >= clause.length) return null
|
||||
val quote = clause[start]
|
||||
if (quote == '"' || quote == '\'') {
|
||||
var cursor = start + 1
|
||||
val out = StringBuilder()
|
||||
while (cursor < clause.length) {
|
||||
val ch = clause[cursor]
|
||||
if (ch == quote) {
|
||||
if (cursor + 1 < clause.length && clause[cursor + 1] == quote) {
|
||||
out.append(quote)
|
||||
cursor += 2
|
||||
continue
|
||||
}
|
||||
return ParsedSqlMacroFilterField(out.toString(), cursor + 1)
|
||||
}
|
||||
out.append(ch)
|
||||
cursor++
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!isSqlMacroFilterIdentifierStart(clause[start])) return null
|
||||
var cursor = start + 1
|
||||
while (cursor < clause.length && isSqlMacroFilterIdentifierPart(clause[cursor])) cursor++
|
||||
return ParsedSqlMacroFilterField(clause.substring(start until cursor), cursor)
|
||||
}
|
||||
|
||||
private fun skipSqlSingleQuotedString(clause: String, start: Int): Int {
|
||||
var cursor = start + 1
|
||||
while (cursor < clause.length) {
|
||||
if (clause[cursor] == '\'') {
|
||||
if (cursor + 1 < clause.length && clause[cursor + 1] == '\'') {
|
||||
cursor += 2
|
||||
continue
|
||||
}
|
||||
return cursor + 1
|
||||
}
|
||||
cursor++
|
||||
}
|
||||
return clause.length
|
||||
}
|
||||
|
||||
private fun skipSqlDoubleQuotedIdentifier(clause: String, start: Int): Int {
|
||||
var cursor = start + 1
|
||||
while (cursor < clause.length) {
|
||||
if (clause[cursor] == '"') {
|
||||
if (cursor + 1 < clause.length && clause[cursor + 1] == '"') {
|
||||
cursor += 2
|
||||
continue
|
||||
}
|
||||
return cursor + 1
|
||||
}
|
||||
cursor++
|
||||
}
|
||||
return clause.length
|
||||
}
|
||||
|
||||
private fun skipSqlLineComment(clause: String, start: Int): Int {
|
||||
var cursor = start + 2
|
||||
while (cursor < clause.length && clause[cursor] != '\n') cursor++
|
||||
return cursor
|
||||
}
|
||||
|
||||
private fun skipSqlBlockComment(clause: String, start: Int): Int {
|
||||
var cursor = start + 2
|
||||
while (cursor + 1 < clause.length) {
|
||||
if (clause[cursor] == '*' && clause[cursor + 1] == '/') return cursor + 2
|
||||
cursor++
|
||||
}
|
||||
return clause.length
|
||||
}
|
||||
|
||||
private fun resolveSqlArgument(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
params: List<Obj>,
|
||||
oneBasedIndex: Int,
|
||||
): Obj {
|
||||
if (oneBasedIndex <= 0 || oneBasedIndex > params.size) {
|
||||
raiseSqlUsage(scope, types, "SQL parameter reference ?$oneBasedIndex is out of range for ${params.size} argument(s)")
|
||||
}
|
||||
return params[oneBasedIndex - 1]
|
||||
}
|
||||
|
||||
private suspend fun buildSqlProjection(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
params: List<Obj>,
|
||||
oneBasedIndex: Int,
|
||||
filter: SqlProjectionFilter = SqlProjectionFilter(),
|
||||
): List<SqlProjectionField> {
|
||||
val source = resolveSqlArgument(scope, types, params, oneBasedIndex)
|
||||
val instance = source as? ObjInstance
|
||||
?: raiseSqlUsage(scope, types, "SQL object expansion expects argument ?$oneBasedIndex to be an object instance, got ${source.objClass.className}")
|
||||
|
||||
val projected = mutableListOf<SqlProjectionField>()
|
||||
val seen = linkedSetOf<String>()
|
||||
val meta = instance.objClass.constructorMeta
|
||||
if (meta != null) {
|
||||
for (param in meta.params) {
|
||||
if (param.isTransient || hasDbExceptAnnotation(param.annotations)) continue
|
||||
if (!seen.add(param.name.lowercase())) {
|
||||
raiseSqlUsage(scope, types, "Ambiguous SQL projection field '${param.name}' in ${instance.objClass.className}")
|
||||
}
|
||||
projected += SqlProjectionField(
|
||||
name = param.name,
|
||||
value = instance.readField(scope, param.name).value,
|
||||
targetType = param.type,
|
||||
annotations = param.annotations
|
||||
)
|
||||
}
|
||||
}
|
||||
for ((storageName, record) in instance.serializingVars) {
|
||||
val name = storageName.substringAfterLast("::")
|
||||
val annotations = instance.objClass.getInstanceMemberOrNull(name)?.annotations ?: emptyList()
|
||||
if (hasDbExceptAnnotation(annotations)) continue
|
||||
if (!seen.add(name.lowercase())) {
|
||||
raiseSqlUsage(scope, types, "Ambiguous SQL projection field '$name' in ${instance.objClass.className}")
|
||||
}
|
||||
projected += SqlProjectionField(
|
||||
name = name,
|
||||
value = record.value,
|
||||
targetType = record.typeDecl ?: TypeDecl.TypeAny,
|
||||
annotations = annotations
|
||||
)
|
||||
}
|
||||
if (projected.isEmpty()) {
|
||||
raiseSqlUsage(scope, types, "SQL object expansion for ${instance.objClass.className} produced no projected fields")
|
||||
}
|
||||
if (filter.excludedFields.isEmpty()) {
|
||||
return projected
|
||||
}
|
||||
val unknownFields = filter.excludedFields.filter { excluded ->
|
||||
projected.none { it.name.lowercase() == excluded }
|
||||
}
|
||||
if (unknownFields.isNotEmpty()) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"SQL object expansion for ${instance.objClass.className} can't exclude unknown field(s): ${unknownFields.joinToString(", ")}"
|
||||
)
|
||||
}
|
||||
val filtered = projected.filter { it.name.lowercase() !in filter.excludedFields }
|
||||
if (filtered.isEmpty()) {
|
||||
raiseSqlUsage(scope, types, "SQL object expansion for ${instance.objClass.className} produced no projected fields after except:")
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
private fun hasDbExceptAnnotation(annotations: List<DeclAnnotation>): Boolean =
|
||||
annotations.any { it.name == "DbExcept" }
|
||||
|
||||
private suspend fun encodeProjectedDbField(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
field: SqlProjectionField,
|
||||
): Obj {
|
||||
if (field.value === ObjNull) return ObjNull
|
||||
val explicit = findExplicitDbEncodingAnnotation(scope, types, field.annotations)
|
||||
if (explicit != null) {
|
||||
return encodeExplicitDbField(scope, types, field, explicit)
|
||||
}
|
||||
if (isDirectDbBindable(field.value)) return field.value
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"Field '${field.name}' of ${field.value.objClass.className} requires explicit DB serialization policy (@DbJson, @DbLynon, or @DbSerializeWith(...))"
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun encodeExplicitDbField(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
field: SqlProjectionField,
|
||||
annotation: DeclAnnotation,
|
||||
): Obj {
|
||||
return when (annotation.name) {
|
||||
"DbJson" -> {
|
||||
if (annotation.positional.isNotEmpty() || annotation.named.isNotEmpty()) {
|
||||
raiseSqlUsage(scope, types, "@DbJson does not take arguments")
|
||||
}
|
||||
ObjString(ObjJsonClass.encodeToJsonElement(scope, field.value, field.targetType).toString())
|
||||
}
|
||||
"DbLynon" -> {
|
||||
if (annotation.positional.isNotEmpty() || annotation.named.isNotEmpty()) {
|
||||
raiseSqlUsage(scope, types, "@DbLynon does not take arguments")
|
||||
}
|
||||
ObjBuffer(ObjLynonClass.encodeAny(scope, field.value).bitArray.asUByteArray())
|
||||
}
|
||||
"DbSerializeWith" -> encodeWithDbFieldAdapter(scope, types, field, annotation)
|
||||
else -> raiseSqlUsage(scope, types, "Unsupported DB field encoding annotation: @${annotation.name}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun encodeWithDbFieldAdapter(
|
||||
scope: Scope,
|
||||
types: SqlRuntimeTypes,
|
||||
field: SqlProjectionField,
|
||||
annotation: DeclAnnotation,
|
||||
): Obj {
|
||||
if (annotation.named.isNotEmpty() || annotation.positional.size != 1) {
|
||||
raiseSqlUsage(scope, types, "@DbSerializeWith(...) expects exactly one adapter instance argument")
|
||||
}
|
||||
val adapter = annotation.positional.first()
|
||||
if (!adapter.isInstanceOf(types.core.dbFieldAdapterClass)) {
|
||||
raiseSqlUsage(scope, types, "@DbSerializeWith(...) argument must implement DbFieldAdapter")
|
||||
}
|
||||
val encoded = try {
|
||||
adapter.invokeInstanceMethod(
|
||||
scope,
|
||||
"encode",
|
||||
Arguments(field.value, runtimeTargetTypeObject(scope, field.targetType))
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"Failed to encode SQL field '${field.name}' with @DbSerializeWith(...): ${e.message ?: e::class.simpleName}"
|
||||
)
|
||||
}
|
||||
if (!isDirectDbBindable(encoded)) {
|
||||
raiseSqlUsage(
|
||||
scope,
|
||||
types,
|
||||
"@DbSerializeWith(...) for field '${field.name}' must return a direct DB-bindable value, got ${encoded.objClass.className}"
|
||||
)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
private fun isDirectDbBindable(value: Obj): Boolean = when (value) {
|
||||
ObjNull -> true
|
||||
is ObjBool, is ObjInt, is ObjReal, is ObjString, is ObjBuffer, is ObjInstant, is ObjDateTime -> true
|
||||
else -> when (value.objClass.className) {
|
||||
"Date", "Decimal" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@ -361,6 +361,168 @@ class LyngSqliteModuleTest {
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSqlObjectExpansionInsertAndUpdateUseDbAnnotations() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Payload(name: String, count: Int)
|
||||
|
||||
class Item(
|
||||
id: Int,
|
||||
title: String,
|
||||
@DbJson meta: Payload,
|
||||
@DbLynon state: Payload
|
||||
) {
|
||||
var note: String = ""
|
||||
@DbExcept var cache: String = "skip"
|
||||
@Transient var transientNote: String = "temp"
|
||||
}
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table item(id integer not null, title text not null, meta text not null, state blob not null, note text not null)")
|
||||
|
||||
val item = Item(1, "first", Payload("json", 10), Payload("bin", 20))
|
||||
item.note = "created"
|
||||
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
|
||||
|
||||
item.title = "second"
|
||||
item.meta = Payload("json2", 11)
|
||||
item.state = Payload("bin2", 21)
|
||||
item.note = "updated"
|
||||
item.cache = "must-not-be-written"
|
||||
item.transientNote = "must-not-be-written"
|
||||
tx.execute("update item set @set(?1) where id = ?2", item, 1)
|
||||
|
||||
val restored = tx.select("select id, title, meta, state, note from item").decodeAs<Item>().first
|
||||
assertEquals(1, restored.id)
|
||||
assertEquals("second", restored.title)
|
||||
assertEquals("json2", restored.meta.name)
|
||||
assertEquals(11, restored.meta.count)
|
||||
assertEquals("bin2", restored.state.name)
|
||||
assertEquals(21, restored.state.count)
|
||||
assertEquals("updated", restored.note)
|
||||
restored.state.count
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-object-expansion>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(21L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSqlObjectExpansionSupportsDbSerializeWithAdapter() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
object TrimAdapter: DbFieldAdapter {
|
||||
override fun encode(value, targetType) =
|
||||
when(value) {
|
||||
null -> null
|
||||
else -> value.toString().trim()
|
||||
}
|
||||
}
|
||||
|
||||
class User(
|
||||
id: Int,
|
||||
@DbSerializeWith(TrimAdapter) name: String
|
||||
)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table user(id integer not null, name text not null)")
|
||||
tx.execute("insert into user(@cols(?1)) values(@vals(?1))", User(7, " Alice "))
|
||||
val row = tx.select("select id, name from user").first
|
||||
val storedName: String = row["name"] as String
|
||||
assertEquals(7, row["id"])
|
||||
assertEquals("Alice", storedName)
|
||||
storedName.size
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-object-expansion-adapter>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(5L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSqlObjectExpansionSupportsClauseLevelExceptFilter() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Item(id: Int, title: String, note: String) {
|
||||
var stamp: String = ""
|
||||
@DbExcept var cache: String = "skip"
|
||||
}
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table item(id integer not null, title text not null, note text not null, stamp text not null default '')")
|
||||
|
||||
val item = Item(1, "first", "keep")
|
||||
item.stamp = "created"
|
||||
tx.execute(
|
||||
"insert into item(@cols(?1 except: \"stamp\")) values(@vals(?1 except: \"stamp\"))",
|
||||
item
|
||||
)
|
||||
|
||||
item.title = "second"
|
||||
item.note = "changed-but-excluded"
|
||||
item.stamp = "updated"
|
||||
item.cache = "still-skip"
|
||||
tx.execute(
|
||||
"update item set @set(?1 except: \"id\", \"note\") where id = ?2",
|
||||
item,
|
||||
1
|
||||
)
|
||||
|
||||
val restored = tx.select("select id, title, note, stamp from item").decodeAs<Item>().first
|
||||
assertEquals(1, restored.id)
|
||||
assertEquals("second", restored.title)
|
||||
assertEquals("keep", restored.note)
|
||||
assertEquals("updated", restored.stamp)
|
||||
restored.stamp.size
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-object-expansion-except>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(7L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSqlObjectExpansionRejectsUnannotatedComplexFields() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Payload(name: String)
|
||||
class BadRecord(id: Int, payload: Payload)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table bad_record(id integer not null, payload text not null)")
|
||||
tx.execute("insert into bad_record(@cols(?1)) values(@vals(?1))", BadRecord(1, Payload("x")))
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
Compiler.compile(Source("<sqlite-object-expansion-bad-field>", code), scope.importManager).execute(scope)
|
||||
}
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNestedTransactionRollbackUsesSavepoint() = runTest {
|
||||
val scope = Script.newScope()
|
||||
|
||||
@ -457,6 +457,168 @@ class LyngSqliteModuleNativeTest {
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSqlObjectExpansionInsertAndUpdateUseDbAnnotations() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Payload(name: String, count: Int)
|
||||
|
||||
class Item(
|
||||
id: Int,
|
||||
title: String,
|
||||
@DbJson meta: Payload,
|
||||
@DbLynon state: Payload
|
||||
) {
|
||||
var note: String = ""
|
||||
@DbExcept var cache: String = "skip"
|
||||
@Transient var transientNote: String = "temp"
|
||||
}
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table item(id integer not null, title text not null, meta text not null, state blob not null, note text not null)")
|
||||
|
||||
val item = Item(1, "first", Payload("json", 10), Payload("bin", 20))
|
||||
item.note = "created"
|
||||
tx.execute("insert into item(@cols(?1)) values(@vals(?1))", item)
|
||||
|
||||
item.title = "second"
|
||||
item.meta = Payload("json2", 11)
|
||||
item.state = Payload("bin2", 21)
|
||||
item.note = "updated"
|
||||
item.cache = "must-not-be-written"
|
||||
item.transientNote = "must-not-be-written"
|
||||
tx.execute("update item set @set(?1) where id = ?2", item, 1)
|
||||
|
||||
val restored = tx.select("select id, title, meta, state, note from item").decodeAs<Item>().first
|
||||
assertEquals(1, restored.id)
|
||||
assertEquals("second", restored.title)
|
||||
assertEquals("json2", restored.meta.name)
|
||||
assertEquals(11, restored.meta.count)
|
||||
assertEquals("bin2", restored.state.name)
|
||||
assertEquals(21, restored.state.count)
|
||||
assertEquals("updated", restored.note)
|
||||
restored.state.count
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-native-object-expansion>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(21L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSqlObjectExpansionSupportsDbSerializeWithAdapter() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
object TrimAdapter: DbFieldAdapter {
|
||||
override fun encode(value, targetType) =
|
||||
when(value) {
|
||||
null -> null
|
||||
else -> value.toString().trim()
|
||||
}
|
||||
}
|
||||
|
||||
class User(
|
||||
id: Int,
|
||||
@DbSerializeWith(TrimAdapter) name: String
|
||||
)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table user(id integer not null, name text not null)")
|
||||
tx.execute("insert into user(@cols(?1)) values(@vals(?1))", User(7, " Alice "))
|
||||
val row = tx.select("select id, name from user").first
|
||||
val storedName: String = row["name"] as String
|
||||
assertEquals(7, row["id"])
|
||||
assertEquals("Alice", storedName)
|
||||
storedName.size
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-native-object-expansion-adapter>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(5L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSqlObjectExpansionSupportsClauseLevelExceptFilter() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Item(id: Int, title: String, note: String) {
|
||||
var stamp: String = ""
|
||||
@DbExcept var cache: String = "skip"
|
||||
}
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table item(id integer not null, title text not null, note text not null, stamp text not null default '')")
|
||||
|
||||
val item = Item(1, "first", "keep")
|
||||
item.stamp = "created"
|
||||
tx.execute(
|
||||
"insert into item(@cols(?1 except: \"stamp\")) values(@vals(?1 except: \"stamp\"))",
|
||||
item
|
||||
)
|
||||
|
||||
item.title = "second"
|
||||
item.note = "changed-but-excluded"
|
||||
item.stamp = "updated"
|
||||
item.cache = "still-skip"
|
||||
tx.execute(
|
||||
"update item set @set(?1 except: \"id\", \"note\") where id = ?2",
|
||||
item,
|
||||
1
|
||||
)
|
||||
|
||||
val restored = tx.select("select id, title, note, stamp from item").decodeAs<Item>().first
|
||||
assertEquals(1, restored.id)
|
||||
assertEquals("second", restored.title)
|
||||
assertEquals("keep", restored.note)
|
||||
assertEquals("updated", restored.stamp)
|
||||
restored.stamp.size
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = Compiler.compile(Source("<sqlite-native-object-expansion-except>", code), scope.importManager).execute(scope) as ObjInt
|
||||
assertEquals(7L, result.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSqlObjectExpansionRejectsUnannotatedComplexFields() = runTest {
|
||||
val scope = Script.newScope()
|
||||
createSqliteModule(scope.importManager)
|
||||
|
||||
val code = """
|
||||
import lyng.io.db.sqlite
|
||||
|
||||
class Payload(name: String)
|
||||
class BadRecord(id: Int, payload: Payload)
|
||||
|
||||
val db = openSqlite(":memory:")
|
||||
db.transaction { tx ->
|
||||
tx.execute("create table bad_record(id integer not null, payload text not null)")
|
||||
tx.execute("insert into bad_record(@cols(?1)) values(@vals(?1))", BadRecord(1, Payload("x")))
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val error = assertFailsWith<ExecutionError> {
|
||||
Compiler.compile(Source("<sqlite-native-object-expansion-bad-field>", code), scope.importManager).execute(scope)
|
||||
}
|
||||
assertEquals("SqlUsageException", error.errorObject.objClass.className)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExecuteRejectsReturningButSelectSupportsIt() = runTest {
|
||||
val scope = Script.newScope()
|
||||
|
||||
@ -24,7 +24,9 @@ extern class SqlColumn {
|
||||
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.
|
||||
class-body fields/properties participating in `decodeAs<T>()` projection,
|
||||
and with `@DbSerializeWith(adapter)` on fields participating in SQL object
|
||||
expansion macros such as `@vals(?1)` and `@set(?1)`.
|
||||
|
||||
`targetType` is the requested Lyng target type represented as a runtime
|
||||
type object, such as a class or a type expression.
|
||||
@ -41,6 +43,10 @@ interface DbFieldAdapter {
|
||||
|
||||
/*
|
||||
Encode one Lyng value into a database field representation.
|
||||
|
||||
The result must be a direct DB-bindable value:
|
||||
null, Bool, Int, Double/Real, Decimal, String, Buffer, Date, DateTime,
|
||||
or Instant.
|
||||
*/
|
||||
fun encode(value: Object?, targetType: Object): Object? =
|
||||
throw NotImplementedException("DB field adapter encode is not implemented")
|
||||
@ -185,6 +191,29 @@ extern class SqlTransaction {
|
||||
- Date, DateTime, Instant
|
||||
|
||||
Unsupported parameter values should fail with `SqlUsageException`.
|
||||
|
||||
SQL object expansion macros are also supported:
|
||||
|
||||
- `@cols(?1)` expands one object argument to a comma-separated column list
|
||||
- `@vals(?1)` expands the same object to matching `?` placeholders and
|
||||
generated bind values
|
||||
- `@set(?1)` expands to `col = ?` pairs and generated bind values
|
||||
- each macro also accepts an optional `except:` filter, for example
|
||||
`@set(?1 except: "id", "updatedAt")`
|
||||
|
||||
When a clause uses `@cols`, `@vals`, or `@set`, any non-expanded scalar
|
||||
parameters in the same clause must use explicit indexed placeholders such
|
||||
as `?2`, `?3`, and so on.
|
||||
|
||||
Projection is declaration-driven:
|
||||
|
||||
- `@Transient` fields are excluded
|
||||
- `@DbExcept` fields are excluded
|
||||
- `except:` excludes additional fields for that specific macro use
|
||||
- `@DbJson` fields are encoded as JSON text
|
||||
- `@DbLynon` fields are encoded as Lynon binary
|
||||
- `@DbSerializeWith(adapter)` fields are encoded through the adapter
|
||||
- unannotated non-primitive fields fail with `SqlUsageException`
|
||||
*/
|
||||
fun select(clause: String, params...): ResultSet
|
||||
|
||||
|
||||
@ -278,3 +278,74 @@ Current recommended projection policy:
|
||||
- then Lynon decode for binary columns when the target is not `Buffer`
|
||||
- no implicit JSON decode for arbitrary text columns
|
||||
- fail on anything else
|
||||
|
||||
## Write-side SQL object expansion
|
||||
|
||||
The symmetric write-side convenience should be explicit and declaration-driven, but it should not attempt semantic SQL analysis.
|
||||
|
||||
Agreed v1 surface:
|
||||
|
||||
- `@cols(?1)` expands one object argument to projected column names
|
||||
- `@vals(?1)` expands the same object argument to matching placeholders and encoded bind values
|
||||
- `@set(?1)` expands the same object argument to `column = ?` pairs and encoded bind values
|
||||
- each macro accepts an optional `except:` filter, for example `@set(?1 except: "id", "updatedAt")`
|
||||
|
||||
Examples:
|
||||
|
||||
```lyng
|
||||
tx.execute(
|
||||
"insert into item(@cols(?1)) values(@vals(?1))",
|
||||
item
|
||||
)
|
||||
|
||||
tx.execute(
|
||||
"update item set @set(?1) where id = ?2",
|
||||
item,
|
||||
item.id
|
||||
)
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- once a clause uses `@cols`, `@vals`, or `@set`, plain sequential `?` placeholders are not allowed in the same clause
|
||||
- non-expanded parameters in macro clauses must use explicit indexed placeholders such as `?2`
|
||||
- the same object argument may be referenced multiple times
|
||||
- object expansion is based on declaration metadata, not SQL metadata
|
||||
- v1 excludes `@Transient` and `@DbExcept` fields automatically
|
||||
- `except:` excludes additional fields for one specific macro use
|
||||
|
||||
### Write-side field encoding policy
|
||||
|
||||
Write-side encoding cannot rely on DB column type inference, so non-trivial field serialization must be explicit.
|
||||
|
||||
For each projected field:
|
||||
|
||||
1. if the value is already directly DB-bindable, bind it as-is
|
||||
2. else if `@DbJson` is present, encode to canonical JSON text
|
||||
3. else if `@DbLynon` is present, encode to Lynon binary
|
||||
4. else if `@DbSerializeWith(adapter)` is present, call `adapter.encode(value, targetType)`
|
||||
5. else fail with `SqlUsageException`
|
||||
|
||||
Direct DB-bindable values in v1:
|
||||
|
||||
- `null`
|
||||
- `Bool`
|
||||
- `Int`, `Real`, `Decimal`
|
||||
- `String`
|
||||
- `Buffer`
|
||||
- `Date`, `DateTime`, `Instant`
|
||||
|
||||
This is intentionally stricter than decode-side behavior. On writes, there is no portable, reliable way to infer the intended target DB representation from SQL text alone.
|
||||
|
||||
### Adapter role
|
||||
|
||||
`DbFieldAdapter` is now symmetric by design:
|
||||
|
||||
- `decode(rawValue, column, row, targetType)` is used by `decodeAs<T>()`
|
||||
- `encode(value, targetType)` is used by SQL object expansion
|
||||
|
||||
The adapter instance is captured in preserved declaration annotation metadata, not passed ad hoc at the call site.
|
||||
|
||||
Future task:
|
||||
|
||||
- consider warnings or lints for risky annotation captures such as stateful adapters or closure-capturing instances
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user