diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt index f3bc665..510269e 100644 --- a/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformJvm.kt @@ -23,6 +23,7 @@ import net.sergeych.lyng.ScopeFacade import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.ObjBool import net.sergeych.lyng.obj.ObjBuffer +import net.sergeych.lyng.obj.ObjDate import net.sergeych.lyng.obj.ObjDateTime import net.sergeych.lyng.obj.ObjEnumEntry 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.ObjString import net.sergeych.lyng.requireScope +import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant @@ -124,7 +126,7 @@ private class JdbcSqliteTransactionBackend( override suspend fun select(scope: ScopeFacade, clause: String, params: List): SqliteResultSetData { try { connection.prepareStatement(clause).use { statement -> - bindParams(statement, params, scope) + bindParams(statement, params, scope, core) statement.executeQuery().use { rs -> return readResultSet(scope, core, rs) } @@ -146,7 +148,7 @@ private class JdbcSqliteTransactionBackend( } try { connection.prepareStatement(clause, Statement.RETURN_GENERATED_KEYS).use { statement -> - bindParams(statement, params, scope) + bindParams(statement, params, scope, core) val hasResultSet = statement.execute() if (hasResultSet) { scope.raiseError( @@ -189,12 +191,12 @@ private class JdbcSqliteTransactionBackend( } } -private suspend fun bindParams(statement: PreparedStatement, params: List, scope: ScopeFacade) { +private suspend fun bindParams(statement: PreparedStatement, params: List, scope: ScopeFacade, core: SqliteCoreModule) { params.forEachIndexed { index, value -> val jdbcIndex = index + 1 when (value) { 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 ObjReal -> statement.setDouble(jdbcIndex, value.value) is ObjString -> statement.setString(jdbcIndex, value.value) @@ -203,7 +205,13 @@ private suspend fun bindParams(statement: PreparedStatement, params: List, is ObjDateTime -> statement.setString(jdbcIndex, value.localDateTime.toString()) else -> when (value.objClass.className) { "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, ): Obj { 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 when (value) { - is Boolean -> ObjBool(value) - is Byte, is Short, is Int -> ObjInt.of((value as Number).toLong()) - is Long -> ObjInt.of(value) + is Boolean -> if (isBooleanNativeType(normalizedNativeType)) ObjBool(value) else ObjInt.of(if (value) 1 else 0) + is Byte, is Short, is Int -> convertIntegerValue(scope, core, normalizedNativeType, (value as Number).toLong()) + is Long -> convertIntegerValue(scope, core, normalizedNativeType, value) is Float, is Double -> ObjReal.of((value as Number).toDouble()) 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()) 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( scope: ScopeFacade, core: SqliteCoreModule, - nativeType: String, + normalizedNativeType: String, value: String, ): Obj { - val normalized = nativeType.trim().uppercase() return when { - normalized == "DECIMAL" || normalized == "NUMERIC" -> decimalFromString(scope, value) - normalized == "DATETIME" || normalized == "TIMESTAMP" -> dateTimeFromString(value) - normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" -> - ObjInstant(Instant.parse(value)) + isBooleanNativeType(normalizedNativeType) -> booleanFromString(scope, core, value) + isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.trim()) + 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) } } -private fun isDecimalNativeType(nativeType: String): Boolean { - val normalized = nativeType.trim().uppercase() - return normalized == "DECIMAL" || normalized == "NUMERIC" +private fun isDecimalNativeType(normalizedNativeType: String): Boolean = + normalizedNativeType == "DECIMAL" || normalizedNativeType == "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 { @@ -282,15 +319,13 @@ private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj { 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() - return if (hasExplicitTimeZone(trimmed)) { - val instant = Instant.parse(trimmed) - ObjDateTime(instant, parseTimeZoneOrUtc(trimmed)) - } else { - val local = LocalDateTime.parse(trimmed) - ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC) + if (hasExplicitTimeZone(trimmed)) { + sqlExecutionFailure(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value") } + val local = LocalDateTime.parse(trimmed) + return ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC) } private fun hasExplicitTimeZone(value: String): Boolean { @@ -306,28 +341,23 @@ private fun hasExplicitTimeZone(value: String): Boolean { private fun containsRowReturningClause(clause: String): Boolean = Regex("""\breturning\b""", RegexOption.IGNORE_CASE).containsMatchIn(clause) -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 normalizeDeclaredTypeName(nativeTypeName: String): String { + val strippedSuffix = nativeTypeName.trim().replace(Regex("""\s*\(.*\)\s*$"""), "") + return strippedSuffix.uppercase().replace(Regex("""\s+"""), " ").trim() } private fun mapSqlType(core: SqliteCoreModule, nativeTypeName: String, jdbcType: Int): ObjEnumEntry { - val normalized = nativeTypeName.trim().uppercase() + val normalized = normalizeDeclaredTypeName(nativeTypeName) return when { - normalized == "BOOLEAN" -> core.sqlTypes.require("Bool") + normalized == "BOOLEAN" || normalized == "BOOL" -> core.sqlTypes.require("Bool") normalized == "DATE" -> core.sqlTypes.require("Date") 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.contains("BLOB") -> core.sqlTypes.require("Binary") 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 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) { try { if (savepoint == null) connection.rollback() else connection.rollback(savepoint) diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt index 5820ad7..e9c259e 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleTest.kt @@ -25,6 +25,7 @@ import net.sergeych.lyng.Scope import net.sergeych.lyng.Script import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.ObjBuffer +import net.sergeych.lyng.obj.ObjBool import net.sergeych.lyng.obj.ObjDateTime import net.sergeych.lyng.obj.ObjExternCallable 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(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 fun testReadOnlyOpenPreventsWrites() = runTest { val scope = Script.newScope() @@ -448,5 +505,11 @@ class LyngSqliteModuleTest { 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() } diff --git a/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt b/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt index d37e692..80bdda7 100644 --- a/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt +++ b/lyngio/src/linuxTest/kotlin/net/sergeych/lyng/io/db/sqlite/LyngSqliteModuleNativeTest.kt @@ -25,6 +25,7 @@ import net.sergeych.lyng.Pos import net.sergeych.lyng.Scope import net.sergeych.lyng.Script import net.sergeych.lyng.obj.Obj +import net.sergeych.lyng.obj.ObjBool import net.sergeych.lyng.obj.ObjBuffer import net.sergeych.lyng.obj.ObjDateTime 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(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 fun testReadOnlyOpenPreventsWrites() = runTest { val scope = Script.newScope() @@ -349,5 +406,11 @@ class LyngSqliteModuleNativeTest { 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() } diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformNative.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformNative.kt index a5648aa..51f1996 100644 --- a/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformNative.kt +++ b/lyngio/src/nativeMain/kotlin/net/sergeych/lyng/io/db/sqlite/PlatformNative.kt @@ -35,6 +35,7 @@ import kotlinx.cinterop.reinterpret import kotlinx.cinterop.toCPointer import kotlinx.cinterop.toKString import kotlinx.cinterop.usePinned +import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone 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.ObjBool import net.sergeych.lyng.obj.ObjBuffer +import net.sergeych.lyng.obj.ObjDate import net.sergeych.lyng.obj.ObjDateTime import net.sergeych.lyng.obj.ObjEnumEntry import net.sergeych.lyng.obj.ObjException @@ -274,7 +276,11 @@ private class NativeSqliteHandle( is ObjDateTime -> bindText(stmt, parameterIndex, value.localDateTime.toString(), memScope) else -> when (value.objClass.className) { "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) { @@ -353,25 +359,25 @@ private class NativeSqliteHandle( index: Int, nativeType: String, ): Obj { - val normalizedNativeType = nativeType.trim().uppercase() + val normalizedNativeType = normalizeDeclaredTypeName(nativeType) return when (val type = sqlite3_column_type(stmt, index)) { SQLITE_NULL -> ObjNull SQLITE_INTEGER -> { val value = sqlite3_column_int64(stmt, index) when { - normalizedNativeType == "BOOLEAN" -> ObjBool(value != 0L) - isDecimalNativeType(nativeType) -> decimalFromString(scope, value.toString()) + isBooleanNativeType(normalizedNativeType) -> integerToBool(scope, core, value) + isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.toString()) else -> ObjInt.of(value) } } SQLITE_FLOAT -> { 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 -> { val textPtr = sqlite3_column_text(stmt, index)?.reinterpret() val value = textPtr?.toKString() ?: "" - convertStringValue(scope, nativeType, value) + convertStringValue(scope, core, normalizedNativeType, value) } SQLITE_BLOB -> { 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 { - val normalized = nativeType.trim().uppercase() + private suspend fun convertStringValue( + scope: ScopeFacade, + core: SqliteCoreModule, + normalizedNativeType: String, + value: String, + ): Obj { return when { - normalized == "DECIMAL" || normalized == "NUMERIC" -> decimalFromString(scope, value) - normalized == "DATETIME" || normalized == "TIMESTAMP" -> dateTimeFromString(value) - normalized == "TIMESTAMP WITH TIME ZONE" || normalized == "TIMESTAMPTZ" -> ObjInstant(Instant.parse(value)) + isBooleanNativeType(normalizedNativeType) -> stringToBool(scope, core, value) + isDecimalNativeType(normalizedNativeType) -> decimalFromString(scope, value.trim()) + 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) } } @@ -483,26 +498,30 @@ private val SQLITE_TRANSIENT = (-1L).toCPointer U private fun emptyResultSet(): SqliteResultSetData = SqliteResultSetData(emptyList(), emptyList()) -private fun mapSqlType(core: SqliteCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when { - nativeType.trim().equals("BOOLEAN", ignoreCase = true) -> core.sqlTypes.require("Bool") - nativeType.trim().equals("DATE", ignoreCase = true) -> core.sqlTypes.require("Date") - nativeType.trim().equals("DATETIME", ignoreCase = true) || nativeType.trim().equals("TIMESTAMP", ignoreCase = true) -> core.sqlTypes.require("DateTime") - nativeType.trim().equals("TIMESTAMP WITH TIME ZONE", ignoreCase = true) || nativeType.trim().equals("TIMESTAMPTZ", ignoreCase = true) -> core.sqlTypes.require("Instant") - nativeType.trim().equals("DECIMAL", ignoreCase = true) || nativeType.trim().equals("NUMERIC", ignoreCase = true) -> core.sqlTypes.require("Decimal") - nativeType.contains("BLOB", ignoreCase = true) -> core.sqlTypes.require("Binary") - nativeType.contains("INT", ignoreCase = true) -> core.sqlTypes.require("Int") - nativeType.contains("CHAR", ignoreCase = true) || nativeType.contains("TEXT", ignoreCase = true) || nativeType.contains("CLOB", ignoreCase = true) -> core.sqlTypes.require("String") - nativeType.contains("REAL", ignoreCase = true) || nativeType.contains("FLOA", ignoreCase = true) || nativeType.contains("DOUB", ignoreCase = true) -> core.sqlTypes.require("Double") - sqliteType == SQLITE_INTEGER -> core.sqlTypes.require("Int") - sqliteType == SQLITE_FLOAT -> core.sqlTypes.require("Double") - sqliteType == SQLITE_BLOB -> core.sqlTypes.require("Binary") - else -> core.sqlTypes.require("String") +private fun mapSqlType(core: SqliteCoreModule, nativeType: String, sqliteType: Int): ObjEnumEntry = when (val normalized = normalizeDeclaredTypeName(nativeType)) { + "BOOLEAN", "BOOL" -> core.sqlTypes.require("Bool") + "DATE" -> core.sqlTypes.require("Date") + "DATETIME", "TIMESTAMP" -> core.sqlTypes.require("DateTime") + "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ", "DATETIME WITH TIME ZONE" -> core.sqlTypes.require("Instant") + "DECIMAL", "NUMERIC" -> core.sqlTypes.require("Decimal") + "TIME", "TIME WITHOUT TIME ZONE", "TIME WITH TIME ZONE" -> core.sqlTypes.require("String") + else -> when { + normalized.contains("BLOB") -> core.sqlTypes.require("Binary") + normalized.contains("INT") -> core.sqlTypes.require("Int") + normalized.contains("CHAR") || normalized.contains("TEXT") || normalized.contains("CLOB") -> core.sqlTypes.require("String") + normalized.contains("REAL") || normalized.contains("FLOA") || normalized.contains("DOUB") -> core.sqlTypes.require("Double") + sqliteType == SQLITE_INTEGER -> core.sqlTypes.require("Int") + 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 { - val normalized = nativeType.trim().uppercase() - return normalized == "DECIMAL" || normalized == "NUMERIC" -} +private fun isDecimalNativeType(normalizedNativeType: String): Boolean = + normalizedNativeType == "DECIMAL" || normalizedNativeType == "NUMERIC" + +private fun isBooleanNativeType(normalizedNativeType: String): Boolean = + normalizedNativeType == "BOOLEAN" || normalizedNativeType == "BOOL" private suspend fun decimalFromString(scope: ScopeFacade, value: String): Obj { 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)) } -private fun dateTimeFromString(value: String): ObjDateTime { +private fun dateTimeFromString(scope: ScopeFacade, core: SqliteCoreModule, value: String): ObjDateTime { val trimmed = value.trim() - return if (hasExplicitTimeZone(trimmed)) { - val instant = Instant.parse(trimmed) - ObjDateTime(instant, parseTimeZoneOrUtc(trimmed)) - } else { - val local = LocalDateTime.parse(trimmed) - ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC) + if (hasExplicitTimeZone(trimmed)) { + throw sqlExecutionError(scope, core, "SQLite TIMESTAMP/DATETIME value must not contain a timezone offset: $value") } + val local = LocalDateTime.parse(trimmed) + return ObjDateTime(local.toInstant(TimeZone.UTC), TimeZone.UTC) } private fun hasExplicitTimeZone(value: String): Boolean { @@ -531,21 +548,6 @@ private fun hasExplicitTimeZone(value: String): Boolean { 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 { scope.raiseError( ObjException( @@ -571,3 +573,30 @@ private fun databaseError(scope: ScopeFacade, core: SqliteCoreModule, message: S 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, + ) +}