Migrates EventStore from Android's SQLLite to KMP
Fixes testing of libsodium between java and android
This commit is contained in:
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
interface IEventStore {
|
||||
fun insert(event: Event): Boolean
|
||||
|
||||
interface ITransaction {
|
||||
fun insert(event: Event): Boolean
|
||||
}
|
||||
|
||||
fun transaction(body: ITransaction.() -> Unit)
|
||||
|
||||
fun <T : Event> query(filter: Filter): List<T>
|
||||
|
||||
fun <T : Event> query(filters: List<Filter>): List<T>
|
||||
|
||||
fun <T : Event> query(
|
||||
filter: Filter,
|
||||
onEach: (T) -> Unit,
|
||||
)
|
||||
|
||||
fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
onEach: (T) -> Unit,
|
||||
)
|
||||
|
||||
fun count(filter: Filter): Int
|
||||
|
||||
fun count(filters: List<Filter>): Int
|
||||
|
||||
fun delete(filter: Filter)
|
||||
|
||||
fun delete(filters: List<Filter>)
|
||||
|
||||
fun deleteExpiredEvents()
|
||||
|
||||
fun close()
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
# Event Store Module
|
||||
|
||||
This module implements an **Event Store** with nostr-native queries.
|
||||
|
||||
The goal was not to make the fastest database, since there could be multiple optimizations made if
|
||||
consistency can be sacrificed, but a database that will never crash and never go corrupt.
|
||||
|
||||
## Features
|
||||
|
||||
- **Storage & Retrieval**:
|
||||
Stores Nostr events and retrieves using Nostr filters
|
||||
|
||||
- **Replaceable Events**:
|
||||
- Forces unique constraint by kind, pubkey
|
||||
- Old versions are removed when newer versions arrive.
|
||||
- Old versions are blocked if newer versions exist.
|
||||
|
||||
- **Addressable Events**:
|
||||
- Forces unique constraint by kind, pubkey, d-tag
|
||||
- Old versions are removed when newer versions arrive.
|
||||
- Old versions are blocked if newer versions exist.
|
||||
|
||||
- **Ephemeral Events**
|
||||
- Ephemeral events never stored.
|
||||
|
||||
- **NIP-40 Expirations**
|
||||
- Deletes expired events.
|
||||
- Blocks expired events from being reinserted
|
||||
|
||||
- **NIP-09 Deletion Events**
|
||||
- Deletes by event id
|
||||
- Deletes by address until the `created_at`
|
||||
- Blocks deleted events from being re-inserted.
|
||||
- GiftWraps are deleted by p-tag
|
||||
|
||||
- **NIP-62 Right to Vanish**
|
||||
- Deletes all user events until the `created_at`
|
||||
- Blocks vanished events from being re-inserted.
|
||||
- GiftWraps are deleted by p-tag
|
||||
|
||||
- **NIP-45 Counts**:
|
||||
- Counts records matching Nostr filters
|
||||
|
||||
- **NIP-50 Full Text Search**:
|
||||
- Custom content/tag indexing
|
||||
- Rich queries over event content and tags
|
||||
- Indexes updated on replaceables, deletions, vanish and expirations.
|
||||
|
||||
- **NIP-91: AND operator for tags**:
|
||||
- Allows queries matching two or more tags at the same time
|
||||
|
||||
- **Immutable Tables**
|
||||
- Tables cannot be updated, only inserted and deleted.
|
||||
|
||||
## Indexing Strategy
|
||||
|
||||
The store indexes events using five dedicated tables:
|
||||
- `event_headers`: stores the canonical event fields.
|
||||
- `event_tags`: indexes tag values as a hash for fast filtering on tag-based queries.
|
||||
- `event_fts`: for the content of full text search
|
||||
- `event_expirations`: to control when expired events must be deleted.
|
||||
- `event_vanish`: to control up to when vanished accounts must be blocked.
|
||||
|
||||
## Querying
|
||||
|
||||
This module supports optimized query planning, producing efficient SQL for multi-filter evaluation
|
||||
across fields and tags, including `limit` clauses per filter:
|
||||
|
||||
For instance, the following filter:
|
||||
```kotlin
|
||||
store.query(
|
||||
listOf(
|
||||
Filter(limit = 10),
|
||||
Filter(authors = listOf(hexkey), kinds = listOf(1, 1111), search = "keywords", limit = 100),
|
||||
Filter(kinds = listOf(20), search = "cats", limit = 30),
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
## How to Use
|
||||
|
||||
The `EventStore` class provides a high-level interface for interacting with the event database.
|
||||
It is initialized with a `SQLiteDatabase` instance, and it manages the underlying tables and query planning.
|
||||
|
||||
### Initialization
|
||||
|
||||
To initialize the `EventStore` in your Application class:
|
||||
|
||||
```kotlin
|
||||
val eventStore = EventStore(context, "dbname.db", relayUrlIdentifier)
|
||||
```
|
||||
|
||||
### Querying Events
|
||||
|
||||
To query events, use the `query` method with one or more `Filter` objects:
|
||||
|
||||
```kotlin
|
||||
val filters = listOf(
|
||||
Filter(limit = 10),
|
||||
Filter(authors = listOf(hexkey))
|
||||
)
|
||||
|
||||
val events = eventStore.query(filters)
|
||||
```
|
||||
|
||||
or to receive events as the cursor sends:
|
||||
|
||||
```kotlin
|
||||
val filters = listOf(
|
||||
Filter(limit = 10),
|
||||
Filter(authors = listOf(hexkey))
|
||||
)
|
||||
|
||||
eventStore.query(filters) { event ->
|
||||
// do something
|
||||
}
|
||||
```
|
||||
|
||||
`count` and `delete` also accept one or more filters.
|
||||
|
||||
### Inserting Events
|
||||
|
||||
Insert a single event using the `insert` method:
|
||||
|
||||
```kotlin
|
||||
eventStore.insert(event)
|
||||
```
|
||||
|
||||
### Deleting Events
|
||||
|
||||
Events should be deleted by adding a DeletionRequest or a VanishRequest to the db, but to manually
|
||||
delete an event by ID, use the `delete` method:
|
||||
|
||||
```kotlin
|
||||
eventStore.delete(event.id)
|
||||
```
|
||||
|
||||
### Full-Text Search
|
||||
|
||||
The store supports full-text search using the `search` parameter in filters:
|
||||
|
||||
```kotlin
|
||||
val result = eventStore.query(Filter(search = "bitcoin", limit = 20))
|
||||
```
|
||||
|
||||
This will match any event whose content contains "bitcoin", returning the most recent 20 results.
|
||||
|
||||
### Periodic cleanup of expired events.
|
||||
|
||||
The store exposes a `deleteExpiredEvents` to be used in a periodic clean up procedure. Users
|
||||
should use a WorkManager or a coroutine to periodically call `store.deleteExpiredEvents()`. We
|
||||
recommend a 15-minute window to remove recently expired events from the database.
|
||||
|
||||
Here's an example of a Worker that should be added to your application class.
|
||||
|
||||
```kotlin
|
||||
class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) {
|
||||
override fun doWork(): Result {
|
||||
YourApplication.store.deleteExpiredEvents()
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
fun schedulePeriodicWork(context: Context) {
|
||||
val periodicWorkRequest =
|
||||
PeriodicWorkRequestBuilder<ExpirationWorker>(15, TimeUnit.MINUTES).build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(periodicWorkRequest)
|
||||
}
|
||||
```
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
class AddressableModule : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE UNIQUE INDEX addressable_idx
|
||||
ON event_headers (kind, pubkey, d_tag)
|
||||
WHERE kind >= 30000 AND kind < 40000
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// deletes older addressables when inserting new ones
|
||||
// if a newer addressable is inserted the unique index
|
||||
// above will be triggered. Delete cascade will take
|
||||
// care of the event_tags table
|
||||
// the duplicate: kind >= 30000 AND kind < 40000
|
||||
// helps SQLlite find the index above
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER delete_older_addressable_event
|
||||
BEFORE INSERT ON event_headers
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.kind >= 30000 AND NEW.kind < 40000)
|
||||
BEGIN
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.d_tag = NEW.d_tag AND
|
||||
event_headers.created_at < NEW.created_at AND
|
||||
event_headers.kind >= 30000 AND event_headers.kind < 40000;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {}
|
||||
}
|
||||
-190
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
|
||||
class DeletionRequestModule(
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : IModule {
|
||||
fun rejectDeletedEventsSQLTemplate(): String =
|
||||
if (indexStrategy.indexTagsWithKindAndPubkey) {
|
||||
"""
|
||||
|SELECT 1 FROM event_tags
|
||||
|WHERE
|
||||
| event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
|
||||
| event_tags.kind = 5 AND
|
||||
| event_tags.pubkey_hash = NEW.pubkey_owner_hash AND
|
||||
| event_tags.created_at >= NEW.created_at
|
||||
""".trimMargin()
|
||||
} else {
|
||||
"""
|
||||
|SELECT 1 FROM event_tags
|
||||
|WHERE
|
||||
| event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
|
||||
| event_tags.kind = 5 AND
|
||||
| event_tags.created_at >= NEW.created_at AND
|
||||
| event_tags.pubkey_hash = NEW.pubkey_owner_hash
|
||||
""".trimMargin()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a trigger to reject events that have been
|
||||
* deleted by ID or ATag including GiftWraps that
|
||||
* must be checked against the p-tag (pubkey_owner_hash)
|
||||
*/
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ")
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER reject_deleted_events
|
||||
BEFORE INSERT ON event_headers
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'blocked: a deletion event exists')
|
||||
WHERE EXISTS (
|
||||
$sql
|
||||
);
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {}
|
||||
|
||||
fun insert(
|
||||
event: Event,
|
||||
db: SQLiteDatabase,
|
||||
) {
|
||||
if (event is DeletionEvent) {
|
||||
val idValues = event.deleteEventIds()
|
||||
val addresses = event.deleteAddresses()
|
||||
|
||||
deleteSQL(event.pubKey, idValues, addresses, hasher(db)).forEach { delete ->
|
||||
db.execSQL(delete.sql, delete.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Delete statement that correctly deletes by id,
|
||||
* by address and by replaceable (no d-tag) using each index
|
||||
* appropriately, including GiftWraps where the owner is the
|
||||
* p-tag (via event_header.pubkey_owner_hash)
|
||||
*/
|
||||
fun deleteSQL(
|
||||
pubkey: HexKey,
|
||||
idValues: List<String>,
|
||||
addresses: List<Address>,
|
||||
hasher: TagNameValueHasher,
|
||||
): List<SqlArgs> {
|
||||
val owner = hasher.hash(pubkey)
|
||||
val idParams = idValues.joinToString(",") { "?" }
|
||||
|
||||
// aligns each type of param with the need to filter d-tag
|
||||
// and thus each index type
|
||||
val addressablesByKind = addresses.filter { it.kind.isAddressable() && it.pubKeyHex == pubkey }.groupBy { it.kind }
|
||||
val replaceablesByKind = addresses.filter { it.kind.isReplaceable() && it.pubKeyHex == pubkey }.groupBy { it.kind }
|
||||
|
||||
val addressableParams =
|
||||
addressablesByKind.keys.joinToString("\n OR\n ") {
|
||||
val tagList = addressablesByKind[it]
|
||||
if (tagList == null) {
|
||||
""
|
||||
} else if (tagList.size == 1) {
|
||||
"(kind = ? AND pubkey = ? AND d_tag = ?)"
|
||||
} else {
|
||||
"(kind = ? AND pubkey = ? AND d_tag IN (${tagList.joinToString(",") { "?" }}))"
|
||||
}
|
||||
}
|
||||
|
||||
val addressableValues =
|
||||
addressablesByKind.flatMap {
|
||||
listOf<Any>(it.key.toLong(), pubkey) + it.value.map { it.dTag }
|
||||
}
|
||||
|
||||
val replaceableKindsParam = replaceablesByKind.keys.joinToString(",") { "?" }
|
||||
val replaceableKindsValues = replaceablesByKind.keys.map { it.toLong() }
|
||||
|
||||
val deleteById =
|
||||
if (idValues.isNotEmpty()) {
|
||||
SqlArgs(
|
||||
"""
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
id IN ($idParams) AND
|
||||
pubkey_owner_hash = ?
|
||||
""".trimIndent(),
|
||||
idValues.plus(owner).toTypedArray(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val deleteByAddress =
|
||||
if (addressableValues.isNotEmpty()) {
|
||||
SqlArgs(
|
||||
"""
|
||||
DELETE FROM event_headers
|
||||
WHERE (
|
||||
$addressableParams
|
||||
) AND
|
||||
kind >= 30000 AND kind < 40000
|
||||
""".trimIndent(),
|
||||
addressableValues.toTypedArray(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val deleteByReplaceable =
|
||||
if (replaceableKindsParam.isNotEmpty()) {
|
||||
SqlArgs(
|
||||
"""
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
kind IN ($replaceableKindsParam) AND
|
||||
pubkey = ? AND
|
||||
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
|
||||
""".trimIndent(),
|
||||
replaceableKindsValues.plus(pubkey).toTypedArray(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return listOfNotNull(deleteById, deleteByAddress, deleteByReplaceable)
|
||||
}
|
||||
|
||||
class SqlArgs(
|
||||
val sql: String,
|
||||
val args: Array<Any>,
|
||||
)
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
class EphemeralModule : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
// Rejects all ephemeral events.
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER reject_ephemeral_events
|
||||
BEFORE INSERT ON event_headers
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.kind >= 20000 AND NEW.kind < 30000)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'blocked: cannot store ephemeral events');
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {}
|
||||
}
|
||||
-220
@@ -1,220 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
|
||||
class EventIndexesModule(
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE event_headers (
|
||||
row_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL,
|
||||
pubkey TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
d_tag TEXT,
|
||||
tags TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
sig TEXT NOT NULL,
|
||||
pubkey_owner_hash INTEGER NOT NULL,
|
||||
etag_hash INTEGER,
|
||||
atag_hash INTEGER
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE event_tags (
|
||||
event_header_row_id INTEGER NOT NULL,
|
||||
tag_hash INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
pubkey_hash INTEGER NOT NULL,
|
||||
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// queries by ID (load events)
|
||||
db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)")
|
||||
|
||||
val orderBy =
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
"created_at DESC, id ASC"
|
||||
} else {
|
||||
"created_at DESC"
|
||||
}
|
||||
|
||||
// queries by limit (latest records), since, until (sync all) alone without any filter by kind.. rare
|
||||
if (indexStrategy.indexEventsByCreatedAtAlone) {
|
||||
db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers ($orderBy)")
|
||||
}
|
||||
|
||||
// queries by kind only, mostly used in Global Feeds when author is not important.
|
||||
db.execSQL("CREATE INDEX query_by_kind_created ON event_headers (kind, $orderBy)")
|
||||
|
||||
// queries by kind + pubkey, but not d-tag, even if they are replaceables and addressables, by date.
|
||||
db.execSQL("CREATE INDEX query_by_kind_pubkey_created ON event_headers (kind, pubkey, $orderBy)")
|
||||
|
||||
// makes deletions on the event_header fast
|
||||
db.execSQL("CREATE INDEX fk_event_tags_header_id ON event_tags (event_header_row_id)")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// These next 3 are a very slow indexes (80% of the insert time goes here)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (indexStrategy.indexTagsByCreatedAtAlone) {
|
||||
// First one is only needed if the user is searching by tags without a kind.
|
||||
db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, created_at DESC)")
|
||||
}
|
||||
|
||||
// This is the default index for most clients: tags by specific kinds that are supported by the client.
|
||||
db.execSQL("CREATE INDEX query_by_tags_hash_kind ON event_tags (tag_hash, kind, created_at DESC)")
|
||||
|
||||
// this one is to allow search of tags by kind and author at the same time: NIP-04 DMs, reports,
|
||||
if (indexStrategy.indexTagsWithKindAndPubkey) {
|
||||
db.execSQL("CREATE INDEX query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)")
|
||||
}
|
||||
|
||||
// Prevent updates to maintain immutability
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER event_headers_prevent_update
|
||||
BEFORE UPDATE ON event_headers
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Error: Updates are not allowed.');
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER event_tags_prevent_update
|
||||
BEFORE UPDATE ON event_tags
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Error: Updates are not allowed.');
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {
|
||||
db.execSQL("DROP TABLE IF EXISTS event_tags")
|
||||
db.execSQL("DROP TABLE IF EXISTS event_headers")
|
||||
}
|
||||
|
||||
val sqlInsertHeader =
|
||||
"""
|
||||
INSERT INTO event_headers
|
||||
(id, pubkey, created_at, kind, tags, content, sig, d_tag, pubkey_owner_hash, etag_hash, atag_hash)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
val sqlInsertTags =
|
||||
"""
|
||||
INSERT OR ROLLBACK INTO event_tags
|
||||
(event_header_row_id, tag_hash, created_at, kind, pubkey_hash)
|
||||
VALUES
|
||||
(?,?,?,?,?)
|
||||
""".trimIndent()
|
||||
|
||||
fun insert(
|
||||
event: Event,
|
||||
db: SQLiteDatabase,
|
||||
): Long {
|
||||
val hasher = hasher(db)
|
||||
val stmt = db.compileStatement(sqlInsertHeader)
|
||||
|
||||
val kindLong = event.kind.toLong()
|
||||
val pubkeyHash = hasher.hash(event.pubKey)
|
||||
|
||||
val eventOwnerHash =
|
||||
if (event is GiftWrapEvent) {
|
||||
event.recipientPubKey()?.let { hasher.hash(it) } ?: pubkeyHash
|
||||
} else {
|
||||
pubkeyHash
|
||||
}
|
||||
|
||||
val eTagHash = hasher.hashETag(event.id)
|
||||
|
||||
stmt.bindString(1, event.id)
|
||||
stmt.bindString(2, event.pubKey)
|
||||
stmt.bindLong(3, event.createdAt)
|
||||
stmt.bindLong(4, kindLong)
|
||||
stmt.bindString(5, OptimizedJsonMapper.toJson(event.tags))
|
||||
stmt.bindString(6, event.content)
|
||||
stmt.bindString(7, event.sig)
|
||||
if (event is AddressableEvent) {
|
||||
val dTag = event.dTag()
|
||||
stmt.bindString(8, dTag)
|
||||
stmt.bindLong(9, eventOwnerHash)
|
||||
stmt.bindLong(10, eTagHash)
|
||||
stmt.bindLong(11, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag)))
|
||||
} else {
|
||||
stmt.bindNull(8)
|
||||
stmt.bindLong(9, eventOwnerHash)
|
||||
stmt.bindLong(10, eTagHash)
|
||||
stmt.bindNull(11)
|
||||
}
|
||||
|
||||
val headerId = stmt.executeInsert()
|
||||
|
||||
val stmtTags = db.compileStatement(sqlInsertTags)
|
||||
|
||||
// sorting helps SQLLite by avoiding
|
||||
// rebalancing the tree every new insert
|
||||
val indexableTags = ArrayList<Long>()
|
||||
for (idx in event.tags.indices) {
|
||||
if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) {
|
||||
indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1]))
|
||||
}
|
||||
}
|
||||
indexableTags.sort()
|
||||
indexableTags.forEach {
|
||||
stmtTags.bindLong(1, headerId)
|
||||
stmtTags.bindLong(2, it)
|
||||
stmtTags.bindLong(3, event.createdAt)
|
||||
stmtTags.bindLong(4, kindLong)
|
||||
stmtTags.bindLong(5, pubkeyHash)
|
||||
stmtTags.executeInsert()
|
||||
}
|
||||
|
||||
return headerId
|
||||
}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {
|
||||
db.execSQL("DELETE FROM event_tags")
|
||||
db.execSQL("DELETE FROM event_headers")
|
||||
}
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
|
||||
class EventStore(
|
||||
context: Context,
|
||||
dbName: String? = "events.db",
|
||||
val relayUrl: String? = "wss://quartz.local",
|
||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : IEventStore {
|
||||
val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy)
|
||||
|
||||
override fun insert(event: Event) = store.insertEvent(event)
|
||||
|
||||
override fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body)
|
||||
|
||||
override fun <T : Event> query(filter: Filter) = store.query<T>(filter)
|
||||
|
||||
override fun <T : Event> query(filters: List<Filter>) = store.query<T>(filters)
|
||||
|
||||
override fun <T : Event> query(
|
||||
filter: Filter,
|
||||
onEach: (T) -> Unit,
|
||||
) = store.query(filter, onEach)
|
||||
|
||||
override fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
onEach: (T) -> Unit,
|
||||
) = store.query(filters, onEach)
|
||||
|
||||
override fun count(filter: Filter) = store.count(filter)
|
||||
|
||||
override fun count(filters: List<Filter>) = store.count(filters)
|
||||
|
||||
override fun delete(filter: Filter) = store.delete(filter)
|
||||
|
||||
override fun delete(filters: List<Filter>) = store.delete(filters)
|
||||
|
||||
override fun deleteExpiredEvents() = store.deleteExpiredEvents()
|
||||
|
||||
override fun close() = store.close()
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
|
||||
class ExpirationModule : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE event_expirations (
|
||||
event_header_row_id INTEGER PRIMARY KEY NOT NULL,
|
||||
expiration INTEGER NOT NULL,
|
||||
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// Rejects old addressables
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER reject_expired_events
|
||||
BEFORE INSERT ON event_expirations
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
-- Check for existing newer record
|
||||
SELECT RAISE(ABORT, 'blocked: this event is expired')
|
||||
WHERE NEW.expiration <= unixepoch();
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {
|
||||
db.execSQL("DROP TABLE IF EXISTS event_expirations")
|
||||
}
|
||||
|
||||
val insertExpiration =
|
||||
"""
|
||||
INSERT OR ROLLBACK INTO event_expirations (event_header_row_id, expiration)
|
||||
VALUES (?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
fun insert(
|
||||
event: Event,
|
||||
headerId: Long,
|
||||
db: SQLiteDatabase,
|
||||
) {
|
||||
val exp = event.expiration()
|
||||
if (exp != null && exp > 0) {
|
||||
val stmt = db.compileStatement(insertExpiration)
|
||||
stmt.bindLong(1, headerId)
|
||||
stmt.bindLong(2, exp)
|
||||
stmt.executeInsert()
|
||||
}
|
||||
}
|
||||
|
||||
val deleteExpiredEvents =
|
||||
"""
|
||||
DELETE FROM event_headers
|
||||
WHERE row_id IN (
|
||||
SELECT event_expirations.event_header_row_id FROM event_expirations
|
||||
WHERE event_expirations.expiration < unixepoch()
|
||||
);
|
||||
""".trimIndent()
|
||||
|
||||
fun deleteExpiredEvents(db: SQLiteDatabase) {
|
||||
db.compileStatement(deleteExpiredEvents).execute()
|
||||
}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {
|
||||
db.execSQL("DELETE FROM event_expirations")
|
||||
}
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteException
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
|
||||
class FullTextSearchModule : IModule {
|
||||
val tableName = "event_fts"
|
||||
val eventHeaderRowIdName = "event_header_row_id"
|
||||
val contentName = "content"
|
||||
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
val ftsVersion = FullTextSearchModule().versionFinder(db)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE VIRTUAL TABLE $tableName
|
||||
USING fts$ftsVersion($eventHeaderRowIdName, $contentName)
|
||||
""",
|
||||
)
|
||||
|
||||
// Foreign key cleanup for full text search
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER fts_foreign_key
|
||||
AFTER DELETE ON event_headers
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DELETE FROM $tableName
|
||||
WHERE old.row_id = $tableName.$eventHeaderRowIdName;
|
||||
END;
|
||||
""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {
|
||||
db.execSQL("DROP TABLE IF EXISTS $tableName")
|
||||
}
|
||||
|
||||
val insertFTS =
|
||||
"""
|
||||
INSERT OR ROLLBACK INTO $tableName ($eventHeaderRowIdName, $contentName)
|
||||
VALUES (?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
fun insert(
|
||||
event: Event,
|
||||
headerId: Long,
|
||||
db: SQLiteDatabase,
|
||||
) {
|
||||
if (event is SearchableEvent) {
|
||||
val stmt = db.compileStatement(insertFTS)
|
||||
stmt.bindLong(1, headerId)
|
||||
stmt.bindString(2, event.indexableContent())
|
||||
stmt.executeInsert()
|
||||
}
|
||||
}
|
||||
|
||||
fun versionFinder(db: SQLiteDatabase): Int =
|
||||
try {
|
||||
try {
|
||||
db.execSQL("CREATE VIRTUAL TABLE dummy_fts5 USING fts5(dummy)")
|
||||
5
|
||||
} catch (e: SQLiteException) {
|
||||
db.execSQL("CREATE VIRTUAL TABLE dummy_fts4 USING fts4(dummy)")
|
||||
4
|
||||
}
|
||||
} catch (e: SQLiteException) {
|
||||
db.execSQL("CREATE VIRTUAL TABLE dummy_fts3 USING fts3(dummy)")
|
||||
3
|
||||
}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {
|
||||
db.execSQL("DELETE FROM event_fts")
|
||||
}
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
interface IModule {
|
||||
fun create(db: SQLiteDatabase)
|
||||
|
||||
fun drop(db: SQLiteDatabase)
|
||||
|
||||
fun deleteAll(db: SQLiteDatabase)
|
||||
}
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
|
||||
interface IndexingStrategy {
|
||||
/**
|
||||
* Activate this if you see too many Filters with just LIMIT, SINCE and
|
||||
* UNTIL filled up.
|
||||
*
|
||||
* Clients never support all kinds, so this is usually
|
||||
* only done with syncing services that must download ALL kinds from
|
||||
* ALL authors.
|
||||
*
|
||||
* The index will make these queries significantly faster, but maybe speed
|
||||
* is not a requirement on Sync services. The size of this index is
|
||||
* considerable.
|
||||
*
|
||||
* Keep in mind that activating too many indexes increases the size of the
|
||||
* DB so much that the indexes themselves won't fit in memory, requiring
|
||||
* frequent reloadings of the index itself from disk.
|
||||
*/
|
||||
val indexEventsByCreatedAtAlone: Boolean
|
||||
|
||||
/**
|
||||
* Activate this if you see too many Tag-centric Filters without
|
||||
* kind, pubkey or id.
|
||||
*
|
||||
* Clients never support all kinds, so this is usually
|
||||
* only done in rare usecases where the client supports all
|
||||
* kinds.
|
||||
*
|
||||
* The index will make these queries significantly faster, but maybe speed
|
||||
* is not a requirement on such services. Because this is an index in
|
||||
* event tags, it becomes QUITE BIG.
|
||||
*
|
||||
* Keep in mind that activating too many indexes increases the size of the
|
||||
* DB so much that the indexes themselves won't fit in memory, requiring
|
||||
* frequent reloadings of the index itself from disk.
|
||||
*/
|
||||
val indexTagsByCreatedAtAlone: Boolean
|
||||
|
||||
/**
|
||||
* Activate this if you see too many Tag-centric Filters without
|
||||
* kind AND pubkey at the same time.
|
||||
*
|
||||
* This is a rarely used index (reports by your follows or
|
||||
* NIP-04 DMs for instance) that becomes quite large without
|
||||
* major gains.
|
||||
*
|
||||
* Keep in mind that activating too many indexes increases the size of the
|
||||
* DB so much that the indexes themselves won't fit in memory, requiring
|
||||
* frequent reloadings of the index itself from disk.
|
||||
*/
|
||||
val indexTagsWithKindAndPubkey: Boolean
|
||||
|
||||
/**
|
||||
* Activate this to make sure queries are always in order when
|
||||
* the same created_at exists. This will impact performance and
|
||||
* the size of indexes, but it provides results that are compliant
|
||||
* with the Nostr Spec
|
||||
*/
|
||||
val useAndIndexIdOnOrderBy: Boolean
|
||||
|
||||
fun shouldIndex(
|
||||
kind: Int,
|
||||
tag: Tag,
|
||||
): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* By default, we index all tags that have a single letter name and some value
|
||||
*/
|
||||
class DefaultIndexingStrategy(
|
||||
override val indexEventsByCreatedAtAlone: Boolean = false,
|
||||
override val indexTagsByCreatedAtAlone: Boolean = false,
|
||||
override val indexTagsWithKindAndPubkey: Boolean = false,
|
||||
override val useAndIndexIdOnOrderBy: Boolean = false,
|
||||
) : IndexingStrategy {
|
||||
override fun shouldIndex(
|
||||
kind: Int,
|
||||
tag: Tag,
|
||||
) = tag.size >= 2 && tag[0].length == 1
|
||||
}
|
||||
-711
@@ -1,711 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
|
||||
class QueryBuilder(
|
||||
val fts: FullTextSearchModule,
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
val indexStrategy: IndexingStrategy,
|
||||
) {
|
||||
// ------------
|
||||
// Main methods
|
||||
// ------------
|
||||
fun <T : Event> query(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): List<T> = db.runQuery(toSql(filter, hasher(db)))
|
||||
|
||||
fun <T : Event> query(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) = db.runQuery(toSql(filter, hasher(db)), onEach)
|
||||
|
||||
fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): List<T> = db.runQuery(toSql(filters, hasher(db)))
|
||||
|
||||
fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) = db.runQuery(toSql(filters, hasher(db)), onEach)
|
||||
|
||||
// ---------------------------
|
||||
// Raw methods for performance
|
||||
// ---------------------------
|
||||
fun rawQuery(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): List<RawEvent> = db.runRawQuery(toSql(filter, hasher(db)))
|
||||
|
||||
fun rawQuery(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = db.runRawQuery(toSql(filter, hasher(db)), onEach)
|
||||
|
||||
fun rawQuery(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): List<RawEvent> = db.runRawQuery(toSql(filters, hasher(db)))
|
||||
|
||||
fun rawQuery(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = db.runRawQuery(toSql(filters, hasher(db)), onEach)
|
||||
|
||||
// -----------
|
||||
// Debug Tools
|
||||
// -----------
|
||||
fun planQuery(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
db: SQLiteDatabase,
|
||||
): String {
|
||||
val query = toSql(filter, hasher)
|
||||
return db.explainQuery(query.sql, query.args.toTypedArray())
|
||||
}
|
||||
|
||||
fun planQuery(
|
||||
filters: List<Filter>,
|
||||
hasher: TagNameValueHasher,
|
||||
db: SQLiteDatabase,
|
||||
): String {
|
||||
val query = toSql(filters, hasher)
|
||||
return db.explainQuery(query.sql, query.args.toTypedArray())
|
||||
}
|
||||
|
||||
fun toSql(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
): QuerySpec {
|
||||
val newFilter = filter.toFilterWithDTags()
|
||||
|
||||
if (newFilter.isSimpleQuery()) {
|
||||
return makeSimpleQuery(
|
||||
project = true,
|
||||
ids = newFilter.ids,
|
||||
authors = newFilter.authors,
|
||||
kinds = newFilter.kinds,
|
||||
dTags = newFilter.dTags,
|
||||
since = newFilter.since,
|
||||
until = newFilter.until,
|
||||
limit = newFilter.limit,
|
||||
)
|
||||
}
|
||||
|
||||
if (newFilter.isSimpleSearch()) {
|
||||
return makeSimpleSearch(
|
||||
search = newFilter.search!!,
|
||||
ids = newFilter.ids,
|
||||
authors = newFilter.authors,
|
||||
kinds = newFilter.kinds,
|
||||
dTags = newFilter.dTags,
|
||||
since = newFilter.since,
|
||||
until = newFilter.until,
|
||||
limit = newFilter.limit,
|
||||
)
|
||||
}
|
||||
|
||||
val rowIdSubqueries = prepareRowIDSubQueries(filter, hasher)
|
||||
|
||||
return if (rowIdSubqueries == null) {
|
||||
QuerySpec(makeEverythingQuery())
|
||||
} else {
|
||||
QuerySpec(
|
||||
makeQueryIn(rowIdSubqueries.sql),
|
||||
rowIdSubqueries.args,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toSql(
|
||||
filters: List<Filter>,
|
||||
hasher: TagNameValueHasher,
|
||||
): QuerySpec {
|
||||
if (filters.size == 1) return toSql(filters.first(), hasher)
|
||||
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher)
|
||||
|
||||
return if (rowIdSubqueries == null) {
|
||||
QuerySpec(
|
||||
makeEverythingQuery(),
|
||||
emptyList(),
|
||||
)
|
||||
} else {
|
||||
QuerySpec(
|
||||
makeQueryIn(rowIdSubqueries.sql),
|
||||
rowIdSubqueries.args,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}"
|
||||
|
||||
private fun makeQueryIn(rowIdQuery: String) =
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
$rowIdQuery
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}
|
||||
""".trimIndent()
|
||||
|
||||
private fun <T : Event> SQLiteDatabase.runQuery(query: QuerySpec): List<T> =
|
||||
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||
ArrayList<T>(cursor.count).apply {
|
||||
while (cursor.moveToNext()) {
|
||||
add(cursor.toEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.runRawQuery(query: QuerySpec): List<RawEvent> =
|
||||
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||
ArrayList<RawEvent>(cursor.count).apply {
|
||||
while (cursor.moveToNext()) {
|
||||
add(cursor.toRawEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T : Event> SQLiteDatabase.runQuery(
|
||||
query: QuerySpec,
|
||||
onEach: (T) -> Unit,
|
||||
) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
onEach(cursor.toEvent())
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun SQLiteDatabase.runRawQuery(
|
||||
query: QuerySpec,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
onEach(cursor.toRawEvent())
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Event> Cursor.toEvent() =
|
||||
EventFactory.create<T>(
|
||||
getString(0).intern(),
|
||||
getString(1).intern(),
|
||||
getLong(2),
|
||||
getInt(3),
|
||||
OptimizedJsonMapper.fromJsonToTagArray(getString(4)),
|
||||
getString(5),
|
||||
getString(6),
|
||||
)
|
||||
|
||||
private fun Cursor.toRawEvent() =
|
||||
RawEvent(
|
||||
getString(0),
|
||||
getString(1),
|
||||
getLong(2),
|
||||
getInt(3),
|
||||
getString(4),
|
||||
getString(5),
|
||||
getString(6),
|
||||
)
|
||||
|
||||
// --------------
|
||||
// Counts
|
||||
// -------------
|
||||
fun count(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val newFilter = filter.toFilterWithDTags()
|
||||
|
||||
if (newFilter.isSimpleQuery()) {
|
||||
val sql =
|
||||
makeSimpleQuery(
|
||||
project = false,
|
||||
ids = newFilter.ids,
|
||||
authors = newFilter.authors,
|
||||
kinds = newFilter.kinds,
|
||||
dTags = newFilter.dTags,
|
||||
since = newFilter.since,
|
||||
until = newFilter.until,
|
||||
limit = newFilter.limit,
|
||||
)
|
||||
return db.countIn(sql.sql, sql.args)
|
||||
}
|
||||
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return if (rowIdSubQuery == null) {
|
||||
db.countEverything()
|
||||
} else {
|
||||
db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
|
||||
}
|
||||
}
|
||||
|
||||
fun count(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything()
|
||||
|
||||
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers")
|
||||
|
||||
private fun SQLiteDatabase.countIn(
|
||||
rowIdQuery: String,
|
||||
args: List<String>,
|
||||
) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args)
|
||||
|
||||
private fun SQLiteDatabase.runCount(
|
||||
sql: String,
|
||||
args: List<String> = emptyList(),
|
||||
): Int =
|
||||
rawQuery(sql, args.toTypedArray()).use { cursor ->
|
||||
cursor.moveToNext()
|
||||
cursor.getInt(0)
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Deletes
|
||||
// -------------
|
||||
fun delete(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return if (rowIdQuery == null) {
|
||||
0
|
||||
} else {
|
||||
db.runDelete(rowIdQuery.sql, rowIdQuery.args)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0
|
||||
|
||||
return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.runDelete(
|
||||
sql: String,
|
||||
args: List<String> = emptyList(),
|
||||
): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray())
|
||||
|
||||
// ---------------------------------
|
||||
// Prepare unions of all the filters
|
||||
// ---------------------------------
|
||||
fun unionSubqueriesIfNeeded(
|
||||
filters: List<Filter>,
|
||||
hasher: TagNameValueHasher,
|
||||
): QuerySpec? {
|
||||
val inner =
|
||||
filters.mapNotNull { filter ->
|
||||
prepareRowIDSubQueries(filter, hasher)
|
||||
}
|
||||
|
||||
if (inner.isEmpty()) return null
|
||||
|
||||
return if (inner.size == 1) {
|
||||
inner.first()
|
||||
} else {
|
||||
QuerySpec(
|
||||
sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" },
|
||||
args = inner.flatMap { it.args },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TagNameForQuery {
|
||||
class InTags(
|
||||
val tagName: String,
|
||||
) : TagNameForQuery()
|
||||
|
||||
class AllTags(
|
||||
val tagName: String,
|
||||
val tagValueIndex: Int,
|
||||
) : TagNameForQuery()
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Inner row id selections
|
||||
// ----------------------------
|
||||
fun prepareRowIDSubQueries(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
): QuerySpec? {
|
||||
if (filter.isEmpty()) return null
|
||||
|
||||
val mustJoinSearch = (filter.search != null)
|
||||
|
||||
val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap()
|
||||
|
||||
val nonDTagsAll = filter.tagsAll?.filter { it.key != "d" } ?: emptyMap()
|
||||
|
||||
val reverseLookup = nonDTagsIn.isNotEmpty() || nonDTagsAll.isNotEmpty()
|
||||
|
||||
val needHeaders =
|
||||
with(filter) {
|
||||
(ids != null) || (tags != null && tags.containsKey("d"))
|
||||
}
|
||||
|
||||
val hasHeaders =
|
||||
with(filter) {
|
||||
(ids != null) ||
|
||||
(authors != null && authors.isNotEmpty()) ||
|
||||
(kinds != null && kinds.isNotEmpty()) ||
|
||||
(tags != null && tags.containsKey("d")) ||
|
||||
(since != null) ||
|
||||
(until != null) ||
|
||||
(limit != null)
|
||||
}
|
||||
|
||||
var defaultTagKey: TagNameForQuery? = null
|
||||
|
||||
val projection =
|
||||
buildString {
|
||||
// always do tags if there are any
|
||||
if (reverseLookup) {
|
||||
append("SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags")
|
||||
|
||||
// it's quite rare to have 2 tags in the filter, but possible
|
||||
nonDTagsIn.keys.forEachIndexed { index, tagName ->
|
||||
if (defaultTagKey != null) {
|
||||
append(" INNER JOIN event_tags as event_tagsIn$index ON event_tagsIn$index.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn$index.created_at = event_tags.created_at")
|
||||
} else {
|
||||
defaultTagKey = TagNameForQuery.InTags(tagName)
|
||||
}
|
||||
}
|
||||
|
||||
nonDTagsAll.keys.forEachIndexed { index, tagName ->
|
||||
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
|
||||
if (defaultTagKey != null) {
|
||||
append(" INNER JOIN event_tags as event_tagsAll${index}_$valueIndex ON event_tagsAll${index}_$valueIndex.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll${index}_$valueIndex.created_at = event_tags.created_at")
|
||||
} else {
|
||||
defaultTagKey = TagNameForQuery.AllTags(tagName, valueIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needHeaders) {
|
||||
append(" INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id")
|
||||
}
|
||||
|
||||
if (mustJoinSearch) {
|
||||
append(" INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id")
|
||||
}
|
||||
} else if (mustJoinSearch) {
|
||||
append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}")
|
||||
|
||||
if (hasHeaders) {
|
||||
append(" INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
|
||||
}
|
||||
} else {
|
||||
// no tags and no search.
|
||||
append("SELECT event_headers.row_id as row_id FROM event_headers")
|
||||
}
|
||||
}
|
||||
|
||||
val clause =
|
||||
where {
|
||||
// the order should match indexes
|
||||
// ids reduce the filter the most
|
||||
filter.ids?.let { equalsOrIn("event_headers.id", it) }
|
||||
|
||||
// it's quite rare to have 2 tags in the filter, but possible
|
||||
nonDTagsIn.keys.forEachIndexed { index, tagName ->
|
||||
val column =
|
||||
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.InTags && defaultTagKey.tagName == tagName)) {
|
||||
"event_tags.tag_hash"
|
||||
} else {
|
||||
"event_tagsIn$index.tag_hash"
|
||||
}
|
||||
|
||||
equalsOrIn(
|
||||
column,
|
||||
nonDTagsIn[tagName]!!.map {
|
||||
hasher.hash(tagName, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// there are indexes for these, starting with tags.
|
||||
nonDTagsAll.keys.forEachIndexed { index, tagName ->
|
||||
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
|
||||
val column =
|
||||
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.AllTags && defaultTagKey.tagName == tagName && defaultTagKey.tagValueIndex == valueIndex)) {
|
||||
"event_tags.tag_hash"
|
||||
} else {
|
||||
"event_tagsAll${index}_$valueIndex.tag_hash"
|
||||
}
|
||||
|
||||
equals(column, hasher.hash(tagName, tagValue))
|
||||
}
|
||||
}
|
||||
|
||||
// range search is bad but most of the time these are up the top with few elements.
|
||||
if (reverseLookup) {
|
||||
filter.kinds?.let { equalsOrIn("event_tags.kind", it) }
|
||||
filter.authors?.let { equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) }
|
||||
|
||||
filter.since?.let { greaterThanOrEquals("event_tags.created_at", it) }
|
||||
filter.until?.let { lessThanOrEquals("event_tags.created_at", it) }
|
||||
|
||||
// there are indexes for these, starting with tags.
|
||||
filter.tags?.forEach { (tagName, tagValues) ->
|
||||
if (tagName == "d") {
|
||||
equalsOrIn("event_headers.d_tag", tagValues)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filter.kinds?.let { equalsOrIn("event_headers.kind", it) }
|
||||
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
|
||||
|
||||
// there are indexes for these, starting with tags.
|
||||
filter.tags?.forEach { (tagName, tagValues) ->
|
||||
if (tagName == "d") {
|
||||
equalsOrIn("event_headers.d_tag", tagValues)
|
||||
}
|
||||
}
|
||||
|
||||
filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) }
|
||||
filter.until?.let { lessThanOrEquals("event_headers.created_at", it) }
|
||||
|
||||
// no need to add the replaceable because query_by_kind_pubkey_created already covers it
|
||||
val isAllAddressable = filter.kinds?.all { it.isAddressable() } ?: false
|
||||
if (isAllAddressable) {
|
||||
// matches unique index kind >= 30000 AND kind < 40000
|
||||
raw("(event_headers.kind >= 30000 AND event_headers.kind < 40000)")
|
||||
}
|
||||
}
|
||||
|
||||
// if search is included, SQLLite will always start here.
|
||||
filter.search?.let {
|
||||
if (it.isNotBlank()) {
|
||||
match(fts.tableName, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sql =
|
||||
buildString {
|
||||
append(projection)
|
||||
if (clause.conditions.isNotEmpty()) {
|
||||
append(" WHERE ${clause.conditions}")
|
||||
}
|
||||
if (filter.limit != null) {
|
||||
if (reverseLookup) {
|
||||
append(" ORDER BY event_tags.created_at DESC")
|
||||
append(" LIMIT ")
|
||||
append(filter.limit)
|
||||
} else {
|
||||
append(" ORDER BY event_headers.created_at DESC")
|
||||
append(" LIMIT ")
|
||||
append(filter.limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return QuerySpec(sql, clause.args)
|
||||
}
|
||||
|
||||
private fun makeSimpleSearch(
|
||||
search: String,
|
||||
ids: List<HexKey>? = null,
|
||||
authors: List<HexKey>? = null,
|
||||
kinds: List<Kind>? = null,
|
||||
dTags: List<String>? = null,
|
||||
since: Long? = null,
|
||||
until: Long? = null,
|
||||
limit: Int? = null,
|
||||
): QuerySpec {
|
||||
val clause =
|
||||
where {
|
||||
// the order should match indexes
|
||||
// ids reduce the filter the most
|
||||
ids?.let { equalsOrIn("event_headers.id", it) }
|
||||
|
||||
match(fts.tableName, search)
|
||||
|
||||
kinds?.let { equalsOrIn("event_headers.kind", it) }
|
||||
authors?.let { equalsOrIn("event_headers.pubkey", it) }
|
||||
|
||||
// there are indexes for these, starting with tags.
|
||||
dTags?.let { equalsOrIn("event_headers.d_tag", it) }
|
||||
|
||||
since?.let { greaterThanOrEquals("event_headers.created_at", it) }
|
||||
until?.let { lessThanOrEquals("event_headers.created_at", it) }
|
||||
|
||||
// if this is a dTag filter, it is likely that all kinds are addressables
|
||||
// and so force the use of the addressable index
|
||||
if (dTags != null && kinds != null) {
|
||||
if (kinds.all { it.isAddressable() }) {
|
||||
// matches unique index kind >= 30000 AND kind < 40000
|
||||
raw("(event_headers.kind >= 30000 AND kind < 40000)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sql =
|
||||
buildString {
|
||||
append("SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers")
|
||||
append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
|
||||
if (clause.conditions.isNotEmpty()) {
|
||||
append("\nWHERE ${clause.conditions}")
|
||||
}
|
||||
append("\nORDER BY event_headers.created_at DESC")
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
append(", event_headers.id ASC")
|
||||
}
|
||||
if (limit != null) {
|
||||
append("\nLIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
|
||||
return QuerySpec(sql, clause.args)
|
||||
}
|
||||
|
||||
private fun makeSimpleQuery(
|
||||
project: Boolean,
|
||||
ids: List<HexKey>? = null,
|
||||
authors: List<HexKey>? = null,
|
||||
kinds: List<Kind>? = null,
|
||||
dTags: List<String>? = null,
|
||||
since: Long? = null,
|
||||
until: Long? = null,
|
||||
limit: Int? = null,
|
||||
): QuerySpec {
|
||||
val clause =
|
||||
where {
|
||||
// the order should match indexes
|
||||
// ids reduce the filter the most
|
||||
ids?.let { equalsOrIn("id", it) }
|
||||
|
||||
kinds?.let { equalsOrIn("kind", it) }
|
||||
authors?.let { equalsOrIn("pubkey", it) }
|
||||
|
||||
// there are indexes for these, starting with tags.
|
||||
dTags?.let { equalsOrIn("d_tag", it) }
|
||||
|
||||
since?.let { greaterThanOrEquals("created_at", it) }
|
||||
until?.let { lessThanOrEquals("created_at", it) }
|
||||
|
||||
// if this is a dTag filter, it is likely that all kinds are addressables
|
||||
// and so force the use of the addressable index
|
||||
if (dTags != null && kinds != null) {
|
||||
if (kinds.all { it.isAddressable() }) {
|
||||
// matches unique index kind >= 30000 AND kind < 40000
|
||||
raw("(kind >= 30000 AND kind < 40000)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sql =
|
||||
buildString {
|
||||
if (project) {
|
||||
append("SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers")
|
||||
} else {
|
||||
append("SELECT row_id FROM event_headers")
|
||||
}
|
||||
if (clause.conditions.isNotEmpty()) {
|
||||
append("\nWHERE ")
|
||||
append(clause.conditions)
|
||||
}
|
||||
if (project) {
|
||||
append("\nORDER BY created_at DESC")
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
append(", id ASC")
|
||||
}
|
||||
}
|
||||
if (limit != null) {
|
||||
append("\nLIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
|
||||
return QuerySpec(sql, clause.args)
|
||||
}
|
||||
|
||||
class FilterWithDTags(
|
||||
val ids: List<HexKey>? = null,
|
||||
val authors: List<HexKey>? = null,
|
||||
val kinds: List<Kind>? = null,
|
||||
val dTags: List<String>? = null,
|
||||
val nonDTagsIn: Map<String, List<String>>? = null,
|
||||
val nonDTagsAll: Map<String, List<String>>? = null,
|
||||
val since: Long? = null,
|
||||
val until: Long? = null,
|
||||
val limit: Int? = null,
|
||||
val search: String? = null,
|
||||
) {
|
||||
fun isSimpleSearch() =
|
||||
search != null &&
|
||||
search.isNotEmpty() &&
|
||||
(nonDTagsIn == null || nonDTagsIn.isEmpty()) &&
|
||||
(nonDTagsAll == null || nonDTagsAll.isEmpty())
|
||||
|
||||
// can be resolved with just event_headers
|
||||
fun isSimpleQuery() =
|
||||
(nonDTagsIn == null || nonDTagsIn.isEmpty()) &&
|
||||
(nonDTagsAll == null || nonDTagsAll.isEmpty()) &&
|
||||
(search == null || search.isEmpty())
|
||||
}
|
||||
|
||||
fun Filter.toFilterWithDTags(): FilterWithDTags =
|
||||
FilterWithDTags(
|
||||
ids = ids,
|
||||
authors = authors,
|
||||
kinds = kinds,
|
||||
dTags = tags?.get("d") ?: tagsAll?.get("d"),
|
||||
nonDTagsIn = tags?.filter { it.key != "d" }?.ifEmpty { null },
|
||||
nonDTagsAll = tagsAll?.filter { it.key != "d" }?.ifEmpty { null },
|
||||
since = since,
|
||||
until = until,
|
||||
limit = limit,
|
||||
search = search,
|
||||
)
|
||||
|
||||
data class QuerySpec(
|
||||
val sql: String,
|
||||
val args: List<String> = emptyList(),
|
||||
)
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
fun SQLiteEventStore.explainQuery(
|
||||
sql: String,
|
||||
args: Array<Any> = emptyArray(),
|
||||
) = readableDatabase.explainQuery(sql, args.map { it.toString() }.toTypedArray())
|
||||
|
||||
fun SQLiteDatabase.explainQuery(
|
||||
sql: String,
|
||||
args: Array<String> = emptyArray(),
|
||||
): String =
|
||||
rawQuery("EXPLAIN QUERY PLAN $sql", args).use { cursor ->
|
||||
val treeIndex = mutableMapOf<Int, PlanNode>()
|
||||
val rootNodes = mutableListOf<PlanNode>()
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getInt(0)
|
||||
val parentId = cursor.getInt(1)
|
||||
val detail = cursor.getString(3)
|
||||
|
||||
val line = PlanNode(detail)
|
||||
|
||||
treeIndex[id] = line
|
||||
|
||||
val parent = treeIndex[parentId]
|
||||
if (parent != null) {
|
||||
parent.children.add(line)
|
||||
} else {
|
||||
rootNodes.add(line)
|
||||
}
|
||||
}
|
||||
|
||||
buildString {
|
||||
appendLine(populateArgs(sql, args))
|
||||
for (idx in rootNodes.indices) {
|
||||
printNode(rootNodes[idx], "", idx == rootNodes.size - 1, idx > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateArgs(
|
||||
sql: String,
|
||||
args: Array<String> = emptyArray(),
|
||||
): String {
|
||||
var result = sql
|
||||
args.forEach {
|
||||
result = result.replaceFirst("?", "\"$it\"")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun StringBuilder.printNode(
|
||||
node: PlanNode,
|
||||
prefix: String,
|
||||
isLast: Boolean,
|
||||
newLine: Boolean,
|
||||
) {
|
||||
if (newLine) append('\n')
|
||||
append(prefix)
|
||||
append(if (isLast) "└── " else "├── ")
|
||||
append(node.detail)
|
||||
|
||||
val newPrefix = prefix + if (isLast) " " else "│ "
|
||||
for (i in node.children.indices) {
|
||||
printNode(node.children[i], newPrefix, i == node.children.size - 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
data class PlanNode(
|
||||
val detail: String,
|
||||
val children: MutableList<PlanNode> = mutableListOf(),
|
||||
)
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
class ReplaceableModule : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE UNIQUE INDEX replaceable_idx
|
||||
ON event_headers (kind, pubkey)
|
||||
WHERE (kind IN (0, 3)) OR (kind >= 10000 AND kind < 20000)
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// deletes older addressables when inserting new ones
|
||||
// if a newer addressable is inserted the unique index
|
||||
// above will be triggered. Delete cascade will take
|
||||
// care of the event_tags table
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER delete_older_replaceable_event
|
||||
BEFORE INSERT ON event_headers
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.kind IN (0, 3)) OR (NEW.kind >= 10000 AND NEW.kind < 20000)
|
||||
BEGIN
|
||||
-- Delete older records if this is the newest
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.created_at < NEW.created_at;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {}
|
||||
}
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
|
||||
class RightToVanishModule(
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
) : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE event_vanish (
|
||||
event_header_row_id INTEGER PRIMARY KEY NOT NULL,
|
||||
pubkey_hash INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey_hash)")
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER delete_older_event_vanish
|
||||
BEFORE INSERT ON event_vanish
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
-- Delete older records if this is the newest
|
||||
DELETE FROM event_vanish
|
||||
WHERE
|
||||
event_vanish.pubkey_hash = NEW.pubkey_hash AND
|
||||
event_vanish.created_at < NEW.created_at;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER delete_events_on_event_vanish
|
||||
AFTER INSERT ON event_vanish
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DELETE FROM event_headers
|
||||
WHERE event_headers.created_at < NEW.created_at AND
|
||||
event_headers.pubkey_owner_hash = NEW.pubkey_hash;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// reject new events inside a right to vanish request
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER reject_events_on_event_vanish
|
||||
BEFORE INSERT ON event_headers
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'blocked: a request to vanish event exists')
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM event_vanish
|
||||
WHERE
|
||||
event_vanish.pubkey_hash = NEW.pubkey_owner_hash AND
|
||||
event_vanish.created_at >= NEW.created_at
|
||||
);
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {
|
||||
db.execSQL("DROP TABLE IF EXISTS event_vanish")
|
||||
}
|
||||
|
||||
val insertRTV =
|
||||
"""
|
||||
INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey_hash, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
fun insert(
|
||||
event: Event,
|
||||
relayUrl: String?,
|
||||
headerId: Long,
|
||||
db: SQLiteDatabase,
|
||||
) {
|
||||
if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) {
|
||||
val stmt = db.compileStatement(insertRTV)
|
||||
stmt.bindLong(1, headerId)
|
||||
stmt.bindLong(2, hasher(db).hash(event.pubKey))
|
||||
stmt.bindLong(3, event.createdAt)
|
||||
stmt.executeInsert()
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {
|
||||
db.execSQL("DELETE FROM event_vanish")
|
||||
}
|
||||
}
|
||||
-257
@@ -1,257 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import androidx.core.database.sqlite.transaction
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SQLiteEventStore(
|
||||
val context: Context,
|
||||
val dbName: String? = "events.db",
|
||||
val relayUrl: String? = null,
|
||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) {
|
||||
companion object {
|
||||
const val DATABASE_VERSION = 2
|
||||
}
|
||||
|
||||
val seedModule = SeedModule()
|
||||
|
||||
val fullTextSearchModule = FullTextSearchModule()
|
||||
val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy)
|
||||
|
||||
val replaceableModule = ReplaceableModule()
|
||||
val addressableModule = AddressableModule()
|
||||
val ephemeralModule = EphemeralModule()
|
||||
|
||||
val deletionModule = DeletionRequestModule(seedModule::hasher)
|
||||
val expirationModule = ExpirationModule()
|
||||
val rightToVanishModule = RightToVanishModule(seedModule::hasher)
|
||||
|
||||
val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy)
|
||||
|
||||
val modules =
|
||||
listOf(
|
||||
seedModule,
|
||||
eventIndexModule,
|
||||
replaceableModule,
|
||||
addressableModule,
|
||||
ephemeralModule,
|
||||
deletionModule,
|
||||
expirationModule,
|
||||
rightToVanishModule,
|
||||
fullTextSearchModule,
|
||||
)
|
||||
|
||||
override fun onConfigure(db: SQLiteDatabase) {
|
||||
super.onConfigure(db)
|
||||
|
||||
// 32MB memory cache
|
||||
db.execSQL("PRAGMA cache_size=-32000;")
|
||||
|
||||
// makes sure the FKs are sane
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
|
||||
// SQLite implements mutations by appending them to a log, which it occasionally
|
||||
// compacts into the database. This is called Write-Ahead Logging (WAL)
|
||||
db.enableWriteAheadLogging()
|
||||
|
||||
// The DB can be corrupted if the OS is shutdown before sync, which generally
|
||||
// doesn't happen on Android
|
||||
db.execSQL("PRAGMA synchronous = OFF;")
|
||||
}
|
||||
|
||||
fun dbSizeMB(): Int {
|
||||
val f1 = context.getDatabasePath(dbName)
|
||||
val f2 = context.getDatabasePath("$dbName-wal")
|
||||
val total = f1.length() + f2.length()
|
||||
return (total / (1024 * 1024)).toInt()
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
modules.forEach {
|
||||
it.create(db)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpgrade(
|
||||
db: SQLiteDatabase,
|
||||
oldVersion: Int,
|
||||
newVersion: Int,
|
||||
) {
|
||||
// Handle all intermediate versions
|
||||
for (version in oldVersion..<newVersion) {
|
||||
when (version) {
|
||||
1 -> {
|
||||
// Upgrade from version 1 to 2
|
||||
// We changed event_tags to use a probabilistic hash as tag
|
||||
modules.reversed().forEach { it.drop(db) }
|
||||
modules.forEach { it.create(db) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDB() {
|
||||
val db = writableDatabase
|
||||
modules.reversed().forEach { it.deleteAll(db) }
|
||||
}
|
||||
|
||||
suspend fun vacuum() {
|
||||
// 1. ANALYZE: Collects statistics about tables and indices
|
||||
// to help the query planner optimize queries.
|
||||
withContext(Dispatchers.IO) {
|
||||
writableDatabase.execSQL("VACUUM")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun analyse() {
|
||||
// 2. VACUUM: Rebuilds the database file, reclaiming unused space
|
||||
// and reducing fragmentation.
|
||||
withContext(Dispatchers.IO) {
|
||||
writableDatabase.execSQL("ANALYZE")
|
||||
}
|
||||
}
|
||||
|
||||
private fun innerInsertEvent(
|
||||
event: Event,
|
||||
db: SQLiteDatabase,
|
||||
) {
|
||||
val headerId = eventIndexModule.insert(event, db)
|
||||
deletionModule.insert(event, db)
|
||||
expirationModule.insert(event, headerId, db)
|
||||
fullTextSearchModule.insert(event, headerId, db)
|
||||
rightToVanishModule.insert(event, relayUrl, headerId, db)
|
||||
}
|
||||
|
||||
fun insertEvent(event: Event): Boolean {
|
||||
if (event.isExpired()) throw SQLiteConstraintException("blocked: Cannot insert an expired event")
|
||||
if (event.kind.isEphemeral()) return false
|
||||
|
||||
writableDatabase.transaction {
|
||||
innerInsertEvent(event, this)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
inner class Transaction(
|
||||
val db: SQLiteDatabase,
|
||||
) : IEventStore.ITransaction {
|
||||
override fun insert(event: Event): Boolean {
|
||||
if (event.isExpired()) throw SQLiteConstraintException("blocked: Cannot insert an expired event")
|
||||
if (event.kind.isEphemeral()) return false
|
||||
|
||||
innerInsertEvent(event, db)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun transaction(body: Transaction.() -> Unit) {
|
||||
writableDatabase.transaction {
|
||||
with(Transaction(this)) {
|
||||
body()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Event> query(filter: Filter): List<T> = queryBuilder.query(filter, readableDatabase)
|
||||
|
||||
fun <T : Event> query(filters: List<Filter>): List<T> = queryBuilder.query(filters, readableDatabase)
|
||||
|
||||
fun <T : Event> query(
|
||||
filter: Filter,
|
||||
onEach: (T) -> Unit,
|
||||
) = queryBuilder.query(filter, readableDatabase, onEach)
|
||||
|
||||
fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
onEach: (T) -> Unit,
|
||||
) = queryBuilder.query(filters, readableDatabase, onEach)
|
||||
|
||||
fun rawQuery(filter: Filter): List<RawEvent> = queryBuilder.rawQuery(filter, readableDatabase)
|
||||
|
||||
fun rawQuery(filters: List<Filter>): List<RawEvent> = queryBuilder.rawQuery(filters, readableDatabase)
|
||||
|
||||
fun rawQuery(
|
||||
filter: Filter,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = queryBuilder.rawQuery(filter, readableDatabase, onEach)
|
||||
|
||||
fun rawQuery(
|
||||
filters: List<Filter>,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = queryBuilder.rawQuery(filters, readableDatabase, onEach)
|
||||
|
||||
fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(readableDatabase), readableDatabase)
|
||||
|
||||
fun planQuery(filters: List<Filter>) = queryBuilder.planQuery(filters, seedModule.hasher(readableDatabase), readableDatabase)
|
||||
|
||||
fun count(filter: Filter): Int = queryBuilder.count(filter, readableDatabase)
|
||||
|
||||
fun count(filters: List<Filter>): Int = queryBuilder.count(filters, readableDatabase)
|
||||
|
||||
fun delete(filter: Filter) {
|
||||
queryBuilder.delete(filter, writableDatabase)
|
||||
}
|
||||
|
||||
fun delete(filters: List<Filter>) {
|
||||
queryBuilder.delete(filters, writableDatabase)
|
||||
}
|
||||
|
||||
fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id))
|
||||
|
||||
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase)
|
||||
}
|
||||
|
||||
class RawEvent(
|
||||
val id: HexKey,
|
||||
val pubKey: HexKey,
|
||||
val createdAt: Long,
|
||||
val kind: Kind,
|
||||
val jsonTags: String,
|
||||
val content: String,
|
||||
val sig: HexKey,
|
||||
) {
|
||||
fun <T : Event> toEvent() =
|
||||
EventFactory.create<T>(
|
||||
id.intern(),
|
||||
pubKey.intern(),
|
||||
createdAt,
|
||||
kind,
|
||||
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
|
||||
content,
|
||||
sig,
|
||||
)
|
||||
}
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
class SeedModule : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE seeds (seed_value INTEGER)")
|
||||
|
||||
val insertSeed = "INSERT INTO seeds (seed_value) VALUES (?)"
|
||||
|
||||
val stmt = db.compileStatement(insertSeed)
|
||||
stmt.bindLong(1, RandomInstance.long())
|
||||
stmt.executeInsert()
|
||||
|
||||
// Prevent updates to maintain immutability
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER block_insert_seeds
|
||||
BEFORE INSERT ON seeds
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Inserts are not allowed on this table');
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER block_update_seeds
|
||||
BEFORE UPDATE ON seeds
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Inserts are not allowed on this table');
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER block_delete_seeds
|
||||
BEFORE DELETE ON seeds
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Inserts are not allowed on this table');
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun getSeed(db: SQLiteDatabase): Long =
|
||||
db.rawQuery("SELECT seed_value FROM seeds LIMIT 1", null).use {
|
||||
it.moveToFirst()
|
||||
it.getLong(0)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {
|
||||
db.execSQL("DROP TABLE IF EXISTS seeds")
|
||||
}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {}
|
||||
|
||||
private var hasherCache: TagNameValueHasher? = null
|
||||
|
||||
fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(getSeed(db)).also { hasherCache = it }
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.bloom.MurmurHash3
|
||||
|
||||
/**
|
||||
* The seed is used to compute the hash of the Key, which
|
||||
* becomes the seed for the hash of the value
|
||||
*/
|
||||
class TagNameValueHasher(
|
||||
val seed: Long,
|
||||
) {
|
||||
val hasher = MurmurHash3()
|
||||
|
||||
// small performance improvements on inserting
|
||||
val pTagHash by lazy {
|
||||
hasher.hash128x64Half("p".encodeToByteArray(), seed)
|
||||
}
|
||||
val eTagHash by lazy {
|
||||
hasher.hash128x64Half("e".encodeToByteArray(), seed)
|
||||
}
|
||||
val aTagHash by lazy {
|
||||
hasher.hash128x64Half("a".encodeToByteArray(), seed)
|
||||
}
|
||||
|
||||
fun hash(
|
||||
key: ByteArray,
|
||||
value: ByteArray,
|
||||
) = hasher.hash128x64Half(value, hasher.hash128x64Half(key, seed))
|
||||
|
||||
/*
|
||||
* We tried caching these values to avoid recomputation of the hash
|
||||
* But caching on a LruCache<String, Long> is slower than recomputing
|
||||
*/
|
||||
fun hash(
|
||||
key: String,
|
||||
value: String,
|
||||
) = hash(key.encodeToByteArray(), value.encodeToByteArray())
|
||||
|
||||
fun hashATag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), aTagHash)
|
||||
|
||||
fun hashETag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), eTagHash)
|
||||
|
||||
fun hashPTag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), pTagHash)
|
||||
|
||||
fun hash(value: HexKey) = hasher.hash128x64Half(value.encodeToByteArray(), seed)
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
|
||||
|
||||
sealed class Condition {
|
||||
data class Raw(
|
||||
val condition: String,
|
||||
) : Condition()
|
||||
|
||||
data class Equals(
|
||||
val column: String,
|
||||
val value: Any?,
|
||||
) : Condition()
|
||||
|
||||
data class NotEquals(
|
||||
val column: String,
|
||||
val value: Any?,
|
||||
) : Condition()
|
||||
|
||||
data class GreaterThan(
|
||||
val column: String,
|
||||
val value: Any,
|
||||
) : Condition()
|
||||
|
||||
data class GreaterThanOrEquals(
|
||||
val column: String,
|
||||
val value: Any,
|
||||
) : Condition()
|
||||
|
||||
data class LessThan(
|
||||
val column: String,
|
||||
val value: Any,
|
||||
) : Condition()
|
||||
|
||||
data class LessThanOrEquals(
|
||||
val column: String,
|
||||
val value: Any,
|
||||
) : Condition()
|
||||
|
||||
data class Like(
|
||||
val column: String,
|
||||
val value: String,
|
||||
) : Condition()
|
||||
|
||||
data class Match(
|
||||
val table: String,
|
||||
val value: String,
|
||||
) : Condition()
|
||||
|
||||
data class IsNull(
|
||||
val column: String,
|
||||
) : Condition()
|
||||
|
||||
data class IsNotNull(
|
||||
val column: String,
|
||||
) : Condition()
|
||||
|
||||
data class In(
|
||||
val column: String,
|
||||
val values: List<Any>,
|
||||
) : Condition()
|
||||
|
||||
data class And(
|
||||
val conditions: List<Condition>,
|
||||
) : Condition()
|
||||
|
||||
data class Or(
|
||||
val conditions: List<Condition>,
|
||||
) : Condition()
|
||||
|
||||
class Empty : Condition()
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
|
||||
|
||||
class SqlSelectionBuilder(
|
||||
private val condition: Condition,
|
||||
) {
|
||||
private val selectionArgs = mutableListOf<String>()
|
||||
|
||||
fun build(): WhereClause {
|
||||
selectionArgs.clear() // Clear previous args for a fresh build
|
||||
val conditions = buildCondition(condition)
|
||||
return WhereClause(conditions, selectionArgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively builds the SQL string for a given condition.
|
||||
* @param cond The [Condition] to build the SQL string for.
|
||||
* @return The SQL string representation of the condition.
|
||||
*/
|
||||
private fun buildCondition(cond: Condition): String =
|
||||
when (cond) {
|
||||
is Condition.Empty -> {
|
||||
""
|
||||
}
|
||||
|
||||
is Condition.Raw -> {
|
||||
cond.condition
|
||||
}
|
||||
|
||||
is Condition.Equals -> {
|
||||
if (cond.value == null) {
|
||||
"${cond.column} IS NULL"
|
||||
} else {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} = ?"
|
||||
}
|
||||
}
|
||||
|
||||
is Condition.NotEquals -> {
|
||||
if (cond.value == null) {
|
||||
"${cond.column} IS NULL"
|
||||
} else {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} != ?"
|
||||
}
|
||||
}
|
||||
|
||||
is Condition.GreaterThan -> {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} > ?"
|
||||
}
|
||||
|
||||
is Condition.GreaterThanOrEquals -> {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} >= ?"
|
||||
}
|
||||
|
||||
is Condition.LessThan -> {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} < ?"
|
||||
}
|
||||
|
||||
is Condition.LessThanOrEquals -> {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} <= ?"
|
||||
}
|
||||
|
||||
is Condition.Like -> {
|
||||
selectionArgs.add(cond.value)
|
||||
"${cond.column} LIKE ?"
|
||||
}
|
||||
|
||||
is Condition.Match -> {
|
||||
selectionArgs.add(cond.value)
|
||||
"${cond.table} MATCH ?"
|
||||
}
|
||||
|
||||
is Condition.IsNull -> {
|
||||
"${cond.column} IS NULL"
|
||||
}
|
||||
|
||||
is Condition.IsNotNull -> {
|
||||
"${cond.column} IS NOT NULL"
|
||||
}
|
||||
|
||||
is Condition.In -> {
|
||||
if (cond.values.isEmpty()) {
|
||||
// Handle empty IN clause gracefully, perhaps by making it always false
|
||||
// or throwing an error, depending on desired behavior.
|
||||
// For now, let's make it an always false condition to avoid SQL errors.
|
||||
"1 = 0" // Always false
|
||||
} else {
|
||||
val placeholders = cond.values.joinToString(", ") { "?" }
|
||||
cond.values.forEach { selectionArgs.add(it.toString()) }
|
||||
"${cond.column} IN ($placeholders)"
|
||||
}
|
||||
}
|
||||
|
||||
is Condition.And -> {
|
||||
if (cond.conditions.isEmpty()) {
|
||||
"1 = 1" // Always true for an empty AND
|
||||
} else {
|
||||
cond.conditions.joinToString(" AND ") { "(${buildCondition(it)})" }
|
||||
}
|
||||
}
|
||||
|
||||
is Condition.Or -> {
|
||||
if (cond.conditions.isEmpty()) {
|
||||
"1 = 0" // Always false for an empty OR
|
||||
} else {
|
||||
cond.conditions.joinToString(" OR ") { "(${buildCondition(it)})" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
|
||||
|
||||
class WhereClauseBuilder {
|
||||
private val conditions = mutableListOf<Condition>()
|
||||
|
||||
fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) }
|
||||
|
||||
fun equals(
|
||||
column: String,
|
||||
value: Any?,
|
||||
) = apply { conditions.add(Condition.Equals(column, value)) }
|
||||
|
||||
fun notEquals(
|
||||
column: String,
|
||||
value: Any?,
|
||||
) = apply { conditions.add(Condition.NotEquals(column, value)) }
|
||||
|
||||
fun greaterThan(
|
||||
column: String,
|
||||
value: Any,
|
||||
) = apply { conditions.add(Condition.GreaterThan(column, value)) }
|
||||
|
||||
fun greaterThanOrEquals(
|
||||
column: String,
|
||||
value: Any,
|
||||
) = apply { conditions.add(Condition.GreaterThanOrEquals(column, value)) }
|
||||
|
||||
fun lessThan(
|
||||
column: String,
|
||||
value: Any,
|
||||
) = apply { conditions.add(Condition.LessThan(column, value)) }
|
||||
|
||||
fun lessThanOrEquals(
|
||||
column: String,
|
||||
value: Any,
|
||||
) = apply { conditions.add(Condition.LessThanOrEquals(column, value)) }
|
||||
|
||||
fun like(
|
||||
column: String,
|
||||
pattern: String,
|
||||
) = apply { conditions.add(Condition.Like(column, pattern)) }
|
||||
|
||||
fun match(
|
||||
table: String,
|
||||
pattern: String,
|
||||
) = apply { conditions.add(Condition.Match(table, pattern)) }
|
||||
|
||||
fun isNull(column: String) = apply { conditions.add(Condition.IsNull(column)) }
|
||||
|
||||
fun isNotNull(column: String) = apply { conditions.add(Condition.IsNotNull(column)) }
|
||||
|
||||
fun isIn(
|
||||
column: String,
|
||||
values: List<Any>,
|
||||
) = apply { conditions.add(Condition.In(column, values)) }
|
||||
|
||||
fun equalsOrIn(
|
||||
column: String,
|
||||
values: List<Any>,
|
||||
) = apply {
|
||||
if (values.size == 1) {
|
||||
equals(column, values.first())
|
||||
} else {
|
||||
isIn(column, values)
|
||||
}
|
||||
}
|
||||
|
||||
fun and(block: WhereClauseBuilder.() -> Unit) =
|
||||
apply {
|
||||
val builder = WhereClauseBuilder().apply(block)
|
||||
val builtCondition = builder.buildAnd()
|
||||
if (builtCondition != null) {
|
||||
conditions.add(builtCondition)
|
||||
}
|
||||
}
|
||||
|
||||
fun or(block: WhereClauseBuilder.() -> Unit) =
|
||||
apply {
|
||||
val builder = WhereClauseBuilder().apply(block)
|
||||
val builtCondition = builder.buildOr()
|
||||
if (builtCondition != null) {
|
||||
conditions.add(builtCondition)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildAnd(): Condition? =
|
||||
when (conditions.size) {
|
||||
0 -> null
|
||||
1 -> conditions.first()
|
||||
else -> Condition.And(conditions.toList())
|
||||
}
|
||||
|
||||
fun buildOr(): Condition? =
|
||||
when (conditions.size) {
|
||||
0 -> null
|
||||
1 -> conditions.first()
|
||||
else -> Condition.Or(conditions.toList())
|
||||
}
|
||||
}
|
||||
|
||||
fun where(block: WhereClauseBuilder.() -> Unit): WhereClause {
|
||||
val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.Empty()
|
||||
return SqlSelectionBuilder(condition).build()
|
||||
}
|
||||
|
||||
class WhereClause(
|
||||
val conditions: String,
|
||||
val args: List<String>,
|
||||
)
|
||||
+28
-2
@@ -20,12 +20,38 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils
|
||||
|
||||
import com.goterl.lazysodium.LazySodium
|
||||
import com.goterl.lazysodium.LazySodiumAndroid
|
||||
import com.goterl.lazysodium.Sodium
|
||||
import com.goterl.lazysodium.SodiumAndroid
|
||||
|
||||
actual object LibSodiumInstance {
|
||||
private val libSodium = SodiumAndroid()
|
||||
private val lazySodium = LazySodiumAndroid(libSodium)
|
||||
private val libSodium: Sodium =
|
||||
try {
|
||||
// If we are running in a host test, SodiumJava might be available.
|
||||
// SodiumJava uses a ResourceLoader to find the dylib/so/dll in the jar.
|
||||
Class
|
||||
.forName("com.goterl.lazysodium.SodiumJava")
|
||||
.getConstructor()
|
||||
.newInstance() as Sodium
|
||||
} catch (_: Exception) {
|
||||
SodiumAndroid()
|
||||
}
|
||||
|
||||
private val lazySodium: LazySodium =
|
||||
if (libSodium is SodiumAndroid) {
|
||||
LazySodiumAndroid(libSodium)
|
||||
} else {
|
||||
// this should only happen on test cases
|
||||
val sodiumJava =
|
||||
Class
|
||||
.forName("com.goterl.lazysodium.SodiumJava")
|
||||
|
||||
Class
|
||||
.forName("com.goterl.lazysodium.LazySodiumJava")
|
||||
.getConstructor(sodiumJava)
|
||||
.newInstance(libSodium) as LazySodium
|
||||
}
|
||||
|
||||
actual fun cryptoAeadXChaCha20Poly1305IetfDecrypt(
|
||||
message: ByteArray,
|
||||
|
||||
Reference in New Issue
Block a user