Align SQLite value conversion across JVM and native
This commit is contained in:
parent
55ba6113e7
commit
5f6f6b9ae4
@ -23,6 +23,7 @@ import net.sergeych.lyng.ScopeFacade
|
|||||||
import net.sergeych.lyng.obj.Obj
|
import net.sergeych.lyng.obj.Obj
|
||||||
import net.sergeych.lyng.obj.ObjBool
|
import net.sergeych.lyng.obj.ObjBool
|
||||||
import net.sergeych.lyng.obj.ObjBuffer
|
import net.sergeych.lyng.obj.ObjBuffer
|
||||||
|
import net.sergeych.lyng.obj.ObjDate
|
||||||
import net.sergeych.lyng.obj.ObjDateTime
|
import net.sergeych.lyng.obj.ObjDateTime
|
||||||
import net.sergeych.lyng.obj.ObjEnumEntry
|
import net.sergeych.lyng.obj.ObjEnumEntry
|
||||||
import net.sergeych.lyng.obj.ObjException
|
import net.sergeych.lyng.obj.ObjException
|
||||||
@ -32,6 +33,7 @@ import net.sergeych.lyng.obj.ObjNull
|
|||||||
import net.sergeych.lyng.obj.ObjReal
|
import net.sergeych.lyng.obj.ObjReal
|
||||||
import net.sergeych.lyng.obj.ObjString
|
import net.sergeych.lyng.obj.ObjString
|
||||||
import net.sergeych.lyng.requireScope
|
import net.sergeych.lyng.requireScope
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
@ -124,7 +126,7 @@ private class JdbcSqliteTransactionBackend(
|
|||||||
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
|
override suspend fun select(scope: ScopeFacade, clause: String, params: List<Obj>): SqliteResultSetData {
|
||||||
try {
|
try {
|
||||||
connection.prepareStatement(clause).use { statement ->
|
connection.prepareStatement(clause).use { statement ->
|
||||||
bindParams(statement, params, scope)
|
bindParams(statement, params, scope, core)
|
||||||
statement.executeQuery().use { rs ->
|
statement.executeQuery().use { rs ->
|
||||||
return readResultSet(scope, core, rs)
|
return readResultSet(scope, core, rs)
|
||||||
}
|
}
|
||||||
@ -146,7 +148,7 @@ private class JdbcSqliteTransactionBackend(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
connection.prepareStatement(clause, Statement.RETURN_GENERATED_KEYS).use { statement ->
|
connection.prepareStatement(clause, Statement.RETURN_GENERATED_KEYS).use { statement ->
|
||||||
bindParams(statement, params, scope)
|
bindParams(statement, params, scope, core)
|
||||||
val hasResultSet = statement.execute()
|
val hasResultSet = statement.execute()
|
||||||
if (hasResultSet) {
|
if (hasResultSet) {
|
||||||
scope.raiseError(
|
scope.raiseError(
|
||||||
@ -189,12 +191,12 @@ private class JdbcSqliteTransactionBackend(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade) {
|
private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>, scope: ScopeFacade, core: SqliteCoreModule) {
|
||||||
params.forEachIndexed { index, value ->
|
params.forEachIndexed { index, value ->
|
||||||
val jdbcIndex = index + 1
|
val jdbcIndex = index + 1
|
||||||
when (value) {
|
when (value) {
|
||||||
ObjNull -> statement.setObject(jdbcIndex, null)
|
ObjNull -> statement.setObject(jdbcIndex, null)
|
||||||
is ObjBool -> statement.setBoolean(jdbcIndex, value.value)
|
is ObjBool -> statement.setLong(jdbcIndex, if (value.value) 1L else 0L)
|
||||||
is ObjInt -> statement.setLong(jdbcIndex, value.value)
|
is ObjInt -> statement.setLong(jdbcIndex, value.value)
|
||||||
is ObjReal -> statement.setDouble(jdbcIndex, value.value)
|
is ObjReal -> statement.setDouble(jdbcIndex, value.value)
|
||||||
is ObjString -> statement.setString(jdbcIndex, value.value)
|
is ObjString -> statement.setString(jdbcIndex, value.value)
|
||||||
@ -203,7 +205,13 @@ private suspend fun bindParams(statement: PreparedStatement, params: List<Obj>,
|
|||||||
is ObjDateTime -> statement.setString(jdbcIndex, value.localDateTime.toString())
|
is ObjDateTime -> statement.setString(jdbcIndex, value.localDateTime.toString())
|
||||||
else -> when (value.objClass.className) {
|
else -> when (value.objClass.className) {
|
||||||
"Date", "Decimal" -> statement.setString(jdbcIndex, scope.toStringOf(value).value)
|
"Date", "Decimal" -> statement.setString(jdbcIndex, scope.toStringOf(value).value)
|
||||||
else -> scope.raiseClassCastError("Unsupported SQLite parameter type: ${value.objClass.className}")
|
else -> scope.raiseError(
|
||||||
|
ObjException(
|
||||||
|
core.sqlUsageException,
|
||||||
|
scope.requireScope(),
|
||||||
|
ObjString("Unsupported SQLite parameter type: ${value.objClass.className}")
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -240,40 +248,69 @@ private suspend fun readColumnValue(
|
|||||||
nativeType: String,
|
nativeType: String,
|
||||||
): Obj {
|
): Obj {
|
||||||
val value = resultSet.getObject(index) ?: return ObjNull
|
val value = resultSet.getObject(index) ?: return ObjNull
|
||||||
if (isDecimalNativeType(nativeType) && value is Number) {
|
val normalizedNativeType = normalizeDeclaredTypeName(nativeType)
|
||||||
|
if (isDecimalNativeType(normalizedNativeType) && value is Number) {
|
||||||
return decimalFromString(scope, value.toString())
|
return decimalFromString(scope, value.toString())
|
||||||
}
|
}
|
||||||
return when (value) {
|
return when (value) {
|
||||||
is Boolean -> ObjBool(value)
|
is Boolean -> if (isBooleanNativeType(normalizedNativeType)) ObjBool(value) else ObjInt.of(if (value) 1 else 0)
|
||||||
is Byte, is Short, is Int -> ObjInt.of((value as Number).toLong())
|
is Byte, is Short, is Int -> convertIntegerValue(scope, core, normalizedNativeType, (value as Number).toLong())
|
||||||
is Long -> ObjInt.of(value)
|
is Long -> convertIntegerValue(scope, core, normalizedNativeType, value)
|
||||||
is Float, is Double -> ObjReal.of((value as Number).toDouble())
|
is Float, is Double -> ObjReal.of((value as Number).toDouble())
|
||||||
is ByteArray -> ObjBuffer(value.toUByteArray())
|
is ByteArray -> ObjBuffer(value.toUByteArray())
|
||||||
is String -> convertStringValue(scope, core, nativeType, value)
|
is String -> convertStringValue(scope, core, normalizedNativeType, value)
|
||||||
is java.math.BigDecimal -> decimalFromString(scope, value.toPlainString())
|
is java.math.BigDecimal -> decimalFromString(scope, value.toPlainString())
|
||||||
else -> ObjString(value.toString())
|
else -> ObjString(value.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun convertIntegerValue(
|
||||||
|
scope: ScopeFacade,
|
||||||
|
core: SqliteCoreModule,
|
||||||
|
normalizedNativeType: String,
|
||||||
|
value: Long,
|
||||||
|
): Obj {
|
||||||
|
if (!isBooleanNativeType(normalizedNativeType)) {
|
||||||
|
return ObjInt.of(value)
|
||||||
|
}
|
||||||
|
return when (value) {
|
||||||
|
0L -> ObjBool(false)
|
||||||
|
1L -> ObjBool(true)
|
||||||
|
else -> sqlExecutionFailure(scope, core, "Invalid SQLite boolean value: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun convertStringValue(
|
private suspend fun convertStringValue(
|
||||||
scope: ScopeFacade,
|
scope: ScopeFacade,
|
||||||
core: SqliteCoreModule,
|
core: SqliteCoreModule,
|
||||||
nativeType: String,
|
normalizedNativeType: String,
|
||||||
value: String,
|
value: String,
|
||||||
): Obj {
|
): Obj {
|
||||||
val normalized = nativeType.trim().uppercase()
|
|
||||||
return when {
|
return when {
|
||||||
normalized == "DECIMAL" || normalized == "NUMERIC" -> decimalFromString(scope, value)
|
isBooleanNativeType(normalizedNativeType) -> booleanFromString(scope, core, value)
|
||||||
normalized == "DATETIME" || normalized == "TIMESTAMP" -> dateTimeFromString(value)
|
isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.trim())
|
||||||
normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" ->
|
normalizedNativeType == "DATE" -> ObjDate(LocalDate.parse(value.trim()))
|
||||||
ObjInstant(Instant.parse(value))
|
normalizedNativeType == "DATETIME" || normalizedNativeType == "TIMESTAMP" ->
|
||||||
|
dateTimeFromString(scope, core, value)
|
||||||
|
normalizedNativeType == "TIMESTAMP WITH TIME ZONE" ||
|
||||||
|
normalizedNativeType == "TIMESTAMPTZ" ||
|
||||||
|
normalizedNativeType == "DATETIME WITH TIME ZONE" -> ObjInstant(Instant.parse(value.trim()))
|
||||||
else -> ObjString(value)
|
else -> ObjString(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isDecimalNativeType(nativeType: String): Boolean {
|
private fun isDecimalNativeType(normalizedNativeType: String): Boolean =
|
||||||
val normalized = nativeType.trim().uppercase()
|
normalizedNativeType == "DECIMAL" || normalizedNativeType == "NUMERIC"
|
||||||
return normalized == "DECIMAL" || normalized == "NUMERIC"
|
|
||||||
|
private fun isBooleanNativeType(normalizedNativeType: String): Boolean =
|
||||||
|
normalizedNativeType == "BOOLEAN" || normalizedNativeType == "BOOL"
|
||||||
|
|
||||||
|
private fun booleanFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): Obj {
|
||||||
|
return when (value.trim().lowercase()) {
|
||||||
|
"true", "t" -> ObjBool(true)
|
||||||
|
"false", "f" -> ObjBool(false)
|
||||||
|
else -> sqlExecutionFailure(scope, core, "Invalid SQLite boolean value: $value")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
||||||
@ -282,15 +319,13 @@ private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
|||||||
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
|
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dateTimeFromString(value: String): ObjDateTime {
|
private fun dateTimeFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): ObjDateTime {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
return if (hasExplicitTimeZone(trimmed)) {
|
if (hasExplicitTimeZone(trimmed)) {
|
||||||
val instant = Instant.parse(trimmed)
|
sqlExecutionFailure(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
|
||||||
ObjDateTime(instant, parseTimeZoneOrUtc(trimmed))
|
|
||||||
} else {
|
|
||||||
val local = LocalDateTime.parse(trimmed)
|
|
||||||
ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC)
|
|
||||||
}
|
}
|
||||||
|
val local = LocalDateTime.parse(trimmed)
|
||||||
|
return ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasExplicitTimeZone(value: String): Boolean {
|
private fun hasExplicitTimeZone(value: String): Boolean {
|
||||||
@ -306,28 +341,23 @@ private fun hasExplicitTimeZone(value: String): Boolean {
|
|||||||
private fun containsRowReturningClause(clause: String): Boolean =
|
private fun containsRowReturningClause(clause: String): Boolean =
|
||||||
Regex("""\breturning\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause)
|
Regex("""\breturning\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause)
|
||||||
|
|
||||||
private fun parseTimeZoneOrUtc(value: String): TimeZone {
|
private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
|
||||||
if (value.endsWith("Z", ignoreCase = true)) return TimeZone.UTC
|
val strippedSuffix = nativeTypeName.trim().replace(Regex("""\s*\(.*\)\s*$"""), "")
|
||||||
val tIndex = value.indexOf('T')
|
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
|
||||||
if (tIndex < 0) return TimeZone.UTC
|
|
||||||
val plus = value.lastIndexOf('+')
|
|
||||||
val minus = value.lastIndexOf('-')
|
|
||||||
val offsetStart = maxOf(plus, minus)
|
|
||||||
if (offsetStart <= tIndex) return TimeZone.UTC
|
|
||||||
return try {
|
|
||||||
TimeZone.of(value.substring(offsetStart))
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
TimeZone.UTC
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mapSqlType(core: SqliteCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
|
private fun mapSqlType(core: SqliteCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry {
|
||||||
val normalized = nativeTypeName.trim().uppercase()
|
val normalized = normalizeDeclaredTypeName(nativeTypeName)
|
||||||
return when {
|
return when {
|
||||||
normalized == "BOOLEAN" -> core.sqlTypes.require("Bool")
|
normalized == "BOOLEAN" || normalized == "BOOL" -> core.sqlTypes.require("Bool")
|
||||||
normalized == "DATE" -> core.sqlTypes.require("Date")
|
normalized == "DATE" -> core.sqlTypes.require("Date")
|
||||||
normalized == "DATETIME" || normalized == "TIMESTAMP" -> core.sqlTypes.require("DateTime")
|
normalized == "DATETIME" || normalized == "TIMESTAMP" -> core.sqlTypes.require("DateTime")
|
||||||
normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" -> core.sqlTypes.require("Instant")
|
normalized == "TIMESTAMP WITH TIME ZONE" ||
|
||||||
|
normalized == "TIMESTAMPTZ" ||
|
||||||
|
normalized == "DATETIME WITH TIME ZONE" -> core.sqlTypes.require("Instant")
|
||||||
|
normalized == "TIME" ||
|
||||||
|
normalized == "TIME WITHOUT TIME ZONE" ||
|
||||||
|
normalized == "TIME WITH TIME ZONE" -> core.sqlTypes.require("String")
|
||||||
normalized == "DECIMAL" || normalized == "NUMERIC" -> core.sqlTypes.require("Decimal")
|
normalized == "DECIMAL" || normalized == "NUMERIC" -> core.sqlTypes.require("Decimal")
|
||||||
normalized.contains("BLOB") -> core.sqlTypes.require("Binary")
|
normalized.contains("BLOB") -> core.sqlTypes.require("Binary")
|
||||||
normalized.contains("INT") -> core.sqlTypes.require("Int")
|
normalized.contains("INT") -> core.sqlTypes.require("Int")
|
||||||
@ -345,6 +375,14 @@ private fun mapSqlType(core: SqliteCoreModule, nativeTypeName: String, jdbcType:
|
|||||||
|
|
||||||
private fun emptyResultSet(core: SqliteCoreModule): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
|
private fun emptyResultSet(core: SqliteCoreModule): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
|
||||||
|
|
||||||
|
private fun sqlExecutionFailure(scope: ScopeFacade, core: SqliteCoreModule, message: String): Nothing {
|
||||||
|
throw ExecutionError(
|
||||||
|
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
||||||
|
scope.pos,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun rollbackQuietly(connection: Connection, savepoint: java.sql.Savepoint? = null) {
|
private fun rollbackQuietly(connection: Connection, savepoint: java.sql.Savepoint? = null) {
|
||||||
try {
|
try {
|
||||||
if (savepoint == null) connection.rollback() else connection.rollback(savepoint)
|
if (savepoint == null) connection.rollback() else connection.rollback(savepoint)
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import net.sergeych.lyng.Scope
|
|||||||
import net.sergeych.lyng.Script
|
import net.sergeych.lyng.Script
|
||||||
import net.sergeych.lyng.obj.Obj
|
import net.sergeych.lyng.obj.Obj
|
||||||
import net.sergeych.lyng.obj.ObjBuffer
|
import net.sergeych.lyng.obj.ObjBuffer
|
||||||
|
import net.sergeych.lyng.obj.ObjBool
|
||||||
import net.sergeych.lyng.obj.ObjDateTime
|
import net.sergeych.lyng.obj.ObjDateTime
|
||||||
import net.sergeych.lyng.obj.ObjExternCallable
|
import net.sergeych.lyng.obj.ObjExternCallable
|
||||||
import net.sergeych.lyng.obj.ObjInt
|
import net.sergeych.lyng.obj.ObjInt
|
||||||
@ -354,6 +355,62 @@ class LyngSqliteModuleTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDateAndBooleanConversionRules() = runTest {
|
||||||
|
val scope = Script.newScope()
|
||||||
|
val db = openMemoryDb(scope)
|
||||||
|
|
||||||
|
val summary = db.invokeInstanceMethod(
|
||||||
|
scope,
|
||||||
|
"transaction",
|
||||||
|
ObjExternCallable.fromBridge {
|
||||||
|
val tx = requiredArg<Obj>(0)
|
||||||
|
tx.invokeInstanceMethod(
|
||||||
|
requireScope(),
|
||||||
|
"execute",
|
||||||
|
ObjString(
|
||||||
|
"create table sample(" +
|
||||||
|
"flag BOOL not null, " +
|
||||||
|
"day DATE not null, " +
|
||||||
|
"clock TIME not null)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tx.invokeInstanceMethod(
|
||||||
|
requireScope(),
|
||||||
|
"execute",
|
||||||
|
ObjString("insert into sample(flag, day, clock) values(?, ?, ?)"),
|
||||||
|
ObjBool(true),
|
||||||
|
dateOf(requireScope(), "2026-04-15"),
|
||||||
|
ObjString("12:34:56")
|
||||||
|
)
|
||||||
|
tx.invokeInstanceMethod(
|
||||||
|
requireScope(),
|
||||||
|
"execute",
|
||||||
|
ObjString("insert into sample(flag, day, clock) values(?, ?, ?)"),
|
||||||
|
ObjString("t"),
|
||||||
|
ObjString("2026-04-16"),
|
||||||
|
ObjString("23:59:59")
|
||||||
|
)
|
||||||
|
val resultSet = tx.invokeInstanceMethod(
|
||||||
|
requireScope(),
|
||||||
|
"select",
|
||||||
|
ObjString("select flag, day, clock from sample order by day")
|
||||||
|
)
|
||||||
|
val rows = rowsOf(requireScope(), resultSet)
|
||||||
|
ObjString(
|
||||||
|
listOf(
|
||||||
|
rows[0].getAt(requireScope(), ObjString("flag")).objClass.className,
|
||||||
|
rows[0].getAt(requireScope(), ObjString("day")).objClass.className,
|
||||||
|
stringValue(requireScope(), rows[0].getAt(requireScope(), ObjString("clock"))),
|
||||||
|
rows[1].getAt(requireScope(), ObjString("flag")).objClass.className,
|
||||||
|
).joinToString("|")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as ObjString
|
||||||
|
|
||||||
|
assertEquals("Bool|Date|12:34:56|Bool", summary.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testReadOnlyOpenPreventsWrites() = runTest {
|
fun testReadOnlyOpenPreventsWrites() = runTest {
|
||||||
val scope = Script.newScope()
|
val scope = Script.newScope()
|
||||||
@ -448,5 +505,11 @@ class LyngSqliteModuleTest {
|
|||||||
return decimalClass.invokeInstanceMethod(scope, "fromString", ObjString(value))
|
return decimalClass.invokeInstanceMethod(scope, "fromString", ObjString(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun dateOf(scope: Scope, value: String): Obj {
|
||||||
|
val timeModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.time")
|
||||||
|
val dateClass = timeModule.requireClass("Date")
|
||||||
|
return dateClass.invoke(scope, ObjNull, ObjString(value))
|
||||||
|
}
|
||||||
|
|
||||||
private fun emptyMapObj(): Obj = ObjMap()
|
private fun emptyMapObj(): Obj = ObjMap()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import net.sergeych.lyng.Pos
|
|||||||
import net.sergeych.lyng.Scope
|
import net.sergeych.lyng.Scope
|
||||||
import net.sergeych.lyng.Script
|
import net.sergeych.lyng.Script
|
||||||
import net.sergeych.lyng.obj.Obj
|
import net.sergeych.lyng.obj.Obj
|
||||||
|
import net.sergeych.lyng.obj.ObjBool
|
||||||
import net.sergeych.lyng.obj.ObjBuffer
|
import net.sergeych.lyng.obj.ObjBuffer
|
||||||
import net.sergeych.lyng.obj.ObjDateTime
|
import net.sergeych.lyng.obj.ObjDateTime
|
||||||
import net.sergeych.lyng.obj.ObjExternCallable
|
import net.sergeych.lyng.obj.ObjExternCallable
|
||||||
@ -252,6 +253,62 @@ class LyngSqliteModuleNativeTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDateAndBooleanConversionRules() = runTest {
|
||||||
|
val scope = Script.newScope()
|
||||||
|
val db = openMemoryDb(scope)
|
||||||
|
|
||||||
|
val summary = db.invokeInstanceMethod(
|
||||||
|
scope,
|
||||||
|
"transaction",
|
||||||
|
ObjExternCallable.fromBridge {
|
||||||
|
val tx = requiredArg<Obj>(0)
|
||||||
|
tx.invokeInstanceMethod(
|
||||||
|
requireScope(),
|
||||||
|
"execute",
|
||||||
|
ObjString(
|
||||||
|
"create table sample(" +
|
||||||
|
"flag BOOL not null, " +
|
||||||
|
"day DATE not null, " +
|
||||||
|
"clock TIME not null)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tx.invokeInstanceMethod(
|
||||||
|
requireScope(),
|
||||||
|
"execute",
|
||||||
|
ObjString("insert into sample(flag, day, clock) values(?, ?, ?)"),
|
||||||
|
ObjBool(true),
|
||||||
|
dateOf(requireScope(), "2026-04-15"),
|
||||||
|
ObjString("12:34:56")
|
||||||
|
)
|
||||||
|
tx.invokeInstanceMethod(
|
||||||
|
requireScope(),
|
||||||
|
"execute",
|
||||||
|
ObjString("insert into sample(flag, day, clock) values(?, ?, ?)"),
|
||||||
|
ObjString("t"),
|
||||||
|
ObjString("2026-04-16"),
|
||||||
|
ObjString("23:59:59")
|
||||||
|
)
|
||||||
|
val resultSet = tx.invokeInstanceMethod(
|
||||||
|
requireScope(),
|
||||||
|
"select",
|
||||||
|
ObjString("select flag, day, clock from sample order by day")
|
||||||
|
)
|
||||||
|
val rows = rowsOf(requireScope(), resultSet)
|
||||||
|
ObjString(
|
||||||
|
listOf(
|
||||||
|
rows[0].getAt(requireScope(), ObjString("flag")).objClass.className,
|
||||||
|
rows[0].getAt(requireScope(), ObjString("day")).objClass.className,
|
||||||
|
stringValue(requireScope(), rows[0].getAt(requireScope(), ObjString("clock"))),
|
||||||
|
rows[1].getAt(requireScope(), ObjString("flag")).objClass.className,
|
||||||
|
).joinToString("|")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) as ObjString
|
||||||
|
|
||||||
|
assertEquals("Bool|Date|12:34:56|Bool", summary.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testReadOnlyOpenPreventsWrites() = runTest {
|
fun testReadOnlyOpenPreventsWrites() = runTest {
|
||||||
val scope = Script.newScope()
|
val scope = Script.newScope()
|
||||||
@ -349,5 +406,11 @@ class LyngSqliteModuleNativeTest {
|
|||||||
return decimalClass.invokeInstanceMethod(scope, "fromString", ObjString(value))
|
return decimalClass.invokeInstanceMethod(scope, "fromString", ObjString(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun dateOf(scope: Scope, value: String): Obj {
|
||||||
|
val timeModule = scope.currentImportProvider.createModuleScope(scope.pos, "lyng.time")
|
||||||
|
val dateClass = timeModule.requireClass("Date")
|
||||||
|
return dateClass.invoke(scope, ObjNull, ObjString(value))
|
||||||
|
}
|
||||||
|
|
||||||
private fun emptyMapObj(): Obj = ObjMap()
|
private fun emptyMapObj(): Obj = ObjMap()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import kotlinx.cinterop.reinterpret
|
|||||||
import kotlinx.cinterop.toCPointer
|
import kotlinx.cinterop.toCPointer
|
||||||
import kotlinx.cinterop.toKString
|
import kotlinx.cinterop.toKString
|
||||||
import kotlinx.cinterop.usePinned
|
import kotlinx.cinterop.usePinned
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
@ -84,6 +85,7 @@ import net.sergeych.lyng.io.db.sqlite.cinterop.sqlite3_step
|
|||||||
import net.sergeych.lyng.obj.Obj
|
import net.sergeych.lyng.obj.Obj
|
||||||
import net.sergeych.lyng.obj.ObjBool
|
import net.sergeych.lyng.obj.ObjBool
|
||||||
import net.sergeych.lyng.obj.ObjBuffer
|
import net.sergeych.lyng.obj.ObjBuffer
|
||||||
|
import net.sergeych.lyng.obj.ObjDate
|
||||||
import net.sergeych.lyng.obj.ObjDateTime
|
import net.sergeych.lyng.obj.ObjDateTime
|
||||||
import net.sergeych.lyng.obj.ObjEnumEntry
|
import net.sergeych.lyng.obj.ObjEnumEntry
|
||||||
import net.sergeych.lyng.obj.ObjException
|
import net.sergeych.lyng.obj.ObjException
|
||||||
@ -274,7 +276,11 @@ private class NativeSqliteHandle(
|
|||||||
is ObjDateTime -> bindText(stmt, parameterIndex, value.localDateTime.toString(), memScope)
|
is ObjDateTime -> bindText(stmt, parameterIndex, value.localDateTime.toString(), memScope)
|
||||||
else -> when (value.objClass.className) {
|
else -> when (value.objClass.className) {
|
||||||
"Date", "Decimal" -> bindText(stmt, parameterIndex, scope.toStringOf(value).value, memScope)
|
"Date", "Decimal" -> bindText(stmt, parameterIndex, scope.toStringOf(value).value, memScope)
|
||||||
else -> scope.raiseClassCastError("Unsupported SQLite parameter type: ${value.objClass.className}")
|
else -> throw usageError(
|
||||||
|
scope,
|
||||||
|
core,
|
||||||
|
"Unsupported SQLite parameter type: ${value.objClass.className}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (rc != SQLITE_OK) {
|
if (rc != SQLITE_OK) {
|
||||||
@ -353,25 +359,25 @@ private class NativeSqliteHandle(
|
|||||||
index: Int,
|
index: Int,
|
||||||
nativeType: String,
|
nativeType: String,
|
||||||
): Obj {
|
): Obj {
|
||||||
val normalizedNativeType = nativeType.trim().uppercase()
|
val normalizedNativeType = normalizeDeclaredTypeName(nativeType)
|
||||||
return when (val type = sqlite3_column_type(stmt, index)) {
|
return when (val type = sqlite3_column_type(stmt, index)) {
|
||||||
SQLITE_NULL -> ObjNull
|
SQLITE_NULL -> ObjNull
|
||||||
SQLITE_INTEGER -> {
|
SQLITE_INTEGER -> {
|
||||||
val value = sqlite3_column_int64(stmt, index)
|
val value = sqlite3_column_int64(stmt, index)
|
||||||
when {
|
when {
|
||||||
normalizedNativeType == "BOOLEAN" -> ObjBool(value != 0L)
|
isBooleanNativeType(normalizedNativeType) -> integerToBool(scope, core, value)
|
||||||
isDecimalNativeType(nativeType) -> decimalFromString(scope, value.toString())
|
isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.toString())
|
||||||
else -> ObjInt.of(value)
|
else -> ObjInt.of(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SQLITE_FLOAT -> {
|
SQLITE_FLOAT -> {
|
||||||
val value = sqlite3_column_double(stmt, index)
|
val value = sqlite3_column_double(stmt, index)
|
||||||
if (isDecimalNativeType(nativeType)) decimalFromString(scope, value.toString()) else ObjReal.of(value)
|
if (isDecimalNativeType(normalizedNativeType)) decimalFromString(scope, value.toString()) else ObjReal.of(value)
|
||||||
}
|
}
|
||||||
SQLITE_TEXT -> {
|
SQLITE_TEXT -> {
|
||||||
val textPtr = sqlite3_column_text(stmt, index)?.reinterpret<ByteVar>()
|
val textPtr = sqlite3_column_text(stmt, index)?.reinterpret<ByteVar>()
|
||||||
val value = textPtr?.toKString() ?: ""
|
val value = textPtr?.toKString() ?: ""
|
||||||
convertStringValue(scope, nativeType, value)
|
convertStringValue(scope, core, normalizedNativeType, value)
|
||||||
}
|
}
|
||||||
SQLITE_BLOB -> {
|
SQLITE_BLOB -> {
|
||||||
val size = sqlite3_column_bytes(stmt, index)
|
val size = sqlite3_column_bytes(stmt, index)
|
||||||
@ -383,12 +389,21 @@ private class NativeSqliteHandle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun convertStringValue(scope: ScopeFacade, nativeType: String, value: String): Obj {
|
private suspend fun convertStringValue(
|
||||||
val normalized = nativeType.trim().uppercase()
|
scope: ScopeFacade,
|
||||||
|
core: SqliteCoreModule,
|
||||||
|
normalizedNativeType: String,
|
||||||
|
value: String,
|
||||||
|
): Obj {
|
||||||
return when {
|
return when {
|
||||||
normalized == "DECIMAL" || normalized == "NUMERIC" -> decimalFromString(scope, value)
|
isBooleanNativeType(normalizedNativeType) -> stringToBool(scope, core, value)
|
||||||
normalized == "DATETIME" || normalized == "TIMESTAMP" -> dateTimeFromString(value)
|
isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.trim())
|
||||||
normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" -> ObjInstant(Instant.parse(value))
|
normalizedNativeType == "DATE" -> ObjDate(LocalDate.parse(value.trim()))
|
||||||
|
normalizedNativeType == "DATETIME" || normalizedNativeType == "TIMESTAMP" ->
|
||||||
|
dateTimeFromString(scope, core, value)
|
||||||
|
normalizedNativeType == "TIMESTAMP WITH TIME ZONE" ||
|
||||||
|
normalizedNativeType == "TIMESTAMPTZ" ||
|
||||||
|
normalizedNativeType == "DATETIME WITH TIME ZONE" -> ObjInstant(Instant.parse(value.trim()))
|
||||||
else -> ObjString(value)
|
else -> ObjString(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -483,26 +498,30 @@ private val SQLITE_TRANSIENT = (-1L).toCPointer<CFunction<(COpaquePointer?) -> U
|
|||||||
|
|
||||||
private fun emptyResultSet(): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
|
private fun emptyResultSet(): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList())
|
||||||
|
|
||||||
private fun mapSqlType(core: SqliteCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when {
|
private fun mapSqlType(core: SqliteCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when (val normalized = normalizeDeclaredTypeName(nativeType)) {
|
||||||
nativeType.trim().equals("BOOLEAN", ignoreCase = true) -> core.sqlTypes.require("Bool")
|
"BOOLEAN", "BOOL" -> core.sqlTypes.require("Bool")
|
||||||
nativeType.trim().equals("DATE", ignoreCase = true) -> core.sqlTypes.require("Date")
|
"DATE" -> core.sqlTypes.require("Date")
|
||||||
nativeType.trim().equals("DATETIME", ignoreCase = true) || nativeType.trim().equals("TIMESTAMP", ignoreCase = true) -> core.sqlTypes.require("DateTime")
|
"DATETIME", "TIMESTAMP" -> core.sqlTypes.require("DateTime")
|
||||||
nativeType.trim().equals("TIMESTAMP WITH TIME ZONE", ignoreCase = true) || nativeType.trim().equals("TIMESTAMPTZ", ignoreCase = true) -> core.sqlTypes.require("Instant")
|
"TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ", "DATETIME WITH TIME ZONE" -> core.sqlTypes.require("Instant")
|
||||||
nativeType.trim().equals("DECIMAL", ignoreCase = true) || nativeType.trim().equals("NUMERIC", ignoreCase = true) -> core.sqlTypes.require("Decimal")
|
"DECIMAL", "NUMERIC" -> core.sqlTypes.require("Decimal")
|
||||||
nativeType.contains("BLOB", ignoreCase = true) -> core.sqlTypes.require("Binary")
|
"TIME", "TIME WITHOUT TIME ZONE", "TIME WITH TIME ZONE" -> core.sqlTypes.require("String")
|
||||||
nativeType.contains("INT", ignoreCase = true) -> core.sqlTypes.require("Int")
|
else -> when {
|
||||||
nativeType.contains("CHAR", ignoreCase = true) || nativeType.contains("TEXT", ignoreCase = true) || nativeType.contains("CLOB", ignoreCase = true) -> core.sqlTypes.require("String")
|
normalized.contains("BLOB") -> core.sqlTypes.require("Binary")
|
||||||
nativeType.contains("REAL", ignoreCase = true) || nativeType.contains("FLOA", ignoreCase = true) || nativeType.contains("DOUB", ignoreCase = true) -> core.sqlTypes.require("Double")
|
normalized.contains("INT") -> core.sqlTypes.require("Int")
|
||||||
sqliteType == SQLITE_INTEGER -> core.sqlTypes.require("Int")
|
normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") -> core.sqlTypes.require("String")
|
||||||
sqliteType == SQLITE_FLOAT -> core.sqlTypes.require("Double")
|
normalized.contains("REAL") || normalized.contains("FLOA") || normalized.contains("DOUB") -> core.sqlTypes.require("Double")
|
||||||
sqliteType == SQLITE_BLOB -> core.sqlTypes.require("Binary")
|
sqliteType == SQLITE_INTEGER -> core.sqlTypes.require("Int")
|
||||||
else -> core.sqlTypes.require("String")
|
sqliteType == SQLITE_FLOAT -> core.sqlTypes.require("Double")
|
||||||
|
sqliteType == SQLITE_BLOB -> core.sqlTypes.require("Binary")
|
||||||
|
else -> core.sqlTypes.require("String")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isDecimalNativeType(nativeType: String): Boolean {
|
private fun isDecimalNativeType(normalizedNativeType: String): Boolean =
|
||||||
val normalized = nativeType.trim().uppercase()
|
normalizedNativeType == "DECIMAL" || normalizedNativeType == "NUMERIC"
|
||||||
return normalized == "DECIMAL" || normalized == "NUMERIC"
|
|
||||||
}
|
private fun isBooleanNativeType(normalizedNativeType: String): Boolean =
|
||||||
|
normalizedNativeType == "BOOLEAN" || normalizedNativeType == "BOOL"
|
||||||
|
|
||||||
private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
||||||
val decimalModule = scope.requireScope().currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
|
val decimalModule = scope.requireScope().currentImportProvider.createModuleScope(scope.pos, "lyng.decimal")
|
||||||
@ -510,15 +529,13 @@ private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj {
|
|||||||
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
|
return decimalClass.invokeInstanceMethod(scope.requireScope(), "fromString", ObjString(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun dateTimeFromString(value: String): ObjDateTime {
|
private fun dateTimeFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): ObjDateTime {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
return if (hasExplicitTimeZone(trimmed)) {
|
if (hasExplicitTimeZone(trimmed)) {
|
||||||
val instant = Instant.parse(trimmed)
|
throw sqlExecutionError(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value")
|
||||||
ObjDateTime(instant, parseTimeZoneOrUtc(trimmed))
|
|
||||||
} else {
|
|
||||||
val local = LocalDateTime.parse(trimmed)
|
|
||||||
ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC)
|
|
||||||
}
|
}
|
||||||
|
val local = LocalDateTime.parse(trimmed)
|
||||||
|
return ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasExplicitTimeZone(value: String): Boolean {
|
private fun hasExplicitTimeZone(value: String): Boolean {
|
||||||
@ -531,21 +548,6 @@ private fun hasExplicitTimeZone(value: String): Boolean {
|
|||||||
return offsetStart > tIndex
|
return offsetStart > tIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseTimeZoneOrUtc(value: String): TimeZone {
|
|
||||||
if (value.endsWith("Z", ignoreCase = true)) return TimeZone.UTC
|
|
||||||
val tIndex = value.indexOf('T')
|
|
||||||
if (tIndex < 0) return TimeZone.UTC
|
|
||||||
val plus = value.lastIndexOf('+')
|
|
||||||
val minus = value.lastIndexOf('-')
|
|
||||||
val offsetStart = maxOf(plus, minus)
|
|
||||||
if (offsetStart <= tIndex) return TimeZone.UTC
|
|
||||||
return try {
|
|
||||||
TimeZone.of(value.substring(offsetStart))
|
|
||||||
} catch (_: IllegalArgumentException) {
|
|
||||||
TimeZone.UTC
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqliteCoreModule): Nothing {
|
private fun raiseExecuteReturningUsage(scope: ScopeFacade, core: SqliteCoreModule): Nothing {
|
||||||
scope.raiseError(
|
scope.raiseError(
|
||||||
ObjException(
|
ObjException(
|
||||||
@ -571,3 +573,30 @@ private fun databaseError(scope: ScopeFacade, core: SqliteCoreModule, message: S
|
|||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun integerToBool(scope: ScopeFacade, core: SqliteCoreModule, value: Long): Obj =
|
||||||
|
when (value) {
|
||||||
|
0L -> ObjBool(false)
|
||||||
|
1L -> ObjBool(true)
|
||||||
|
else -> throw sqlExecutionError(scope, core, "Invalid SQLite boolean value: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stringToBool(scope: ScopeFacade, core: SqliteCoreModule, value: String): Obj =
|
||||||
|
when (value.trim().lowercase()) {
|
||||||
|
"true", "t" -> ObjBool(true)
|
||||||
|
"false", "f" -> ObjBool(false)
|
||||||
|
else -> throw sqlExecutionError(scope, core, "Invalid SQLite boolean value: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeDeclaredTypeName(nativeTypeName: String): String {
|
||||||
|
val strippedSuffix = nativeTypeName.trim().replace(Regex("""\s*\(.*\)\s*$"""), "")
|
||||||
|
return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sqlExecutionError(scope: ScopeFacade, core: SqliteCoreModule, message: String): ExecutionError {
|
||||||
|
return ExecutionError(
|
||||||
|
ObjException(core.sqlExecutionException, scope.requireScope(), ObjString(message)),
|
||||||
|
scope.pos,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user