├── sample ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── themes.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ └── drawable │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ └── ic_launcher_background.xml │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── mercury │ │ │ └── sqkon │ │ │ └── sample │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── mercury │ │ └── sqkon │ │ └── sample │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle.kts ├── assets └── logo.png ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── library ├── src │ ├── commonMain │ │ ├── sqldelight │ │ │ ├── databases │ │ │ │ └── 1.db │ │ │ ├── migrations │ │ │ │ └── 1.sqm │ │ │ └── com │ │ │ │ └── mercury │ │ │ │ └── sqkon │ │ │ │ └── db │ │ │ │ ├── entity.sq │ │ │ │ └── metadata.sq │ │ └── kotlin │ │ │ └── com │ │ │ └── mercury │ │ │ └── sqkon │ │ │ └── db │ │ │ ├── utils │ │ │ ├── DateExt.kt │ │ │ ├── RequestHash.kt │ │ │ └── SqkonTransacter.kt │ │ │ ├── SqkonDatabaseDriver.kt │ │ │ ├── adapters │ │ │ └── InstantColumnAdapter.kt │ │ │ ├── MetadataQueries.kt │ │ │ ├── serialization │ │ │ ├── SqkonSerializer.kt │ │ │ └── KotlinSqkonSerializer.kt │ │ │ ├── paging │ │ │ ├── QueryPagingSource.kt │ │ │ └── OffsetQueryPagingSource.kt │ │ │ ├── ResultRow.kt │ │ │ ├── Sqkon.kt │ │ │ ├── JsonPath.kt │ │ │ ├── EntityQueries.kt │ │ │ ├── QueryExt.kt │ │ │ └── KeyValueStorage.kt │ ├── jvmMain │ │ └── kotlin │ │ │ └── com │ │ │ └── mercury │ │ │ └── sqkon │ │ │ └── db │ │ │ ├── EntityQueries.jvm.kt │ │ │ ├── Sqkon.jvm.kt │ │ │ └── SqkonDatabaseDriver.jvm.kt │ ├── androidMain │ │ └── kotlin │ │ │ └── com │ │ │ └── mercury │ │ │ └── sqkon │ │ │ └── db │ │ │ ├── EntityQueries.android.kt │ │ │ ├── Sqkon.android.kt │ │ │ └── SqkonDatabaseDriver.android.kt │ ├── commonTest │ │ └── kotlin │ │ │ └── com │ │ │ └── mercury │ │ │ └── sqkon │ │ │ ├── db │ │ │ ├── SqkonDatabaseDriverTest.kt │ │ │ ├── KeyValueStorageEnumTest.kt │ │ │ ├── DeserializationTest.kt │ │ │ ├── MetadataTest.kt │ │ │ ├── KeyValueStorageSealedTest.kt │ │ │ ├── OffsetPagingTest.kt │ │ │ ├── JsonPathBuilderTest.kt │ │ │ ├── KeyValueStorageExpiresTest.kt │ │ │ └── KeyValueStorageStaleTest.kt │ │ │ ├── serialization │ │ │ └── KotlinSqkonSerializerKtTest.kt │ │ │ ├── TestUtilsExt.kt │ │ │ ├── DescriptorTest.kt │ │ │ └── TestDataClasses.kt │ ├── jvmTest │ │ └── kotlin │ │ │ └── com │ │ │ └── mercury │ │ │ └── sqkon │ │ │ └── db │ │ │ └── SqkonDatabaseDriverTest.jvm.kt │ └── androidInstrumentedTest │ │ └── kotlin │ │ └── com │ │ └── mercury │ │ └── sqkon │ │ └── db │ │ └── SqkonDatabaseDriverTest.android.kt ├── gradle.properties └── build.gradle.kts ├── .gitignore ├── gradle.properties ├── .github └── workflows │ ├── deploy-to-maven.yml │ └── ci.yml ├── settings.gradle.kts ├── gradlew.bat ├── README.MD ├── gradlew ├── .junie └── guidelines.md ├── LICENSE └── scripts └── create-release.sh /sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/assets/logo.png -------------------------------------------------------------------------------- /sample/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | sample 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /library/src/commonMain/sqldelight/databases/1.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/library/src/commonMain/sqldelight/databases/1.db -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /library/src/jvmMain/kotlin/com/mercury/sqkon/db/EntityQueries.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | actual typealias SqlException = java.sql.SQLException -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /library/src/androidMain/kotlin/com/mercury/sqkon/db/EntityQueries.android.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | actual typealias SqlException = java.sql.SQLException -------------------------------------------------------------------------------- /library/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=library 2 | POM_NAME=Library 3 | POM_DESCRIPTION=Core library for Sqkon, includes runtime and code used to make sqkon work. 4 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/db/SqkonDatabaseDriverTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | internal expect fun driverFactory(): DriverFactory 4 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MercuryTechnologies/sqkon/HEAD/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.iml 3 | .gradle 4 | .idea 5 | .kotlin 6 | .DS_Store 7 | build 8 | */build 9 | captures 10 | .externalNativeBuild 11 | .cxx 12 | local.properties 13 | xcuserdata/ 14 | Pods/ 15 | *.jks 16 | *yarn.lock 17 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/utils/DateExt.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db.utils 2 | 3 | import kotlinx.datetime.Clock 4 | 5 | /** 6 | * Now in milliseconds. Unix epoch. 7 | * 8 | * TODO pass Clock through from the builder for testing 9 | */ 10 | fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() -------------------------------------------------------------------------------- /library/src/jvmTest/kotlin/com/mercury/sqkon/db/SqkonDatabaseDriverTest.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType 4 | 5 | 6 | internal actual fun driverFactory(): DriverFactory { 7 | return DriverFactory( 8 | databaseType = AndroidxSqliteDatabaseType.Memory, 9 | ) 10 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/utils/RequestHash.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db.utils 2 | 3 | import kotlin.coroutines.AbstractCoroutineContextElement 4 | import kotlin.coroutines.CoroutineContext 5 | 6 | internal class RequestHash(val hash: Int) : AbstractCoroutineContextElement(Key) { 7 | companion object Key : CoroutineContext.Key 8 | } -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /sample/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /sample/src/test/java/com/mercury/sqkon/sample/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.sample 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/SqkonDatabaseDriver.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | import kotlinx.coroutines.CoroutineDispatcher 5 | 6 | internal expect val connectionPoolSize: Int 7 | @PublishedApi 8 | internal expect val dbWriteDispatcher: CoroutineDispatcher 9 | @PublishedApi 10 | internal expect val dbReadDispatcher: CoroutineDispatcher 11 | 12 | internal expect class DriverFactory { 13 | fun createDriver(): SqlDriver 14 | } 15 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/adapters/InstantColumnAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db.adapters 2 | 3 | import app.cash.sqldelight.ColumnAdapter 4 | import kotlinx.datetime.Instant 5 | 6 | internal class InstantColumnAdapter : ColumnAdapter { 7 | override fun decode(databaseValue: Long): Instant { 8 | return Instant.fromEpochMilliseconds(databaseValue) 9 | } 10 | 11 | override fun encode(value: Instant): Long { 12 | return value.toEpochMilliseconds() 13 | } 14 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/MetadataQueries.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import app.cash.sqldelight.db.SqlDriver 4 | import com.mercury.sqkon.db.adapters.InstantColumnAdapter 5 | 6 | /** 7 | * Factory method to create [MetadataQueries] instance 8 | */ 9 | internal fun MetadataQueries(driver: SqlDriver): MetadataQueries { 10 | return MetadataQueries( 11 | driver = driver, 12 | metadataAdapter = Metadata.Adapter( 13 | lastWriteAtAdapter = InstantColumnAdapter(), 14 | lastReadAtAdapter = InstantColumnAdapter(), 15 | ), 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/serialization/SqkonSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db.serialization 2 | 3 | import kotlin.reflect.KType 4 | import kotlin.reflect.typeOf 5 | 6 | interface SqkonSerializer { 7 | fun serialize(type: KType, value: T?): String? 8 | fun deserialize(type: KType, value: String?): T? 9 | } 10 | 11 | inline fun SqkonSerializer.serialize(value: T?): String? { 12 | return serialize(typeOf(), value) 13 | } 14 | 15 | inline fun SqkonSerializer.deserialize(value: String?): T? { 16 | return deserialize(typeOf(), value) 17 | } 18 | -------------------------------------------------------------------------------- /library/src/androidInstrumentedTest/kotlin/com/mercury/sqkon/db/SqkonDatabaseDriverTest.android.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import kotlin.uuid.ExperimentalUuidApi 5 | import kotlin.uuid.Uuid 6 | 7 | @OptIn(ExperimentalUuidApi::class) 8 | internal actual fun driverFactory(): DriverFactory { 9 | val uuid = Uuid.random() 10 | return DriverFactory( 11 | context = InstrumentationRegistry.getInstrumentation().targetContext, 12 | // random file each time to make sure testing against file system and WAL is enabled 13 | name = "sqkon-test-$uuid.db", 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /library/src/commonMain/sqldelight/migrations/1.sqm: -------------------------------------------------------------------------------- 1 | import kotlinx.datetime.Instant; 2 | 3 | CREATE TABLE metadata ( 4 | entity_name TEXT NOT NULL PRIMARY KEY, 5 | lastReadAt INTEGER AS Instant, 6 | lastWriteAt INTEGER AS Instant 7 | ); 8 | 9 | ALTER TABLE entity ADD COLUMN read_at INTEGER; 10 | ALTER TABLE entity ADD COLUMN write_at INTEGER; 11 | 12 | UPDATE entity SET write_at = CURRENT_TIMESTAMP; 13 | 14 | -- Index read_at 15 | CREATE INDEX idx_entity_read_at ON entity (read_at); 16 | -- Index write_at 17 | CREATE INDEX idx_entity_write_at ON entity (write_at); 18 | -- Index expires_at 19 | CREATE INDEX idx_entity_expires_at ON entity (expires_at); 20 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/serialization/KotlinSqkonSerializerKtTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.serialization 2 | 3 | import com.mercury.sqkon.TestObject 4 | import com.mercury.sqkon.TestSealed 5 | import com.mercury.sqkon.db.serialization.SqkonJson 6 | import kotlin.test.Test 7 | import kotlin.test.assertNotEquals 8 | 9 | class KotlinSqkonSerializerKtTest { 10 | 11 | @Test 12 | fun testSealedSerialization() { 13 | val json = SqkonJson { } 14 | 15 | val one = TestObject() 16 | val two = one.copy(sealed = TestSealed.Impl(boolean = true)) 17 | 18 | assertNotEquals( 19 | json.encodeToString(one).also { println(it) }, 20 | json.encodeToString(two).also { println(it) } 21 | ) 22 | 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/paging/QueryPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db.paging 2 | 3 | import app.cash.paging.PagingSource 4 | import app.cash.sqldelight.Query 5 | import com.mercury.sqkon.db.Entity 6 | import kotlin.properties.Delegates 7 | 8 | internal abstract class QueryPagingSource : PagingSource(), 9 | Query.Listener { 10 | 11 | protected var currentQuery: Query? by Delegates.observable(null) { _, old, new -> 12 | old?.removeListener(this) 13 | new?.addListener(this) 14 | } 15 | 16 | init { 17 | registerInvalidatedCallback { 18 | currentQuery?.removeListener(this) 19 | currentQuery = null 20 | } 21 | } 22 | 23 | final override fun queryResultsChanged() = invalidate() 24 | 25 | } 26 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/mercury/sqkon/sample/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.sample 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.mercury.sqkon.sample", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /sample/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /sample/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /sample/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /library/src/jvmMain/kotlin/com/mercury/sqkon/db/Sqkon.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType 4 | import com.mercury.sqkon.db.serialization.SqkonJson 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.serialization.json.Json 7 | 8 | fun Sqkon( 9 | scope: CoroutineScope, 10 | json: Json = SqkonJson { }, 11 | type: AndroidxSqliteDatabaseType = AndroidxSqliteDatabaseType.Memory, 12 | config: KeyValueStorage.Config = KeyValueStorage.Config(), 13 | ): Sqkon { 14 | val factory = DriverFactory(type) 15 | val driver = factory.createDriver() 16 | val metadataQueries = MetadataQueries(driver) 17 | val entityQueries = EntityQueries(driver) 18 | return Sqkon( 19 | entityQueries, metadataQueries, scope, json, config, 20 | readDispatcher = dbReadDispatcher, 21 | writeDispatcher = dbWriteDispatcher, 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx4G 3 | org.gradle.caching=true 4 | org.gradle.configuration-cache=true 5 | org.gradle.daemon=true 6 | org.gradle.parallel=true 7 | # Maven 8 | GROUP=com.mercury.sqkon 9 | VERSION_NAME=1.2.0 10 | POM_NAME=Sqkon 11 | POM_INCEPTION_YEAR=2024 12 | POM_URL=https://github.com/MercuryTechnologies/sqkon/ 13 | POM_LICENSE_NAME=The Apache Software License, Version 2.0 14 | POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt 15 | POM_LICENSE_DIST=repo 16 | POM_SCM_URL=https://github.com/MercuryTechnologies/sqkon/ 17 | POM_SCM_CONNECTION=scm:git:git://github.com/MercuryTechnologies/sqkon.git 18 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/MercuryTechnologies/sqkon.git 19 | POM_DEVELOPER_NAME=MercuryTechnologies 20 | POM_DEVELOPER_URL=https://github.com/MercuryTechnologies/ 21 | #Kotlin 22 | kotlin.code.style=official 23 | kotlin.daemon.jvmargs=-Xmx4G 24 | #Android 25 | android.useAndroidX=true 26 | android.nonTransitiveRClass=true 27 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/TestUtilsExt.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.TimeoutCancellationException 5 | import kotlinx.coroutines.delay 6 | import kotlinx.coroutines.withContext 7 | import kotlinx.coroutines.withTimeout 8 | import kotlin.time.Duration 9 | import kotlin.time.Duration.Companion.seconds 10 | 11 | 12 | private val timeoutDispatcher = Dispatchers.Default.limitedParallelism(1, "timeout") 13 | 14 | /** 15 | * Will iterate on the block until it returns true or the timeout is reached. 16 | * 17 | * @throws TimeoutCancellationException if the timeout is reached. 18 | */ 19 | @Throws(TimeoutCancellationException::class) 20 | suspend fun until(timeout: Duration = 5.seconds, block: suspend () -> Boolean) = 21 | withContext(timeoutDispatcher) { 22 | withTimeout(timeout) { 23 | while (!block()) { 24 | delay(100) 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/ResultRow.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import kotlinx.datetime.Clock 4 | import kotlinx.datetime.Instant 5 | 6 | data class ResultRow( 7 | val addedAt: Instant, 8 | val updatedAt: Instant, 9 | val expiresAt: Instant?, 10 | val readAt: Instant?, 11 | val writeAt: Instant, 12 | val value: T, 13 | ) { 14 | internal constructor(entity: Entity, value: T) : this( 15 | addedAt = Instant.fromEpochMilliseconds(entity.added_at), 16 | updatedAt = Instant.fromEpochMilliseconds(entity.updated_at), 17 | expiresAt = entity.expires_at?.let { Instant.fromEpochMilliseconds(it) }, 18 | // By reading this value, we are marking it as read, we just update the db async 19 | readAt = Clock.System.now(), 20 | // While write_at col is nullable, we always set it. (Sqlite limitation) 21 | writeAt = Instant.fromEpochMilliseconds(entity.write_at!!), 22 | value = value, 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /library/src/commonMain/sqldelight/com/mercury/sqkon/db/entity.sq: -------------------------------------------------------------------------------- 1 | import kotlin.String; 2 | 3 | CREATE TABLE entity ( 4 | entity_name TEXT NOT NULL, 5 | entity_key TEXT NOT NULL, 6 | -- UTC timestamp in milliseconds 7 | added_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | -- UTC timestamp in milliseconds 9 | updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, 10 | -- UTC timestamp in milliseconds 11 | expires_at INTEGER, 12 | -- JSONB Blob use jsonb_ operators 13 | value BLOB AS String NOT NULL, 14 | -- UTC timestamp in milliseconds 15 | read_at INTEGER, 16 | -- UTC timestamp in milliseconds 17 | write_at INTEGER, 18 | PRIMARY KEY (entity_name, entity_key) 19 | ); 20 | 21 | -- Index read_at 22 | CREATE INDEX idx_entity_read_at ON entity (read_at); 23 | -- Index write_at 24 | CREATE INDEX idx_entity_write_at ON entity (write_at); 25 | -- Index expires_at 26 | CREATE INDEX idx_entity_expires_at ON entity (expires_at); 27 | 28 | -- insertEntity: 29 | -- INSERT INTO entity VALUES ?; 30 | -- selectByName: 31 | -- SELECT * FROM entity WHERE entity_name = ? 32 | -- count: 33 | -- SELECT COUNT(*) FROM entity WHERE entity_name = ?; 34 | -- delete: 35 | -- DELETE FROM entity WHERE entity_name = ?; 36 | -------------------------------------------------------------------------------- /.github/workflows/deploy-to-maven.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a release to Maven Central 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [released] 7 | 8 | jobs: 9 | build-and-publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 13 | - name: Setup Java JDK 14 | uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 15 | with: 16 | distribution: 'zulu' 17 | java-version: '21' 18 | - name: Setup Gradle 19 | uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 20 | 21 | - name: Build and publish 22 | run: ./gradlew publishAllPublicationsToMavenCentralRepository --no-configuration-cache 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | RELEASE_VERSION: ${{ github.event.release.name }} 26 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SIGNING_KEY }} 27 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_PASSPHRASE }} 28 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.CENTRAL_PORTAL_USERNAME }} 29 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.CENTRAL_PORTAL_PASSWORD }} 30 | -------------------------------------------------------------------------------- /sample/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | } 5 | 6 | android { 7 | namespace = "com.mercury.sqkon.sample" 8 | compileSdk = 35 9 | 10 | defaultConfig { 11 | applicationId = "com.mercury.sqkon.sample" 12 | minSdk = 24 13 | targetSdk = 35 14 | versionCode = 1 15 | versionName = "1.0" 16 | 17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | isMinifyEnabled = false 23 | proguardFiles( 24 | getDefaultProguardFile("proguard-android-optimize.txt"), 25 | "proguard-rules.pro" 26 | ) 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility = JavaVersion.VERSION_11 31 | targetCompatibility = JavaVersion.VERSION_11 32 | } 33 | kotlinOptions { 34 | jvmTarget = "11" 35 | } 36 | } 37 | 38 | dependencies { 39 | //noinspection UseTomlInstead 40 | //https://maven.pkg.github.com/MercuryTechnologies/sqkon/com/mercury/sqkon/library-android/0.1.0-alpha01/library-android-0.1.0-alpha01.pom 355 ms 0 B 0 B/s 41 | //implementation("com.mercury.sqkon:library-android:0.1.0-alpha03") 42 | } -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/DescriptorTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon 2 | 3 | import kotlinx.serialization.ExperimentalSerializationApi 4 | import kotlinx.serialization.descriptors.SerialDescriptor 5 | import kotlinx.serialization.descriptors.StructureKind 6 | import kotlinx.serialization.descriptors.elementDescriptors 7 | import kotlinx.serialization.descriptors.elementNames 8 | import kotlinx.serialization.internal.jsonCachedSerialNames 9 | import kotlinx.serialization.serializer 10 | import kotlin.test.Test 11 | import kotlin.test.assertEquals 12 | import kotlin.test.assertTrue 13 | 14 | @OptIn(ExperimentalSerializationApi::class) 15 | class DescriptorTest { 16 | 17 | @Test 18 | fun getDescriptorInfoOfInlineClass() { 19 | val d = getDescriptor() 20 | assertTrue(d.isInline) 21 | } 22 | 23 | @Test 24 | fun getDescriptorInfoOfLis() { 25 | val d = getDescriptor>() 26 | assertEquals(StructureKind.LIST, d.kind) 27 | println(d.elementDescriptors.forEach { println(it) }) 28 | val typeD = d.elementDescriptors.first() 29 | println(typeD) 30 | assertEquals(StructureKind.CLASS, typeD.kind) 31 | } 32 | 33 | private inline fun getDescriptor(): SerialDescriptor { 34 | return serializer().descriptor 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | rootProject.name = "sqkon" 4 | 5 | pluginManagement { 6 | repositories { 7 | google { 8 | content { 9 | includeGroupByRegex("com\\.android.*") 10 | includeGroupByRegex("com\\.google.*") 11 | includeGroupByRegex("androidx.*") 12 | includeGroupByRegex("android.*") 13 | } 14 | } 15 | gradlePluginPortal() 16 | mavenCentral() 17 | } 18 | } 19 | 20 | dependencyResolutionManagement { 21 | repositories { 22 | maven { 23 | setUrl("https://jitpack.io") 24 | content { 25 | includeGroupByRegex("com\\.github.*") 26 | } 27 | } 28 | google { 29 | content { 30 | includeGroupByRegex("com\\.android.*") 31 | includeGroupByRegex("com\\.google.*") 32 | includeGroupByRegex("androidx.*") 33 | includeGroupByRegex("android.*") 34 | } 35 | } 36 | mavenCentral() 37 | mavenLocal { 38 | content { 39 | includeGroup("com.mercury.sqkon") 40 | } 41 | } 42 | maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots") { 43 | content { 44 | includeGroup("com.eygraber") 45 | } 46 | } 47 | } 48 | } 49 | 50 | include( 51 | ":library", 52 | //":sample" 53 | ) 54 | -------------------------------------------------------------------------------- /library/src/androidMain/kotlin/com/mercury/sqkon/db/Sqkon.android.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import android.content.Context 4 | import com.mercury.sqkon.db.serialization.SqkonJson 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.serialization.json.Json 7 | 8 | @Deprecated("Use other Sqkon method instead") 9 | fun Sqkon( 10 | context: Context, 11 | scope: CoroutineScope, 12 | json: Json = SqkonJson { }, 13 | inMemory: Boolean = false, 14 | config: KeyValueStorage.Config = KeyValueStorage.Config(), 15 | ): Sqkon = Sqkon( 16 | context = context, 17 | scope = scope, 18 | json = json, 19 | dbFileName = if (inMemory) null else "sqkon.db", 20 | config = config, 21 | ) 22 | 23 | /** 24 | * Main entry point for Sqkon on Android 25 | * 26 | * @param dbFileName name of the db file on disk, if null we create an in-memory db 27 | */ 28 | fun Sqkon( 29 | context: Context, 30 | scope: CoroutineScope, 31 | json: Json = SqkonJson { }, 32 | dbFileName: String? = "sqkon.db", 33 | config: KeyValueStorage.Config = KeyValueStorage.Config(), 34 | ): Sqkon { 35 | val factory = DriverFactory(context = context, name = dbFileName) 36 | val driver = factory.createDriver() 37 | val metadataQueries = MetadataQueries(driver) 38 | val entityQueries = EntityQueries(driver) 39 | return Sqkon( 40 | entityQueries, metadataQueries, scope, json, config, 41 | readDispatcher = dbReadDispatcher, 42 | writeDispatcher = dbWriteDispatcher, 43 | ) 44 | } 45 | 46 | -------------------------------------------------------------------------------- /library/src/commonMain/sqldelight/com/mercury/sqkon/db/metadata.sq: -------------------------------------------------------------------------------- 1 | import kotlinx.datetime.Instant; 2 | 3 | CREATE TABLE metadata ( 4 | entity_name TEXT NOT NULL PRIMARY KEY, 5 | lastReadAt INTEGER AS Instant, 6 | lastWriteAt INTEGER AS Instant 7 | ); 8 | 9 | selectByEntityName: 10 | SELECT * FROM metadata WHERE entity_name = ?; 11 | 12 | upsertRead: 13 | INSERT INTO metadata (entity_name, lastReadAt) 14 | VALUES (:entity_name, :lastReadAt) 15 | ON CONFLICT(entity_name) 16 | DO 17 | UPDATE SET lastReadAt = :lastReadAt 18 | WHERE entity_name = :entity_name; 19 | 20 | updateReadForEntities: 21 | UPDATE entity SET read_at = :readAt 22 | WHERE entity_name = :entity_name AND entity_key IN :entity_keys; 23 | 24 | upsertWrite: 25 | INSERT INTO metadata (entity_name, lastWriteAt) 26 | VALUES (:entity_name, :lastWriteAt) 27 | ON CONFLICT(entity_name) 28 | DO 29 | UPDATE SET lastWriteAt = :lastWriteAt 30 | WHERE entity_name = :entity_name; 31 | 32 | purgeExpires: 33 | DELETE FROM entity 34 | WHERE entity_name = :entity_name AND expires_at IS NOT NULL AND expires_at < :expiresAfter; 35 | 36 | purgeStale: 37 | DELETE FROM entity 38 | WHERE entity_name = :entity_name 39 | AND write_at < :writeInstant 40 | AND (read_at IS NULL OR read_at < :readInstant); 41 | 42 | purgeStaleWrite: 43 | DELETE FROM entity WHERE entity_name = :entity_name AND write_at < :writeInstant; 44 | 45 | purgeStaleRead: 46 | DELETE FROM entity WHERE entity_name = :entity_name AND read_at < :readInstant; 47 | -------------------------------------------------------------------------------- /library/src/jvmMain/kotlin/com/mercury/sqkon/db/SqkonDatabaseDriver.jvm.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import androidx.sqlite.driver.bundled.BundledSQLiteDriver 4 | import app.cash.sqldelight.db.SqlDriver 5 | import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration 6 | import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType 7 | import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver 8 | import com.eygraber.sqldelight.androidx.driver.SqliteJournalMode 9 | import com.eygraber.sqldelight.androidx.driver.SqliteSync 10 | import kotlinx.coroutines.Dispatchers 11 | 12 | internal actual const val connectionPoolSize = 4 // Default is 4 as per AndroidxSqliteConfiguration 13 | 14 | @PublishedApi 15 | internal actual val dbWriteDispatcher by lazy { Dispatchers.IO.limitedParallelism(1) } 16 | 17 | @PublishedApi 18 | internal actual val dbReadDispatcher by lazy { 19 | Dispatchers.IO.limitedParallelism(connectionPoolSize) 20 | } 21 | 22 | internal actual class DriverFactory( 23 | private val databaseType: AndroidxSqliteDatabaseType = AndroidxSqliteDatabaseType.Memory, 24 | ) { 25 | 26 | actual fun createDriver(): SqlDriver { 27 | return AndroidxSqliteDriver( 28 | driver = BundledSQLiteDriver(), 29 | databaseType = databaseType, 30 | schema = SqkonDatabase.Schema, 31 | configuration = AndroidxSqliteConfiguration( 32 | journalMode = SqliteJournalMode.WAL, 33 | sync = SqliteSync.Normal, 34 | readerConnectionsCount = connectionPoolSize, 35 | ) 36 | ) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/serialization/KotlinSqkonSerializer.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db.serialization 2 | 3 | import kotlinx.serialization.json.Json 4 | import kotlinx.serialization.json.JsonBuilder 5 | import kotlinx.serialization.serializer 6 | import kotlin.reflect.KType 7 | 8 | /** 9 | * Default serialization for KeyValueStorage. Uses kotlinx.serialization.Json for serialization. 10 | */ 11 | class KotlinSqkonSerializer( 12 | val json: Json = SqkonJson {} 13 | ) : SqkonSerializer { 14 | override fun serialize(type: KType, value: T?): String? { 15 | value ?: return null 16 | return json.encodeToString(serializer(type), value) 17 | } 18 | 19 | @Suppress("UNCHECKED_CAST") 20 | override fun deserialize(type: KType, value: String?): T? { 21 | value ?: return null 22 | return json.decodeFromString(serializer(type), value) as T 23 | } 24 | } 25 | 26 | 27 | /** 28 | * Recommended default json configuration for KeyValueStorage. 29 | * 30 | * This configuration generally allows changes to json and enables ordering and querying. 31 | * 32 | * - `ignoreUnknownKeys = true` is generally recommended to allow for removing fields from classes. 33 | * - `encodeDefaults` = true, is required to be able to query on default values, otherwise that field 34 | * is missing in the db. 35 | * - `useArrayPolymorphism = true` is required for polymorphic serialization when you use value 36 | * classes without custom descriptors 37 | */ 38 | fun SqkonJson(builder: JsonBuilder.() -> Unit) = Json { 39 | ignoreUnknownKeys = true 40 | encodeDefaults = true 41 | // https://github.com/Kotlin/kotlinx.serialization/issues/2049#issuecomment-1271536271 42 | useArrayPolymorphism = true 43 | builder() 44 | } 45 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/utils/SqkonTransacter.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db.utils 2 | 3 | import app.cash.sqldelight.Transacter 4 | import app.cash.sqldelight.TransacterImpl 5 | import app.cash.sqldelight.TransactionWithReturn 6 | import app.cash.sqldelight.TransactionWithoutReturn 7 | import app.cash.sqldelight.db.SqlDriver 8 | 9 | open class SqkonTransacter(driver: SqlDriver) : TransacterImpl(driver) { 10 | 11 | // Child -> Parent 12 | protected val trxMap = mutableMapOf() 13 | 14 | fun Transacter.Transaction.parentTransactionHash(): Int { 15 | // Get enclosing transaction, otherwise we're probably top level, use that hashcode 16 | return trxMap[this]?.parentTransactionHash() ?: this.hashCode() 17 | } 18 | 19 | override fun transaction( 20 | noEnclosing: Boolean, 21 | body: TransactionWithoutReturn.() -> Unit 22 | ) { 23 | val parentTrx = driver.currentTransaction() 24 | return super.transaction( 25 | noEnclosing = noEnclosing, 26 | body = { 27 | // Inside transaction here 28 | val currentTrx = driver.currentTransaction()!! 29 | trxMap[currentTrx] = parentTrx 30 | try { 31 | body() 32 | } finally { 33 | // End of transaction 34 | trxMap.remove(currentTrx) 35 | } 36 | } 37 | ) 38 | } 39 | 40 | override fun transactionWithResult( 41 | noEnclosing: Boolean, 42 | bodyWithReturn: TransactionWithReturn.() -> R 43 | ): R { 44 | val parentTrx = driver.currentTransaction() 45 | return super.transactionWithResult( 46 | noEnclosing = noEnclosing, 47 | bodyWithReturn = { 48 | // Inside transaction here 49 | val currentTrx = driver.currentTransaction()!! 50 | trxMap[currentTrx] = parentTrx 51 | try { 52 | bodyWithReturn() 53 | } finally { 54 | // End of transaction 55 | trxMap.remove(currentTrx) 56 | } 57 | } 58 | ) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageEnumTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalUuidApi::class) 2 | 3 | package com.mercury.sqkon.db 4 | 5 | import com.mercury.sqkon.TestEnum 6 | import com.mercury.sqkon.TestObject 7 | import kotlinx.coroutines.MainScope 8 | import kotlinx.coroutines.cancel 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.test.runTest 11 | import kotlin.test.AfterTest 12 | import kotlin.test.Test 13 | import kotlin.test.assertEquals 14 | import kotlin.uuid.ExperimentalUuidApi 15 | 16 | class KeyValueStorageEnumTest { 17 | 18 | private val mainScope = MainScope() 19 | private val driver = driverFactory().createDriver() 20 | private val entityQueries = EntityQueries(driver) 21 | private val metadataQueries = MetadataQueries(driver) 22 | private val testObjectStorage = keyValueStorage( 23 | "test-object", entityQueries, metadataQueries, mainScope 24 | ) 25 | 26 | @AfterTest 27 | fun tearDown() { 28 | mainScope.cancel() 29 | } 30 | 31 | 32 | @Test 33 | fun select_byEnum() = runTest { 34 | val expected = (1..10).map { 35 | TestObject(testEnum = TestEnum.FIRST) 36 | }.associateBy { it.id } 37 | testObjectStorage.insertAll(expected) 38 | val bySecondEnum = testObjectStorage.select( 39 | where = TestObject::testEnum eq TestEnum.SECOND, 40 | ).first() 41 | val byFirstEnum = testObjectStorage.select( 42 | where = TestObject::testEnum eq TestEnum.FIRST, 43 | ).first() 44 | 45 | assertEquals(0, bySecondEnum.size) 46 | assertEquals(expected.size, byFirstEnum.size) 47 | } 48 | 49 | @Test 50 | fun select_bySerialNameEnum() = runTest { 51 | val expected = (1..10).map { 52 | TestObject(testEnum = TestEnum.LAST) 53 | }.associateBy { it.id } 54 | testObjectStorage.insertAll(expected) 55 | val bySecondEnum = testObjectStorage.select( 56 | where = TestObject::testEnum eq TestEnum.SECOND, 57 | ).first() 58 | // Broken due to lack of serialName support from descriptors 59 | val byLastEnum = testObjectStorage.select( 60 | where = TestObject::testEnum eq "unknown", 61 | ).first() 62 | 63 | assertEquals(0, bySecondEnum.size) 64 | assertEquals(expected.size, byLastEnum.size) 65 | } 66 | 67 | 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | pull_request_target: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | jvm-tests: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 18 | - name: Setup Java JDK 19 | uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 20 | with: 21 | distribution: 'zulu' 22 | java-version: '21' 23 | - name: Setup Gradle 24 | uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 25 | 26 | - name: Verify SqlDelight Migration 27 | run: ./gradlew verifySqlDelightMigration 28 | 29 | - name: Build and publish 30 | run: ./gradlew jvmTest 31 | 32 | - name: JUnit Report 33 | uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 34 | if: always() 35 | with: 36 | check_name: JVM Test Results 37 | report_paths: '**/build/test-results/jvmTest/**/*.xml' 38 | require_tests: true 39 | 40 | run-android-tests: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 44 | - name: Setup Java JDK 45 | uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0 46 | with: 47 | distribution: 'zulu' 48 | java-version: '21' 49 | - name: Setup Gradle 50 | uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0 51 | 52 | - name: Enable KVM group perms 53 | run: | 54 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules 55 | sudo udevadm control --reload-rules 56 | sudo udevadm trigger --name-match=kvm 57 | 58 | - name: Run Android Integration Tests 59 | run: ./gradlew allDevicesDebugAndroidTest -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect 60 | 61 | - name: JUnit Report 62 | uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 63 | if: always() 64 | with: 65 | check_name: Android Test Results 66 | report_paths: '**/build/outputs/androidTest-results/**/*.xml' 67 | require_tests: true 68 | 69 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/Sqkon.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import com.mercury.sqkon.db.serialization.KotlinSqkonSerializer 4 | import com.mercury.sqkon.db.serialization.SqkonJson 5 | import com.mercury.sqkon.db.utils.SqkonTransacter 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.serialization.json.Json 10 | 11 | /** 12 | * Main entry point for Sqkon. 13 | * 14 | * Each platform has a `Sqkon` function that creates a `Sqkon` instance. 15 | * 16 | * Simple usage: 17 | * ``` 18 | * val sqkon = Sqkon() 19 | * val merchantStore = sqkon.keyValueStorage("merchant") 20 | * merchantStore.insert( 21 | * id = "123", 22 | * value = Merchant(id = "123", name = "Chipotle", category = "Restaurant") 23 | * ) 24 | * val merchant = merchantStore.selectByKey("123").first() 25 | * val merchants = merchantStore.select(where = Merchant::category like "Restaurant") 26 | * ``` 27 | */ 28 | class Sqkon internal constructor( 29 | @PublishedApi internal val entityQueries: EntityQueries, 30 | @PublishedApi internal val metadataQueries: MetadataQueries, 31 | @PublishedApi internal val scope: CoroutineScope, 32 | json: Json = SqkonJson {}, 33 | @PublishedApi 34 | internal val config: KeyValueStorage.Config = KeyValueStorage.Config(), 35 | @PublishedApi internal val readDispatcher: CoroutineDispatcher = 36 | Dispatchers.Default.limitedParallelism(4), 37 | @PublishedApi internal val writeDispatcher: CoroutineDispatcher = 38 | Dispatchers.Default.limitedParallelism(1), 39 | ) { 40 | 41 | /** 42 | * Sqkon internal transactor. 43 | */ 44 | @PublishedApi 45 | internal val transactor = SqkonTransacter(driver = entityQueries.sqlDriver) 46 | 47 | @PublishedApi 48 | internal val serializer = KotlinSqkonSerializer(json) 49 | 50 | 51 | /** 52 | * Create a KeyValueStorage for the given entity name. 53 | * 54 | * @param T the type of the entity to store. 55 | * @param name the name of the entity to store. 56 | * @param config configuration for the KeyValueStorage. Overrides the default configuration 57 | * passed into Sqkon. 58 | */ 59 | inline fun keyValueStorage( 60 | name: String, 61 | config: KeyValueStorage.Config = this.config, 62 | ): KeyValueStorage { 63 | return keyValueStorage( 64 | name, entityQueries, metadataQueries, scope, serializer, config, 65 | readDispatcher = readDispatcher, 66 | writeDispatcher = writeDispatcher, 67 | transactor = transactor, 68 | ) 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | androidx-monitor = "1.8.0" 3 | androidx-runner = "1.7.0" 4 | androidx-sqlite = "2.6.1" 5 | kotlin = "2.2.20" 6 | agp = "8.13.0" 7 | kotlinx-coroutines = "1.10.2" 8 | kotlinx-serialization = { require = "1.9.0" } 9 | kotlinx-datetime = "0.6.2" 10 | paging = "3.3.0-alpha02-0.5.1" 11 | sqlDelight = "2.1.0" 12 | turbine = "1.2.1" 13 | 14 | [libraries] 15 | androidx-test-monitor = { module = "androidx.test:monitor", version.ref = "androidx-monitor" } 16 | androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-runner" } 17 | androidx-sqlite-core = { module = "androidx.sqlite:sqlite", version.ref = "androidx-sqlite" } 18 | androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" } 19 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 20 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } 21 | kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } 22 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } 23 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } 24 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } 25 | paging-common = { module = "app.cash.paging:paging-common", version.ref = "paging" } 26 | paging-testing = { module = "app.cash.paging:paging-testing", version.ref = "paging" } 27 | sqlDelight-androidx-driver = { module = "com.eygraber:sqldelight-androidx-driver", version = "0.0.16" } 28 | sqlDelight-driver-sqlite = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqlDelight" } 29 | sqlDelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } 30 | sqlDelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqlDelight" } 31 | turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } 32 | 33 | 34 | [plugins] 35 | android-application = { id = "com.android.application", version.ref = "agp" } 36 | android-library = { id = "com.android.library", version.ref = "agp" } 37 | maven-publish = { id = "com.vanniktech.maven.publish", version = "0.34.0" } 38 | multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 39 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 40 | kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 41 | sqlDelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" } 42 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/TestDataClasses.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon 2 | 3 | import kotlinx.datetime.Clock 4 | import kotlinx.datetime.Instant 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | import kotlin.random.Random 8 | import kotlin.uuid.ExperimentalUuidApi 9 | import kotlin.uuid.Uuid 10 | 11 | @OptIn(ExperimentalUuidApi::class) 12 | @Serializable 13 | data class TestObject( 14 | val id: String = Uuid.random().toString(), 15 | val name: String = "Name ${Uuid.random()}", 16 | val nullable: String? = "Name ${Uuid.random()}", 17 | val value: Int = Random.nextInt(Int.MAX_VALUE), 18 | val description: String = "Description ${Uuid.random()}", 19 | val testValue: TestValue = TestValue(Uuid.random().toString()), 20 | @SerialName("different_name") 21 | val serialName: String? = null, 22 | val child: TestObjectChild = TestObjectChild(), 23 | val list: List = List(2) { TestObjectChild() }, 24 | val attributes: List? = (1..10).map { it.toString() }, 25 | val sealed: TestSealed = TestSealed.Impl2("value"), 26 | val testEnum: TestEnum? = null, 27 | ) 28 | 29 | 30 | @Serializable 31 | data class TestObjectChild( 32 | val createdAt: Instant = Clock.System.now(), 33 | val updatedAt: Instant = Clock.System.now(), 34 | val subList: List = listOf("1", "2", "3"), 35 | ) 36 | 37 | @Serializable 38 | data class UnSerializable( 39 | val differentField: String 40 | ) 41 | 42 | @JvmInline 43 | @Serializable 44 | value class TestValue(val test: String) 45 | 46 | 47 | @Serializable 48 | sealed interface TestSealed { 49 | @Serializable 50 | @SerialName("Impl") 51 | data class Impl(val boolean: Boolean) : TestSealed 52 | 53 | @JvmInline 54 | @Serializable 55 | @SerialName("Impl2") 56 | value class Impl2(val value: String) : TestSealed 57 | 58 | } 59 | 60 | @Serializable 61 | sealed interface BaseSealed { 62 | 63 | val id: String 64 | 65 | @JvmInline 66 | @Serializable 67 | @SerialName("TypeOne") 68 | value class TypeOne( 69 | val data: TypeOneData 70 | ) : BaseSealed { 71 | override val id: String get() = data.key 72 | } 73 | 74 | @Serializable 75 | @SerialName("TypeTwo") 76 | data class TypeTwo( 77 | val data: TypeTwoData 78 | ) : BaseSealed { 79 | override val id: String get() = data.key 80 | } 81 | 82 | } 83 | 84 | @Serializable 85 | data class TypeOneData( 86 | val key: String, 87 | val value: String 88 | ) 89 | 90 | @Serializable 91 | data class TypeTwoData( 92 | val key: String, 93 | val otherValue: Int 94 | ) 95 | 96 | enum class TestEnum { 97 | FIRST, 98 | SECOND, 99 | 100 | @SerialName("unknown") 101 | LAST; 102 | } 103 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/db/DeserializationTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import com.mercury.sqkon.TestObject 4 | import com.mercury.sqkon.UnSerializable 5 | import com.mercury.sqkon.until 6 | import kotlinx.coroutines.MainScope 7 | import kotlinx.coroutines.cancel 8 | import kotlinx.coroutines.flow.first 9 | import kotlinx.coroutines.test.runTest 10 | import kotlinx.serialization.SerializationException 11 | import org.junit.After 12 | import org.junit.Test 13 | import kotlin.test.assertEquals 14 | import kotlin.test.assertIs 15 | import kotlin.test.assertNull 16 | import kotlin.test.fail 17 | 18 | class DeserializationTest { 19 | 20 | private val mainScope = MainScope() 21 | private val driver = driverFactory().createDriver() 22 | private val entityQueries = EntityQueries(driver) 23 | private val metadataQueries = MetadataQueries(driver) 24 | private val testObjectStorage = keyValueStorage( 25 | "test-object", entityQueries, metadataQueries, mainScope 26 | ) 27 | private val testObjectStorageError = keyValueStorage( 28 | "test-object", entityQueries, metadataQueries, mainScope, 29 | config = KeyValueStorage.Config( 30 | deserializePolicy = KeyValueStorage.Config.DeserializePolicy.ERROR // default 31 | ) 32 | ) 33 | 34 | private val testObjectStorageDelete = keyValueStorage( 35 | "test-object", entityQueries, metadataQueries, mainScope, 36 | config = KeyValueStorage.Config( 37 | deserializePolicy = KeyValueStorage.Config.DeserializePolicy.DELETE 38 | ) 39 | ) 40 | 41 | @After 42 | fun tearDown() { 43 | mainScope.cancel() 44 | } 45 | 46 | @Test 47 | fun deserializeWithError() = runTest { 48 | val expected = TestObject() 49 | testObjectStorage.insert(expected.id, expected) 50 | val actual = testObjectStorage.selectAll().first().first() 51 | assertEquals(expected, actual) 52 | 53 | try { 54 | testObjectStorageError.selectByKey(expected.id).first() 55 | fail("Expected exception") 56 | } catch (e: Exception) { 57 | assertIs(e) 58 | } 59 | } 60 | 61 | 62 | @Test 63 | fun deserializeWithDelete() = runTest { 64 | val expected = TestObject() 65 | testObjectStorage.insert(expected.id, expected) 66 | val actual = testObjectStorage.selectAll().first().first() 67 | assertEquals(expected, actual) 68 | 69 | val result = testObjectStorageDelete.selectByKey(expected.id).first() 70 | assertNull(result) 71 | val resultList = testObjectStorageDelete.selectAll().first() 72 | assertEquals(0, resultList.size) 73 | 74 | until { testObjectStorage.count().first() == 0 } 75 | // Should have been deleted 76 | assertEquals(0, testObjectStorage.count().first()) 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/db/MetadataTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import app.cash.turbine.test 4 | import com.mercury.sqkon.TestObject 5 | import kotlinx.coroutines.MainScope 6 | import kotlinx.coroutines.cancel 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.test.runTest 9 | import kotlinx.datetime.Clock 10 | import org.junit.After 11 | import org.junit.Test 12 | import java.lang.Thread.sleep 13 | import kotlin.test.Ignore 14 | import kotlin.test.assertEquals 15 | import kotlin.test.assertNotNull 16 | import kotlin.test.assertNull 17 | import kotlin.test.assertTrue 18 | 19 | class MetadataTest { 20 | 21 | private val mainScope = MainScope() 22 | private val driver = driverFactory().createDriver() 23 | private val entityQueries = EntityQueries(driver) 24 | private val metadataQueries = MetadataQueries(driver) 25 | private val testObjectStorage = keyValueStorage( 26 | "test-object", entityQueries, metadataQueries, mainScope 27 | ) 28 | 29 | @After 30 | fun tearDown() { 31 | mainScope.cancel() 32 | } 33 | 34 | @Test 35 | fun updateWrite_onInsert() = runTest { 36 | val expected = (1..20).map { _ -> TestObject() }.associateBy { it.id } 37 | testObjectStorage.insertAll(expected) 38 | 39 | testObjectStorage.metadata().test { 40 | sleep(1) 41 | val now = Clock.System.now() 42 | awaitItem().also { 43 | assertEquals("test-object", it.entity_name) 44 | assertNotNull(it.lastWriteAt) 45 | assertNull(it.lastReadAt) 46 | assertTrue("lastWriteAt not <= now") { it.lastWriteAt <= now } 47 | } 48 | } 49 | } 50 | 51 | @Test 52 | fun updateWrite_onUpdate() = runTest { 53 | val expected = (1..20).map { _ -> TestObject() }.associateBy { it.id } 54 | testObjectStorage.insertAll(expected) 55 | 56 | testObjectStorage.metadata().test { 57 | val now = Clock.System.now() 58 | sleep(2) 59 | awaitItem() 60 | testObjectStorage.updateAll(expected) 61 | awaitItem().also { 62 | assertEquals("test-object", it.entity_name) 63 | assertNotNull(it.lastWriteAt) 64 | assertNull(it.lastReadAt) 65 | assertTrue { it.lastWriteAt > now } 66 | } 67 | } 68 | } 69 | 70 | @Test 71 | fun updateRead_onSelect() = runTest { 72 | val expected = (1..20).map { _ -> TestObject() }.associateBy { it.id } 73 | testObjectStorage.insertAll(expected) 74 | sleep(1) 75 | val now = Clock.System.now() 76 | sleep(2) 77 | testObjectStorage.metadata().test { 78 | awaitItem() 79 | testObjectStorage.selectAll().first() 80 | awaitItem().also { 81 | assertEquals("test-object", it.entity_name) 82 | assertNotNull(it.lastWriteAt) 83 | assertNotNull(it.lastReadAt) 84 | assertTrue { it.lastWriteAt <= now } 85 | assertTrue { it.lastReadAt > now } 86 | } 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/paging/OffsetQueryPagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db.paging 2 | 3 | import app.cash.paging.PagingSourceLoadParams 4 | import app.cash.paging.PagingSourceLoadParamsAppend 5 | import app.cash.paging.PagingSourceLoadParamsPrepend 6 | import app.cash.paging.PagingSourceLoadParamsRefresh 7 | import app.cash.paging.PagingSourceLoadResult 8 | import app.cash.paging.PagingSourceLoadResultInvalid 9 | import app.cash.paging.PagingSourceLoadResultPage 10 | import app.cash.paging.PagingState 11 | import app.cash.sqldelight.Query 12 | import app.cash.sqldelight.Transacter 13 | import app.cash.sqldelight.TransactionCallbacks 14 | import com.mercury.sqkon.db.Entity 15 | import kotlinx.coroutines.withContext 16 | import kotlin.coroutines.CoroutineContext 17 | 18 | internal class OffsetQueryPagingSource( 19 | private val queryProvider: (limit: Int, offset: Int) -> Query, 20 | private val countQuery: Query, 21 | private val transacter: Transacter, 22 | private val context: CoroutineContext, 23 | private val deserialize: (Entity) -> T?, 24 | private val initialOffset: Int, 25 | ) : QueryPagingSource() { 26 | 27 | override val jumpingSupported get() = true 28 | 29 | override suspend fun load( 30 | params: PagingSourceLoadParams, 31 | ): PagingSourceLoadResult = withContext(context) { 32 | val key = params.key ?: initialOffset 33 | val limit = when (params) { 34 | is PagingSourceLoadParamsPrepend<*> -> minOf(key, params.loadSize) 35 | else -> params.loadSize 36 | } 37 | val getPagingSourceLoadResult: TransactionCallbacks.() -> PagingSourceLoadResultPage = 38 | { 39 | val count = countQuery.executeAsOne() 40 | val offset = when (params) { 41 | is PagingSourceLoadParamsPrepend<*> -> maxOf(0, key - params.loadSize) 42 | is PagingSourceLoadParamsAppend<*> -> key 43 | is PagingSourceLoadParamsRefresh<*> -> { 44 | if (key >= count) maxOf(0, count - params.loadSize) else key 45 | } 46 | 47 | else -> error("Unknown PagingSourceLoadParams ${params::class}") 48 | } 49 | val data = queryProvider(limit, offset) 50 | .also { currentQuery = it } 51 | .executeAsList() 52 | .mapNotNull { deserialize(it) } 53 | val nextPosToLoad = offset + data.size 54 | PagingSourceLoadResultPage( 55 | data = data, 56 | prevKey = offset.takeIf { it > 0 && data.isNotEmpty() }, 57 | nextKey = nextPosToLoad.takeIf { data.isNotEmpty() && data.size >= limit && it < count }, 58 | itemsBefore = offset, 59 | itemsAfter = maxOf(0, count - nextPosToLoad), 60 | ) 61 | } 62 | val loadResult = transacter 63 | .transactionWithResult(bodyWithReturn = getPagingSourceLoadResult) 64 | (if (invalid) PagingSourceLoadResultInvalid() else loadResult) 65 | } 66 | 67 | override fun getRefreshKey(state: PagingState): Int? { 68 | return state.anchorPosition?.let { maxOf(0, it - (state.config.initialLoadSize / 2)) } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /library/src/androidMain/kotlin/com/mercury/sqkon/db/SqkonDatabaseDriver.android.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.res.Resources 6 | import androidx.sqlite.SQLiteConnection 7 | import androidx.sqlite.driver.bundled.BundledSQLiteDriver 8 | import androidx.sqlite.driver.bundled.SQLITE_OPEN_CREATE 9 | import androidx.sqlite.driver.bundled.SQLITE_OPEN_FULLMUTEX 10 | import androidx.sqlite.driver.bundled.SQLITE_OPEN_READWRITE 11 | import app.cash.sqldelight.db.SqlDriver 12 | import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConfiguration 13 | import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteConnectionFactory 14 | import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDatabaseType 15 | import com.eygraber.sqldelight.androidx.driver.AndroidxSqliteDriver 16 | import com.eygraber.sqldelight.androidx.driver.FileProvider 17 | import com.eygraber.sqldelight.androidx.driver.SqliteJournalMode 18 | import com.eygraber.sqldelight.androidx.driver.SqliteSync 19 | import kotlinx.coroutines.CoroutineDispatcher 20 | import kotlinx.coroutines.DelicateCoroutinesApi 21 | import kotlinx.coroutines.Dispatchers 22 | import kotlinx.coroutines.newFixedThreadPoolContext 23 | 24 | internal actual val connectionPoolSize: Int by lazy { getWALConnectionPoolSize() } 25 | 26 | @OptIn(DelicateCoroutinesApi::class) 27 | @PublishedApi 28 | internal actual val dbWriteDispatcher: CoroutineDispatcher by lazy { 29 | newFixedThreadPoolContext(nThreads = 1, "SqkonReadDispatcher") 30 | } 31 | 32 | @PublishedApi 33 | internal actual val dbReadDispatcher: CoroutineDispatcher by lazy { 34 | Dispatchers.IO.limitedParallelism(connectionPoolSize) 35 | } 36 | 37 | /** 38 | * @param name The name of the database to open or create. If null, an in-memory database will 39 | * be created, which will not persist across application restarts. Defaults to "sqkon.db". 40 | */ 41 | internal actual class DriverFactory( 42 | private val context: Context, 43 | private val name: String? = "sqkon.db" 44 | ) { 45 | private val bundledDriver = BundledSQLiteDriver() 46 | actual fun createDriver(): SqlDriver { 47 | return AndroidxSqliteDriver( 48 | connectionFactory = SqkonAndroidxSqliteConnectionFactory(bundledDriver), 49 | databaseType = when (name) { 50 | null -> AndroidxSqliteDatabaseType.Memory 51 | else -> AndroidxSqliteDatabaseType.FileProvider(context = context, name = name) 52 | }, 53 | schema = SqkonDatabase.Schema, 54 | configuration = AndroidxSqliteConfiguration( 55 | journalMode = SqliteJournalMode.WAL, 56 | sync = SqliteSync.Normal, 57 | readerConnectionsCount = connectionPoolSize, 58 | ), 59 | ) 60 | } 61 | } 62 | 63 | @SuppressLint("DiscouragedApi") 64 | private fun getWALConnectionPoolSize(): Int { 65 | val resources = Resources.getSystem() 66 | val resId = resources.getIdentifier("db_connection_pool_size", "integer", "android") 67 | return if (resId != 0) { 68 | resources.getInteger(resId) 69 | } else { 70 | 4 // Default is 4 readers as per AndroidxSqliteConfiguration 71 | } 72 | } 73 | 74 | private class SqkonAndroidxSqliteConnectionFactory( 75 | override val driver: BundledSQLiteDriver, 76 | ) : AndroidxSqliteConnectionFactory { 77 | override fun createConnection(name: String): SQLiteConnection = 78 | driver.open( 79 | fileName = name, 80 | flags = SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE or SQLITE_OPEN_FULLMUTEX 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /library/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import app.cash.sqldelight.VERSION 2 | import com.android.build.api.variant.HasUnitTestBuilder 3 | import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi 4 | import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree 5 | 6 | plugins { 7 | alias(libs.plugins.android.library) 8 | alias(libs.plugins.multiplatform) 9 | alias(libs.plugins.kotlinx.serialization) 10 | alias(libs.plugins.sqlDelight) 11 | alias(libs.plugins.maven.publish) 12 | } 13 | 14 | kotlin { 15 | applyDefaultHierarchyTemplate() 16 | androidTarget { 17 | publishLibraryVariants("release") 18 | } 19 | jvmToolchain(21) 20 | jvm() 21 | sourceSets { 22 | commonMain.dependencies { 23 | implementation(libs.androidx.sqlite.core) 24 | implementation(libs.androidx.sqlite.bundled) 25 | implementation(libs.kotlinx.coroutines.core) 26 | implementation(libs.sqlDelight.androidx.driver) 27 | implementation(libs.sqlDelight.coroutines) 28 | implementation(libs.kotlinx.serialization.json) 29 | implementation(libs.kotlinx.datetime) 30 | implementation(libs.paging.common) 31 | // Don't include other paging, just the base to generate pageable queries 32 | } 33 | 34 | commonTest.dependencies { 35 | implementation(libs.kotlinx.coroutines.test) 36 | implementation(kotlin("test")) 37 | implementation(libs.paging.testing) 38 | implementation(libs.turbine) 39 | } 40 | 41 | androidMain.dependencies { 42 | implementation(libs.kotlinx.coroutines.android) 43 | implementation(libs.sqlDelight.driver.android) 44 | } 45 | 46 | jvmMain.dependencies { 47 | implementation(libs.kotlinx.coroutines.swing) 48 | api(libs.sqlDelight.androidx.driver) // Expose the driver for transitive dependencies 49 | } 50 | 51 | } 52 | compilerOptions { 53 | freeCompilerArgs.add("-Xexpect-actual-classes") 54 | } 55 | 56 | androidTarget { 57 | @OptIn(ExperimentalKotlinGradlePluginApi::class) 58 | instrumentedTestVariant.sourceSetTree.set(KotlinSourceSetTree.test) 59 | } 60 | } 61 | 62 | android { 63 | namespace = "com.mercury.sqkon" 64 | compileSdk = 36 65 | 66 | defaultConfig { 67 | minSdk = 23 68 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 69 | } 70 | 71 | @Suppress("UnstableApiUsage") 72 | testOptions { 73 | managedDevices { 74 | localDevices { 75 | create("mediumPhoneApi35") { 76 | device = "Medium Phone" 77 | apiLevel = 35 78 | systemImageSource = "aosp-atd" 79 | } 80 | } 81 | } 82 | } 83 | } 84 | 85 | dependencies { 86 | androidTestImplementation(libs.androidx.test.monitor) 87 | androidTestImplementation(libs.androidx.test.runner) 88 | } 89 | 90 | sqldelight { 91 | databases { 92 | create("SqkonDatabase") { 93 | // Database configuration here. 94 | // https://cashapp.github.io/sqldelight 95 | // We disable async as it's effectively broken on multithreaded platforms with 96 | // coroutines (more of a driver issue) 97 | generateAsync = false 98 | packageName.set("com.mercury.sqkon.db") 99 | schemaOutputDirectory.set(file("src/commonMain/sqldelight/databases")) 100 | // We're technically using 3.45.0, but 3.38 is the latest supported version 101 | dialect("app.cash.sqldelight:sqlite-3-38-dialect:$VERSION") 102 | } 103 | } 104 | } 105 | 106 | androidComponents { 107 | beforeVariants { 108 | it.enableAndroidTest = true 109 | (it as HasUnitTestBuilder).enableUnitTest = false 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageSealedTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalUuidApi::class) 2 | 3 | package com.mercury.sqkon.db 4 | 5 | import com.mercury.sqkon.BaseSealed 6 | import com.mercury.sqkon.TestObject 7 | import com.mercury.sqkon.TestSealed 8 | import com.mercury.sqkon.TypeOneData 9 | import com.mercury.sqkon.TypeTwoData 10 | import kotlinx.coroutines.MainScope 11 | import kotlinx.coroutines.cancel 12 | import kotlinx.coroutines.flow.first 13 | import kotlinx.coroutines.test.runTest 14 | import kotlin.test.AfterTest 15 | import kotlin.test.Test 16 | import kotlin.test.assertEquals 17 | import kotlin.uuid.ExperimentalUuidApi 18 | import kotlin.uuid.Uuid 19 | 20 | class KeyValueStorageSealedTest { 21 | 22 | private val mainScope = MainScope() 23 | private val driver = driverFactory().createDriver() 24 | private val entityQueries = EntityQueries(driver) 25 | private val metadataQueries = MetadataQueries(driver) 26 | private val testObjectStorage = keyValueStorage( 27 | "test-object", entityQueries, metadataQueries, mainScope 28 | ) 29 | 30 | private val baseSealedStorage = keyValueStorage( 31 | "test-object", entityQueries, metadataQueries, mainScope 32 | ) 33 | 34 | @AfterTest 35 | fun tearDown() { 36 | mainScope.cancel() 37 | } 38 | 39 | 40 | @Test 41 | fun select_byEntitySealedImpl() = runTest { 42 | val expected = (1..10).map { 43 | TestObject(sealed = TestSealed.Impl(boolean = true)) 44 | }.associateBy { it.id } 45 | testObjectStorage.insertAll(expected) 46 | val actualBySealedBooleanFalse = testObjectStorage.select( 47 | where = TestObject::sealed.then(TestSealed.Impl::boolean) eq false, 48 | ).first() 49 | val actualBySealedBooleanTrue = testObjectStorage.select( 50 | where = TestObject::sealed.then(TestSealed.Impl::boolean) eq true, 51 | ).first() 52 | 53 | assertEquals(0, actualBySealedBooleanFalse.size) 54 | assertEquals(expected.size, actualBySealedBooleanTrue.size) 55 | } 56 | 57 | @Test 58 | fun select_byEntitySealedImpl2() = runTest { 59 | val expected = (1..10).map { 60 | TestObject(sealed = TestSealed.Impl2(value = "test value")) 61 | }.associateBy { it.id } 62 | testObjectStorage.insertAll(expected) 63 | val actualBySealedValue1 = testObjectStorage.select( 64 | where = TestObject::sealed.then(TestSealed.Impl2::value) eq "test", 65 | ).first() 66 | val actualBySealedValue2 = testObjectStorage.select( 67 | where = TestObject::sealed.then(TestSealed.Impl2::value) eq "test value", 68 | ).first() 69 | 70 | assertEquals(0, actualBySealedValue1.size) 71 | assertEquals(expected.size, actualBySealedValue2.size) 72 | } 73 | 74 | @Test 75 | fun select_byBaseSealedId() = runTest { 76 | val expectedT1 = (1..10).map { 77 | BaseSealed.TypeOne( 78 | data = TypeOneData(key = it.toString(), value = Uuid.random().toString()) 79 | ) 80 | }.associateBy { it.id } 81 | val exceptedT2 = (11..20).map { 82 | BaseSealed.TypeTwo( 83 | data = TypeTwoData(key = it.toString(), otherValue = it) 84 | ) 85 | }.associateBy { it.id } 86 | baseSealedStorage.insertAll(expectedT1 + exceptedT2) 87 | val count = baseSealedStorage.count().first() 88 | assertEquals(20, count) 89 | 90 | val actualT1 = baseSealedStorage.select( 91 | where = BaseSealed::class.with(BaseSealed.TypeOne::data) { 92 | then(TypeOneData::key) 93 | } eq "1", 94 | ).first() 95 | assertEquals(1, actualT1.size) 96 | assertEquals(expectedT1["1"], actualT1.first() as BaseSealed.TypeOne) 97 | val actualT2 = baseSealedStorage.select( 98 | where = BaseSealed::class.with(BaseSealed.TypeTwo::data) { 99 | then(TypeTwoData::key) 100 | } eq "11", 101 | ).first() 102 | assertEquals(1, actualT2.size) 103 | assertEquals(exceptedT2["11"], actualT2.first() as BaseSealed.TypeTwo) 104 | 105 | // Can't search on a getter 106 | baseSealedStorage.select(where = BaseSealed::id.eq("1")).first().also { 107 | assertEquals(0, it.size) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/db/OffsetPagingTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import androidx.paging.PagingSource.LoadResult 4 | import app.cash.paging.Pager 5 | import app.cash.paging.PagingConfig 6 | import app.cash.paging.PagingData 7 | import app.cash.paging.testing.TestPager 8 | import app.cash.paging.testing.asSnapshot 9 | import com.mercury.sqkon.TestObject 10 | import com.mercury.sqkon.until 11 | import kotlinx.coroutines.MainScope 12 | import kotlinx.coroutines.cancel 13 | import kotlinx.coroutines.flow.SharingStarted 14 | import kotlinx.coroutines.flow.shareIn 15 | import kotlinx.coroutines.launch 16 | import kotlinx.coroutines.test.runTest 17 | import org.junit.After 18 | import org.junit.Test 19 | import kotlin.test.assertEquals 20 | import kotlin.test.assertNull 21 | 22 | class OffsetPagingTest { 23 | 24 | private val mainScope = MainScope() 25 | private val driver = driverFactory().createDriver() 26 | private val entityQueries = EntityQueries(driver) 27 | private val metadataQueries = MetadataQueries(driver) 28 | private val testObjectStorage = keyValueStorage( 29 | "test-object", entityQueries, metadataQueries, mainScope 30 | ) 31 | 32 | @After 33 | fun tearDown() { 34 | mainScope.cancel() 35 | } 36 | 37 | @Test 38 | fun offsetPageByTen() = runTest { 39 | val expected = (1..100).map { _ -> TestObject() }.associateBy { it.id } 40 | testObjectStorage.insertAll(expected) 41 | 42 | val config = PagingConfig(pageSize = 10, prefetchDistance = 0, initialLoadSize = 10) 43 | val pager = TestPager(config, testObjectStorage.selectPagingSource()) 44 | 45 | val result = pager.refresh() as LoadResult.Page 46 | assertEquals(10, result.data.size, "Page result should contain 10 items") 47 | assertNull(result.prevKey, "Prev key should be null") 48 | assertEquals(10, result.nextKey, "Next key should be offset 10") 49 | assertEquals(0, result.itemsBefore, "Items before should be 0") 50 | assertEquals(90, result.itemsAfter, "Items after should be 90") 51 | 52 | assertEquals(1, pager.getPages().size, "Should have 1 page") 53 | } 54 | 55 | @Test 56 | fun offsetPageByTenAppending() = runTest { 57 | val expected = (1..100).map { _ -> TestObject() }.associateBy { it.id } 58 | testObjectStorage.insertAll(expected) 59 | 60 | val config = PagingConfig(pageSize = 10, prefetchDistance = 0, initialLoadSize = 10) 61 | val pager = TestPager(config, testObjectStorage.selectPagingSource()) 62 | 63 | val result = with(pager) { 64 | refresh() 65 | append() 66 | append() 67 | } as LoadResult.Page 68 | assertEquals(10, result.data.size, "Page result should contain 10 items") 69 | assertEquals(20, result.prevKey, "Prev key should be 20") 70 | assertEquals(30, result.nextKey, "Next key should be offset 30") 71 | assertEquals(20, result.itemsBefore, "Items before should be 20") 72 | assertEquals(70, result.itemsAfter, "Items after should be 70") 73 | 74 | assertEquals(3, pager.getPages().size, "Should have 3 pages") 75 | } 76 | 77 | @Test 78 | fun offsetPageByTen_Invalidation() = runTest { 79 | val expected = (1..100).map { _ -> TestObject() }.associateBy { it.id } 80 | testObjectStorage.insertAll(expected) 81 | 82 | val config = PagingConfig(pageSize = 10, prefetchDistance = 0, initialLoadSize = 10) 83 | val results = mutableListOf>() 84 | val pagerFlow = Pager( 85 | config, pagingSourceFactory = { testObjectStorage.selectPagingSource() } 86 | ).flow.shareIn(scope = backgroundScope, replay = 1, started = SharingStarted.Eagerly) 87 | 88 | backgroundScope.launch { 89 | pagerFlow.collect { results.add(it) } 90 | } 91 | val initialSet = pagerFlow.asSnapshot { this.refresh() } 92 | assertEquals(10, initialSet.size, "Should have 10 items") 93 | 94 | until { results.size >= 2 } 95 | assertEquals(2, results.size, "Should have 2 results") 96 | 97 | // Insert new value to invalidate the query 98 | TestObject().also { testObjectStorage.insert(it.id, it) } 99 | 100 | // Should refresh 101 | until { results.size >= 3 } 102 | assertEquals(3, results.size, "Should have 3 results after invalidation") 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/db/JsonPathBuilderTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import com.mercury.sqkon.BaseSealed 4 | import com.mercury.sqkon.TestObject 5 | import com.mercury.sqkon.TestObjectChild 6 | import com.mercury.sqkon.TestSealed 7 | import com.mercury.sqkon.TestValue 8 | import com.mercury.sqkon.TypeOneData 9 | import com.mercury.sqkon.TypeTwoData 10 | import org.junit.Test 11 | import kotlin.test.assertEquals 12 | 13 | class JsonPathBuilderTest { 14 | 15 | @Test 16 | fun build_with_simple_path() { 17 | val builder = TestObject::class.with(TestObject::child) { 18 | then(TestObjectChild::createdAt) 19 | } 20 | assertEquals(expected = "$.child.createdAt", actual = builder.buildPath()) 21 | } 22 | 23 | @Test 24 | fun build_next_simple_path() { 25 | val builder = TestObject::child.then(TestObjectChild::createdAt) 26 | assertEquals(expected = "$.child.createdAt", actual = builder.buildPath()) 27 | } 28 | 29 | @Test 30 | fun build_builder_simple_path() { 31 | val builder = TestObject::child.builder { 32 | then(TestObjectChild::createdAt) 33 | } 34 | assertEquals(expected = "$.child.createdAt", actual = builder.buildPath()) 35 | } 36 | 37 | @Test 38 | fun build_with_value_class() { 39 | val builder = TestObject::class.with(TestObject::testValue) { 40 | then(TestValue::test) 41 | } 42 | assertEquals(expected = "$.testValue", actual = builder.buildPath()) 43 | } 44 | 45 | @Test 46 | fun build_with_value_class_next() { 47 | val builder = TestObject::testValue.then(TestValue::test) 48 | assertEquals(expected = "$.testValue", actual = builder.buildPath()) 49 | } 50 | 51 | @Test 52 | fun build_with_value_class_builder() { 53 | val builder = TestObject::testValue.builder { 54 | then(TestValue::test) 55 | } 56 | assertEquals(expected = "$.testValue", actual = builder.buildPath()) 57 | } 58 | 59 | @Test 60 | fun build_with_serialName_class_builder() { 61 | val builder = TestObject::serialName.builder(serialName = "different_name") 62 | // Should use the serial name annotation override 63 | assertEquals(expected = "$.different_name", actual = builder.buildPath()) 64 | } 65 | 66 | @Test 67 | fun build_with_sealed_path() { 68 | val builder = TestObject::sealed.builder { 69 | then(TestSealed.Impl::boolean) {} 70 | } 71 | assertEquals(expected = "$.sealed[1].boolean", actual = builder.buildPath()) 72 | } 73 | 74 | @Test 75 | fun build_with_sealed_inline_value_path() { 76 | val builder = TestObject::sealed.builder { 77 | then(TestSealed.Impl2::value) {} 78 | } 79 | assertEquals(expected = "$.sealed[1]", actual = builder.buildPath()) 80 | } 81 | 82 | @Test 83 | fun build_with_base_sealed_value_path() { 84 | val builder = BaseSealed::class.with(BaseSealed.TypeOne::data) { 85 | then(TypeOneData::key) 86 | } 87 | assertEquals(expected = "$[1].key", actual = builder.buildPath()) 88 | } 89 | 90 | @Test 91 | fun build_with_base_sealed_data_path() { 92 | val builder = BaseSealed::class.with(BaseSealed.TypeTwo::data) { 93 | then(TypeTwoData::key) 94 | } 95 | assertEquals(expected = "$[1].data.key", actual = builder.buildPath()) 96 | } 97 | 98 | @Test 99 | fun build_with_list_builder() { 100 | val builder = TestObject::list.builderFromList { 101 | then(TestObjectChild::createdAt) 102 | } 103 | assertEquals(expected = "$.list[%].createdAt", actual = builder.buildPath()) 104 | } 105 | 106 | @Test 107 | fun build_with_then_list() { 108 | val builder = TestObject::child.then(TestObjectChild::subList) 109 | assertEquals(expected = "$.child.subList[%]", actual = builder.buildPath()) 110 | } 111 | 112 | 113 | @Test 114 | fun build_with_list_then() { 115 | val builder = TestObject::list.then(TestObjectChild::createdAt) 116 | assertEquals(expected = "$.list[%].createdAt", actual = builder.buildPath()) 117 | } 118 | 119 | @Test 120 | fun build_with_list_path() { 121 | val builder = TestObject::class.withList(TestObject::list) { 122 | then(TestObjectChild::createdAt) 123 | } 124 | assertEquals(expected = "$.list[%].createdAt", actual = builder.buildPath()) 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageExpiresTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import com.mercury.sqkon.TestObject 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.MainScope 6 | import kotlinx.coroutines.cancel 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.test.runTest 9 | import kotlinx.datetime.Clock 10 | import org.junit.After 11 | import org.junit.Test 12 | import kotlin.test.assertEquals 13 | import kotlin.time.Duration.Companion.milliseconds 14 | 15 | class KeyValueStorageExpiresTest { 16 | 17 | private val mainScope = MainScope() 18 | private val driver = driverFactory().createDriver() 19 | private val entityQueries = EntityQueries(driver) 20 | private val metadataQueries = MetadataQueries(driver) 21 | private val testObjectStorage = keyValueStorage( 22 | "test-object", entityQueries, metadataQueries, mainScope, 23 | ) 24 | 25 | @After 26 | fun tearDown() { 27 | mainScope.cancel() 28 | } 29 | 30 | @Test 31 | fun insertAll() = runTest { 32 | val now = Clock.System.now() 33 | val expected = (0..10).map { TestObject() } 34 | .associateBy { it.id } 35 | .toSortedMap() 36 | testObjectStorage.insertAll(expected, expiresAt = now.minus(1.milliseconds)) 37 | val actual = testObjectStorage.selectAll(expiresAfter = now).first() 38 | assertEquals(0, actual.size) 39 | } 40 | 41 | @Test 42 | fun update() = runTest { 43 | val now = Clock.System.now() 44 | val inserted = TestObject() 45 | testObjectStorage.insert(inserted.id, inserted, expiresAt = now.minus(1.milliseconds)) 46 | val actualInserted = testObjectStorage.selectAll(expiresAfter = now).first() 47 | assertEquals(0, actualInserted.size) 48 | // update with new expires 49 | testObjectStorage.update(inserted.id, inserted, expiresAt = now.plus(1.milliseconds)) 50 | val actualUpdated = testObjectStorage.selectAll(expiresAfter = now).first() 51 | assertEquals(1, actualUpdated.size) 52 | } 53 | 54 | @Test 55 | fun upsert() = runTest { 56 | val now = Clock.System.now() 57 | val inserted = TestObject() 58 | testObjectStorage.upsert(inserted.id, inserted, expiresAt = now.minus(1.milliseconds)) 59 | val actualInserted = testObjectStorage.selectAll(expiresAfter = now).first() 60 | assertEquals(0, actualInserted.size) 61 | testObjectStorage.upsert(inserted.id, inserted, expiresAt = now.plus(1.milliseconds)) 62 | val actualUpdated = testObjectStorage.selectAll(expiresAfter = now).first() 63 | assertEquals(1, actualUpdated.size) 64 | } 65 | 66 | @Test 67 | fun deleteExpired() = runTest { 68 | val now = Clock.System.now() 69 | val expected = (0..10).map { TestObject() }.associateBy { it.id } 70 | testObjectStorage.insertAll(expected, expiresAt = now.minus(1.milliseconds)) 71 | val actual = testObjectStorage.selectAll().first() // all results 72 | assertEquals(expected.size, actual.size) 73 | 74 | testObjectStorage.deleteExpired(expiresAfter = now) 75 | // No expires to return everything 76 | val actualAfterDelete = testObjectStorage.selectAll().first() 77 | assertEquals(0, actualAfterDelete.size) 78 | } 79 | 80 | @Test 81 | fun delete_byEntityId() = runTest { 82 | val expected = (0..10).map { TestObject() }.associateBy { it.id } 83 | testObjectStorage.insertAll(expected) 84 | val actual = testObjectStorage.selectAll().first() 85 | assertEquals(expected.size, actual.size) 86 | 87 | val key = expected.keys.toList()[5] 88 | testObjectStorage.delete( 89 | where = TestObject::id eq key 90 | ) 91 | val actualAfterDelete = testObjectStorage.selectAll().first() 92 | assertEquals(expected.size - 1, actualAfterDelete.size) 93 | } 94 | 95 | @Test 96 | fun count() = runTest { 97 | val now = Clock.System.now() 98 | val empty = testObjectStorage.count().first() 99 | assertEquals(expected = 0, empty) 100 | 101 | val expected = (0..10).map { TestObject() }.associateBy { it.id } 102 | testObjectStorage.insertAll(expected, expiresAt = now.minus(1.milliseconds)) 103 | val zero = testObjectStorage.count(expiresAfter = now).first() 104 | assertEquals(0, zero) 105 | val ten = testObjectStorage.count(expiresAfter = now.minus(1.milliseconds)).first() 106 | assertEquals(expected.size, ten) 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Sqkon 2 | 3 | ![sqkon](assets/logo.png) 4 | 5 | Sqkon (/sk-on/) A Kotlin Multiplatform KeyValue Store with the ability to query on values using 6 | SQLite and JSONB. 7 | 8 | ![Maven Central Version](https://img.shields.io/maven-central/v/com.mercury.sqkon/library) 9 | ![GitHub branch check runs](https://img.shields.io/github/check-runs/MercuryTechnologies/sqkon/main) 10 | 11 | ## Usage 12 | 13 | ```kotlin 14 | // Create a new instance of Sqkon 15 | val sqkon = Sqkon( 16 | context = context // (only for Android) 17 | ) 18 | 19 | // Create a Store for each type/entity you want to create 20 | val merchantStore = sqkon.keyValueStore("merchant") 21 | 22 | // Insert a new entity 23 | val merchant = Merchant( 24 | id = MerchantKey("1"), 25 | name = "Chipotle", 26 | category = "Food" 27 | ) 28 | 29 | // Insert, similar to a SQL INSERT, no table definition needed. 30 | merchantStore.insert(key = merchant.id.value, value = merchant) 31 | 32 | // Query on any field 33 | val flow: Flow> = merchantStore.select(where = Merchant::name like "Chi%") 34 | 35 | 36 | // Example entity 37 | @Serializable 38 | data class Merchant( 39 | val id: MerchantKey, 40 | val name: String, 41 | val category: String, 42 | ) 43 | ``` 44 | 45 | ### Sealed (Subclass) Classes 46 | Sealed and subclasses are supported, but there are some caveats, as searching on json paths on works 47 | on data that is serialized to json. I.e. getters/setters are not queryable. 48 | 49 | Take the following example: 50 | 51 | ```kotlin 52 | sealed class Card { 53 | val id: Uuid 54 | val cardNumber: String 55 | @Serializable 56 | data class CreditCard( 57 | val key: Uuid, 58 | val last4: String, 59 | val expiry: String, 60 | ) : Card() { 61 | override val id: Uuid get() = key 62 | override val cardNumber: String get() = last4 63 | } 64 | 65 | @Serializable 66 | data class DebitCard( 67 | val key: Uuid, 68 | val last4: String, 69 | val expiry: String, 70 | ) : Card() { 71 | override val id: Uuid get() = key 72 | override val cardNumber: String get() = last4 73 | } 74 | } 75 | ``` 76 | 77 | As `id` and `cardNumber` are abstract properties of the sealed class, they never get serialized to 78 | json, so they would not be queryable. (Unless you made your concrete classes override and serialize them.) 79 | 80 | `with` will accepts sub types of the parent class, please open issues of complex data structures if stuck. 81 | 82 | The following would be valid and invalid queries: 83 | ```kotlin 84 | // These will search across the sealed class fields 85 | val idWhere = Card::class.with(CreditCard::key) eq "1" 86 | val last4Where = Card::class.with(CreditCard::last4) eq "1234" 87 | // This will not work tho as cardNumber is a getter 88 | val cardNumberWhere = Card::cardNumber eq "1234" 89 | 90 | ``` 91 | 92 | ## Installation 93 | 94 | ### Gradle 95 | 96 | Multiplatform projects (Android, JVM, iOS (coming soon)) 97 | 98 | ```kotlin 99 | commonMain { 100 | dependencies { 101 | implementation("com.mercury.sqkon:library:1.2.0") 102 | } 103 | } 104 | ``` 105 | 106 | Or you can use the platform specific dependencies, e.g: Android only: 107 | 108 | ```kotlin 109 | dependencies { 110 | implementation("com.mercury.sqkon:library-android:1.2.0") 111 | } 112 | ``` 113 | 114 | ## Project Requirements 115 | 116 | The project is built upon [SQLDelight](https://github.com/sqldelight/sqldelight) 117 | and [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization), these are transitive 118 | dependencies, but you will not be able to use the library with applying the 119 | kotlinx-serialization plugin. If you are not using kotlinx serialization, I suggest you read about 120 | it 121 | here: https://github.com/Kotlin/kotlinx.serialization. 122 | 123 | ## Expiry/Cache Busting 124 | 125 | Sqkon doesn't provide default cache busting out of the box, but it does provide the tools to do 126 | this if that's what you require. 127 | 128 | - `KeyValueStore.selectResult` will expose a ResultRow with a `expiresAt`, `writeAt` and `readAt` 129 | fields, with this you can handle cache busting yourself. 130 | - Most methods support `expiresAt`, `expiresAfter` which let you set expiry times, we don't auto purge fields that have "expired" use 131 | use `deleteExpired` to remove them. We track `readAt`,`writeAt` when rows are read/written too. 132 | - We provide `deleteWhere`, `deleteExpired`, `deleteStale`, the docs explain there differences. 133 | 134 | ## Testing 135 | 136 | ```bash 137 | # Run all tests 138 | ./gradlew :library:allTests 139 | 140 | # JVM tests only 141 | ./gradlew :library:jvmTest 142 | 143 | # Android tests (managed devices - recommended for CI) 144 | ./gradlew :library:allDevicesDebugAndroidTest 145 | 146 | # Android tests (connected device/emulator) 147 | ./gradlew :library:connectedDebugAndroidTest 148 | 149 | # Specific test class 150 | ./gradlew :library:jvmTest --tests "com.mercury.sqkon.db.KeyValueStorageTest" 151 | 152 | # Specific test method 153 | ./gradlew :library:jvmTest --tests "com.mercury.sqkon.db.KeyValueStorageTest.testInsertAndSelect" 154 | ``` 155 | 156 | Test reports: `library/build/reports/tests/jvmTest/index.html` (JVM), `library/build/reports/androidTests/connected/debug/index.html` (Android) 157 | 158 | ### Build platform artifacts 159 | 160 | #### Android aar 161 | 162 | - Run `./gradlew :core:assembleRelease` 163 | 164 | #### JVM jar 165 | 166 | - Run `./gradlew :core:jvmJar` 167 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageStaleTest.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import app.cash.turbine.test 4 | import com.mercury.sqkon.TestObject 5 | import kotlinx.coroutines.MainScope 6 | import kotlinx.coroutines.cancel 7 | import kotlinx.coroutines.flow.first 8 | import kotlinx.coroutines.test.runTest 9 | import kotlinx.datetime.Clock 10 | import java.lang.Thread.sleep 11 | import kotlin.test.AfterTest 12 | import kotlin.test.Test 13 | import kotlin.test.assertEquals 14 | import kotlin.test.assertNotNull 15 | 16 | class KeyValueStorageStaleTest { 17 | 18 | private val mainScope = MainScope() 19 | private val driver = driverFactory().createDriver() 20 | private val entityQueries = EntityQueries(driver) 21 | private val metadataQueries = MetadataQueries(driver) 22 | private val testObjectStorage = keyValueStorage( 23 | "test-object", entityQueries, metadataQueries, mainScope 24 | ) 25 | 26 | @AfterTest 27 | fun tearDown() { 28 | mainScope.cancel() 29 | } 30 | 31 | @Test 32 | fun insertAll_staleInPast() = runTest { 33 | val now = Clock.System.now() 34 | val expected = (0..10).map { TestObject() } 35 | .associateBy { it.id } 36 | .toSortedMap() 37 | testObjectStorage.insertAll(expected) 38 | // Clean up older than now 39 | testObjectStorage.deleteStale(writeInstant = now, readInstant = now) 40 | val actualAfterDelete = testObjectStorage.selectAll().first() 41 | assertEquals(expected.size, actualAfterDelete.size) 42 | } 43 | 44 | @Test 45 | fun insertAll_staleWrite() = runTest { 46 | val expected = (0..10).map { TestObject() } 47 | .associateBy { it.id } 48 | .toSortedMap() 49 | testObjectStorage.insertAll(expected) 50 | sleep(1) 51 | val now = Clock.System.now() 52 | // Clean up older than now 53 | testObjectStorage.deleteStale(writeInstant = now, readInstant = now) 54 | val actualAfterDelete = testObjectStorage.selectAll().first() 55 | assertEquals(0, actualAfterDelete.size) 56 | } 57 | 58 | @Test 59 | fun insertAll_staleWrite_purgeReadNotStale() = runTest { 60 | val expected = (0..10).map { TestObject() } 61 | .associateBy { it.id } 62 | .toSortedMap() 63 | testObjectStorage.insertAll(expected) 64 | sleep(1) 65 | val now = Clock.System.now() 66 | sleep(1) 67 | testObjectStorage.selectAll().first() 68 | // Clean up older than now 69 | testObjectStorage.deleteStale(writeInstant = null, readInstant = now) 70 | val actualAfterDelete = testObjectStorage.selectAll().first() 71 | assertEquals(expected.size, actualAfterDelete.size) 72 | } 73 | 74 | @Test 75 | fun insertAll_staleWrite_purgeStaleWrite() = runTest { 76 | val expected = (0..10).map { TestObject() } 77 | .associateBy { it.id } 78 | .toSortedMap() 79 | testObjectStorage.insertAll(expected) 80 | sleep(1) 81 | val now = Clock.System.now() 82 | sleep(1) 83 | testObjectStorage.selectAll().first() 84 | // Clean up older than now 85 | testObjectStorage.deleteStale(writeInstant = now, readInstant = null) 86 | val actualAfterDelete = testObjectStorage.selectAll().first() 87 | assertEquals(0, actualAfterDelete.size) 88 | } 89 | 90 | @Test 91 | fun insertAll_readInPast() = runTest { 92 | val expected = (0..10).map { TestObject() } 93 | .associateBy { it.id } 94 | .toSortedMap() 95 | testObjectStorage.insertAll(expected) 96 | testObjectStorage.selectAll().first() 97 | sleep(10) 98 | val now = Clock.System.now() 99 | // write again so read is in the past 100 | testObjectStorage.updateAll(expected) 101 | // Read in the past write is after now 102 | testObjectStorage.deleteStale(writeInstant = now, readInstant = now) 103 | val actualAfterDelete = testObjectStorage.selectAll().first() 104 | assertEquals(expected.size, actualAfterDelete.size) 105 | } 106 | 107 | @Test 108 | fun insertAll_readInPast_purgeStaleRead() = runTest { 109 | val expected = (0..10).map { TestObject() } 110 | .associateBy { it.id } 111 | .toSortedMap() 112 | testObjectStorage.insertAll(expected) 113 | testObjectStorage.selectAll().first() 114 | sleep(1) 115 | val now = Clock.System.now() 116 | sleep(1) 117 | // write again so read is in the past 118 | testObjectStorage.updateAll(expected) 119 | // Read in the past write is after now 120 | testObjectStorage.deleteStale(writeInstant = null, readInstant = now) 121 | testObjectStorage.selectAll().test { 122 | var actualAfterDelete = awaitItem() 123 | if (actualAfterDelete.size != 0) { 124 | actualAfterDelete = awaitItem() 125 | } 126 | assertEquals(0, actualAfterDelete.size) 127 | cancelAndIgnoreRemainingEvents() 128 | } 129 | } 130 | 131 | @Test 132 | fun insertAll_readInPast_purgeWriteNotStale() = runTest { 133 | val expected = (0..10).map { TestObject() } 134 | .associateBy { it.id } 135 | .toSortedMap() 136 | testObjectStorage.insertAll(expected) 137 | testObjectStorage.selectAll().first() 138 | val now = Clock.System.now() 139 | sleep(10) 140 | // write again so read is in the past 141 | testObjectStorage.updateAll(expected) 142 | // Read in the past write is after now 143 | testObjectStorage.deleteStale(writeInstant = now, readInstant = null) 144 | val actualAfterDelete = testObjectStorage.selectAll().first() 145 | assertEquals(expected.size, actualAfterDelete.size) 146 | } 147 | 148 | @Test 149 | fun insertAll_staleRead() = runTest { 150 | val expected = (0..10).map { TestObject() } 151 | .associateBy { it.id } 152 | .toSortedMap() 153 | testObjectStorage.insertAll(expected) 154 | sleep(10) 155 | testObjectStorage.selectAll().first() 156 | sleep(10) 157 | val now = Clock.System.now() 158 | // Clean write and read are in the past 159 | testObjectStorage.deleteStale(writeInstant = now, readInstant = now) 160 | val actualAfterDelete = testObjectStorage.selectResult().first() 161 | assertEquals(0, actualAfterDelete.size) 162 | } 163 | 164 | @Test 165 | fun selectResult_readWriteSet() = runTest { 166 | val expected = (0..10).map { TestObject() } 167 | .associateBy { it.id } 168 | .toSortedMap() 169 | testObjectStorage.insertAll(expected) 170 | val actual = testObjectStorage.selectResult().first() 171 | actual.forEach { result -> 172 | assertNotNull(result.readAt) 173 | assertNotNull(result.value) 174 | } 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /.junie/guidelines.md: -------------------------------------------------------------------------------- 1 | # Sqkon Development Guidelines 2 | 3 | ## Project Overview 4 | Sqkon is a Kotlin Multiplatform library for key-value storage built on top of SQLDelight and AndroidX SQLite. It provides a type-safe, coroutine-based API with Flow support for reactive data access. 5 | 6 | ## Build Configuration 7 | 8 | ### Kotlin Multiplatform Setup 9 | - **Targets**: Android and JVM 10 | - **JVM Toolchain**: Java 21 11 | - **Kotlin Version**: 2.2.20 12 | - **Gradle**: Uses Kotlin DSL (`build.gradle.kts`) 13 | 14 | ### Important Compiler Flags 15 | The project uses `-Xexpect-actual-classes` compiler flag for multiplatform expect/actual declarations. This is configured in: 16 | - Root `build.gradle.kts` for all subprojects 17 | - `library/build.gradle.kts` for the library module 18 | 19 | ### SQLDelight Configuration 20 | Located in `library/build.gradle.kts`: 21 | ```kotlin 22 | sqldelight { 23 | databases { 24 | create("SqkonDatabase") { 25 | generateAsync = false // Disabled due to driver issues on multithreaded platforms 26 | packageName.set("com.mercury.sqkon.db") 27 | schemaOutputDirectory.set(file("src/commonMain/sqldelight/databases")) 28 | dialect("app.cash.sqldelight:sqlite-3-38-dialect:$VERSION") 29 | } 30 | } 31 | } 32 | ``` 33 | 34 | **Key Point**: `generateAsync = false` is intentionally disabled as async operations are problematic with coroutines on multithreaded platforms (driver limitation). 35 | 36 | ### Source Sets 37 | - `commonMain`: Common code for all platforms 38 | - `commonTest`: Shared test code 39 | - `androidMain`: Android-specific implementations 40 | - `jvmMain`: JVM-specific implementations 41 | - `androidInstrumentedTest`: Android instrumented tests 42 | - `jvmTest`: JVM test implementations 43 | 44 | ## Testing 45 | 46 | ### Test Framework Stack 47 | - **kotlin.test**: Core assertions and annotations 48 | - **JUnit**: Test structure (`@Test`, `@After` annotations) 49 | - **kotlinx-coroutines-test**: `runTest` for coroutine testing 50 | - **Turbine**: Flow testing library (`test{}`, `awaitItem()`, `expectNoEvents()`) 51 | - **Cash App Paging Testing**: For pagination testing 52 | 53 | ### Running Tests 54 | 55 | #### Run all tests for the library module: 56 | ```bash 57 | ./gradlew :library:test 58 | ``` 59 | 60 | #### Run tests for specific platform: 61 | ```bash 62 | ./gradlew :library:jvmTest # JVM tests only 63 | ./gradlew :library:testDebugUnitTest # Android unit tests (disabled by config) 64 | ``` 65 | 66 | #### Run a specific test class: 67 | ```bash 68 | ./gradlew :library:jvmTest --tests "com.mercury.sqkon.SimpleGuidelineTest" 69 | ``` 70 | 71 | #### Run a specific test method: 72 | ```bash 73 | ./gradlew :library:jvmTest --tests "com.mercury.sqkon.SimpleGuidelineTest.basicInsertAndSelect" 74 | ``` 75 | 76 | ### Android Testing Notes 77 | - **Unit tests are disabled** for Android target: `enableUnitTest = false` in `build.gradle.kts` 78 | - **Instrumented tests are enabled**: Run on Android devices/emulators 79 | - **Managed device configured**: `mediumPhoneApi35` (API 35, aosp-atd) 80 | - Test runner: `androidx.test.runner.AndroidJUnitRunner` 81 | 82 | ### Writing Tests 83 | 84 | #### Basic Test Structure 85 | ```kotlin 86 | package com.mercury.sqkon 87 | 88 | import com.mercury.sqkon.db.* 89 | import kotlinx.coroutines.MainScope 90 | import kotlinx.coroutines.cancel 91 | import kotlinx.coroutines.flow.first 92 | import kotlinx.coroutines.test.runTest 93 | import org.junit.After 94 | import org.junit.Test 95 | import kotlin.test.assertEquals 96 | 97 | class MyTest { 98 | private val mainScope = MainScope() 99 | private val driver = driverFactory().createDriver() 100 | private val entityQueries = EntityQueries(driver) 101 | private val metadataQueries = MetadataQueries(driver) 102 | private val storage = keyValueStorage( 103 | "my-test", entityQueries, metadataQueries, mainScope 104 | ) 105 | 106 | @After 107 | fun tearDown() { 108 | mainScope.cancel() 109 | } 110 | 111 | @Test 112 | fun myTest() = runTest { 113 | // Test code here 114 | } 115 | } 116 | ``` 117 | 118 | #### Key Components: 119 | 1. **MainScope**: Required for background database operations 120 | 2. **driverFactory()**: Expect/actual function providing platform-specific test drivers 121 | - JVM: In-memory database (`AndroidxSqliteDatabaseType.Memory`) 122 | - Android: Context-based test database 123 | 3. **tearDown**: Always cancel MainScope to prevent resource leaks 124 | 4. **runTest**: Wraps suspend test functions for coroutine testing 125 | 126 | #### Testing Flows with Turbine 127 | ```kotlin 128 | import app.cash.turbine.test 129 | 130 | @Test 131 | fun flowTest() = runTest { 132 | storage.selectAll().test { 133 | val items = awaitItem() 134 | assertEquals(0, items.size) 135 | 136 | storage.insert("key", TestObject()) 137 | val updatedItems = awaitItem() 138 | assertEquals(1, updatedItems.size) 139 | 140 | expectNoEvents() 141 | } 142 | } 143 | ``` 144 | 145 | #### Testing with turbineScope 146 | ```kotlin 147 | import app.cash.turbine.turbineScope 148 | 149 | @Test 150 | fun multipleFlowsTest() = runTest { 151 | turbineScope { 152 | val flow1 = storage.selectAll().testIn(backgroundScope) 153 | val flow2 = storage.count().testIn(backgroundScope) 154 | 155 | val items = flow1.awaitItem() 156 | val count = flow2.awaitItem() 157 | // assertions... 158 | } 159 | } 160 | ``` 161 | 162 | ### Test Data Models 163 | Test models are defined in `library/src/commonTest/kotlin/com/mercury/sqkon/TestDataClasses.kt`: 164 | - `TestObject`: Main test entity with various field types 165 | - `TestObjectChild`: Nested object with timestamps 166 | - `TestValue`: Value class example 167 | - `TestSealed`: Sealed interface implementations 168 | - `TestEnum`: Enum example 169 | 170 | These models demonstrate serialization of: 171 | - Nested objects 172 | - Value classes (@JvmInline) 173 | - Sealed interfaces 174 | - Nullable fields 175 | - Lists 176 | - Custom SerialNames 177 | - Enums 178 | 179 | ### Test Utilities 180 | Located in `library/src/commonTest/kotlin/com/mercury/sqkon/TestUtilsExt.kt`: 181 | 182 | **`until()` function**: Poll-based assertion helper 183 | ```kotlin 184 | suspend fun until(timeout: Duration = 5.seconds, block: suspend () -> Boolean) 185 | ``` 186 | 187 | Usage: 188 | ```kotlin 189 | val results = mutableListOf() 190 | backgroundScope.launch { /* populate results */ } 191 | until { results.size > 5 } 192 | ``` 193 | 194 | ## Development Information 195 | 196 | ### Expect/Actual Pattern 197 | The project extensively uses Kotlin Multiplatform's expect/actual mechanism: 198 | - **Common**: Declares `expect` functions/classes 199 | - **Platform-specific**: Provides `actual` implementations 200 | 201 | Key expect/actual declarations: 202 | - `DriverFactory`: Database driver creation 203 | - `driverFactory()`: Test database setup 204 | - `Sqkon.create()`: Platform-specific initialization 205 | 206 | ### SQLDelight Schema Management 207 | - Schema files: `library/src/commonMain/sqldelight/com/mercury/sqkon/db/` 208 | - `entity.sq`: Entity storage queries 209 | - `metadata.sq`: Metadata tracking queries 210 | - Migrations: `library/src/commonMain/sqldelight/migrations/` 211 | - Schema output: `library/src/commonMain/sqldelight/databases/` 212 | 213 | ### Core Architecture 214 | - **KeyValueStorage**: Main API for type-safe storage operations 215 | - **EntityQueries**: Generated SQLDelight queries for entities 216 | - **MetadataQueries**: Generated SQLDelight queries for metadata 217 | - **KotlinSqkonSerializer**: Kotlinx.serialization-based JSON serialization 218 | - **Flow-based**: All queries return Flows for reactive updates 219 | 220 | ### Key Libraries 221 | - **SQLDelight 2.1.0**: Type-safe SQL generation 222 | - **AndroidX SQLite**: Cross-platform SQLite driver 223 | - **Kotlinx Serialization 1.9.0**: JSON serialization 224 | - **Kotlinx Coroutines 1.10.2**: Async operations 225 | - **Kotlinx Datetime 0.6.2**: Timestamp handling 226 | - **Cash App Paging 3.3.0**: Pagination support 227 | 228 | ### Publishing 229 | - Configured for Maven Central and GitHub Packages 230 | - Uses `com.vanniktech.maven.publish` plugin 231 | - Version from `VERSION_NAME` property or `RELEASE_VERSION` environment variable 232 | - Automatic release enabled for Maven Central 233 | 234 | ### Code Style Notes 235 | - **Coroutines**: All database operations are suspend functions 236 | - **Flows**: Reactive data streams with automatic updates 237 | - **Transactions**: Use `storage.transaction { }` for atomic operations 238 | - **JSON Path**: Supports querying nested JSON fields with type-safe builders 239 | - **Value Classes**: Supported for inline wrapping of primitives 240 | - **Sealed Interfaces**: Supported with proper SerialName annotations 241 | 242 | ### Common Patterns 243 | 244 | #### Creating Storage 245 | ```kotlin 246 | val storage = keyValueStorage( 247 | storageKey = "my-storage", 248 | entityQueries = entityQueries, 249 | metadataQueries = metadataQueries, 250 | mainScope = mainScope 251 | ) 252 | ``` 253 | 254 | #### Querying with Conditions 255 | ```kotlin 256 | // Simple equality 257 | storage.select(where = MyType::field eq "value") 258 | 259 | // Nested field access 260 | storage.select(where = MyType::child.then(Child::field) eq "value") 261 | 262 | // Combining conditions 263 | storage.select( 264 | where = (MyType::field1 eq "value1").and(MyType::field2 eq "value2") 265 | ) 266 | 267 | // Ordering 268 | storage.selectAll( 269 | orderBy = listOf(OrderBy(MyType::field, OrderDirection.ASC)) 270 | ) 271 | ``` 272 | 273 | #### Working with Transactions 274 | ```kotlin 275 | storage.transaction { 276 | storage.deleteAll() 277 | storage.insertAll(newItems) 278 | // All or nothing - atomic operation 279 | } 280 | ``` 281 | 282 | ### Debugging Tips 283 | - Enable SQLDelight query logging by setting appropriate log levels 284 | - Use `slowWrite = true` on EntityQueries for testing transaction boundaries 285 | - Flow emissions can be tested with Turbine's `expectNoEvents()` to ensure operations are properly batched 286 | - Check that MainScope is properly cancelled in tests to avoid resource leaks 287 | 288 | ## Build Commands 289 | 290 | ### Clean build: 291 | ```bash 292 | ./gradlew clean build 293 | ``` 294 | 295 | ### Run all tests: 296 | ```bash 297 | ./gradlew test 298 | ``` 299 | 300 | ### Publish to local Maven: 301 | ```bash 302 | ./gradlew publishToMavenLocal 303 | ``` 304 | 305 | ### Check version: 306 | ```bash 307 | ./gradlew version 308 | ``` 309 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/JsonPath.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import kotlinx.serialization.ExperimentalSerializationApi 4 | import kotlinx.serialization.descriptors.PolymorphicKind 5 | import kotlinx.serialization.descriptors.SerialDescriptor 6 | import kotlinx.serialization.descriptors.StructureKind 7 | import kotlinx.serialization.serializer 8 | import kotlin.reflect.KClass 9 | import kotlin.reflect.KProperty1 10 | import kotlin.reflect.KType 11 | import kotlin.reflect.typeOf 12 | 13 | /** 14 | * Create a Path builder using one of the manu reified methods. 15 | * 16 | * From a class: 17 | * 18 | * ``` 19 | * val builder = TestObject::class.with(TestObject::statuses) { 20 | * then(Status::createdAt) 21 | * } 22 | * ``` 23 | * 24 | * From a property: 25 | * 26 | * ``` 27 | * val builder = TestObject::statuses.builder { 28 | * then(Status::createdAt) 29 | * } 30 | * 31 | * Quick joining two properties: 32 | * 33 | * ``` 34 | * val builder = TestObject::statuses.then(Status::createdAt) { 35 | * // can optionally keep going 36 | * } 37 | * ``` 38 | * Classes like [OrderBy] and [Where] operators take [JsonPathBuilder] or [KProperty1] to build 39 | * the path for sql queries. 40 | */ 41 | class JsonPathBuilder 42 | @PublishedApi internal constructor() { 43 | 44 | @PublishedApi 45 | internal var parentNode: JsonPathNode? = null 46 | 47 | @PublishedApi 48 | internal inline fun with( 49 | property: KProperty1, 50 | serialName: String? = null, 51 | block: JsonPathNode.() -> Unit = {} 52 | ): JsonPathBuilder { 53 | parentNode = JsonPathNode( 54 | //parent = null, 55 | propertyName = serialName ?: property.name, 56 | receiverDescriptor = serializer().descriptor, 57 | valueDescriptor = serializer().descriptor 58 | ).also(block) 59 | return this 60 | } 61 | 62 | @PublishedApi 63 | internal inline fun with( 64 | baseType: KType, 65 | property: KProperty1, 66 | serialName: String? = null, 67 | block: JsonPathNode.() -> Unit = {} 68 | ): JsonPathBuilder { 69 | parentNode = JsonPathNode( 70 | //parent = null, 71 | propertyName = serialName ?: property.name, 72 | receiverBaseDescriptor = if (baseType != typeOf()) { 73 | serializer(baseType).descriptor 74 | } else null, 75 | receiverDescriptor = serializer().descriptor, 76 | valueDescriptor = serializer().descriptor 77 | ).also(block) 78 | return this 79 | } 80 | 81 | // Handles collection property type and extracts the element type vs the list type 82 | @PublishedApi 83 | @JvmName("withList") 84 | internal inline fun with( 85 | property: KProperty1>, 86 | serialName: String? = null, 87 | block: JsonPathNode.() -> Unit = {} 88 | ): JsonPathBuilder { 89 | parentNode = JsonPathNode( 90 | //parent = null, 91 | propertyName = serialName ?: property.name, 92 | receiverDescriptor = serializer().descriptor, 93 | valueDescriptor = serializer>().descriptor 94 | ).also(block) 95 | return this 96 | } 97 | 98 | 99 | private fun nodes(): List> { 100 | val nodes = mutableListOf>() 101 | var node: JsonPathNode<*, *>? = parentNode 102 | while (node != null) { 103 | // Insert additional node for parent classes incase they are sealed classes 104 | if (node.receiverBaseDescriptor != null) { 105 | nodes.add( 106 | JsonPathNode( 107 | propertyName = "", 108 | receiverDescriptor = node.receiverBaseDescriptor, 109 | valueDescriptor = node.receiverBaseDescriptor 110 | ) 111 | ) 112 | } 113 | nodes.add(node) 114 | node = node.child 115 | } 116 | return nodes 117 | } 118 | 119 | @OptIn(ExperimentalSerializationApi::class) 120 | fun fieldNames(): List { 121 | return nodes().mapNotNull { it -> 122 | if (it.receiverDescriptor.isInline) return@mapNotNull null // Skip inline classes 123 | val prefix = if (it.propertyName.isNotBlank()) "." else "" 124 | when (it.valueDescriptor.kind) { 125 | StructureKind.LIST -> "$prefix${it.propertyName}[%]" 126 | PolymorphicKind.SEALED -> "$prefix${it.propertyName}[1]" 127 | else -> "$prefix${it.propertyName}" 128 | } 129 | } 130 | } 131 | 132 | fun buildPath(): String { 133 | return fieldNames().joinToString("", prefix = "\$") 134 | } 135 | } 136 | 137 | // Builder Methods to start building paths 138 | 139 | inline fun KProperty1.builder( 140 | serialName: String? = null, 141 | block: JsonPathNode.() -> Unit = {} 142 | ): JsonPathBuilder { 143 | return JsonPathBuilder().with(property = this, serialName = serialName, block = block) 144 | } 145 | 146 | inline fun KProperty1>.builderFromList( 147 | block: JsonPathNode.() -> Unit = {} 148 | ): JsonPathBuilder { 149 | return JsonPathBuilder().with(property = this, block = block) 150 | } 151 | 152 | inline fun KProperty1.then( 153 | property: KProperty1, 154 | fromSerialName: String? = null, 155 | thenSerialName: String? = null, 156 | block: JsonPathNode.() -> Unit = {} 157 | ): JsonPathBuilder { 158 | return JsonPathBuilder() 159 | .with(property = this, serialName = fromSerialName) { 160 | then(property = property, serialName = thenSerialName, block = block) 161 | } 162 | } 163 | 164 | @JvmName("thenFromList") 165 | inline fun KProperty1>.then( 166 | property: KProperty1, 167 | fromSerialName: String? = null, 168 | thenSerialName: String? = null, 169 | block: JsonPathNode.() -> Unit = {} 170 | ): JsonPathBuilder { 171 | return JsonPathBuilder() 172 | .with(property = this, fromSerialName) { 173 | then(property = property, serialName = thenSerialName, block = block) 174 | } 175 | } 176 | 177 | 178 | @JvmName("thenList") 179 | inline fun KProperty1.then( 180 | property: KProperty1>, 181 | block: JsonPathNode.() -> Unit = {} 182 | ): JsonPathBuilder { 183 | return JsonPathBuilder().with(property = this) { 184 | then(property = property, block = block) 185 | } 186 | } 187 | 188 | inline fun KClass.with( 189 | property: KProperty1, 190 | serialName: String? = null, 191 | block: JsonPathNode.() -> Unit = {} 192 | ): JsonPathBuilder { 193 | return JsonPathBuilder().with( 194 | baseType = typeOf(), property = property, block = block, serialName = serialName, 195 | ) 196 | } 197 | 198 | // Handles collection property type 199 | @Suppress("UnusedReceiverParameter") 200 | inline fun KClass.withList( 201 | property: KProperty1>, 202 | serialName: String? = null, 203 | block: JsonPathNode.() -> Unit = {} 204 | ): JsonPathBuilder { 205 | return JsonPathBuilder() 206 | .with(property = property, block = block, serialName = serialName) 207 | } 208 | 209 | /** 210 | * Represents a path in a JSON object, using limited reflection and descriptors to build the path. 211 | * 212 | * Start building using [with]. 213 | */ 214 | class JsonPathNode 215 | @PublishedApi 216 | internal constructor( 217 | //@PublishedApi internal val parent: JsonPathNode<*, R>?, 218 | val propertyName: String, 219 | internal val receiverBaseDescriptor: SerialDescriptor? = null, 220 | internal val receiverDescriptor: SerialDescriptor, 221 | @PublishedApi internal val valueDescriptor: SerialDescriptor, 222 | ) { 223 | 224 | @PublishedApi 225 | internal var child: JsonPathNode? = null 226 | 227 | /** 228 | * @param serialName we can't detect overridden serial names so if you have `@SerialName` set, 229 | * then you will need to pass this through here. 230 | */ 231 | inline fun then( 232 | property: KProperty1, 233 | serialName: String? = null, 234 | block: JsonPathNode.() -> Unit = {} 235 | ): JsonPathNode { 236 | child = JsonPathNode( 237 | //parent = this, 238 | propertyName = serialName ?: property.name, 239 | receiverDescriptor = serializer().descriptor, 240 | valueDescriptor = serializer().descriptor 241 | ).also(block) 242 | return this 243 | } 244 | 245 | /** 246 | * Support list, as lists need to be handled differently than object path. 247 | * 248 | * This returns the Collection element type, so you can chain into the next property. 249 | */ 250 | @JvmName("thenList") 251 | inline fun then( 252 | property: KProperty1>, 253 | serialName: String? = null, 254 | block: JsonPathNode.() -> Unit = {} 255 | ): JsonPathNode { 256 | child = JsonPathNode( 257 | //parent = this, 258 | propertyName = serialName ?: property.name, 259 | receiverDescriptor = valueDescriptor, 260 | valueDescriptor = serializer>().descriptor 261 | ).also(block) 262 | return this 263 | } 264 | 265 | override fun toString(): String { 266 | return "JsonPathNode(propertyName='$propertyName', receiverDescriptor=$receiverDescriptor, valueDescriptor=$valueDescriptor)" 267 | } 268 | } 269 | 270 | //private fun testBlock() { 271 | // val pathBuilder: JsonPathBuilder = Test::class.with(Test::child) { 272 | // then(TestChild::child2) { 273 | // then(TestChild2::childValue2) 274 | // } 275 | // } 276 | // val pathBuilderWithList = Test::class.withList(Test::childList) { 277 | // then(TestChild::childValue) 278 | // } 279 | //} 280 | 281 | //private data class Test( 282 | // val value: String, 283 | // val child: TestChild, 284 | // val childList: List, 285 | //) 286 | // 287 | //private data class TestChild( 288 | // val childValue: String, 289 | // val child2: TestChild2, 290 | //) 291 | // 292 | //private data class TestChild2( 293 | // val childValue2: String, 294 | //) 295 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/EntityQueries.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import app.cash.sqldelight.Query 4 | import app.cash.sqldelight.TransacterImpl 5 | import app.cash.sqldelight.db.QueryResult 6 | import app.cash.sqldelight.db.SqlCursor 7 | import app.cash.sqldelight.db.SqlDriver 8 | import com.mercury.sqkon.db.utils.nowMillis 9 | import kotlinx.datetime.Clock 10 | import kotlinx.datetime.Instant 11 | import org.jetbrains.annotations.VisibleForTesting 12 | 13 | class EntityQueries( 14 | @PublishedApi 15 | internal val sqlDriver: SqlDriver, 16 | ) : TransacterImpl(sqlDriver) { 17 | 18 | // Used to slow down insert/updates for testing 19 | @VisibleForTesting 20 | internal var slowWrite: Boolean = false 21 | 22 | fun insertEntity(entity: Entity, ignoreIfExists: Boolean) { 23 | val identifier = identifier("insert", ignoreIfExists.toString()) 24 | val orIgnore = if (ignoreIfExists) "OR IGNORE" else "" 25 | driver.execute( 26 | identifier = identifier, 27 | sql = """ 28 | INSERT $orIgnore INTO entity ( 29 | entity_name, entity_key, added_at, updated_at, expires_at, write_at, value 30 | ) 31 | VALUES (?, ?, ?, ?, ?, ?, jsonb(?)) 32 | """.trimIndent(), 33 | parameters = 7 34 | ) { 35 | bindString(0, entity.entity_name) 36 | bindString(1, entity.entity_key) 37 | bindLong(2, entity.added_at) 38 | bindLong(3, entity.updated_at) 39 | bindLong(4, entity.expires_at) 40 | // While write_at is nullable on the db col, we always set it here (sqlite limitation) 41 | bindLong(5, entity.write_at ?: nowMillis()) 42 | bindString(6, entity.value_) 43 | } 44 | notifyQueries(identifier) { emit -> 45 | emit("entity") 46 | emit("entity_${entity.entity_name}") 47 | } 48 | if (slowWrite) { 49 | Thread.sleep(100) 50 | } 51 | } 52 | 53 | fun updateEntity( 54 | entityName: String, 55 | entityKey: String, 56 | expiresAt: Instant?, 57 | value: String, 58 | ) { 59 | val now = Clock.System.now() 60 | val identifier = identifier("update") 61 | driver.execute( 62 | identifier = identifier, 63 | sql = """ 64 | UPDATE entity SET updated_at = ?, expires_at = ?, write_at = ?, value = jsonb(?) 65 | WHERE entity_name = ? AND entity_key = ? 66 | """.trimMargin(), 5 67 | ) { 68 | bindLong(0, now.toEpochMilliseconds()) 69 | bindLong(1, expiresAt?.toEpochMilliseconds()) 70 | bindLong(2, now.toEpochMilliseconds()) 71 | bindString(3, value) 72 | bindString(4, entityName) 73 | bindString(5, entityKey) 74 | } 75 | notifyQueries(identifier) { emit -> 76 | emit("entity") 77 | emit("entity_${entityName}") 78 | } 79 | if (slowWrite) { 80 | Thread.sleep(100) 81 | } 82 | } 83 | 84 | fun select( 85 | entityName: String, 86 | entityKeys: Collection? = null, 87 | where: Where<*>? = null, 88 | orderBy: List> = emptyList(), 89 | limit: Long? = null, 90 | offset: Long? = null, 91 | expiresAt: Instant? = null, 92 | ): Query = SelectQuery( 93 | entityName = entityName, 94 | entityKeys = entityKeys, 95 | where = where, 96 | orderBy = orderBy, 97 | limit = limit, 98 | offset = offset, 99 | expiresAt = expiresAt, 100 | ) { cursor -> 101 | Entity( 102 | entity_name = cursor.getString(0)!!, 103 | entity_key = cursor.getString(1)!!, 104 | added_at = cursor.getLong(2)!!, 105 | updated_at = cursor.getLong(3)!!, 106 | expires_at = cursor.getLong(4), 107 | read_at = cursor.getLong(5), 108 | write_at = cursor.getLong(6), 109 | value_ = cursor.getString(7)!!, 110 | ) 111 | } 112 | 113 | private inner class SelectQuery( 114 | private val entityName: String, 115 | private val entityKeys: Collection? = null, 116 | private val where: Where<*>? = null, 117 | private val orderBy: List>, 118 | private val limit: Long? = null, 119 | private val offset: Long? = null, 120 | private val expiresAt: Instant? = null, 121 | mapper: (SqlCursor) -> Entity, 122 | ) : Query(mapper) { 123 | 124 | override fun addListener(listener: Listener) { 125 | driver.addListener("entity_$entityName", listener = listener) 126 | } 127 | 128 | override fun removeListener(listener: Listener) { 129 | driver.removeListener("entity_$entityName", listener = listener) 130 | } 131 | 132 | override fun execute(mapper: (SqlCursor) -> QueryResult): QueryResult { 133 | val queries = buildList { 134 | add( 135 | SqlQuery( 136 | where = "entity_name = ?", 137 | parameters = 1, 138 | bindArgs = { bindString(entityName) }, 139 | ) 140 | ) 141 | if (expiresAt != null) add( 142 | SqlQuery( 143 | where = "expires_at IS NULL OR expires_at >= ?", 144 | parameters = 1, 145 | bindArgs = { bindLong(expiresAt.toEpochMilliseconds()) }, 146 | ) 147 | ) 148 | when (entityKeys?.size) { 149 | null, 0 -> {} 150 | 151 | 1 -> add( 152 | SqlQuery( 153 | where = "entity_key = ?", 154 | parameters = 1, 155 | bindArgs = { bindString(entityKeys.first()) }, 156 | ) 157 | ) 158 | 159 | else -> add( 160 | SqlQuery( 161 | where = "entity_key IN (${entityKeys.joinToString(",") { "?" }})", 162 | parameters = entityKeys.size, 163 | bindArgs = { entityKeys.forEach { bindString(it) } }, 164 | ) 165 | ) 166 | } 167 | addAll(listOfNotNull(where?.toSqlQuery(increment = 1))) 168 | addAll(orderBy.toSqlQueries()) 169 | } 170 | val identifier: Int = identifier( 171 | "select", 172 | queries.identifier().toString(), 173 | limit?.let { "limit" }, 174 | offset?.let { "offset" }, 175 | ) 176 | val sql = """ 177 | SELECT DISTINCT entity.entity_name, entity.entity_key, entity.added_at, 178 | entity.updated_at, entity.expires_at, entity.read_at, entity.write_at, 179 | json_extract(entity.value, '$') value 180 | FROM entity${queries.buildFrom()} ${queries.buildWhere()} ${queries.buildOrderBy()} 181 | ${limit?.let { "LIMIT ?" } ?: ""} ${offset?.let { "OFFSET ?" } ?: ""} 182 | """.trimIndent().replace('\n', ' ') 183 | return try { 184 | driver.executeQuery( 185 | identifier = identifier, 186 | sql = sql.replace('\n', ' '), 187 | mapper = mapper, 188 | parameters = queries.sumParameters() + (if (limit != null) 1 else 0) + (if (offset != null) 1 else 0), 189 | ) { 190 | val binder = AutoIncrementSqlPreparedStatement(preparedStatement = this) 191 | queries.forEach { it.bindArgs(binder) } 192 | if (limit != null) binder.bindLong(limit) 193 | if (offset != null) binder.bindLong(offset) 194 | } 195 | } catch (ex: SqlException) { 196 | println("SQL Error: $sql") 197 | throw ex 198 | } 199 | } 200 | 201 | override fun toString(): String = "select" 202 | } 203 | 204 | fun delete( 205 | entityName: String, 206 | entityKeys: Collection? = null, 207 | where: Where<*>? = null, 208 | ) { 209 | val queries = buildList { 210 | add( 211 | SqlQuery( 212 | where = "entity_name = ?", 213 | parameters = 1, 214 | bindArgs = { bindString(entityName) } 215 | )) 216 | when (entityKeys?.size) { 217 | null, 0 -> {} 218 | 219 | 1 -> add( 220 | SqlQuery( 221 | where = "entity_key = ?", 222 | parameters = 1, 223 | bindArgs = { bindString(entityKeys.first()) } 224 | )) 225 | 226 | else -> add( 227 | SqlQuery( 228 | where = "entity_key IN (${entityKeys.joinToString(",") { "?" }})", 229 | parameters = entityKeys.size, 230 | bindArgs = { entityKeys.forEach { bindString(it) } } 231 | )) 232 | } 233 | 234 | addAll(listOfNotNull(where?.toSqlQuery(increment = 1))) 235 | } 236 | val identifier = identifier("delete", queries.identifier().toString()) 237 | val whereSubQuerySql = if (queries.size <= 1) "" 238 | else """ 239 | AND entity_key IN (SELECT entity_key FROM entity${queries.buildFrom()} ${queries.buildWhere()}) 240 | """.trimIndent() 241 | val sql = "DELETE FROM entity WHERE entity_name = ? $whereSubQuerySql" 242 | try { 243 | driver.execute( 244 | identifier = identifier, 245 | sql = sql.replace('\n', ' '), 246 | parameters = 1 + if (queries.size > 1) queries.sumParameters() else 0, 247 | ) { 248 | bindString(0, entityName) 249 | val preparedStatement = AutoIncrementSqlPreparedStatement( 250 | index = 1, preparedStatement = this 251 | ) 252 | if (queries.size > 1) { 253 | queries.forEach { q -> q.bindArgs(preparedStatement) } 254 | } 255 | } 256 | } catch (ex: SqlException) { 257 | println("SQL Error: $sql") 258 | throw ex 259 | } 260 | notifyQueries(identifier) { emit -> 261 | emit("entity") 262 | emit("entity_$entityName") 263 | } 264 | } 265 | 266 | fun count( 267 | entityName: String, 268 | where: Where<*>? = null, 269 | expiresAfter: Instant? = null 270 | ): Query = CountQuery(entityName, where, expiresAfter) { cursor -> 271 | cursor.getLong(0)!!.toInt() 272 | } 273 | 274 | private inner class CountQuery( 275 | private val entityName: String, 276 | private val where: Where<*>? = null, 277 | private val expiresAfter: Instant? = null, 278 | mapper: (SqlCursor) -> T, 279 | ) : Query(mapper) { 280 | 281 | override fun addListener(listener: Listener) { 282 | driver.addListener("entity_$entityName", listener = listener) 283 | } 284 | 285 | override fun removeListener(listener: Listener) { 286 | driver.removeListener("entity_$entityName", listener = listener) 287 | } 288 | 289 | override fun execute(mapper: (SqlCursor) -> QueryResult): QueryResult { 290 | val queries = buildList { 291 | add( 292 | SqlQuery( 293 | where = "entity_name = ?", 294 | parameters = 1, 295 | bindArgs = { bindString(entityName) } 296 | )) 297 | if (expiresAfter != null) add( 298 | SqlQuery( 299 | where = "expires_at IS NULL OR expires_at >= ?", 300 | parameters = 1, 301 | bindArgs = { bindLong(expiresAfter.toEpochMilliseconds()) } 302 | ) 303 | ) 304 | addAll(listOfNotNull(where?.toSqlQuery(increment = 1))) 305 | } 306 | val identifier: Int = identifier("count", queries.identifier().toString()) 307 | val sql = """ 308 | SELECT COUNT(*) FROM entity${queries.buildFrom()} ${queries.buildWhere()} 309 | """.trimIndent().replace('\n', ' ') 310 | return try { 311 | driver.executeQuery( 312 | identifier = identifier, 313 | sql = sql, 314 | mapper = mapper, 315 | parameters = queries.sumParameters(), 316 | ) { 317 | val binder = AutoIncrementSqlPreparedStatement(preparedStatement = this) 318 | queries.forEach { it.bindArgs(binder) } 319 | } 320 | } catch (ex: SqlException) { 321 | println("SQL Error: $sql") 322 | throw ex 323 | } 324 | } 325 | 326 | override fun toString(): String = "count" 327 | } 328 | 329 | } 330 | 331 | /** 332 | * Generate an identifier for a query based on changing query sqlstring 333 | * (binding parameters don't change the structure of the string) 334 | */ 335 | private fun identifier(vararg values: String?): Int { 336 | return values.filterNotNull().joinToString("_").hashCode() 337 | } 338 | 339 | 340 | expect class SqlException : Exception -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/QueryExt.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import app.cash.sqldelight.db.SqlPreparedStatement 4 | import kotlin.reflect.KProperty1 5 | 6 | /** 7 | * Equivalent to `=` in SQL 8 | */ 9 | data class Eq( 10 | private val builder: JsonPathBuilder, private val value: V?, 11 | ) : Where() { 12 | override fun toSqlQuery(increment: Int): SqlQuery { 13 | val treeName = "eq_$increment" 14 | return SqlQuery( 15 | from = "json_tree(entity.value, '$') as $treeName", 16 | where = "($treeName.fullkey LIKE ? AND $treeName.value = ?)", 17 | parameters = 2, 18 | bindArgs = { 19 | bindString(builder.buildPath()) 20 | bindValue(value) 21 | } 22 | ) 23 | } 24 | } 25 | 26 | /** 27 | * Equivalent to `=` in SQL 28 | */ 29 | infix fun JsonPathBuilder.eq(value: V?): Eq = 30 | Eq(builder = this, value = value) 31 | 32 | /** 33 | * Equivalent to `=` in SQL 34 | */ 35 | inline infix fun KProperty1.eq(value: VALUE?): Eq = 36 | Eq(this.builder(), value) 37 | 38 | /** 39 | * Equivalent to `!=` in SQL 40 | */ 41 | data class NotEq( 42 | private val builder: JsonPathBuilder, private val value: V?, 43 | ) : Where() { 44 | override fun toSqlQuery(increment: Int): SqlQuery { 45 | val treeName = "eq_$increment" 46 | return SqlQuery( 47 | from = "json_tree(entity.value, '$') as $treeName", 48 | where = "($treeName.fullkey LIKE ? AND $treeName.value != ?)", 49 | parameters = 2, 50 | bindArgs = { 51 | bindString(builder.buildPath()) 52 | bindValue(value) 53 | } 54 | ) 55 | } 56 | } 57 | 58 | /** 59 | * Equivalent to `!=` in SQL 60 | */ 61 | infix fun JsonPathBuilder.neq(value: V?): NotEq = 62 | NotEq(builder = this, value = value) 63 | 64 | /** 65 | * Equivalent to `!=` in SQL 66 | */ 67 | inline infix fun KProperty1.neq(value: VALUE?): NotEq = 68 | NotEq(this.builder(), value) 69 | 70 | /** 71 | * Equivalent to `IN` in SQL 72 | */ 73 | data class In( 74 | private val builder: JsonPathBuilder, private val value: Collection, 75 | ) : Where() { 76 | override fun toSqlQuery(increment: Int): SqlQuery { 77 | val treeName = "in_$increment" 78 | return SqlQuery( 79 | from = "json_tree(entity.value, '$') as $treeName", 80 | where = "($treeName.fullkey LIKE ? AND $treeName.value IN (${value.joinToString(",") { "?" }}))", 81 | parameters = 1 + value.size, 82 | bindArgs = { 83 | bindString(builder.buildPath()) 84 | value.forEach { bindValue(it) } 85 | } 86 | ) 87 | } 88 | } 89 | 90 | data class NotIn( 91 | private val builder: JsonPathBuilder, private val value: Collection, 92 | ) : Where() { 93 | override fun toSqlQuery(increment: Int): SqlQuery { 94 | val treeName = "in_$increment" 95 | return SqlQuery( 96 | from = "json_tree(entity.value, '$') as $treeName", 97 | where = "($treeName.fullkey LIKE ? AND $treeName.value NOT IN (${value.joinToString(",") { "?" }}))", 98 | parameters = 1 + value.size, 99 | bindArgs = { 100 | bindString(builder.buildPath()) 101 | value.forEach { bindValue(it) } 102 | } 103 | ) 104 | } 105 | } 106 | 107 | 108 | /** 109 | * Equivalent to `IN` in SQL 110 | */ 111 | infix fun JsonPathBuilder.inList(value: Collection): In = 112 | In(builder = this, value = value) 113 | 114 | /** 115 | * Equivalent to `IN` in SQL 116 | */ 117 | inline infix fun KProperty1.inList(value: Collection): In = 118 | In(builder = this.builder(), value = value) 119 | 120 | /** 121 | * Equivalent to `NOT IN` in SQL 122 | */ 123 | infix fun JsonPathBuilder.notInList(value: Collection): NotIn = 124 | NotIn(builder = this, value = value) 125 | 126 | /** 127 | * Equivalent to `NOT IN` in SQL 128 | */ 129 | inline fun KProperty1.notInList(value: Collection): NotIn = 130 | NotIn(builder = this.builder(), value = value) 131 | 132 | /** 133 | * Equivalent to `LIKE` in SQL 134 | */ 135 | data class Like( 136 | private val builder: JsonPathBuilder, private val value: String?, 137 | ) : Where() { 138 | override fun toSqlQuery(increment: Int): SqlQuery { 139 | val treeName = "like_$increment" 140 | return SqlQuery( 141 | from = "json_tree(entity.value, '$') as $treeName", 142 | where = "($treeName.fullkey LIKE ? AND $treeName.value LIKE ?)", 143 | parameters = 2, 144 | bindArgs = { 145 | bindString(builder.buildPath()) 146 | bindString(value) 147 | } 148 | ) 149 | } 150 | } 151 | 152 | /** 153 | * Equivalent to `LIKE` in SQL 154 | */ 155 | infix fun JsonPathBuilder.like(value: String?): Like = 156 | Like(builder = this, value = value) 157 | 158 | /** 159 | * Equivalent to `LIKE` in SQL 160 | */ 161 | inline infix fun KProperty1.like(value: String?): Like = 162 | Like(this.builder(), value) 163 | 164 | /** 165 | * Equivalent to `>` in SQL 166 | * 167 | * @param value note that gt will only really work with numbers right now. 168 | */ 169 | data class GreaterThan( 170 | private val builder: JsonPathBuilder, private val value: V?, 171 | ) : Where() { 172 | 173 | override fun toSqlQuery(increment: Int): SqlQuery { 174 | val treeName = "gt_$increment" 175 | return SqlQuery( 176 | from = "json_tree(entity.value, '$') as $treeName", 177 | where = "($treeName.fullkey LIKE ? AND $treeName.value > ?)", 178 | parameters = 2, 179 | bindArgs = { 180 | bindString(builder.buildPath()) 181 | bindValue(value) 182 | } 183 | ) 184 | } 185 | } 186 | 187 | /** 188 | * Equivalent to `>` in SQL 189 | */ 190 | infix fun JsonPathBuilder.gt(value: V?): GreaterThan = 191 | GreaterThan(builder = this, value = value) 192 | 193 | /** 194 | * Equivalent to `>` in SQL 195 | */ 196 | inline infix fun KProperty1.gt(value: VALUE?): GreaterThan = 197 | GreaterThan(this.builder(), value) 198 | 199 | 200 | /** 201 | * Equivalent to `<` in SQL 202 | */ 203 | data class LessThan( 204 | private val builder: JsonPathBuilder, private val value: V?, 205 | ) : Where() { 206 | 207 | override fun toSqlQuery(increment: Int): SqlQuery { 208 | val treeName = "lt_$increment" 209 | return SqlQuery( 210 | from = "json_tree(entity.value, '$') as $treeName", 211 | where = "($treeName.fullkey LIKE ? AND $treeName.value < ?)", 212 | parameters = 2, 213 | bindArgs = { 214 | bindString(builder.buildPath()) 215 | bindValue(value) 216 | } 217 | ) 218 | } 219 | } 220 | 221 | /** 222 | * Equivalent to `<` in SQL 223 | */ 224 | infix fun JsonPathBuilder.lt(value: V?): LessThan = 225 | LessThan(builder = this, value = value) 226 | 227 | /** 228 | * Equivalent to `<` in SQL 229 | */ 230 | inline infix fun KProperty1.lt(value: VALUE?): LessThan = 231 | LessThan(this.builder(), value) 232 | 233 | 234 | /** 235 | * Equivalent to `NOT ($where)` in SQL 236 | * 237 | * This just wraps the passed in where clause. 238 | */ 239 | data class Not(private val where: Where) : Where() { 240 | override fun toSqlQuery(increment: Int): SqlQuery { 241 | val query = where.toSqlQuery(increment) 242 | return SqlQuery( 243 | from = query.from, 244 | where = "NOT (${query.where})", 245 | parameters = query.parameters, 246 | bindArgs = query.bindArgs, 247 | orderBy = query.orderBy 248 | ) 249 | } 250 | } 251 | 252 | /** 253 | * Equivalent to `NOT ($where)` in SQL 254 | */ 255 | fun not(where: Where): Not = Not(where) 256 | 257 | /** 258 | * Equivalent to `AND` in SQL 259 | */ 260 | data class And(private val left: Where, private val right: Where) : Where() { 261 | override fun toSqlQuery(increment: Int): SqlQuery { 262 | val leftQuery = left.toSqlQuery(increment * 10) 263 | val rightQuery = right.toSqlQuery((increment * 10) + 1) 264 | return SqlQuery(leftQuery, rightQuery, operator = "AND") 265 | } 266 | } 267 | 268 | /** 269 | * Equivalent to `AND` in SQL 270 | */ 271 | infix fun Where.and(other: Where): Where = And(this, other) 272 | 273 | /** 274 | * Equivalent to `OR` in SQL 275 | */ 276 | data class Or(private val left: Where, private val right: Where) : Where() { 277 | override fun toSqlQuery(increment: Int): SqlQuery { 278 | val leftQuery = left.toSqlQuery(increment * 10) 279 | val rightQuery = right.toSqlQuery((increment * 10) + 1) 280 | return SqlQuery(leftQuery, rightQuery, operator = "OR") 281 | } 282 | } 283 | 284 | /** 285 | * Equivalent to `OR` in SQL 286 | */ 287 | infix fun Where.or(other: Where): Where = Or(this, other) 288 | 289 | abstract class Where { 290 | abstract fun toSqlQuery(increment: Int): SqlQuery 291 | } 292 | 293 | data class OrderBy( 294 | private val builder: JsonPathBuilder, 295 | /** 296 | * Sqlite defaults to ASC when not specified 297 | */ 298 | internal val direction: OrderDirection? = null, 299 | ) { 300 | val path: String = builder.buildPath() 301 | } 302 | 303 | inline fun OrderBy( 304 | property: KProperty1, direction: OrderDirection? = null, 305 | ) = OrderBy(property.builder(), direction) 306 | 307 | enum class OrderDirection(val value: String) { 308 | ASC(value = "ASC"), 309 | DESC(value = "DESC") 310 | } 311 | 312 | fun List>.toSqlQueries(): List { 313 | if (isEmpty()) return emptyList() 314 | return mapIndexed { index, orderBy -> 315 | val treeName = "order_$index" 316 | SqlQuery( 317 | from = "json_tree(entity.value, '$') as $treeName", 318 | where = "$treeName.fullkey LIKE ?", 319 | parameters = 1, 320 | bindArgs = { bindString(orderBy.path) }, 321 | orderBy = "$treeName.value ${orderBy.direction?.value ?: ""}", 322 | ) 323 | } 324 | } 325 | 326 | data class SqlQuery( 327 | val from: String? = null, 328 | val where: String? = null, 329 | val parameters: Int = 0, 330 | val bindArgs: AutoIncrementSqlPreparedStatement.() -> Unit = {}, 331 | val orderBy: String? = null, 332 | ) { 333 | constructor(left: SqlQuery, right: SqlQuery, operator: String) : this( 334 | from = listOfNotNull(left.from, right.from).joinToString(", "), 335 | where = "(${left.where} $operator ${right.where})", 336 | parameters = left.parameters + right.parameters, 337 | bindArgs = { 338 | left.bindArgs(this) 339 | right.bindArgs(this) 340 | } 341 | ) 342 | 343 | fun identifier(): Int { 344 | var result = from?.hashCode() ?: 0 345 | result = 31 * result + (where?.hashCode() ?: 0) 346 | result = 31 * result + (orderBy?.hashCode() ?: 0) 347 | return result 348 | } 349 | } 350 | 351 | internal fun List.buildFrom(prefix: String = ", ") = mapNotNull { it.from } 352 | .joinToString(", ") { it } 353 | .let { if (it.isNotBlank()) "$prefix$it" else "" } 354 | 355 | internal fun List.buildWhere(prefix: String = "WHERE") = mapNotNull { it.where } 356 | .joinToString(" AND ") { it } 357 | .let { if (it.isNotBlank()) "$prefix $it" else "" } 358 | 359 | internal fun List.buildOrderBy(prefix: String = "ORDER BY") = mapNotNull { it.orderBy } 360 | .joinToString(", ") { it } 361 | .let { if (it.isNotBlank()) "$prefix $it" else "" } 362 | 363 | 364 | internal fun List.sumParameters(): Int = sumOf { it.parameters } 365 | 366 | internal fun List.identifier(): Int = fold(0) { acc, sqlQuery -> 367 | 31 * acc + sqlQuery.identifier() 368 | } 369 | 370 | class AutoIncrementSqlPreparedStatement( 371 | private var index: Int = 0, 372 | private val preparedStatement: SqlPreparedStatement, 373 | ) { 374 | fun bindBoolean(boolean: Boolean?) { 375 | preparedStatement.bindBoolean(index, boolean) 376 | index++ 377 | } 378 | 379 | fun bindBytes(bytes: ByteArray?) { 380 | preparedStatement.bindBytes(index, bytes) 381 | index++ 382 | } 383 | 384 | fun bindDouble(double: Double?) { 385 | preparedStatement.bindDouble(index, double) 386 | index++ 387 | } 388 | 389 | fun bindLong(long: Long?) { 390 | preparedStatement.bindLong(index, long) 391 | index++ 392 | } 393 | 394 | fun bindString(string: String?) { 395 | preparedStatement.bindString(index, string) 396 | index++ 397 | } 398 | 399 | fun bindValue(value: T?) { 400 | when (value) { 401 | is Boolean -> bindBoolean(value) 402 | is ByteArray -> bindBytes(value) 403 | is Double -> bindDouble(value) 404 | is Number -> bindLong(value.toLong()) 405 | is String -> bindString(value) 406 | is Enum<*> -> { 407 | // Doesn't support @SerialName for now https://github.com/Kotlin/kotlinx.serialization/issues/2956 408 | // val e = value as T 409 | // e::class.serializerOrNull()?.let { 410 | // val sName = it.descriptor.getElementDescriptor(value.ordinal).serialName 411 | // bindString(sName) 412 | // } ?: bindString(null) 413 | bindString(value.name) // use ordinal name for now (which is default serialization) 414 | } 415 | 416 | null -> bindString(null) 417 | else -> { 418 | // Compiler bug doesn't smart cast the value to non-null 419 | val v = requireNotNull(value) { "Unsupported value type: null" } 420 | throw IllegalArgumentException("Unsupported value type: ${v::class.simpleName}") 421 | } 422 | } 423 | } 424 | } 425 | -------------------------------------------------------------------------------- /scripts/create-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Color codes for pretty printing 6 | RED='\033[0;31m' 7 | GREEN='\033[0;32m' 8 | YELLOW='\033[1;33m' 9 | BLUE='\033[0;34m' 10 | CYAN='\033[0;36m' 11 | NC='\033[0m' # No Color 12 | 13 | # Global variables 14 | DRY_RUN=false 15 | MANUAL_VERSION="" 16 | REPO_OWNER="" 17 | REPO_NAME="" 18 | 19 | # Function to print colored output 20 | print_step() { 21 | echo -e "${CYAN}==>${NC} $1" 22 | } 23 | 24 | print_success() { 25 | echo -e "${GREEN}✓${NC} $1" 26 | } 27 | 28 | print_error() { 29 | echo -e "${RED}✗${NC} $1" 30 | } 31 | 32 | print_warning() { 33 | echo -e "${YELLOW}⚠${NC} $1" 34 | } 35 | 36 | print_info() { 37 | echo -e "${BLUE}ℹ${NC} $1" 38 | } 39 | 40 | # Function to ask for user confirmation 41 | confirm() { 42 | local prompt="$1" 43 | local response 44 | echo -e "${YELLOW}${prompt} (y/n):${NC} " 45 | read -r response 46 | case "$response" in 47 | [yY][eE][sS]|[yY]) 48 | return 0 49 | ;; 50 | *) 51 | return 1 52 | ;; 53 | esac 54 | } 55 | 56 | # Function to show usage 57 | usage() { 58 | cat << EOF 59 | Usage: $(basename "$0") [OPTIONS] 60 | 61 | Create a new GitHub release and tag for the Sqkon project. 62 | 63 | OPTIONS: 64 | -v, --version VERSION Manually specify the version (e.g., 1.2.3) 65 | -d, --dry-run Show what would be done without making changes 66 | -h, --help Show this help message 67 | 68 | EXAMPLES: 69 | $(basename "$0") # Auto-increment minor version 70 | $(basename "$0") -v 2.0.0 # Create release v2.0.0 71 | $(basename "$0") --dry-run # Preview changes without executing 72 | 73 | EOF 74 | exit 0 75 | } 76 | 77 | # Parse command line arguments 78 | parse_args() { 79 | while [[ $# -gt 0 ]]; do 80 | case $1 in 81 | -d|--dry-run) 82 | DRY_RUN=true 83 | print_warning "DRY RUN MODE - No changes will be made" 84 | shift 85 | ;; 86 | -v|--version) 87 | MANUAL_VERSION="$2" 88 | shift 2 89 | ;; 90 | -h|--help) 91 | usage 92 | ;; 93 | *) 94 | print_error "Unknown option: $1" 95 | usage 96 | ;; 97 | esac 98 | done 99 | } 100 | 101 | # Check if required commands are available 102 | check_requirements() { 103 | print_step "Checking requirements..." 104 | 105 | local missing_commands=() 106 | 107 | if ! command -v git &> /dev/null; then 108 | missing_commands+=("git") 109 | fi 110 | 111 | if ! command -v gh &> /dev/null; then 112 | missing_commands+=("gh") 113 | fi 114 | 115 | if [ ${#missing_commands[@]} -gt 0 ]; then 116 | print_error "Missing required commands: ${missing_commands[*]}" 117 | echo "" 118 | echo "Please install missing dependencies:" 119 | for cmd in "${missing_commands[@]}"; do 120 | case $cmd in 121 | gh) 122 | echo " GitHub CLI: https://cli.github.com/" 123 | ;; 124 | git) 125 | echo " Git: https://git-scm.com/" 126 | ;; 127 | esac 128 | done 129 | exit 1 130 | fi 131 | 132 | # Check if gh is authenticated 133 | if ! gh auth status &> /dev/null; then 134 | print_error "GitHub CLI is not authenticated" 135 | echo "Please run: gh auth login" 136 | exit 1 137 | fi 138 | 139 | print_success "All requirements satisfied" 140 | } 141 | 142 | # Get repository information 143 | get_repo_info() { 144 | print_step "Getting repository information..." 145 | 146 | local remote_url 147 | remote_url=$(git remote get-url origin 2>/dev/null || echo "") 148 | 149 | if [ -z "$remote_url" ]; then 150 | print_error "No git remote 'origin' found" 151 | exit 1 152 | fi 153 | 154 | # Parse owner and repo from URL (supports both SSH and HTTPS) 155 | if [[ $remote_url =~ github\.com[:/]([^/]+)/([^/.]+) ]]; then 156 | REPO_OWNER="${BASH_REMATCH[1]}" 157 | REPO_NAME="${BASH_REMATCH[2]}" 158 | else 159 | print_error "Could not parse repository information from remote URL" 160 | exit 1 161 | fi 162 | 163 | print_success "Repository: $REPO_OWNER/$REPO_NAME" 164 | } 165 | 166 | # Check current branch 167 | check_branch() { 168 | print_step "Checking current branch..." 169 | 170 | local current_branch 171 | current_branch=$(git rev-parse --abbrev-ref HEAD) 172 | 173 | print_info "Current branch: $current_branch" 174 | 175 | if [ "$current_branch" = "main" ] || [ "$current_branch" = "master" ]; then 176 | print_success "On main branch" 177 | else 178 | print_warning "Not on main branch (currently on: $current_branch)" 179 | print_info "This script must be run from the main/master branch" 180 | 181 | # Check if main or master branch exists 182 | local target_branch="" 183 | if git show-ref --verify --quiet refs/heads/main; then 184 | target_branch="main" 185 | elif git show-ref --verify --quiet refs/heads/master; then 186 | target_branch="master" 187 | else 188 | print_error "Neither 'main' nor 'master' branch found in repository" 189 | exit 1 190 | fi 191 | 192 | if $DRY_RUN; then 193 | print_warning "DRY RUN: Would ask to switch to $target_branch branch" 194 | else 195 | if confirm "Would you like to switch to the $target_branch branch now?"; then 196 | print_step "Switching to $target_branch branch..." 197 | if git checkout "$target_branch"; then 198 | print_success "Switched to $target_branch branch" 199 | else 200 | print_error "Failed to switch to $target_branch branch" 201 | exit 1 202 | fi 203 | else 204 | print_error "Cannot continue on feature branch. Aborted by user." 205 | exit 1 206 | fi 207 | fi 208 | fi 209 | 210 | # Check if there are uncommitted changes 211 | if ! git diff-index --quiet HEAD -- 2>/dev/null; then 212 | print_warning "You have uncommitted changes" 213 | if ! $DRY_RUN; then 214 | if ! confirm "Continue anyway?"; then 215 | print_error "Aborted by user" 216 | exit 1 217 | fi 218 | fi 219 | fi 220 | } 221 | 222 | # Get the latest release version 223 | get_latest_version() { 224 | print_step "Fetching latest release..." >&2 225 | 226 | local latest_release 227 | latest_release=$(gh release list --repo "$REPO_OWNER/$REPO_NAME" --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || echo "") 228 | 229 | if [ -z "$latest_release" ]; then 230 | print_warning "No previous releases found" >&2 231 | echo "0.0.0" 232 | else 233 | print_success "Latest release: $latest_release" >&2 234 | echo "$latest_release" 235 | fi 236 | } 237 | 238 | # Parse version string (removes 'v' prefix if present) 239 | parse_version() { 240 | local version="$1" 241 | # Remove 'v' prefix if present 242 | version="${version#v}" 243 | echo "$version" 244 | } 245 | 246 | # Increment minor version 247 | increment_minor_version() { 248 | local version="$1" 249 | 250 | # Parse version components 251 | local major minor patch 252 | if [[ $version =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then 253 | major="${BASH_REMATCH[1]}" 254 | minor="${BASH_REMATCH[2]}" 255 | patch="${BASH_REMATCH[3]}" 256 | 257 | # Increment minor version and reset patch 258 | minor=$((minor + 1)) 259 | patch=0 260 | 261 | echo "${major}.${minor}.${patch}" 262 | else 263 | print_error "Invalid version format: $version" 264 | exit 1 265 | fi 266 | } 267 | 268 | # Validate version format 269 | validate_version() { 270 | local version="$1" 271 | if [[ ! $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 272 | print_error "Invalid version format: $version (expected: X.Y.Z)" 273 | exit 1 274 | fi 275 | } 276 | 277 | # Determine the new version 278 | determine_version() { 279 | print_step "Determining new version..." >&2 280 | 281 | local latest_release new_version 282 | latest_release=$(get_latest_version) 283 | latest_release=$(parse_version "$latest_release") 284 | 285 | if [ -n "$MANUAL_VERSION" ]; then 286 | new_version=$(parse_version "$MANUAL_VERSION") 287 | validate_version "$new_version" 288 | print_info "Using manually specified version: $new_version" >&2 289 | else 290 | new_version=$(increment_minor_version "$latest_release") 291 | print_info "Auto-incrementing minor version: $latest_release -> $new_version" >&2 292 | fi 293 | 294 | echo "$new_version" 295 | } 296 | 297 | # Generate release notes 298 | generate_release_notes() { 299 | local previous_tag="$1" 300 | local new_version="$2" 301 | 302 | print_step "Generating release notes..." >&2 303 | 304 | local notes_file 305 | notes_file=$(mktemp) 306 | 307 | { 308 | echo "## What's Changed" 309 | echo "" 310 | 311 | if [ "$previous_tag" = "v0.0.0" ]; then 312 | echo "Initial release" 313 | else 314 | # Get commit messages between tags 315 | git log "${previous_tag}..HEAD" --pretty=format:"* %s (%h)" --no-merges 2>/dev/null || echo "* No changes found" 316 | fi 317 | 318 | echo "" 319 | echo "---" 320 | echo "" 321 | echo "**Full Changelog**: https://github.com/$REPO_OWNER/$REPO_NAME/compare/${previous_tag}...${new_version}" 322 | } > "$notes_file" 323 | 324 | echo "$notes_file" 325 | } 326 | 327 | # Create the release 328 | create_release() { 329 | local new_version="$1" 330 | local previous_tag="$2" 331 | 332 | print_step "Preparing to create release $new_version..." 333 | 334 | # Generate release notes 335 | local notes_file 336 | notes_file=$(generate_release_notes "$previous_tag" "$new_version") 337 | 338 | # Show release notes preview 339 | echo "" 340 | print_info "Release notes preview:" 341 | echo "---" 342 | cat "$notes_file" 343 | echo "---" 344 | echo "" 345 | 346 | if $DRY_RUN; then 347 | print_warning "DRY RUN: Would create release $new_version with the above notes" 348 | print_warning "DRY RUN: Would create tag $new_version" 349 | rm -f "$notes_file" 350 | return 0 351 | fi 352 | 353 | # Final confirmation 354 | print_warning "DANGEROUS OPERATION: This will create a new release and tag" 355 | print_info "Release: $new_version" 356 | print_info "Repository: $REPO_OWNER/$REPO_NAME" 357 | 358 | if ! confirm "Are you sure you want to create this release?"; then 359 | print_error "Release creation aborted by user" 360 | rm -f "$notes_file" 361 | exit 1 362 | fi 363 | 364 | print_step "Creating release $new_version..." 365 | 366 | # Create the release using gh CLI 367 | if gh release create "$new_version" \ 368 | --repo "$REPO_OWNER/$REPO_NAME" \ 369 | --title "$new_version" \ 370 | --notes-file "$notes_file"; then 371 | print_success "Release $new_version created successfully!" 372 | print_info "Release URL: https://github.com/$REPO_OWNER/$REPO_NAME/releases/tag/$new_version" 373 | else 374 | print_error "Failed to create release" 375 | rm -f "$notes_file" 376 | exit 1 377 | fi 378 | 379 | rm -f "$notes_file" 380 | } 381 | 382 | # Update README.MD and gradle.properties with new version 383 | update_readme_version() { 384 | local new_version="$1" 385 | 386 | print_step "Updating README.MD and gradle.properties with version $new_version..." 387 | 388 | local readme_path="README.MD" 389 | local gradle_props_path="gradle.properties" 390 | 391 | if [ ! -f "$readme_path" ]; then 392 | print_error "README.MD not found at $readme_path" 393 | return 1 394 | fi 395 | 396 | if [ ! -f "$gradle_props_path" ]; then 397 | print_error "gradle.properties not found at $gradle_props_path" 398 | return 1 399 | fi 400 | 401 | if $DRY_RUN; then 402 | print_warning "DRY RUN: Would update README.MD with version $new_version" 403 | print_warning "DRY RUN: Would update gradle.properties VERSION_NAME to $new_version" 404 | print_warning "DRY RUN: Would commit changes with message 'Update README and gradle.properties with version $new_version [skip ci]'" 405 | print_warning "DRY RUN: Would push commit to main branch" 406 | return 0 407 | fi 408 | 409 | # Backup the files first 410 | cp "$readme_path" "${readme_path}.bak" 411 | cp "$gradle_props_path" "${gradle_props_path}.bak" 412 | 413 | # Update version in multiplatform dependency example (line 101) 414 | # Pattern: implementation("com.mercury.sqkon:library:X.Y.Z") 415 | if sed -i.tmp "s|implementation(\"com.mercury.sqkon:library:[^\"]*\")|implementation(\"com.mercury.sqkon:library:$new_version\")|g" "$readme_path"; then 416 | print_success "Updated multiplatform dependency version" 417 | else 418 | print_error "Failed to update multiplatform dependency version" 419 | mv "${readme_path}.bak" "$readme_path" 420 | mv "${gradle_props_path}.bak" "$gradle_props_path" 421 | return 1 422 | fi 423 | 424 | # Update version in platform-specific dependency example (line 110) 425 | # Pattern: implementation("com.mercury.sqkon:library-android:X.Y.Z") 426 | if sed -i.tmp "s|implementation(\"com.mercury.sqkon:library-android:[^\"]*\")|implementation(\"com.mercury.sqkon:library-android:$new_version\")|g" "$readme_path"; then 427 | print_success "Updated platform-specific dependency version" 428 | else 429 | print_error "Failed to update platform-specific dependency version" 430 | mv "${readme_path}.bak" "$readme_path" 431 | mv "${gradle_props_path}.bak" "$gradle_props_path" 432 | return 1 433 | fi 434 | 435 | # Clean up sed temporary file 436 | rm -f "${readme_path}.tmp" 437 | 438 | # Update VERSION_NAME in gradle.properties 439 | # Pattern: VERSION_NAME=X.Y.Z 440 | if sed -i.tmp "s|^VERSION_NAME=.*|VERSION_NAME=$new_version|g" "$gradle_props_path"; then 441 | print_success "Updated VERSION_NAME in gradle.properties" 442 | else 443 | print_error "Failed to update VERSION_NAME in gradle.properties" 444 | mv "${readme_path}.bak" "$readme_path" 445 | mv "${gradle_props_path}.bak" "$gradle_props_path" 446 | return 1 447 | fi 448 | 449 | # Clean up sed temporary file 450 | rm -f "${gradle_props_path}.tmp" 451 | 452 | # Check if there are actual changes 453 | if git diff --quiet "$readme_path" && git diff --quiet "$gradle_props_path"; then 454 | print_warning "No changes detected in README.MD or gradle.properties (versions might already be up to date)" 455 | rm -f "${readme_path}.bak" 456 | rm -f "${gradle_props_path}.bak" 457 | return 0 458 | fi 459 | 460 | print_success "README.MD and gradle.properties updated successfully" 461 | 462 | # Show the diff 463 | print_info "Changes made to README.MD:" 464 | git diff "$readme_path" 465 | echo "" 466 | print_info "Changes made to gradle.properties:" 467 | git diff "$gradle_props_path" 468 | 469 | # Commit the changes 470 | print_step "Committing README.MD and gradle.properties changes..." 471 | if git add "$readme_path" "$gradle_props_path" && git commit -m "Update README and gradle.properties with version $new_version [skip ci]"; then 472 | print_success "Changes committed" 473 | else 474 | print_error "Failed to commit changes" 475 | mv "${readme_path}.bak" "$readme_path" 476 | mv "${gradle_props_path}.bak" "$gradle_props_path" 477 | return 1 478 | fi 479 | 480 | # Push to main 481 | print_step "Pushing changes to main branch..." 482 | if git push origin main; then 483 | print_success "Changes pushed to main branch" 484 | else 485 | print_error "Failed to push changes to main branch" 486 | print_warning "You may need to manually push the commit" 487 | return 1 488 | fi 489 | 490 | # Clean up backups 491 | rm -f "${readme_path}.bak" 492 | rm -f "${gradle_props_path}.bak" 493 | 494 | return 0 495 | } 496 | 497 | # Main function 498 | main() { 499 | echo "" 500 | print_info "Sqkon Release Creation Script" 501 | echo "" 502 | 503 | parse_args "$@" 504 | check_requirements 505 | get_repo_info 506 | 507 | # Fetch and display version information early 508 | local latest_release new_version 509 | latest_release=$(get_latest_version) 510 | new_version=$(determine_version) 511 | 512 | echo "" 513 | print_info "Release Information:" 514 | print_info " Repository: $REPO_OWNER/$REPO_NAME" 515 | print_info " Current release: $latest_release" 516 | print_info " New release: $new_version" 517 | if $DRY_RUN; then 518 | print_warning " Mode: DRY RUN" 519 | fi 520 | echo "" 521 | 522 | # Now check branch (and potentially switch to main) 523 | check_branch 524 | 525 | echo "" 526 | print_info "Proceeding with release creation..." 527 | echo "" 528 | 529 | create_release "$new_version" "$latest_release" 530 | 531 | # Update README and gradle.properties with new version and push to main 532 | echo "" 533 | update_readme_version "$new_version" 534 | 535 | echo "" 536 | print_success "Done!" 537 | echo "" 538 | 539 | if ! $DRY_RUN; then 540 | print_info "The GitHub Actions workflow should now trigger to publish to Maven Central" 541 | print_info "Monitor the workflow at: https://github.com/$REPO_OWNER/$REPO_NAME/actions" 542 | print_info "README.MD and gradle.properties have been updated and pushed to main with [skip ci]" 543 | fi 544 | } 545 | 546 | # Run main function with all arguments 547 | main "$@" 548 | -------------------------------------------------------------------------------- /library/src/commonMain/kotlin/com/mercury/sqkon/db/KeyValueStorage.kt: -------------------------------------------------------------------------------- 1 | package com.mercury.sqkon.db 2 | 3 | import app.cash.paging.PagingSource 4 | import app.cash.sqldelight.Transacter 5 | import app.cash.sqldelight.TransactionCallbacks 6 | import app.cash.sqldelight.coroutines.asFlow 7 | import app.cash.sqldelight.coroutines.mapToList 8 | import app.cash.sqldelight.coroutines.mapToOne 9 | import app.cash.sqldelight.coroutines.mapToOneNotNull 10 | import com.mercury.sqkon.db.KeyValueStorage.Config.DeserializePolicy 11 | import com.mercury.sqkon.db.paging.OffsetQueryPagingSource 12 | import com.mercury.sqkon.db.serialization.KotlinSqkonSerializer 13 | import com.mercury.sqkon.db.serialization.SqkonJson 14 | import com.mercury.sqkon.db.serialization.SqkonSerializer 15 | import com.mercury.sqkon.db.utils.SqkonTransacter 16 | import com.mercury.sqkon.db.utils.nowMillis 17 | import kotlinx.coroutines.CoroutineDispatcher 18 | import kotlinx.coroutines.CoroutineScope 19 | import kotlinx.coroutines.Dispatchers 20 | import kotlinx.coroutines.flow.Flow 21 | import kotlinx.coroutines.flow.distinctUntilChanged 22 | import kotlinx.coroutines.flow.map 23 | import kotlinx.coroutines.flow.onEach 24 | import kotlinx.coroutines.launch 25 | import kotlinx.datetime.Clock 26 | import kotlinx.datetime.Instant 27 | import org.jetbrains.annotations.VisibleForTesting 28 | import kotlin.reflect.KType 29 | import kotlin.reflect.typeOf 30 | 31 | /** 32 | * Base interaction to the database. 33 | * 34 | * @param serializer if providing your own, recommend using [SqkonJson] to make sure you create 35 | * fields consistently. 36 | */ 37 | open class KeyValueStorage( 38 | protected val entityName: String, 39 | protected val entityQueries: EntityQueries, 40 | protected val metadataQueries: MetadataQueries, 41 | protected val scope: CoroutineScope, 42 | protected val type: KType, 43 | protected val serializer: SqkonSerializer = KotlinSqkonSerializer(), 44 | protected val config: Config = Config(), 45 | protected val readDispatcher: CoroutineDispatcher, 46 | protected val writeDispatcher: CoroutineDispatcher, 47 | private val transacter: SqkonTransacter, 48 | ) : Transacter by transacter { 49 | 50 | /** 51 | * Insert a row. 52 | * 53 | * @param ignoreIfExists if true, will not insert if a row with the same key already exists. 54 | * Otherwise, throw primary key constraint violation. Useful for "upserting". 55 | * 56 | * @see update 57 | * @see upsert 58 | */ 59 | fun insert( 60 | key: String, value: T, 61 | ignoreIfExists: Boolean = false, 62 | expiresAt: Instant? = null, 63 | ) = transaction { 64 | val now = nowMillis() 65 | val entity = Entity( 66 | entity_name = entityName, 67 | entity_key = key, 68 | added_at = now, 69 | updated_at = now, 70 | expires_at = expiresAt?.toEpochMilliseconds(), 71 | read_at = null, 72 | write_at = now, 73 | value_ = serializer.serialize(type, value) ?: error("Failed to serialize value") 74 | ) 75 | entityQueries.insertEntity(entity, ignoreIfExists) 76 | updateWriteAt() 77 | } 78 | 79 | /** 80 | * Insert multiple rows. 81 | * 82 | * @param ignoreIfExists if true, will not insert if a row with the same key already exists. 83 | * @param expiresAt if set, will be used to expire the row when requesting data before it has 84 | * expired. 85 | * 86 | * @see updateAll 87 | * @see upsertAll 88 | */ 89 | fun insertAll( 90 | values: Map, 91 | ignoreIfExists: Boolean = false, 92 | expiresAt: Instant? = null, 93 | ) = transaction { 94 | values.forEach { (key, value) -> insert(key, value, ignoreIfExists, expiresAt) } 95 | } 96 | 97 | /** 98 | * Update a row. If the row does not exist, it will update nothing, use [insert] if you want to 99 | * insert if the row does not exist. 100 | * 101 | * We also provide [upsert] convenience function to insert or update. 102 | * 103 | * @param expiresAt if set, will be used to expire the row when requesting data before it has 104 | * expired. 105 | * @see insert 106 | * @see upsert 107 | */ 108 | fun update(key: String, value: T, expiresAt: Instant? = null) = transaction { 109 | entityQueries.updateEntity( 110 | entityName = entityName, 111 | entityKey = key, 112 | expiresAt = expiresAt, 113 | value = serializer.serialize(type, value) ?: error("Failed to serialize value") 114 | ) 115 | updateWriteAt() 116 | } 117 | 118 | /** 119 | * Convenience function to insert collection of rows. If the row does not exist, ti will update 120 | * nothing, use [insert] if you want to insert if the row does not exist. 121 | * 122 | * @param expiresAt if set, will be used to expire the row when requesting data before it has 123 | * expired. 124 | * @see insertAll 125 | * @see upsertAll 126 | */ 127 | fun updateAll( 128 | values: Map, expiresAt: Instant? = null, 129 | ) = transaction { 130 | values.forEach { (key, value) -> update(key, value, expiresAt) } 131 | } 132 | 133 | 134 | /** 135 | * Convenience function to insert a new row or update an existing row. 136 | * 137 | * @param expiresAt if set, will be used to expire the row when requesting data before it has 138 | * expired. 139 | * @see insert 140 | * @see update 141 | */ 142 | fun upsert( 143 | key: String, value: T, expiresAt: Instant? = null, 144 | ) = transaction { 145 | update(key, value, expiresAt = expiresAt) 146 | insert(key, value, ignoreIfExists = true, expiresAt = expiresAt) 147 | } 148 | 149 | /** 150 | * Convenience function to insert new or update existing multiple rows. 151 | * 152 | * Basically an alias for [updateAll] and [insertAll] with ignoreIfExists set to true. 153 | * 154 | * @param expiresAt if set, will be used to expire the row when requesting data before it has 155 | * expired. 156 | * @see insertAll 157 | * @see updateAll 158 | */ 159 | fun upsertAll( 160 | values: Map, 161 | expiresAt: Instant? = null, 162 | ) = transaction { 163 | values.forEach { (key, value) -> 164 | update(key, value, expiresAt = expiresAt) 165 | insert(key, value, ignoreIfExists = true, expiresAt = expiresAt) 166 | } 167 | } 168 | 169 | /** 170 | * Select all rows. Effectively an alias for [select] with no where set. 171 | */ 172 | fun selectAll( 173 | orderBy: List> = emptyList(), 174 | expiresAfter: Instant? = null, 175 | ): Flow> { 176 | return select(where = null, orderBy = orderBy, expiresAfter = expiresAfter) 177 | } 178 | 179 | /** 180 | * Select by key. 181 | * 182 | * Key selection will always be more performant than using where clause. Keys are indexed. 183 | */ 184 | fun selectByKey(key: String): Flow { 185 | return selectByKeys(listOf(key)).map { it.firstOrNull() } 186 | } 187 | 188 | /** 189 | * Select by keys with optional ordering 190 | * 191 | * Key selection will always be more performant than using where clause. Keys are indexed. 192 | */ 193 | fun selectByKeys( 194 | keys: Collection, 195 | orderBy: List> = emptyList(), 196 | expiresAfter: Instant? = null, 197 | ): Flow> { 198 | return entityQueries 199 | .select( 200 | entityName = entityName, 201 | entityKeys = keys, 202 | orderBy = orderBy, 203 | expiresAt = expiresAfter, 204 | ) 205 | .asFlow() 206 | .mapToList(readDispatcher) 207 | .onEach { list -> 208 | updateReadAt(list.map { it.entity_key }) 209 | } 210 | .map { list -> 211 | if (list.isEmpty()) return@map emptyList() 212 | list.mapNotNull { entity -> entity.deserialize() } 213 | } 214 | } 215 | 216 | /** 217 | * Select using where clause. If where is null, all rows will be selected. 218 | * 219 | * Simple example with where and orderBy: 220 | * ``` 221 | * val merchantsFlow = store.select( 222 | * where = Merchant::category like "Restaurant", 223 | * orderBy = listOf(OrderBy(Merchant::createdAt, OrderDirection.DESC)) 224 | * ) 225 | * ``` 226 | */ 227 | fun select( 228 | where: Where? = null, 229 | orderBy: List> = emptyList(), 230 | limit: Long? = null, 231 | offset: Long? = null, 232 | expiresAfter: Instant? = null, 233 | ): Flow> { 234 | return entityQueries 235 | .select( 236 | entityName, 237 | where = where, 238 | orderBy = orderBy, 239 | limit = limit, 240 | offset = offset, 241 | expiresAt = expiresAfter, 242 | ) 243 | .asFlow() 244 | .mapToList(readDispatcher) 245 | .onEach { list -> updateReadAt(list.map { it.entity_key }) } 246 | .map { list -> 247 | if (list.isEmpty()) return@map emptyList() 248 | list.mapNotNull { entity -> entity.deserialize() } 249 | } 250 | } 251 | 252 | /** 253 | * Select using where clause. If where is null, all rows will be selected. 254 | * 255 | * Simple example with where and orderBy: 256 | * ``` 257 | * val merchantsFlow = store.select( 258 | * where = Merchant::category like "Restaurant", 259 | * orderBy = listOf(OrderBy(Merchant::createdAt, OrderDirection.DESC)) 260 | * ) 261 | * ``` 262 | * 263 | * The result row is useful if you need metadata on the row level specific to Sqkon intead of 264 | * your entity. 265 | */ 266 | fun selectResult( 267 | where: Where? = null, 268 | orderBy: List> = emptyList(), 269 | limit: Long? = null, 270 | offset: Long? = null, 271 | expiresAfter: Instant? = null, 272 | ): Flow>> { 273 | return entityQueries 274 | .select( 275 | entityName, 276 | where = where, 277 | orderBy = orderBy, 278 | limit = limit, 279 | offset = offset, 280 | expiresAt = expiresAfter, 281 | ) 282 | .asFlow() 283 | .mapToList(readDispatcher) 284 | .onEach { list -> updateReadAt(list.map { it.entity_key }) } 285 | .map { list -> 286 | if (list.isEmpty()) return@map emptyList>() 287 | list.mapNotNull { entity -> 288 | entity.deserialize()?.let { v -> ResultRow(entity, v) } 289 | } 290 | } 291 | .distinctUntilChanged() 292 | } 293 | 294 | /** 295 | * Create a [PagingSource] that pages through results according to queries generated by from the 296 | * passed in [where] and [orderBy]. [initialOffset] initial offset to start paging from. 297 | * 298 | * Queries will be executed on [Config.dispatcher]. 299 | * 300 | * Note: Offset Paging is not very efficient on large datasets. Use wisely. We are working 301 | * on supporting [keyset paging](https://sqldelight.github.io/sqldelight/2.0.2/common/androidx_paging_multiplatform/#keyset-paging) in the future. 302 | * 303 | * @param expiresAfter null ignores expiresAt, will not return any row which has expired set 304 | * and is before expiresAfter. This is normally [Clock.System.now]. 305 | */ 306 | fun selectPagingSource( 307 | where: Where? = null, 308 | orderBy: List> = emptyList(), 309 | initialOffset: Int = 0, 310 | expiresAfter: Instant? = null, 311 | ): PagingSource = OffsetQueryPagingSource( 312 | queryProvider = { limit, offset -> 313 | entityQueries.select( 314 | entityName, 315 | where = where, 316 | orderBy = orderBy, 317 | limit = limit.toLong(), 318 | offset = offset.toLong(), 319 | expiresAt = expiresAfter, 320 | ).also { entities -> 321 | updateReadAt(entities.executeAsList().map { it.entity_key }) 322 | } 323 | }, 324 | countQuery = entityQueries.count(entityName, where = where), 325 | transacter = entityQueries, 326 | context = readDispatcher, 327 | deserialize = { it.deserialize() }, 328 | initialOffset = initialOffset, 329 | ) 330 | 331 | /** 332 | * Delete all rows. Basically an alias for [delete] with no where set. 333 | */ 334 | fun deleteAll() = delete(where = null) 335 | 336 | /** 337 | * Delete by key. 338 | * 339 | * If you need to delete all rows, use [deleteAll]. 340 | * If you need to specify which rows to delete, use [delete] with a [Where]. Note, using where 341 | * will be less performant than deleting by key. 342 | * 343 | * @see delete 344 | * @see deleteAll 345 | */ 346 | fun deleteByKey(key: String) { 347 | deleteByKeys(key) 348 | } 349 | 350 | /** 351 | * Delete by keys. 352 | * 353 | * If you need to delete all rows, use [deleteAll]. 354 | * If you need to specify which rows to delete, use [delete] with a [Where]. Note, using where 355 | * will be less performant than deleting by key. 356 | * 357 | * @see delete 358 | * @see deleteAll 359 | */ 360 | fun deleteByKeys(vararg key: String) = transaction { 361 | entityQueries.delete(entityName, entityKeys = key.toSet()) 362 | updateWriteAt() 363 | } 364 | 365 | /** 366 | * Delete using where clause. If where is null, all rows will be deleted. 367 | * 368 | * Note, it will always be more performant to delete by key, than using where clause pointing 369 | * at your entities id. 370 | * 371 | * @see deleteAll 372 | * @see deleteByKey 373 | */ 374 | fun delete(where: Where? = null) = transaction { 375 | entityQueries.delete(entityName, where = where) 376 | updateWriteAt() 377 | } 378 | 379 | /** 380 | * Purge all rows that have there `expired_at` field NOT null and less than (<) the date passed 381 | * in. (Usually [Clock.System.now]). 382 | * 383 | * For example to have a 24 hour expiry you would insert with `expiresAt = Clock.System.now().plus(1.days)`. 384 | * When querying you pass in select(expiresAfter = Clock.System.now()) to only get rows that have not expired. 385 | * If you want to then clean-up/purge those expired rows, you would call this function. 386 | * 387 | * @see deleteStale 388 | */ 389 | fun deleteExpired(expiresAfter: Instant = Clock.System.now()) = transaction { 390 | metadataQueries.purgeExpires(entityName, expiresAfter.toEpochMilliseconds()) 391 | updateWriteAt() 392 | } 393 | 394 | /** 395 | * Unlike [deleteExpired], this will clean up rows that have not been touched (read/written) 396 | * before the passed in time. 397 | * 398 | * For example, you want to clean up rows that have not been read or written to in the last 24 399 | * hours. You would call this function with `Clock.System.now().minus(1.days)`. This is not the same as 400 | * [deleteExpired] which is based on the `expires_at` field. 401 | * 402 | * @param writeInstant if set, will delete rows that have not been written to before this time. 403 | * @param readInstant if set, will delete rows that have not been read before this time. 404 | * 405 | * @see deleteExpired 406 | */ 407 | fun deleteStale( 408 | writeInstant: Instant? = Clock.System.now(), 409 | readInstant: Instant? = Clock.System.now(), 410 | ) = transaction { 411 | when { 412 | writeInstant != null && readInstant != null -> { 413 | metadataQueries.purgeStale( 414 | entity_name = entityName, 415 | writeInstant = writeInstant.toEpochMilliseconds(), 416 | readInstant = readInstant.toEpochMilliseconds() 417 | ) 418 | } 419 | 420 | writeInstant != null -> { 421 | metadataQueries.purgeStaleWrite( 422 | entity_name = entityName, 423 | writeInstant = writeInstant.toEpochMilliseconds() 424 | ) 425 | } 426 | 427 | readInstant != null -> { 428 | metadataQueries.purgeStaleRead( 429 | entity_name = entityName, 430 | readInstant = readInstant.toEpochMilliseconds() 431 | ) 432 | } 433 | 434 | else -> return@transaction 435 | } 436 | updateWriteAt() 437 | } 438 | 439 | /** 440 | * Unlike [deleteExpired], this will clean up rows that have not been touched (read/written) 441 | * before the passed in time. 442 | * 443 | * For example, you want to clean up rows that have not been read or written to in the last 24 444 | * hours. You would call this function with `Clock.System.now().minus(1.days)`. This is not the same as 445 | * [deleteExpired] which is based on the `expires_at` field. 446 | * 447 | * @see deleteExpired 448 | */ 449 | fun deleteState(instant: Instant) { 450 | deleteStale(instant, instant) 451 | } 452 | 453 | fun count( 454 | where: Where? = null, 455 | expiresAfter: Instant? = null, 456 | ): Flow { 457 | return entityQueries.count(entityName, where, expiresAfter) 458 | .asFlow() 459 | .mapToOne(readDispatcher) 460 | } 461 | 462 | /** 463 | * Metadata for the entity, this will tell you the last time 464 | * the entity store was read and written to, useful for cache invalidation. 465 | */ 466 | fun metadata(): Flow = metadataQueries 467 | .selectByEntityName(entityName) 468 | .asFlow() 469 | .mapToOneNotNull(readDispatcher) 470 | .distinctUntilChanged() 471 | 472 | private fun Entity?.deserialize(): T? { 473 | this ?: return null 474 | return try { 475 | serializer.deserialize(type, value_) ?: error("Failed to deserialize value") 476 | } catch (e: Exception) { 477 | when (config.deserializePolicy) { 478 | DeserializePolicy.ERROR -> throw e 479 | DeserializePolicy.DELETE -> { 480 | scope.launch(writeDispatcher) { deleteByKey(entity_key) } 481 | null 482 | } 483 | } 484 | } 485 | } 486 | 487 | private fun updateReadAt(keys: Collection) { 488 | scope.launch(writeDispatcher) { 489 | metadataQueries.transaction { 490 | metadataQueries.upsertRead(entityName, Clock.System.now()) 491 | metadataQueries.updateReadForEntities( 492 | Clock.System.now().toEpochMilliseconds(), entityName, keys 493 | ) 494 | } 495 | } 496 | } 497 | 498 | private val updateWriteHashes = mutableSetOf() 499 | 500 | /** 501 | * Will run after the transaction is committed. This way inside of multiple inserts we only 502 | * update the write_at once. 503 | */ 504 | @VisibleForTesting 505 | internal fun TransactionCallbacks.updateWriteAt() { 506 | val requestHash = with(transacter) { 507 | entityQueries.sqlDriver.currentTransaction()!!.parentTransactionHash() 508 | } 509 | if (requestHash in updateWriteHashes) return 510 | updateWriteHashes.add(requestHash) 511 | afterCommit { 512 | updateWriteHashes.remove(requestHash) 513 | scope.launch(writeDispatcher) { 514 | metadataQueries.transaction { 515 | metadataQueries.upsertWrite(entityName, Clock.System.now()) 516 | } 517 | } 518 | } 519 | } 520 | 521 | // TODO exposed transaction should get a lock on the current context to see if it needs 522 | // to wait to acquire a lock instead of being on the same thread, this will make sure only one 523 | // transaction is running at a time. (as right now ThreadLocal means we can muddle ops by 524 | // calling suspending functions back to back) 525 | 526 | data class Config( 527 | val deserializePolicy: DeserializePolicy = DeserializePolicy.ERROR, 528 | @Deprecated("Use we use predefined dispatchers for read/write. This is unused now.") 529 | val dispatcher: CoroutineDispatcher = Dispatchers.Default, 530 | ) { 531 | enum class DeserializePolicy { 532 | /** 533 | * Will throw an error if the value can't be deserialized. 534 | * It is up to you do handle and recover from the error. 535 | */ 536 | ERROR, 537 | 538 | /** 539 | * Will delete and return null if the value can't be deserialized. 540 | */ 541 | DELETE, 542 | } 543 | } 544 | 545 | } 546 | 547 | /** 548 | * @param serializer if providing your own, recommend using [SqkonJson] builder. 549 | */ 550 | inline fun keyValueStorage( 551 | entityName: String, 552 | entityQueries: EntityQueries, 553 | metadataQueries: MetadataQueries, 554 | scope: CoroutineScope, 555 | serializer: SqkonSerializer = KotlinSqkonSerializer(), 556 | config: KeyValueStorage.Config = KeyValueStorage.Config(), 557 | readDispatcher: CoroutineDispatcher = dbReadDispatcher, 558 | writeDispatcher: CoroutineDispatcher = dbWriteDispatcher, 559 | transactor: SqkonTransacter = SqkonTransacter(entityQueries.sqlDriver), 560 | ): KeyValueStorage { 561 | return KeyValueStorage( 562 | entityName = entityName, 563 | entityQueries = entityQueries, 564 | metadataQueries = metadataQueries, 565 | scope = scope, 566 | type = typeOf(), 567 | serializer = serializer, 568 | config = config, 569 | readDispatcher = readDispatcher, 570 | writeDispatcher = writeDispatcher, 571 | transacter = transactor, 572 | ) 573 | } 574 | --------------------------------------------------------------------------------