diff --git a/README.md b/README.md index fb78c87..cb3060d 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,6 @@ Both docs exist: 2 after the transaction In case of an exception, there is a rollback. ```kotlin - // rollbacks happen if there are exceptions val another = MyModel( title = "Transactional", diff --git a/build.gradle.kts b/build.gradle.kts index dd99122..7b73a58 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -60,6 +60,16 @@ configure { removeContainers = true useComposeFiles = listOf("docker-compose.yml") setProjectName("pg-docstore") + listOf("/usr/bin/docker","/usr/local/bin/docker").firstOrNull { + File(it).exists() + }?.let { docker -> + // works around an issue where the docker + // command is not found + // falls back to the default, which may work on + // some platforms + dockerExecutable.set(docker) + } + } val sourcesJar by tasks.registering(Jar::class) { diff --git a/src/main/kotlin/com/tryformation/pgdocstore/DocStore.kt b/src/main/kotlin/com/tryformation/pgdocstore/DocStore.kt index 7ca5c3e..f7d2aeb 100644 --- a/src/main/kotlin/com/tryformation/pgdocstore/DocStore.kt +++ b/src/main/kotlin/com/tryformation/pgdocstore/DocStore.kt @@ -30,6 +30,11 @@ data class DocStoreEntry( val similarity: Float? = null ) +enum class BooleanOperator { + OR, + AND +} + fun String.sanitizeInputForDB(): String { // Define a regular expression for disallowed characters or strings // For example, this regex will remove single quotes, double quotes, semicolons, and SQL comment syntax @@ -66,7 +71,7 @@ val RowData.docStoreEntry }, text = getString(DocStoreEntry::text.name), // only there on searches with a text - similarity = this.size.takeIf { it > 6}?.let { getFloat("rank") }, + similarity = this.size.takeIf { it > 6 }?.let { getFloat("rank") }, ) class DocStore( @@ -180,7 +185,7 @@ class DocStore( * Retrieve multiple documents by their [ids]. */ suspend fun multiGetById(ids: List): List { - return if(ids.isEmpty()) { + return if (ids.isEmpty()) { emptyList() } else { connection.sendPreparedStatement( @@ -457,15 +462,17 @@ class DocStore( * from the specified [offset] * * You can optionally constrain - * the query with [tags]. If you set [orTags] to true, a logical OR will be used. + * the query with [tags]. If you set [whereClauseOperator] to OR, + * a logical OR will be used instead of the default AND. * * You can also specify a text [query]. In that case the results will be ordered by * their ranking. You can use [similarityThreshold] to control how strict it matches. */ suspend fun documentsByRecency( tags: List = emptyList(), - orTags: Boolean = false, query: String? = null, + tagsClauseOperator: BooleanOperator = BooleanOperator.AND, + whereClauseOperator: BooleanOperator = BooleanOperator.AND, limit: Int = 100, offset: Int = 0, similarityThreshold: Double = 0.1, @@ -473,9 +480,10 @@ class DocStore( val q = constructQuery( tags = tags, query = query, - orTags = orTags, + tagsClauseOperator = tagsClauseOperator, limit = limit, offset = offset, + whereClauseOperator = whereClauseOperator, similarityThreshold = similarityThreshold ) return connection.sendPreparedStatement(q, tags + listOfNotNull(query)).let { result -> @@ -491,8 +499,9 @@ class DocStore( */ suspend fun entriesByRecency( tags: List = emptyList(), - orTags: Boolean = false, query: String? = null, + tagsClauseOperator: BooleanOperator = BooleanOperator.AND, + whereClauseOperator: BooleanOperator = BooleanOperator.AND, limit: Int = 100, offset: Int = 0, similarityThreshold: Double = 0.1, @@ -500,9 +509,10 @@ class DocStore( val q = constructQuery( tags = tags, query = query, - orTags = orTags, + tagsClauseOperator = tagsClauseOperator, limit = limit, offset = offset, + whereClauseOperator = whereClauseOperator, similarityThreshold = similarityThreshold ) return connection.sendPreparedStatement(q, tags + listOfNotNull(query)).let { result -> @@ -521,23 +531,25 @@ class DocStore( * You can use this to efficiently process all documents in your store. * * You can optionally constrain - * the query with [tags]. If you set [orTags] to true, a logical OR will be used - * and otherwise it defaults to an AND. + * the query with [tags]. If you set [whereClauseOperator] to OR, + * a logical OR will be used instead of the default AND. * * You can also specify a text [query]. In that case the results will be ordered by * their ranking. You can use [similarityThreshold] to control how strict it matches. */ suspend fun documentsByRecencyScrolling( tags: List = emptyList(), - orTags: Boolean = false, query: String? = null, - fetchSize: Int = 100, + tagsClauseOperator: BooleanOperator = BooleanOperator.AND, + whereClauseOperator: BooleanOperator = BooleanOperator.AND, similarityThreshold: Double = 0.1, + fetchSize: Int = 100, ): Flow { val q = constructQuery( tags = tags, query = query, - orTags = orTags, + tagsClauseOperator = tagsClauseOperator, + whereClauseOperator = whereClauseOperator, similarityThreshold = similarityThreshold ) return queryFlow( @@ -558,8 +570,9 @@ class DocStore( */ suspend fun entriesByRecencyScrolling( tags: List = emptyList(), - orTags: Boolean = false, query: String? = null, + tagsClauseOperator: BooleanOperator = BooleanOperator.AND, + whereClauseOperator: BooleanOperator = BooleanOperator.AND, fetchSize: Int = 100, similarityThreshold: Double = 0.1, ): Flow { @@ -567,7 +580,8 @@ class DocStore( query = constructQuery( tags = tags, query = query, - orTags = orTags, + tagsClauseOperator = tagsClauseOperator, + whereClauseOperator = whereClauseOperator, similarityThreshold = similarityThreshold ), // query is used in select and then once more in the where @@ -581,7 +595,8 @@ class DocStore( private fun constructQuery( tags: List, query: String?, - orTags: Boolean, + tagsClauseOperator: BooleanOperator = BooleanOperator.AND, + whereClauseOperator: BooleanOperator = BooleanOperator.AND, limit: Int? = null, offset: Int = 0, similarityThreshold: Double = 0.01 @@ -599,7 +614,7 @@ class DocStore( tags.takeIf { it.isNotEmpty() } ?.let { tags.joinToString( - if (orTags) " OR " else " AND " + " $tagsClauseOperator " ) { "? = ANY(tags)" } } ?.let { @@ -609,7 +624,7 @@ class DocStore( query?.takeIf { q -> q.isNotBlank() }?.let { """similarity(text, ?) > $similarityThreshold""" } - ).joinToString(" AND ") + ).joinToString(" $whereClauseOperator ") } diff --git a/src/test/kotlin/com/tryformation/pgdocstore/TaggingTest.kt b/src/test/kotlin/com/tryformation/pgdocstore/TaggingTest.kt index 2a7c778..6a46b42 100644 --- a/src/test/kotlin/com/tryformation/pgdocstore/TaggingTest.kt +++ b/src/test/kotlin/com/tryformation/pgdocstore/TaggingTest.kt @@ -31,7 +31,7 @@ class TaggingTest : DbTestBase() { ds.entriesByRecency(listOf("foo")).count() shouldBe 2 ds.entriesByRecencyScrolling(listOf("foo")).count() shouldBe 2 ds.documentsByRecencyScrolling(listOf("foo", "bar")).count() shouldBe 1 - ds.documentsByRecencyScrolling(listOf("foo", "bar"), orTags = true).count() shouldBe 3 + ds.documentsByRecencyScrolling(listOf("foo", "bar"), tagsClauseOperator = BooleanOperator.OR).count() shouldBe 3 } } \ No newline at end of file diff --git a/src/test/kotlin/com/tryformation/pgdocstore/TextSearchTest.kt b/src/test/kotlin/com/tryformation/pgdocstore/TextSearchTest.kt index a7426f0..5b26b63 100644 --- a/src/test/kotlin/com/tryformation/pgdocstore/TextSearchTest.kt +++ b/src/test/kotlin/com/tryformation/pgdocstore/TextSearchTest.kt @@ -12,6 +12,7 @@ class SearchableModel( val title: String, val description: String? = null, val id: String = UUID.randomUUID().toString(), + val tags: List = emptyList() ) class TextSearchTest : DbTestBase() { @@ -50,7 +51,7 @@ class TextSearchTest : DbTestBase() { @Test fun shouldRankCorrectly() = coRun { - val ds = DocStore( + val ds = DocStore( db, SearchableModel.serializer(), tableName, @@ -80,9 +81,52 @@ class TextSearchTest : DbTestBase() { it shouldHaveSize 0 } } + + @Test + fun shouldDoANDorOR() = coRun { + val ds = DocStore( + db, + SearchableModel.serializer(), + tableName, + textExtractor = { listOfNotNull(it.title, it.description).joinToString("\n") }, + tagExtractor = SearchableModel::tags + ) + ds.bulkInsert( + listOf( + SearchableModel("Document Numero Uno", tags = listOf("foo", "bar")), + SearchableModel("The second one", tags = listOf("foo")), + SearchableModel("Another Document", tags = listOf("bar")), + ) + ) + ds.documentsByRecency( + tags = listOf("foo"), + tagsClauseOperator = BooleanOperator.AND, + query = "Another", + whereClauseOperator = BooleanOperator.AND, + ) shouldHaveSize 0 + ds.documentsByRecency( + tags = listOf("foo"), + tagsClauseOperator = BooleanOperator.AND, + query = "Another", + whereClauseOperator = BooleanOperator.OR, + ) shouldHaveSize 3 + ds.documentsByRecency( + tags = listOf("foo", "foobar"), + tagsClauseOperator = BooleanOperator.AND, + query = "Another", + whereClauseOperator = BooleanOperator.AND, + ) shouldHaveSize 0 + ds.documentsByRecency( + tags = listOf("foo", "foobar"), + tagsClauseOperator = BooleanOperator.OR, + query = "Document", + whereClauseOperator = BooleanOperator.AND, + ) shouldHaveSize 1 + + } } -private suspend fun DocStore<*>.search(query: String, similarityThreshold:Double = 0.1) = +private suspend fun DocStore<*>.search(query: String, similarityThreshold: Double = 0.1) = entriesByRecency(query = query, similarityThreshold = similarityThreshold).also { println("Found for '$query' with threshold $similarityThreshold:") it.forEach { e -> diff --git a/src/test/kotlin/com/tryformation/pgdocstore/docs/DocGenerationTest.kt b/src/test/kotlin/com/tryformation/pgdocstore/docs/DocGenerationTest.kt index d2ef287..1b2c71f 100644 --- a/src/test/kotlin/com/tryformation/pgdocstore/docs/DocGenerationTest.kt +++ b/src/test/kotlin/com/tryformation/pgdocstore/docs/DocGenerationTest.kt @@ -392,7 +392,6 @@ val readmeMd = sourceGitRepository.md { """.trimIndent() example { - // rollbacks happen if there are exceptions val another = MyModel( title = "Transactional", diff --git a/versions.properties b/versions.properties index d29cb5e..d47c41d 100644 --- a/versions.properties +++ b/versions.properties @@ -15,7 +15,7 @@ version.ch.qos.logback..logback-classic=1.4.14 version.com.github.jasync-sql..jasync-postgresql=2.2.4 -version.com.github.jillesvangurp..kotlin4example=1.1.1 +version.com.github.jillesvangurp..kotlin4example=1.1.2 version.io.github.microutils..kotlin-logging=3.0.5 ## # available=4.0.0-beta-1 @@ -28,6 +28,7 @@ version.kotest=5.8.0 version.kotlin=1.9.22 ## # available=2.0.0-Beta1 ## # available=2.0.0-Beta2 +## # available=2.0.0-Beta3 version.kotlinx.coroutines=1.7.3 ## # available=1.8.0-RC @@ -41,14 +42,18 @@ version.org.apache.logging.log4j..log4j-to-slf4j=2.22.1 ## # available=3.0.0-alpha1 ## # available=3.0.0-beta1 -version.org.slf4j..jcl-over-slf4j=2.0.10 +version.org.slf4j..jcl-over-slf4j=2.0.11 ## # available=2.1.0-alpha0 +## # available=2.1.0-alpha1 -version.org.slf4j..jul-to-slf4j=2.0.10 +version.org.slf4j..jul-to-slf4j=2.0.11 ## # available=2.1.0-alpha0 +## # available=2.1.0-alpha1 -version.org.slf4j..log4j-over-slf4j=2.0.10 +version.org.slf4j..log4j-over-slf4j=2.0.11 ## # available=2.1.0-alpha0 +## # available=2.1.0-alpha1 -version.org.slf4j..slf4j-api=2.0.10 +version.org.slf4j..slf4j-api=2.0.11 ## # available=2.1.0-alpha0 +## # available=2.1.0-alpha1