From a84c8289b186b83d1ba8dcc18c6548760265a57b Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 27 Feb 2025 23:38:38 -0500 Subject: [PATCH] WIP on count impl; reworking comparisons --- src/integration-test/kotlin/Types.kt | 1 - src/integration-test/kotlin/common/Count.kt | 69 +++++++++++++++ .../kotlin/common/Definition.kt | 23 ++++- .../kotlin/postgresql/CountIT.kt | 47 +++++++++++ .../kotlin/postgresql/DefinitionIT.kt | 10 +++ src/integration-test/kotlin/sqlite/CountIT.kt | 41 +++++++++ .../kotlin/sqlite/DefinitionIT.kt | 16 +++- .../kotlin/sqlite/SQLiteDB.kt | 1 - src/main/kotlin/Comparison.kt | 55 ++++++++---- src/main/kotlin/ConnectionExtensions.kt | 44 +++++++++- src/main/kotlin/Count.kt | 84 +++++++++++++++++++ src/main/kotlin/Definition.kt | 23 ++++- src/main/kotlin/Delete.kt | 6 +- src/main/kotlin/DocumentIndex.kt | 13 +++ src/main/kotlin/Field.kt | 65 +++++++++++--- src/main/kotlin/Parameters.kt | 20 +---- src/main/kotlin/query/Definition.kt | 21 ++++- src/test/kotlin/ComparisonTest.kt | 32 +++---- src/test/kotlin/DocumentIndexTest.kt | 24 ++++++ src/test/kotlin/query/DefinitionTest.kt | 30 +++++++ 20 files changed, 548 insertions(+), 77 deletions(-) create mode 100644 src/integration-test/kotlin/common/Count.kt create mode 100644 src/integration-test/kotlin/postgresql/CountIT.kt create mode 100644 src/integration-test/kotlin/sqlite/CountIT.kt create mode 100644 src/main/kotlin/DocumentIndex.kt create mode 100644 src/test/kotlin/DocumentIndexTest.kt diff --git a/src/integration-test/kotlin/Types.kt b/src/integration-test/kotlin/Types.kt index c191561..c698ad5 100644 --- a/src/integration-test/kotlin/Types.kt +++ b/src/integration-test/kotlin/Types.kt @@ -1,7 +1,6 @@ package solutions.bitbadger.documents import kotlinx.serialization.Serializable -import java.sql.Connection /** The test table name to use for integration tests */ const val TEST_TABLE = "test_table" diff --git a/src/integration-test/kotlin/common/Count.kt b/src/integration-test/kotlin/common/Count.kt new file mode 100644 index 0000000..adffb9f --- /dev/null +++ b/src/integration-test/kotlin/common/Count.kt @@ -0,0 +1,69 @@ +package solutions.bitbadger.documents.common + +import solutions.bitbadger.documents.* +import kotlin.test.assertEquals + +/** + * Integration tests for the `Count` object + */ +object Count { + + fun all(db: ThrowawayDatabase) { + JsonDocument.load(db) + assertEquals(5L, db.conn.countAll(TEST_TABLE), "There should have been 5 documents in the table") + } + + fun byFieldsNumeric(db: ThrowawayDatabase) { + JsonDocument.load(db) + assertEquals( + 3L, + db.conn.countByFields(TEST_TABLE, listOf(Field.between("num_value", 10, 20))), + "There should have been 3 matching documents" + ) + } + + fun byFieldsAlpha(db: ThrowawayDatabase) { + JsonDocument.load(db) + assertEquals( + 1L, + db.conn.countByFields(TEST_TABLE, listOf(Field.between("value", "aardvark", "apple"))), + "There should have been 1 matching document" + ) + } + + fun byContainsMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + assertEquals( + 2L, + db.conn.countByContains(TEST_TABLE, mapOf("value" to "purple")), + "There should have been 2 matching documents" + ) + } + + fun byContainsNoMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + assertEquals( + 0L, + db.conn.countByContains(TEST_TABLE, mapOf("value" to "magenta")), + "There should have been no matching documents" + ) + } + + fun byJsonPathMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + assertEquals( + 2L, + db.conn.countByJsonPath(TEST_TABLE, "$.num_value ? (@ < 5)"), + "There should have been 2 matching documents" + ) + } + + fun byJsonPathNoMatch(db: ThrowawayDatabase) { + JsonDocument.load(db) + assertEquals( + 0L, + db.conn.countByJsonPath(TEST_TABLE, "$.num_value ? (@ > 100)"), + "There should have been no matching documents" + ) + } +} diff --git a/src/integration-test/kotlin/common/Definition.kt b/src/integration-test/kotlin/common/Definition.kt index c8f7715..70e99b4 100644 --- a/src/integration-test/kotlin/common/Definition.kt +++ b/src/integration-test/kotlin/common/Definition.kt @@ -1,9 +1,6 @@ package solutions.bitbadger.documents.common -import solutions.bitbadger.documents.TEST_TABLE -import solutions.bitbadger.documents.ThrowawayDatabase -import solutions.bitbadger.documents.ensureFieldIndex -import solutions.bitbadger.documents.ensureTable +import solutions.bitbadger.documents.* import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -25,4 +22,22 @@ object Definition { db.conn.ensureFieldIndex(TEST_TABLE, "test", listOf("id", "category")) assertTrue(db.dbObjectExists("idx_${TEST_TABLE}_test"), "The test index should now exist") } + + fun ensureDocumentIndexFull(db: ThrowawayDatabase) { + assertFalse(db.dbObjectExists("doc_table"), "The 'doc_table' table should not exist") + db.conn.ensureTable("doc_table") + assertTrue(db.dbObjectExists("doc_table"), "The 'doc_table' table should exist") + assertFalse(db.dbObjectExists("idx_doc_table_document"), "The document index should not exist") + db.conn.ensureDocumentIndex("doc_table", DocumentIndex.FULL) + assertTrue(db.dbObjectExists("idx_doc_table_document"), "The document index should exist") + } + + fun ensureDocumentIndexOptimized(db: ThrowawayDatabase) { + assertFalse(db.dbObjectExists("doc_table"), "The 'doc_table' table should not exist") + db.conn.ensureTable("doc_table") + assertTrue(db.dbObjectExists("doc_table"), "The 'doc_table' table should exist") + assertFalse(db.dbObjectExists("idx_doc_table_document"), "The document index should not exist") + db.conn.ensureDocumentIndex("doc_table", DocumentIndex.OPTIMIZED) + assertTrue(db.dbObjectExists("idx_doc_table_document"), "The document index should exist") + } } diff --git a/src/integration-test/kotlin/postgresql/CountIT.kt b/src/integration-test/kotlin/postgresql/CountIT.kt new file mode 100644 index 0000000..cf3c6d1 --- /dev/null +++ b/src/integration-test/kotlin/postgresql/CountIT.kt @@ -0,0 +1,47 @@ +package solutions.bitbadger.documents.postgresql + +import org.junit.jupiter.api.DisplayName +import solutions.bitbadger.documents.common.Count +import kotlin.test.Test + +/** + * PostgreSQL integration tests for the `Count` object / `count*` connection extension functions + */ +@DisplayName("PostgreSQL - Count") +class CountIT { + + @Test + @DisplayName("all counts all documents") + fun all() = + PgDB().use(Count::all) + + @Test + @DisplayName("byFields counts documents by a numeric value") + fun byFieldsNumeric() = + PgDB().use(Count::byFieldsNumeric) + + @Test + @DisplayName("byFields counts documents by a alphanumeric value") + fun byFieldsAlpha() = + PgDB().use(Count::byFieldsAlpha) + + @Test + @DisplayName("byContains counts documents when matches are found") + fun byContainsMatch() = + PgDB().use(Count::byContainsMatch) + + @Test + @DisplayName("byContains counts documents when no matches are found") + fun byContainsNoMatch() = + PgDB().use(Count::byContainsNoMatch) + + @Test + @DisplayName("byJsonPath counts documents when matches are found") + fun byJsonPathMatch() = + PgDB().use(Count::byJsonPathMatch) + + @Test + @DisplayName("byJsonPath counts documents when no matches are found") + fun byJsonPathNoMatch() = + PgDB().use(Count::byJsonPathNoMatch) +} diff --git a/src/integration-test/kotlin/postgresql/DefinitionIT.kt b/src/integration-test/kotlin/postgresql/DefinitionIT.kt index 826fd28..2533fdc 100644 --- a/src/integration-test/kotlin/postgresql/DefinitionIT.kt +++ b/src/integration-test/kotlin/postgresql/DefinitionIT.kt @@ -19,4 +19,14 @@ class DefinitionIT { @DisplayName("ensureFieldIndex creates an index") fun ensureFieldIndex() = PgDB().use(Definition::ensureFieldIndex) + + @Test + @DisplayName("ensureDocumentIndex creates a full index") + fun ensureDocumentIndexFull() = + PgDB().use(Definition::ensureDocumentIndexFull) + + @Test + @DisplayName("ensureDocumentIndex creates an optimized index") + fun ensureDocumentIndexOptimized() = + PgDB().use(Definition::ensureDocumentIndexOptimized) } diff --git a/src/integration-test/kotlin/sqlite/CountIT.kt b/src/integration-test/kotlin/sqlite/CountIT.kt new file mode 100644 index 0000000..969b534 --- /dev/null +++ b/src/integration-test/kotlin/sqlite/CountIT.kt @@ -0,0 +1,41 @@ +package solutions.bitbadger.documents.sqlite + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.assertThrows +import solutions.bitbadger.documents.DocumentException +import solutions.bitbadger.documents.common.Count +import kotlin.test.Test + +/** + * SQLite integration tests for the `Count` object / `count*` connection extension functions + */ +@DisplayName("SQLite - Count") +class CountIT { + + @Test + @DisplayName("all counts all documents") + fun all() = + SQLiteDB().use(Count::all) + + @Test + @DisplayName("byFields counts documents by a numeric value") + fun byFieldsNumeric() = + SQLiteDB().use(Count::byFieldsNumeric) + + @Test + @DisplayName("byFields counts documents by a alphanumeric value") + fun byFieldsAlpha() = + SQLiteDB().use(Count::byFieldsAlpha) + + @Test + @DisplayName("byContains fails") + fun byContainsMatch() { + assertThrows { SQLiteDB().use(Count::byContainsMatch) } + } + + @Test + @DisplayName("byJsonPath fails") + fun byJsonPathMatch() { + assertThrows { SQLiteDB().use(Count::byJsonPathMatch) } + } +} diff --git a/src/integration-test/kotlin/sqlite/DefinitionIT.kt b/src/integration-test/kotlin/sqlite/DefinitionIT.kt index 4bb14f7..8866751 100644 --- a/src/integration-test/kotlin/sqlite/DefinitionIT.kt +++ b/src/integration-test/kotlin/sqlite/DefinitionIT.kt @@ -1,12 +1,10 @@ package solutions.bitbadger.documents.sqlite import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.assertThrows import solutions.bitbadger.documents.* import solutions.bitbadger.documents.common.Definition -import java.sql.Connection import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue /** * SQLite integration tests for the `Definition` object / `ensure*` connection extension functions @@ -23,4 +21,16 @@ class DefinitionIT { @DisplayName("ensureFieldIndex creates an index") fun ensureFieldIndex() = SQLiteDB().use(Definition::ensureFieldIndex) + + @Test + @DisplayName("ensureDocumentIndex fails for full index") + fun ensureDocumentIndexFull() { + assertThrows { SQLiteDB().use(Definition::ensureDocumentIndexFull) } + } + + @Test + @DisplayName("ensureDocumentIndex fails for optimized index") + fun ensureDocumentIndexOptimized() { + assertThrows { SQLiteDB().use(Definition::ensureDocumentIndexOptimized) } + } } diff --git a/src/integration-test/kotlin/sqlite/SQLiteDB.kt b/src/integration-test/kotlin/sqlite/SQLiteDB.kt index 5528e45..1724e34 100644 --- a/src/integration-test/kotlin/sqlite/SQLiteDB.kt +++ b/src/integration-test/kotlin/sqlite/SQLiteDB.kt @@ -2,7 +2,6 @@ package solutions.bitbadger.documents.sqlite import solutions.bitbadger.documents.* import java.io.File -import java.sql.Connection /** * A wrapper for a throwaway SQLite database diff --git a/src/main/kotlin/Comparison.kt b/src/main/kotlin/Comparison.kt index 73ff2fe..ac0c0e0 100644 --- a/src/main/kotlin/Comparison.kt +++ b/src/main/kotlin/Comparison.kt @@ -1,27 +1,54 @@ package solutions.bitbadger.documents +interface Comparison { + + val op: Op + + val isNumeric: Boolean + + val value: T +} + /** - * A comparison against a field in a JSON document + * A single-value comparison against a field in a JSON document * * @property op The operation for the field comparison * @property value The value against which the comparison will be made */ -class Comparison(val op: Op, val value: T) { +class SingleComparison(override val op: Op, override val value: T) : Comparison { /** Is the value for this comparison a numeric value? */ - val isNumeric: Boolean - get() { - val toCheck = when (op) { - Op.IN -> { - val values = value as? Collection<*> - if (values.isNullOrEmpty()) "" else values.elementAt(0) - } - Op.BETWEEN -> (value as Pair<*, *>).first - else -> value - } - return toCheck is Byte || toCheck is Short || toCheck is Int || toCheck is Long - } + override val isNumeric: Boolean + get() = value.let { it is Byte || it is Short || it is Int || it is Long } override fun toString() = "$op $value" } + +/** + * A range comparison against a field in a JSON document + */ +class BetweenComparison(override val op: Op = Op.BETWEEN, override val value: Pair) : Comparison> { + + override val isNumeric: Boolean + get() = value.first.let { it is Byte || it is Short || it is Int || it is Long } +} + +/** + * A check within a collection of values + */ +class InComparison(override val op: Op = Op.IN, override val value: Collection) : Comparison> { + + override val isNumeric: Boolean + get() = !value.isEmpty() && value.elementAt(0).let { it is Byte || it is Short || it is Int || it is Long } +} + +/** + * A check within a collection of values + */ +class InArrayComparison(override val op: Op = Op.IN_ARRAY, override val value: Pair>) : Comparison>> { + + override val isNumeric: Boolean + get() = !value.second.isEmpty() && value.second.elementAt(0) + .let { it is Byte || it is Short || it is Int || it is Long } +} diff --git a/src/main/kotlin/ConnectionExtensions.kt b/src/main/kotlin/ConnectionExtensions.kt index 733fe49..2d4edf3 100644 --- a/src/main/kotlin/ConnectionExtensions.kt +++ b/src/main/kotlin/ConnectionExtensions.kt @@ -72,6 +72,16 @@ fun Connection.ensureTable(tableName: String) = fun Connection.ensureFieldIndex(tableName: String, indexName: String, fields: Collection) = Definition.ensureFieldIndex(tableName, indexName, fields, this) +/** + * Create a document index on a table (PostgreSQL only) + * + * @param tableName The table to be indexed (may include schema) + * @param indexType The type of index to ensure + * @throws DocumentException If called on a SQLite connection + */ +fun Connection.ensureDocumentIndex(tableName: String, indexType: DocumentIndex) = + Definition.ensureDocumentIndex(tableName, indexType, this) + // ~~~ DOCUMENT MANIPULATION QUERIES ~~~ /** @@ -94,6 +104,38 @@ inline fun Connection.insert(tableName: String, document: TDoc) = fun Connection.countAll(tableName: String) = Count.all(tableName, this) +/** + * Count documents using a field comparison + * + * @param tableName The name of the table in which documents should be counted + * @param fields The fields which should be compared + * @param howMatched How the fields should be matched + * @return A count of the matching documents in the table + */ +fun Connection.countByFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = + Count.byFields(tableName, fields, howMatched, this) + +/** + * Count documents using a JSON containment query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be counted + * @param criteria The object for which JSON containment should be checked + * @return A count of the matching documents in the table + * @throws DocumentException If called on a SQLite connection + */ +inline fun Connection.countByContains(tableName: String, criteria: T) = + Count.byContains(tableName, criteria, this) + +/** + * Count documents using a JSON containment query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be counted + * @param path The JSON path comparison to match + * @return A count of the matching documents in the table + * @throws DocumentException If called on a SQLite connection + */ +fun Connection.countByJsonPath(tableName: String, path: String) = + Count.byJsonPath(tableName, path, this) // ~~~ DOCUMENT RETRIEVAL QUERIES ~~~ @@ -134,4 +176,4 @@ fun Connection.byId(tableName: String, docId: TKey) = * @param howMatched How the fields should be matched */ fun Connection.deleteByFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = - Delete.byField(tableName, fields, howMatched, this) + Delete.byFields(tableName, fields, howMatched, this) diff --git a/src/main/kotlin/Count.kt b/src/main/kotlin/Count.kt index c168a22..ddf3010 100644 --- a/src/main/kotlin/Count.kt +++ b/src/main/kotlin/Count.kt @@ -26,4 +26,88 @@ object Count { */ fun all(tableName: String) = Configuration.dbConn().use { all(tableName, it) } + + /** + * Count documents using a field comparison + * + * @param tableName The name of the table in which documents should be counted + * @param fields The fields which should be compared + * @param howMatched How the fields should be matched + * @param conn The connection on which the deletion should be executed + * @return A count of the matching documents in the table + */ + fun byFields( + tableName: String, + fields: Collection>, + howMatched: FieldMatch? = null, + conn: Connection + ): Long { + val named = Parameters.nameFields(fields) + return conn.customScalar( + Count.byFields(tableName, named, howMatched), + Parameters.addFields(named), + Results::toCount + ) + } + + /** + * Count documents using a field comparison + * + * @param tableName The name of the table in which documents should be counted + * @param fields The fields which should be compared + * @param howMatched How the fields should be matched + * @return A count of the matching documents in the table + */ + fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = + Configuration.dbConn().use { byFields(tableName, fields, howMatched, it) } + + /** + * Count documents using a JSON containment query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be counted + * @param criteria The object for which JSON containment should be checked + * @param conn The connection on which the deletion should be executed + * @return A count of the matching documents in the table + * @throws DocumentException If called on a SQLite connection + */ + inline fun byContains(tableName: String, criteria: T, conn: Connection) = + conn.customScalar(Count.byContains(tableName), listOf(Parameters.json(":criteria", criteria)), Results::toCount) + + /** + * Count documents using a JSON containment query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be counted + * @param criteria The object for which JSON containment should be checked + * @return A count of the matching documents in the table + * @throws DocumentException If called on a SQLite connection + */ + inline fun byContains(tableName: String, criteria: T) = + Configuration.dbConn().use { byContains(tableName, criteria, it) } + + /** + * Count documents using a JSON containment query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be counted + * @param path The JSON path comparison to match + * @param conn The connection on which the deletion should be executed + * @return A count of the matching documents in the table + * @throws DocumentException If called on a SQLite connection + */ + fun byJsonPath(tableName: String, path: String, conn: Connection) = + conn.customScalar( + Count.byJsonPath(tableName), + listOf(Parameter(":path", ParameterType.STRING, path)), + Results::toCount + ) + + /** + * Count documents using a JSON containment query (PostgreSQL only) + * + * @param tableName The name of the table in which documents should be counted + * @param path The JSON path comparison to match + * @return A count of the matching documents in the table + * @throws DocumentException If called on a SQLite connection + */ + fun byJsonPath(tableName: String, path: String) = + Configuration.dbConn().use { byJsonPath(tableName, path, it) } } diff --git a/src/main/kotlin/Definition.kt b/src/main/kotlin/Definition.kt index 9ad8da0..d61712c 100644 --- a/src/main/kotlin/Definition.kt +++ b/src/main/kotlin/Definition.kt @@ -41,11 +41,32 @@ object Definition { /** * Create an index on field(s) within documents in the specified table if necessary + * * @param tableName The table to be indexed (may include schema) * @param indexName The name of the index to create * @param fields One or more fields to be indexed< - * @param conn The connection on which the query should be executed */ fun ensureFieldIndex(tableName: String, indexName: String, fields: Collection) = Configuration.dbConn().use { ensureFieldIndex(tableName, indexName, fields, it) } + + /** + * Create a document index on a table (PostgreSQL only) + * + * @param tableName The table to be indexed (may include schema) + * @param indexType The type of index to ensure + * @param conn The connection on which the query should be executed + * @throws DocumentException If called on a SQLite connection + */ + fun ensureDocumentIndex(tableName: String, indexType: DocumentIndex, conn: Connection) = + conn.customNonQuery(Definition.ensureDocumentIndexOn(tableName, indexType)) + + /** + * Create a document index on a table (PostgreSQL only) + * + * @param tableName The table to be indexed (may include schema) + * @param indexType The type of index to ensure + * @throws DocumentException If called on a SQLite connection + */ + fun ensureDocumentIndex(tableName: String, indexType: DocumentIndex) = + Configuration.dbConn().use { ensureDocumentIndex(tableName, indexType, it) } } diff --git a/src/main/kotlin/Delete.kt b/src/main/kotlin/Delete.kt index 3adfe5e..d9002b0 100644 --- a/src/main/kotlin/Delete.kt +++ b/src/main/kotlin/Delete.kt @@ -38,7 +38,7 @@ object Delete { * @param howMatched How the fields should be matched * @param conn The connection on which the deletion should be executed */ - fun byField(tableName: String, fields: Collection>, howMatched: FieldMatch? = null, conn: Connection) { + fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null, conn: Connection) { val named = Parameters.nameFields(fields) conn.customNonQuery(Delete.byFields(tableName, named, howMatched), Parameters.addFields(named)) } @@ -50,6 +50,6 @@ object Delete { * @param fields The fields which should be compared * @param howMatched How the fields should be matched */ - fun byField(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = - Configuration.dbConn().use { byField(tableName, fields, howMatched, it) } + fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = + Configuration.dbConn().use { byFields(tableName, fields, howMatched, it) } } diff --git a/src/main/kotlin/DocumentIndex.kt b/src/main/kotlin/DocumentIndex.kt new file mode 100644 index 0000000..3ce8743 --- /dev/null +++ b/src/main/kotlin/DocumentIndex.kt @@ -0,0 +1,13 @@ +package solutions.bitbadger.documents + +/** + * The type of index to generate for the document + */ +enum class DocumentIndex(val sql: String) { + + /** A GIN index with standard operations (all operators supported) */ + FULL(""), + + /** A GIN index with JSONPath operations (optimized for @>, @?, @@ operators) */ + OPTIMIZED(" jsonb_path_ops") +} diff --git a/src/main/kotlin/Field.kt b/src/main/kotlin/Field.kt index e1796aa..680609a 100644 --- a/src/main/kotlin/Field.kt +++ b/src/main/kotlin/Field.kt @@ -92,6 +92,47 @@ class Field private constructor( } } + /** + * Append the parameters required for this field + * + * @param existing The existing parameters + * @return The collection with the necessary parameters appended + */ + fun appendParameter(existing: MutableCollection>): MutableCollection> { + val typ = if (comparison.isNumeric) ParameterType.NUMBER else ParameterType.STRING + when (comparison) { + is BetweenComparison<*> -> { + existing.add(Parameter("${parameterName}min", typ, comparison.value.first)) + existing.add(Parameter("${parameterName}max", typ, comparison.value.second)) + } + + is InComparison<*> -> { + comparison.value.forEachIndexed { index, item -> + existing.add(Parameter("${parameterName}_$index", typ, item)) + } + } + + is InArrayComparison<*> -> { + val mkString = Configuration.dialect("append parameters for InArray") == Dialect.POSTGRESQL + // TODO: I think this is actually Pair> + comparison.value.second.forEachIndexed { index, item -> + if (mkString) { + existing.add(Parameter("${parameterName}_$index", ParameterType.STRING, "$item")) + } else { + existing.add(Parameter("${parameterName}_$index", typ, item)) + } + } + } + + else -> { + if (comparison.op != Op.EXISTS && comparison.op != Op.NOT_EXISTS) { + existing.add(Parameter(parameterName!!, typ, comparison.value)) + } + } + } + return existing + } + override fun toString() = "Field ${parameterName ?: ""} $comparison${qualifier?.let { " (qualifier $it)"} ?: ""}" @@ -106,7 +147,7 @@ class Field private constructor( * @return A `Field` with the given comparison */ fun equal(name: String, value: T, paramName: String? = null) = - Field(name, Comparison(Op.EQUAL, value), paramName) + Field(name, SingleComparison(Op.EQUAL, value), paramName) /** * Create a field greater-than comparison @@ -117,7 +158,7 @@ class Field private constructor( * @return A `Field` with the given comparison */ fun greater(name: String, value: T, paramName: String? = null) = - Field(name, Comparison(Op.GREATER, value), paramName) + Field(name, SingleComparison(Op.GREATER, value), paramName) /** * Create a field greater-than-or-equal-to comparison @@ -128,7 +169,7 @@ class Field private constructor( * @return A `Field` with the given comparison */ fun greaterOrEqual(name: String, value: T, paramName: String? = null) = - Field(name, Comparison(Op.GREATER_OR_EQUAL, value), paramName) + Field(name, SingleComparison(Op.GREATER_OR_EQUAL, value), paramName) /** * Create a field less-than comparison @@ -139,7 +180,7 @@ class Field private constructor( * @return A `Field` with the given comparison */ fun less(name: String, value: T, paramName: String? = null) = - Field(name, Comparison(Op.LESS, value), paramName) + Field(name, SingleComparison(Op.LESS, value), paramName) /** * Create a field less-than-or-equal-to comparison @@ -150,7 +191,7 @@ class Field private constructor( * @return A `Field` with the given comparison */ fun lessOrEqual(name: String, value: T, paramName: String? = null) = - Field(name, Comparison(Op.LESS_OR_EQUAL, value), paramName) + Field(name, SingleComparison(Op.LESS_OR_EQUAL, value), paramName) /** * Create a field inequality comparison @@ -161,7 +202,7 @@ class Field private constructor( * @return A `Field` with the given comparison */ fun notEqual(name: String, value: T, paramName: String? = null) = - Field(name, Comparison(Op.NOT_EQUAL, value), paramName) + Field(name, SingleComparison(Op.NOT_EQUAL, value), paramName) /** * Create a field range comparison @@ -173,7 +214,7 @@ class Field private constructor( * @return A `Field` with the given comparison */ fun between(name: String, minValue: T, maxValue: T, paramName: String? = null) = - Field(name, Comparison(Op.BETWEEN, Pair(minValue, maxValue)), paramName) + Field(name, BetweenComparison(value = Pair(minValue, maxValue)), paramName) /** * Create a field where any values match (SQL `IN`) @@ -184,7 +225,7 @@ class Field private constructor( * @return A `Field` with the given comparison */ fun any(name: String, values: Collection, paramName: String? = null) = - Field(name, Comparison(Op.IN, values), paramName) + Field(name, InComparison(value = values), paramName) /** * Create a field where values should exist in a document's array @@ -196,16 +237,16 @@ class Field private constructor( * @return A `Field` with the given comparison */ fun inArray(name: String, tableName: String, values: Collection, paramName: String? = null) = - Field(name, Comparison(Op.IN_ARRAY, Pair(tableName, values)), paramName) + Field(name, InArrayComparison(value = Pair(tableName, values)), paramName) fun exists(name: String) = - Field(name, Comparison(Op.EXISTS, "")) + Field(name, SingleComparison(Op.EXISTS, "")) fun notExists(name: String) = - Field(name, Comparison(Op.NOT_EXISTS, "")) + Field(name, SingleComparison(Op.NOT_EXISTS, "")) fun named(name: String) = - Field(name, Comparison(Op.EQUAL, "")) + Field(name, SingleComparison(Op.EQUAL, "")) fun nameToPath(name: String, dialect: Dialect, format: FieldFormat): String { val path = StringBuilder("data") diff --git a/src/main/kotlin/Parameters.kt b/src/main/kotlin/Parameters.kt index 961cdcb..c428795 100644 --- a/src/main/kotlin/Parameters.kt +++ b/src/main/kotlin/Parameters.kt @@ -46,22 +46,8 @@ object Parameters { * @param existing Any existing parameters for the query (optional, defaults to empty collection) * @return A collection of parameters for the query */ - fun addFields( - fields: Collection>, - existing: MutableCollection> = mutableListOf() - ): MutableCollection> { - existing.addAll( - fields - .filter { it.comparison.op != Op.EXISTS && it.comparison.op != Op.NOT_EXISTS } - .map { - Parameter( - it.parameterName!!, - if (it.comparison.isNumeric) ParameterType.NUMBER else ParameterType.STRING, - it.comparison.value - ) - }) - return existing - } + fun addFields(fields: Collection>, existing: MutableCollection> = mutableListOf()) = + fields.fold(existing) { acc, field -> field.appendParameter(acc) } /** * Replace the parameter names in the query with question marks @@ -72,7 +58,7 @@ object Parameters { */ fun replaceNamesInQuery(query: String, parameters: Collection>) = parameters.sortedByDescending { it.name.length }.fold(query) { acc, param -> acc.replace(param.name, "?") } - + .also(::println) /** * Apply the given parameters to the given query, returning a prepared statement * diff --git a/src/main/kotlin/query/Definition.kt b/src/main/kotlin/query/Definition.kt index 8180383..e3c2ccd 100644 --- a/src/main/kotlin/query/Definition.kt +++ b/src/main/kotlin/query/Definition.kt @@ -1,9 +1,6 @@ package solutions.bitbadger.documents.query -import solutions.bitbadger.documents.Configuration -import solutions.bitbadger.documents.Dialect -import solutions.bitbadger.documents.Field -import solutions.bitbadger.documents.FieldFormat +import solutions.bitbadger.documents.* /** * Functions to create queries to define tables and indexes @@ -76,4 +73,20 @@ object Definition { */ fun ensureKey(tableName: String, dialect: Dialect? = null) = ensureIndexOn(tableName, "key", listOf(Configuration.idField), dialect).replace("INDEX", "UNIQUE INDEX") + + /** + * Create a document-wide index on a table (PostgreSQL only) + * + * @param tableName The name of the table on which the document index should be created + * @param indexType The type of index to be created + * @return The SQL statement to create an index on JSON documents in the specified table + * @throws DocumentException If the database mode is not PostgreSQL + */ + fun ensureDocumentIndexOn(tableName: String, indexType: DocumentIndex): String { + if (Configuration.dialect("create document index query") != Dialect.POSTGRESQL) { + throw DocumentException("'Document indexes are only supported on PostgreSQL") + } + val (_, tbl) = splitSchemaAndTable(tableName) + return "CREATE INDEX IF NOT EXISTS idx_${tbl}_document ON $tableName USING GIN (data${indexType.sql})"; + } } diff --git a/src/test/kotlin/ComparisonTest.kt b/src/test/kotlin/ComparisonTest.kt index daa0545..fb558df 100644 --- a/src/test/kotlin/ComparisonTest.kt +++ b/src/test/kotlin/ComparisonTest.kt @@ -14,82 +14,82 @@ class ComparisonTest { @Test @DisplayName("isNumeric is false for empty list of values") fun isNumericFalseForEmptyList() = - assertFalse(Comparison(Op.IN, listOf()).isNumeric, "An IN with empty list should not be numeric") + assertFalse(InComparison(Op.IN, listOf()).isNumeric, "An IN with empty list should not be numeric") @Test @DisplayName("isNumeric is false for IN with strings") fun isNumericFalseForStringsAndIn() = - assertFalse(Comparison(Op.IN, listOf("a", "b", "c")).isNumeric, "An IN with strings should not be numeric") + assertFalse(InComparison(Op.IN, listOf("a", "b", "c")).isNumeric, "An IN with strings should not be numeric") @Test @DisplayName("isNumeric is true for IN with bytes") fun isNumericTrueForByteAndIn() = - assertTrue(Comparison(Op.IN, listOf(4, 8)).isNumeric, "An IN with bytes should be numeric") + assertTrue(InComparison(Op.IN, listOf(4, 8)).isNumeric, "An IN with bytes should be numeric") @Test @DisplayName("isNumeric is true for IN with shorts") fun isNumericTrueForShortAndIn() = - assertTrue(Comparison(Op.IN, listOf(18, 22)).isNumeric, "An IN with shorts should be numeric") + assertTrue(InComparison(Op.IN, listOf(18, 22)).isNumeric, "An IN with shorts should be numeric") @Test @DisplayName("isNumeric is true for IN with ints") fun isNumericTrueForIntAndIn() = - assertTrue(Comparison(Op.IN, listOf(7, 8, 9)).isNumeric, "An IN with ints should be numeric") + assertTrue(InComparison(Op.IN, listOf(7, 8, 9)).isNumeric, "An IN with ints should be numeric") @Test @DisplayName("isNumeric is true for IN with longs") fun isNumericTrueForLongAndIn() = - assertTrue(Comparison(Op.IN, listOf(3L)).isNumeric, "An IN with longs should be numeric") + assertTrue(InComparison(Op.IN, listOf(3L)).isNumeric, "An IN with longs should be numeric") @Test @DisplayName("isNumeric is false for BETWEEN with strings") fun isNumericFalseForStringsAndBetween() = - assertFalse(Comparison(Op.BETWEEN, Pair("eh", "zed")).isNumeric, + assertFalse(BetweenComparison(Op.BETWEEN, Pair("eh", "zed")).isNumeric, "A BETWEEN with strings should not be numeric") @Test @DisplayName("isNumeric is true for BETWEEN with bytes") fun isNumericTrueForByteAndBetween() = - assertTrue(Comparison(Op.BETWEEN, Pair(7, 11)).isNumeric, "A BETWEEN with bytes should be numeric") + assertTrue(BetweenComparison(Op.BETWEEN, Pair(7, 11)).isNumeric, "A BETWEEN with bytes should be numeric") @Test @DisplayName("isNumeric is true for BETWEEN with shorts") fun isNumericTrueForShortAndBetween() = - assertTrue(Comparison(Op.BETWEEN, Pair(0, 9)).isNumeric, + assertTrue(BetweenComparison(Op.BETWEEN, Pair(0, 9)).isNumeric, "A BETWEEN with shorts should be numeric") @Test @DisplayName("isNumeric is true for BETWEEN with ints") fun isNumericTrueForIntAndBetween() = - assertTrue(Comparison(Op.BETWEEN, Pair(15, 44)).isNumeric, "A BETWEEN with ints should be numeric") + assertTrue(BetweenComparison(Op.BETWEEN, Pair(15, 44)).isNumeric, "A BETWEEN with ints should be numeric") @Test @DisplayName("isNumeric is true for BETWEEN with longs") fun isNumericTrueForLongAndBetween() = - assertTrue(Comparison(Op.BETWEEN, Pair(9L, 12L)).isNumeric, "A BETWEEN with longs should be numeric") + assertTrue(BetweenComparison(Op.BETWEEN, Pair(9L, 12L)).isNumeric, "A BETWEEN with longs should be numeric") @Test @DisplayName("isNumeric is false for string value") fun isNumericFalseForString() = - assertFalse(Comparison(Op.EQUAL, "80").isNumeric, "A string should not be numeric") + assertFalse(SingleComparison(Op.EQUAL, "80").isNumeric, "A string should not be numeric") @Test @DisplayName("isNumeric is true for byte value") fun isNumericTrueForByte() = - assertTrue(Comparison(Op.EQUAL, 47.toByte()).isNumeric, "A byte should be numeric") + assertTrue(SingleComparison(Op.EQUAL, 47.toByte()).isNumeric, "A byte should be numeric") @Test @DisplayName("isNumeric is true for short value") fun isNumericTrueForShort() = - assertTrue(Comparison(Op.EQUAL, 2.toShort()).isNumeric, "A short should be numeric") + assertTrue(SingleComparison(Op.EQUAL, 2.toShort()).isNumeric, "A short should be numeric") @Test @DisplayName("isNumeric is true for int value") fun isNumericTrueForInt() = - assertTrue(Comparison(Op.EQUAL, 555).isNumeric, "An int should be numeric") + assertTrue(SingleComparison(Op.EQUAL, 555).isNumeric, "An int should be numeric") @Test @DisplayName("isNumeric is true for long value") fun isNumericTrueForLong() = - assertTrue(Comparison(Op.EQUAL, 82L).isNumeric, "A long should be numeric") + assertTrue(SingleComparison(Op.EQUAL, 82L).isNumeric, "A long should be numeric") } diff --git a/src/test/kotlin/DocumentIndexTest.kt b/src/test/kotlin/DocumentIndexTest.kt new file mode 100644 index 0000000..0947752 --- /dev/null +++ b/src/test/kotlin/DocumentIndexTest.kt @@ -0,0 +1,24 @@ +package solutions.bitbadger.documents + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +/** + * Unit tests for the `DocumentIndex` enum + */ +@DisplayName("Op") +class DocumentIndexTest { + + @Test + @DisplayName("FULL uses proper SQL") + fun fullSQL() { + assertEquals("", DocumentIndex.FULL.sql, "The SQL for Full is incorrect") + } + + @Test + @DisplayName("OPTIMIZED uses proper SQL") + fun optimizedSQL() { + assertEquals(" jsonb_path_ops", DocumentIndex.OPTIMIZED.sql, "The SQL for Optimized is incorrect") + } +} diff --git a/src/test/kotlin/query/DefinitionTest.kt b/src/test/kotlin/query/DefinitionTest.kt index 018fb1d..bcad33a 100644 --- a/src/test/kotlin/query/DefinitionTest.kt +++ b/src/test/kotlin/query/DefinitionTest.kt @@ -7,6 +7,7 @@ import org.junit.jupiter.api.assertThrows import solutions.bitbadger.documents.Configuration import solutions.bitbadger.documents.Dialect import solutions.bitbadger.documents.DocumentException +import solutions.bitbadger.documents.DocumentIndex import kotlin.test.assertEquals /** @@ -101,4 +102,33 @@ class DefinitionTest { Definition.ensureIndexOn(tbl, "nest", listOf("a.b.c"), Dialect.SQLITE), "CREATE INDEX for nested SQLite field incorrect" ) + + @Test + @DisplayName("ensureDocumentIndexOn generates Full for PostgreSQL") + fun ensureDocumentIndexOnFullPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "CREATE INDEX IF NOT EXISTS idx_${tbl}_document ON $tbl USING GIN (data)", + Definition.ensureDocumentIndexOn(tbl, DocumentIndex.FULL), + "CREATE INDEX for full document index incorrect" + ) + } + + @Test + @DisplayName("ensureDocumentIndexOn generates Optimized for PostgreSQL") + fun ensureDocumentIndexOnOptimizedPostgres() { + Configuration.dialectValue = Dialect.POSTGRESQL + assertEquals( + "CREATE INDEX IF NOT EXISTS idx_${tbl}_document ON $tbl USING GIN (data jsonb_path_ops)", + Definition.ensureDocumentIndexOn(tbl, DocumentIndex.OPTIMIZED), + "CREATE INDEX for optimized document index incorrect" + ) + } + + @Test + @DisplayName("ensureDocumentIndexOn fails for SQLite") + fun ensureDocumentIndexOnFailsSQLite() { + Configuration.dialectValue = Dialect.SQLITE + assertThrows { Definition.ensureDocumentIndexOn(tbl, DocumentIndex.FULL) } + } }