From ce4e7c07fb03096a7d5d36118d9887578a774c52 Mon Sep 17 00:00:00 2001
From: Christopher Jenkins <chris.mark.jenkins@gmail.com>
Date: Tue, 14 Jan 2025 14:15:41 -0800
Subject: [PATCH] added delete by keys (#7)

added delete by read/write methods
---
 .../com/mercury/sqkon/db/EntityQueries.kt     | 24 ++++++---
 .../com/mercury/sqkon/db/KeyValueStorage.kt   | 50 +++++++++++++++++--
 .../com/mercury/sqkon/db/metadata.sq          |  3 +-
 .../sqkon/db/KeyValueStorageStaleTest.kt      |  8 +--
 .../mercury/sqkon/db/KeyValueStorageTest.kt   | 14 ++++++
 5 files changed, 82 insertions(+), 17 deletions(-)

diff --git a/library/src/commonMain/kotlin/com/mercury/sqkon/db/EntityQueries.kt b/library/src/commonMain/kotlin/com/mercury/sqkon/db/EntityQueries.kt
index ad48e25..5f68ddc 100644
--- a/library/src/commonMain/kotlin/com/mercury/sqkon/db/EntityQueries.kt
+++ b/library/src/commonMain/kotlin/com/mercury/sqkon/db/EntityQueries.kt
@@ -197,7 +197,7 @@ class EntityQueries(
 
     suspend fun delete(
         entityName: String,
-        entityKey: String? = null,
+        entityKeys: Collection<String>? = null,
         where: Where<*>? = null,
     ) {
         val queries = buildList {
@@ -206,19 +206,29 @@ class EntityQueries(
                 parameters = 1,
                 bindArgs = { bindString(entityName) }
             ))
-            if (entityKey != null) add(SqlQuery(
-                where = "entity_key = ?",
-                parameters = 1,
-                bindArgs = { bindString(entityKey) }
-            ))
+            when(entityKeys?.size) {
+                null, 0 -> {}
+                1 -> add(SqlQuery(
+                    where = "entity_key = ?",
+                    parameters = 1,
+                    bindArgs = { bindString(entityKeys.first()) }
+                ))
+                else -> add(SqlQuery(
+                    where = "entity_key IN (${entityKeys.joinToString(",") { "?" }})",
+                    parameters = entityKeys.size,
+                    bindArgs = { entityKeys.forEach { bindString(it) } }
+                ))
+            }
+
             addAll(listOfNotNull(where?.toSqlQuery(increment = 1)))
         }
         val identifier = identifier("delete", queries.identifier().toString())
         val whereSubQuerySql = if (queries.size <= 1) ""
         else """
-            AND entity_key = (SELECT entity_key FROM entity${queries.buildFrom()} ${queries.buildWhere()})
+            AND entity_key IN (SELECT entity_key FROM entity${queries.buildFrom()} ${queries.buildWhere()})
             """.trimIndent()
         val sql = "DELETE FROM entity WHERE entity_name = ? $whereSubQuerySql"
+        println("SQL: $sql")
         try {
             driver.execute(
                 identifier = identifier,
diff --git a/library/src/commonMain/kotlin/com/mercury/sqkon/db/KeyValueStorage.kt b/library/src/commonMain/kotlin/com/mercury/sqkon/db/KeyValueStorage.kt
index cb10291..b4d244d 100644
--- a/library/src/commonMain/kotlin/com/mercury/sqkon/db/KeyValueStorage.kt
+++ b/library/src/commonMain/kotlin/com/mercury/sqkon/db/KeyValueStorage.kt
@@ -197,12 +197,14 @@ open class KeyValueStorage<T : Any>(
     fun selectByKeys(
         keys: Collection<String>,
         orderBy: List<OrderBy<T>> = emptyList(),
+        expiresAfter: Instant? = null,
     ): Flow<List<T>> {
         return entityQueries
             .select(
                 entityName = entityName,
                 entityKeys = keys,
                 orderBy = orderBy,
+                expiresAt = expiresAfter,
             )
             .asFlow()
             .mapToList(config.dispatcher)
@@ -344,8 +346,22 @@ open class KeyValueStorage<T : Any>(
      * @see delete
      * @see deleteAll
      */
-    suspend fun deleteByKey(key: String) = transaction {
-        entityQueries.delete(entityName, entityKey = key)
+    suspend fun deleteByKey(key: String) {
+        deleteByKeys(key)
+    }
+
+    /**
+     * Delete by keys.
+     *
+     * If you need to delete all rows, use [deleteAll].
+     * If you need to specify which rows to delete, use [delete] with a [Where]. Note, using where
+     * will be less performant than deleting by key.
+     *
+     * @see delete
+     * @see deleteAll
+     */
+    suspend fun deleteByKeys(vararg key: String) = transaction {
+        entityQueries.delete(entityName, entityKeys = key.toSet())
         updateWriteAt(currentCoroutineContext()[RequestHash.Key]?.hash ?: key.hashCode())
     }
 
@@ -388,9 +404,33 @@ open class KeyValueStorage<T : Any>(
      *
      * @see deleteExpired
      */
-    suspend fun deleteStale(instant: Instant = Clock.System.now()) = transaction {
-        metadataQueries.purgeStale(entityName, instant.toEpochMilliseconds())
-        updateWriteAt(currentCoroutineContext()[RequestHash.Key]?.hash ?: instant.hashCode())
+    suspend fun deleteStale(
+        writeInstant: Instant = Clock.System.now(),
+        readInstant: Instant = Clock.System.now()
+    ) = transaction {
+        metadataQueries.purgeStale(
+            entity_name = entityName,
+            writeInstant = writeInstant.toEpochMilliseconds(),
+            readInstant = readInstant.toEpochMilliseconds()
+        )
+        updateWriteAt(
+            currentCoroutineContext()[RequestHash.Key]?.hash
+                ?: (writeInstant.hashCode() + readInstant.hashCode())
+        )
+    }
+
+    /**
+     * Unlike [deleteExpired], this will clean up rows that have not been touched (read/written)
+     * before the passed in time.
+     *
+     * For example, you want to clean up rows that have not been read or written to in the last 24
+     * hours. You would call this function with `Clock.System.now().minus(1.days)`. This is not the same as
+     * [deleteExpired] which is based on the `expires_at` field.
+     *
+     * @see deleteExpired
+     */
+    suspend fun deleteState(instant: Instant = Clock.System.now()) {
+        deleteStale(instant, instant)
     }
 
     fun count(
diff --git a/library/src/commonMain/sqldelight/com/mercury/sqkon/db/metadata.sq b/library/src/commonMain/sqldelight/com/mercury/sqkon/db/metadata.sq
index 327fa9a..b67b769 100644
--- a/library/src/commonMain/sqldelight/com/mercury/sqkon/db/metadata.sq
+++ b/library/src/commonMain/sqldelight/com/mercury/sqkon/db/metadata.sq
@@ -36,4 +36,5 @@ DELETE FROM entity
 purgeStale:
 DELETE FROM entity
     WHERE entity_name = :entity_name
-    AND write_at < :instant AND (read_at IS NULL OR read_at < :instant);
+    AND write_at < :writeInstant
+    AND (read_at IS NULL OR read_at < :readInstant);
diff --git a/library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageStaleTest.kt b/library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageStaleTest.kt
index 774d52e..48ebc04 100644
--- a/library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageStaleTest.kt
+++ b/library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageStaleTest.kt
@@ -34,7 +34,7 @@ class KeyValueStorageStaleTest {
             .toSortedMap()
         testObjectStorage.insertAll(expected)
         // Clean up older than now
-        testObjectStorage.deleteStale(instant = now)
+        testObjectStorage.deleteStale(writeInstant = now, readInstant = now)
         val actualAfterDelete = testObjectStorage.selectAll().first()
         assertEquals(expected.size, actualAfterDelete.size)
     }
@@ -48,7 +48,7 @@ class KeyValueStorageStaleTest {
         sleep(1)
         val now = Clock.System.now()
         // Clean up older than now
-        testObjectStorage.deleteStale(instant = now)
+        testObjectStorage.deleteStale(writeInstant = now, readInstant = now)
         val actualAfterDelete = testObjectStorage.selectAll().first()
         assertEquals(0, actualAfterDelete.size)
     }
@@ -65,7 +65,7 @@ class KeyValueStorageStaleTest {
         // write again so read is in the past
         testObjectStorage.updateAll(expected)
         // Read in the past write is after now
-        testObjectStorage.deleteStale(instant = now)
+        testObjectStorage.deleteStale(writeInstant = now, readInstant = now)
         val actualAfterDelete = testObjectStorage.selectAll().first()
         assertEquals(expected.size, actualAfterDelete.size)
     }
@@ -81,7 +81,7 @@ class KeyValueStorageStaleTest {
         sleep(10)
         val now = Clock.System.now()
         // Clean write and read are in the past
-        testObjectStorage.deleteStale(instant = now)
+        testObjectStorage.deleteStale(writeInstant = now, readInstant = now)
         val actualAfterDelete = testObjectStorage.selectResult().first()
         assertEquals(0, actualAfterDelete.size)
     }
diff --git a/library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageTest.kt b/library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageTest.kt
index 3dc393b..5130386 100644
--- a/library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageTest.kt
+++ b/library/src/commonTest/kotlin/com/mercury/sqkon/db/KeyValueStorageTest.kt
@@ -316,6 +316,20 @@ class KeyValueStorageTest {
         assertEquals(expected.size - 1, actualAfterDelete.size)
     }
 
+    @Test
+    fun delete_byKeys() = runTest {
+        val expected = (0..10).map { TestObject() }.associateBy { it.id }
+        testObjectStorage.insertAll(expected)
+        val actual = testObjectStorage.selectAll().first()
+        assertEquals(expected.size, actual.size)
+
+        val key1 = expected.keys.toList()[5]
+        val key2 = expected.keys.toList()[6]
+        testObjectStorage.deleteByKeys(key1, key2)
+        val actualAfterDelete = testObjectStorage.selectAll().first()
+        assertEquals(expected.size - 2, actualAfterDelete.size)
+    }
+
     @Test
     fun delete_byEntityId() = runTest {
         val expected = (0..10).map { TestObject() }.associateBy { it.id }