diff --git a/.idea/artifacts/database_jvm.xml b/.idea/artifacts/database_jvm.xml
new file mode 100644
index 00000000..e1faad6e
--- /dev/null
+++ b/.idea/artifacts/database_jvm.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/database/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/database/.gitignore b/database/.gitignore
new file mode 100644
index 00000000..796b96d1
--- /dev/null
+++ b/database/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/database/build.gradle.kts b/database/build.gradle.kts
new file mode 100644
index 00000000..957ee5cc
--- /dev/null
+++ b/database/build.gradle.kts
@@ -0,0 +1,42 @@
+plugins {
+ kotlin("multiplatform")
+ id("com.android.library")
+ id("com.squareup.sqldelight") version "1.5.0"
+}
+
+kotlin {
+ android()
+ jvm("desktop") { compilations.all { kotlinOptions.jvmTarget = "11" } }
+ sourceSets {
+ val commonMain by getting
+ val commonTest by getting
+ val androidMain by getting {
+ dependencies { implementation(libs.thirdparty.sqldelight.androidDriver) }
+ }
+ val androidTest by getting
+ val desktopMain by getting { dependencies { implementation(libs.thirdparty.sqldelight.jvmDriver) } }
+ val desktopTest by getting {
+ dependencies {
+ implementation(libs.kotlin.coroutines.core)
+ implementation(kotlin("test-junit"))
+ }
+ }
+ }
+}
+
+android {
+ compileSdkVersion(30)
+ sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
+ defaultConfig {
+ minSdkVersion(23)
+ targetSdkVersion(30)
+ consumerProguardFiles("consumer-rules.pro")
+ }
+}
+
+configure {
+ database("LobstersDatabase") {
+ packageName = "dev.msfjarvis.lobsters.database"
+ sourceFolders = listOf("sqldelight")
+ }
+}
diff --git a/database/consumer-rules.pro b/database/consumer-rules.pro
new file mode 100644
index 00000000..a4b76f7c
--- /dev/null
+++ b/database/consumer-rules.pro
@@ -0,0 +1 @@
+-keep class dev.msfjarvis.lobsters.model.** { *; }
diff --git a/database/src/androidMain/AndroidManifest.xml b/database/src/androidMain/AndroidManifest.xml
new file mode 100644
index 00000000..1ac3d442
--- /dev/null
+++ b/database/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
diff --git a/database/src/androidMain/kotlin/dev/msfjarvis/lobsters/data/local/Database.kt b/database/src/androidMain/kotlin/dev/msfjarvis/lobsters/data/local/Database.kt
new file mode 100644
index 00000000..37209b72
--- /dev/null
+++ b/database/src/androidMain/kotlin/dev/msfjarvis/lobsters/data/local/Database.kt
@@ -0,0 +1,12 @@
+package dev.msfjarvis.lobsters.data.local
+
+import android.content.Context
+import com.squareup.sqldelight.android.AndroidSqliteDriver
+import com.squareup.sqldelight.db.SqlDriver
+import dev.msfjarvis.lobsters.database.LobstersDatabase
+
+actual class DriverFactory(private val context: Context) {
+ actual fun createDriver(): SqlDriver {
+ return AndroidSqliteDriver(LobstersDatabase.Schema, context, LobstersDatabaseName)
+ }
+}
diff --git a/database/src/commonMain/kotlin/dev/msfjarvis/lobsters/data/local/Database.kt b/database/src/commonMain/kotlin/dev/msfjarvis/lobsters/data/local/Database.kt
new file mode 100644
index 00000000..1ab3266e
--- /dev/null
+++ b/database/src/commonMain/kotlin/dev/msfjarvis/lobsters/data/local/Database.kt
@@ -0,0 +1,18 @@
+package dev.msfjarvis.lobsters.data.local
+
+import com.squareup.sqldelight.db.SqlDriver
+import dev.msfjarvis.lobsters.data.model.TagsAdapter
+import dev.msfjarvis.lobsters.database.LobstersDatabase
+
+internal const val LobstersDatabaseName = "SavedPosts.db"
+
+expect class DriverFactory {
+ fun createDriver(): SqlDriver
+}
+
+private fun getTagsAdapter() = TagsAdapter()
+
+fun createDatabase(driverFactory: DriverFactory): LobstersDatabase {
+ val driver = driverFactory.createDriver()
+ return LobstersDatabase(driver, SavedPost.Adapter(getTagsAdapter()))
+}
diff --git a/database/src/commonMain/kotlin/dev/msfjarvis/lobsters/data/model/TagsAdapter.kt b/database/src/commonMain/kotlin/dev/msfjarvis/lobsters/data/model/TagsAdapter.kt
new file mode 100644
index 00000000..3b8fcb48
--- /dev/null
+++ b/database/src/commonMain/kotlin/dev/msfjarvis/lobsters/data/model/TagsAdapter.kt
@@ -0,0 +1,17 @@
+package dev.msfjarvis.lobsters.data.model
+
+import com.squareup.sqldelight.ColumnAdapter
+
+class TagsAdapter : ColumnAdapter, String> {
+ override fun decode(databaseValue: String): List {
+ return databaseValue.split(SEPARATOR)
+ }
+
+ override fun encode(value: List): String {
+ return value.joinToString(SEPARATOR)
+ }
+
+ private companion object {
+ private const val SEPARATOR = ","
+ }
+}
diff --git a/database/src/commonMain/sqldelight/dev/msfjarvis/lobsters/data/local/SavedPost.sq b/database/src/commonMain/sqldelight/dev/msfjarvis/lobsters/data/local/SavedPost.sq
new file mode 100644
index 00000000..b1977c08
--- /dev/null
+++ b/database/src/commonMain/sqldelight/dev/msfjarvis/lobsters/data/local/SavedPost.sq
@@ -0,0 +1,39 @@
+import kotlin.collections.List;
+
+CREATE TABLE IF NOT EXISTS SavedPost(
+ shortId TEXT NOT NULL PRIMARY KEY,
+ title TEXT NOT NULL,
+ url TEXT NOT NULL,
+ createdAt TEXT NOT NULL,
+ commentsUrl TEXT NOT NULL,
+ submitterName TEXT NOT NULL,
+ submitterAvatarUrl TEXT NOT NULL,
+ tags TEXT AS List NOT NULL
+);
+
+insertOrReplacePost:
+INSERT OR REPLACE
+INTO SavedPost
+VALUES ?;
+
+selectAllPosts:
+SELECT *
+FROM SavedPost;
+
+selectCount:
+SELECT COUNT(*)
+FROM SavedPost;
+
+deleteAllPosts:
+DELETE
+FROM SavedPost;
+
+deletePost:
+DELETE
+FROM SavedPost
+WHERE shortId = ?;
+
+selectPost:
+SELECT *
+FROM SavedPost
+WHERE shortId = ?;
diff --git a/database/src/desktopMain/kotlin/dev/msfjarvis/lobsters/data/local/Database.kt b/database/src/desktopMain/kotlin/dev/msfjarvis/lobsters/data/local/Database.kt
new file mode 100644
index 00000000..9883a8a3
--- /dev/null
+++ b/database/src/desktopMain/kotlin/dev/msfjarvis/lobsters/data/local/Database.kt
@@ -0,0 +1,13 @@
+package dev.msfjarvis.lobsters.data.local
+
+import com.squareup.sqldelight.db.SqlDriver
+import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
+import dev.msfjarvis.lobsters.database.LobstersDatabase
+
+actual class DriverFactory {
+ actual fun createDriver(): SqlDriver {
+ val driver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
+ LobstersDatabase.Schema.create(driver)
+ return driver
+ }
+}
diff --git a/database/src/desktopTest/kotlin/dev/msfjarvis/lobsters/data/local/SqlDelightQueriesTest.kt b/database/src/desktopTest/kotlin/dev/msfjarvis/lobsters/data/local/SqlDelightQueriesTest.kt
new file mode 100644
index 00000000..5ca12981
--- /dev/null
+++ b/database/src/desktopTest/kotlin/dev/msfjarvis/lobsters/data/local/SqlDelightQueriesTest.kt
@@ -0,0 +1,153 @@
+package dev.msfjarvis.lobsters.data.local
+
+import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
+import dev.msfjarvis.lobsters.data.model.TagsAdapter
+import dev.msfjarvis.lobsters.database.LobstersDatabase
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlinx.coroutines.runBlocking
+import org.junit.Before
+
+@OptIn(ExperimentalStdlibApi::class)
+class SqlDelightQueriesTest {
+
+ private lateinit var postQueries: SavedPostQueries
+
+ @Before
+ fun setUp() {
+ val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
+ LobstersDatabase.Schema.create(driver)
+ val database =
+ LobstersDatabase(
+ driver,
+ SavedPost.Adapter(TagsAdapter()),
+ )
+ postQueries = database.savedPostQueries
+ }
+
+ @Test
+ fun selectCount() = runBlocking {
+ val posts = createTestData(5)
+
+ posts.forEach { postQueries.insertOrReplacePost(it) }
+
+ val postCount = postQueries.selectCount().executeAsOne()
+ assertEquals(5, postCount)
+ }
+
+ @Test
+ fun insertIntoDatabase() = runBlocking {
+ // Get 5 posts
+ val posts = createTestData(5)
+
+ // Insert posts into DB
+ posts.forEach { postQueries.insertOrReplacePost(it) }
+
+ // Check post count
+ val postsCount = postQueries.selectCount().executeAsOne()
+ assertEquals(5, postsCount)
+ }
+
+ @Test
+ fun replaceFromDatabase() = runBlocking {
+ // Get 1 post
+ val post = createTestData(1)[0]
+
+ // Insert post into DB
+ postQueries.insertOrReplacePost(post)
+
+ // Create a new post and try replacing it
+ val newPost = post.copy(submitterName = "Fake name")
+ postQueries.insertOrReplacePost(newPost)
+
+ // Check post count
+ val postsCount = postQueries.selectCount().executeAsOne()
+ assertEquals(1, postsCount)
+
+ // Check if post is updated
+ val postFromDb = postQueries.selectPost(post.shortId).executeAsOne()
+ assertEquals("Fake name", postFromDb.submitterName)
+ }
+
+ @Test
+ fun selectPost() = runBlocking {
+ // Get 1 post
+ val post = createTestData(1)[0]
+
+ // Insert post into DB
+ postQueries.insertOrReplacePost(post)
+
+ val postFromDb = postQueries.selectAllPosts().executeAsOne()
+ assertEquals("test_id_1", postFromDb.shortId)
+ }
+
+ @Test
+ fun selectAllPosts() = runBlocking {
+ // Get 5 post
+ val posts = createTestData(5)
+
+ // Insert posts into DB
+ posts.forEach { postQueries.insertOrReplacePost(it) }
+
+ val postsFromDb = postQueries.selectAllPosts().executeAsList()
+
+ // Check if all posts have correct shortId
+ for (i in 1..5) {
+ assertEquals("test_id_$i", postsFromDb[i - 1].shortId)
+ }
+ }
+
+ @Test
+ fun deletePost() = runBlocking {
+ // Create 3 posts and insert them to DB
+ val posts = createTestData(3)
+ posts.forEach { postQueries.insertOrReplacePost(it) }
+
+ // Delete 2nd post
+ postQueries.deletePost("test_id_2")
+
+ val postsFromDB = postQueries.selectAllPosts().executeAsList()
+
+ // Check if size is 2, and only the correct post is deleted
+ assertEquals(2, postsFromDB.size)
+ assertEquals("test_id_1", postsFromDB[0].shortId)
+ assertEquals("test_id_3", postsFromDB[1].shortId)
+ }
+
+ @Test
+ fun deleteAllPost() = runBlocking {
+ // Create 5 posts and insert them to DB
+ val posts = createTestData(5)
+ posts.forEach { postQueries.insertOrReplacePost(it) }
+
+ // Delete all posts
+ postQueries.deleteAllPosts()
+
+ val postsCount = postQueries.selectCount().executeAsOne()
+
+ // Check if db is empty
+ assertEquals(0, postsCount)
+ }
+
+ private fun createTestData(count: Int): ArrayList {
+ val posts = arrayListOf()
+
+ for (i in 1..count) {
+ val post =
+ SavedPost(
+ shortId = "test_id_$i",
+ createdAt = "0",
+ title = "test",
+ url = "test_url",
+ commentsUrl = "test_comments_url",
+ submitterName = "test_user_$i",
+ submitterAvatarUrl = "test_avatar_url",
+ tags = listOf(),
+ )
+
+ posts.add(post)
+ }
+
+ return posts
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 07ab6c14..1d86de8c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -3,6 +3,7 @@ coroutines = "1.5.0"
kotlin = "1.5.10"
moshix = "0.11.2"
retrofit = "2.9.0"
+sqldelight = "1.5.0"
[libraries]
@@ -17,5 +18,8 @@ thirdparty-moshix-metadatareflect = { module = "dev.zacsweers.moshix:moshi-metad
thirdparty-retrofit-lib = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
thirdparty-retrofit-moshiConverter = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
+thirdparty-sqldelight-jvmDriver = { module = "com.squareup.sqldelight:sqlite-driver", version.ref = "sqldelight" }
+thirdparty-sqldelight-androidDriver = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }
+
testing-kotlintest-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
testing-mockWebServer = "com.squareup.okhttp3:mockwebserver3-junit4:5.0.0-alpha.2"
diff --git a/settings.gradle.kts b/settings.gradle.kts
index f916bc25..db9eacb8 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,4 +19,6 @@ include(":api")
include(":common")
+include(":database")
+
include(":desktop")