From 3826009dfa6c3acdc358fbdbf8534534e1689c8c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 16 Feb 2025 22:59:32 -0500 Subject: [PATCH] WIP on named-param queries --- src/common/pom.xml | 27 ++++- src/common/src/main/kotlin/AutoId.kt | 18 ++-- src/common/src/main/kotlin/Configuration.kt | 51 +++++++++- .../src/main/kotlin/ConnectionExtensions.kt | 40 ++++++++ .../src/main/kotlin/DocumentException.kt | 9 ++ src/common/src/main/kotlin/Parameter.kt | 15 +++ src/common/src/main/kotlin/ParameterType.kt | 13 +++ src/common/src/main/kotlin/Parameters.kt | 83 ++++++++++++++++ src/common/src/main/kotlin/Query.kt | 13 ++- src/common/src/main/kotlin/Results.kt | 75 ++++++++++++++ src/common/src/test/kotlin/ParameterTest.kt | 34 +++++++ src/common/src/test/kotlin/ParametersTest.kt | 18 ++++ src/sqlite/src/main/kotlin/Query.kt | 12 ++- src/sqlite/src/test/kotlin/QueryTest.kt | 99 +++++++++++++++++++ 14 files changed, 479 insertions(+), 28 deletions(-) create mode 100644 src/common/src/main/kotlin/ConnectionExtensions.kt create mode 100644 src/common/src/main/kotlin/DocumentException.kt create mode 100644 src/common/src/main/kotlin/Parameter.kt create mode 100644 src/common/src/main/kotlin/ParameterType.kt create mode 100644 src/common/src/main/kotlin/Parameters.kt create mode 100644 src/common/src/main/kotlin/Results.kt create mode 100644 src/common/src/test/kotlin/ParameterTest.kt create mode 100644 src/common/src/test/kotlin/ParametersTest.kt create mode 100644 src/sqlite/src/test/kotlin/QueryTest.kt diff --git a/src/common/pom.xml b/src/common/pom.xml index 8c65730..4b91d63 100644 --- a/src/common/pom.xml +++ b/src/common/pom.xml @@ -12,6 +12,8 @@ UTF-8 official 1.8 + 2.1.0 + 1.8.0 @@ -28,7 +30,7 @@ org.jetbrains.kotlin kotlin-maven-plugin - 2.1.0 + ${kotlin.version} compile @@ -45,6 +47,18 @@ + + + kotlinx-serialization + + + + + org.jetbrains.kotlin + kotlin-maven-serialization + ${kotlin.version} + + maven-surefire-plugin @@ -75,18 +89,23 @@ org.jetbrains.kotlin kotlin-test-junit5 - 2.1.0 + ${kotlin.version} test org.jetbrains.kotlin kotlin-stdlib - 2.1.0 + ${kotlin.version} org.jetbrains.kotlin kotlin-reflect - 2.0.20 + ${kotlin.version} + + + org.jetbrains.kotlinx + kotlinx-serialization-json + ${serialization.version} diff --git a/src/common/src/main/kotlin/AutoId.kt b/src/common/src/main/kotlin/AutoId.kt index cf34802..f152a8d 100644 --- a/src/common/src/main/kotlin/AutoId.kt +++ b/src/common/src/main/kotlin/AutoId.kt @@ -54,19 +54,13 @@ enum class AutoId { if (id == null) throw IllegalArgumentException("$idProp not found in document") if (strategy == NUMBER) { - if (id.returnType == Byte::class.createType()) { - return id.call(document) == 0.toByte() + return when (id.returnType) { + Byte::class.createType() -> id.call(document) == 0.toByte() + Short::class.createType() -> id.call(document) == 0.toShort() + Int::class.createType() -> id.call(document) == 0 + Long::class.createType() -> id.call(document) == 0.toLong() + else -> throw IllegalArgumentException("$idProp was not a number; cannot auto-generate number ID") } - if (id.returnType == Short::class.createType()) { - return id.call(document) == 0.toShort() - } - if (id.returnType == Int::class.createType()) { - return id.call(document) == 0 - } - if (id.returnType == Long::class.createType()) { - return id.call(document) == 0.toLong() - } - throw IllegalArgumentException("$idProp was not a number; cannot auto-generate number ID") } if (id.returnType == String::class.createType()) { diff --git a/src/common/src/main/kotlin/Configuration.kt b/src/common/src/main/kotlin/Configuration.kt index ca82639..34c5e93 100644 --- a/src/common/src/main/kotlin/Configuration.kt +++ b/src/common/src/main/kotlin/Configuration.kt @@ -1,8 +1,21 @@ package solutions.bitbadger.documents.common +import kotlinx.serialization.json.Json +import java.sql.Connection +import java.sql.DriverManager + object Configuration { - // TODO: var jsonOpts = Json { some cool options } + /** + * JSON serializer; replace to configure with non-default options + * + * The default sets `encodeDefaults` to `true` and `explicitNulls` to `false`; see + * https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md for all configuration options + */ + var json = Json { + encodeDefaults = true + explicitNulls = false + } /** The field in which a document's ID is stored */ var idField = "id" @@ -12,4 +25,40 @@ object Configuration { /** The length of automatic random hex character string */ var idStringLength = 16 + + /** The connection string for the JDBC connection */ + var connectionString: String? = null + + /** + * Retrieve a new connection to the configured database + * + * @return A new connection to the configured database + * @throws IllegalArgumentException If the connection string is not set before calling this + */ + fun dbConn(): Connection { + if (connectionString == null) { + throw IllegalArgumentException("Please provide a connection string before attempting data access") + } + return DriverManager.getConnection(connectionString) + } + + private var dialectValue: Dialect? = null + + /** The dialect in use */ + val dialect: Dialect + get() { + if (dialectValue == null) { + if (connectionString == null) { + throw IllegalArgumentException("Please provide a connection string before attempting data access") + } + val it = connectionString!! + dialectValue = when { + it.contains("sqlite") -> Dialect.SQLITE + it.contains("postgresql") -> Dialect.POSTGRESQL + else -> throw IllegalArgumentException("Cannot determine dialect from [$it]") + } + } + + return dialectValue!! + } } diff --git a/src/common/src/main/kotlin/ConnectionExtensions.kt b/src/common/src/main/kotlin/ConnectionExtensions.kt new file mode 100644 index 0000000..e18a7f4 --- /dev/null +++ b/src/common/src/main/kotlin/ConnectionExtensions.kt @@ -0,0 +1,40 @@ +package solutions.bitbadger.documents.common + +import java.sql.Connection +import java.sql.ResultSet + +/** + * Execute a query that returns a list of results + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function between the document and the domain item + * @return A list of results for the given query + */ +inline fun Connection.customList(query: String, parameters: Collection>, + mapFunc: (ResultSet) -> TDoc): List = + Parameters.apply(this, query, parameters).use { stmt -> + Results.toCustomList(stmt, mapFunc) + } + +/** + * Execute a query that returns one or no results + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + * @param mapFunc The mapping function between the document and the domain item + * @return The document if one matches the query, `null` otherwise + */ +inline fun Connection.customSingle(query: String, parameters: Collection>, + mapFunc: (ResultSet) -> TDoc): TDoc? = + this.customList("$query LIMIT 1", parameters, mapFunc).singleOrNull() + +/** + * Execute a query that returns no results + * + * @param query The query to retrieve the results + * @param parameters Parameters to use for the query + */ +fun Connection.customNonQuery(query: String, parameters: Collection>) { + Parameters.apply(this, query, parameters).use { it.executeUpdate() } +} diff --git a/src/common/src/main/kotlin/DocumentException.kt b/src/common/src/main/kotlin/DocumentException.kt new file mode 100644 index 0000000..6901d2b --- /dev/null +++ b/src/common/src/main/kotlin/DocumentException.kt @@ -0,0 +1,9 @@ +package solutions.bitbadger.documents.common + +/** + * An exception caused by invalid operations in the document library + * + * @param message The message for the exception + * @param cause The underlying exception (optional) + */ +class DocumentException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/src/common/src/main/kotlin/Parameter.kt b/src/common/src/main/kotlin/Parameter.kt new file mode 100644 index 0000000..eca25b7 --- /dev/null +++ b/src/common/src/main/kotlin/Parameter.kt @@ -0,0 +1,15 @@ +package solutions.bitbadger.documents.common + +/** + * A parameter to use for a query + * + * @property name The name of the parameter (prefixed with a colon) + * @property type The type of this parameter + * @property value The value of the parameter + */ +class Parameter(val name: String, val type: ParameterType, val value: T) { + init { + if (!name.startsWith(':') && !name.startsWith('@')) + throw DocumentException("Name must start with : or @ ($name)") + } +} diff --git a/src/common/src/main/kotlin/ParameterType.kt b/src/common/src/main/kotlin/ParameterType.kt new file mode 100644 index 0000000..53d6e1b --- /dev/null +++ b/src/common/src/main/kotlin/ParameterType.kt @@ -0,0 +1,13 @@ +package solutions.bitbadger.documents.common + +/** + * The types of parameters supported by the document library + */ +enum class ParameterType { + /** The parameter value is some sort of number (`Byte`, `Short`, `Int`, or `Long`) */ + NUMBER, + /** The parameter value is a string */ + STRING, + /** The parameter should be JSON-encoded */ + JSON, +} diff --git a/src/common/src/main/kotlin/Parameters.kt b/src/common/src/main/kotlin/Parameters.kt new file mode 100644 index 0000000..4675abc --- /dev/null +++ b/src/common/src/main/kotlin/Parameters.kt @@ -0,0 +1,83 @@ +package solutions.bitbadger.documents.common + +import java.sql.Connection +import java.sql.PreparedStatement +import java.sql.SQLException +import java.sql.Types + +/** + * Functions to assist with the creation and implementation of parameters for SQL queries + * + * @author Daniel J. Summers + */ +object Parameters { + + /** + * Replace the parameter names in the query with question marks + * + * @param query The query with named placeholders + * @param parameters The parameters for the query + * @return The query, with name parameters changed to `?`s + */ + fun replaceNamesInQuery(query: String, parameters: Collection>) = + parameters.sortedByDescending { it.name.length }.fold(query) { acc, param -> acc.replace(param.name, "?") } + + /** + * Apply the given parameters to the given query, returning a prepared statement + * + * @param conn The active JDBC connection + * @param query The query + * @param parameters The parameters for the query + * @return A `PreparedStatement` with the parameter names replaced with `?` and parameter values bound + * @throws DocumentException If parameter names are invalid or number value types are invalid + */ + fun apply(conn: Connection, query: String, parameters: Collection>): PreparedStatement { + if (parameters.isEmpty()) return try { + conn.prepareStatement(query) + } catch (ex: SQLException) { + throw DocumentException("Error preparing no-parameter query: ${ex.message}", ex) + } + + val replacements = mutableListOf>>() + parameters.sortedByDescending { it.name.length }.forEach { + var startPos = query.indexOf(it.name) + while (startPos > -1) { + replacements.add(Pair(startPos, it)) + startPos = query.indexOf(it.name, startPos + it.name.length + 1) + } + } + + return try { + replaceNamesInQuery(query, parameters) + .let { conn.prepareStatement(it) } + .also { stmt -> + replacements.sortedBy { it.first }.map { it.second }.forEachIndexed { index, param -> + val idx = index + 1 + when (param.type) { + ParameterType.NUMBER -> { + when (param.value) { + null -> stmt.setNull(idx, Types.NULL) + is Byte -> stmt.setByte(idx, param.value) + is Short -> stmt.setShort(idx, param.value) + is Int -> stmt.setInt(idx, param.value) + is Long -> stmt.setLong(idx, param.value) + else -> throw DocumentException( + "Number parameter must be Byte, Short, Int, or Long (${param.value::class.simpleName})") + } + } + ParameterType.STRING -> { + when (param.value) { + null -> stmt.setNull(idx, Types.NULL) + is String -> stmt.setString(idx, param.value) + else -> stmt.setString(idx, param.value.toString()) + } + } + ParameterType.JSON -> stmt.setString(idx, Configuration.json.encodeToString(param.value)) + } + } + } + } catch (ex: SQLException) { + throw DocumentException("Error creating query / binding parameters: ${ex.message}", ex) + } + } +} diff --git a/src/common/src/main/kotlin/Query.kt b/src/common/src/main/kotlin/Query.kt index 31e6a67..4a0dbad 100644 --- a/src/common/src/main/kotlin/Query.kt +++ b/src/common/src/main/kotlin/Query.kt @@ -146,22 +146,21 @@ object Query { } else { Pair, String?>(it, null) } - val path = - if (field.name.startsWith("n:")) { - val fld = Field.named(field.name.substring(2)) + val path = when { + field.name.startsWith("n:") -> Field.named(field.name.substring(2)).let { fld -> when (dialect) { Dialect.POSTGRESQL -> "(${fld.path(dialect)})::numeric" Dialect.SQLITE -> fld.path(dialect) } - } else if (field.name.startsWith("i:")) { - val p = Field.named(field.name.substring(2)).path(dialect) + } + field.name.startsWith("i:") -> Field.named(field.name.substring(2)).path(dialect).let { p -> when (dialect) { Dialect.POSTGRESQL -> "LOWER($p)" Dialect.SQLITE -> "$p COLLATE NOCASE" } - } else { - field.path(dialect) } + else -> field.path(dialect) + } "$path${direction ?: ""}" } return " ORDER BY $orderFields" diff --git a/src/common/src/main/kotlin/Results.kt b/src/common/src/main/kotlin/Results.kt new file mode 100644 index 0000000..777a228 --- /dev/null +++ b/src/common/src/main/kotlin/Results.kt @@ -0,0 +1,75 @@ +package solutions.bitbadger.documents.common + +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.SQLException + +/** + * Helper functions for handling results + */ +object Results { + + /** + * Create a domain item from a document, specifying the field in which the document is found + * + * @param field The field name containing the JSON document + * @param rs A `ResultSet` set to the row with the document to be constructed + * @return The constructed domain item + */ + inline fun fromDocument(field: String, rs: ResultSet): TDoc = + Configuration.json.decodeFromString(rs.getString(field)) + + /** + * Create a domain item from a document + * + * @param rs A `ResultSet` set to the row with the document to be constructed< + * @return The constructed domain item + */ + inline fun fromData(rs: ResultSet): TDoc = + fromDocument("data", rs) + + /** + * Create a list of items for the results of the given command, using the specified mapping function + * + * @param stmt The prepared statement to execute + * @param mapFunc The mapping function from data reader to domain class instance + * @return A list of items from the query's result + * @throws DocumentException If there is a problem executing the query + */ + inline fun toCustomList(stmt: PreparedStatement, mapFunc: (ResultSet) -> TDoc): List = + try { + stmt.executeQuery().use { + val results = mutableListOf() + while (it.next()) { + results.add(mapFunc(it)) + } + results.toList() + } + } catch (ex: SQLException) { + throw DocumentException("Error retrieving documents from query: ${ex.message}", ex) + } + + /** + * Extract a count from the first column + * + * @param rs A `ResultSet` set to the row with the count to retrieve + * @return The count from the row + */ + fun toCount(rs: ResultSet): Long = + when (Configuration.dialect) { + Dialect.POSTGRESQL -> rs.getInt("it").toLong() + Dialect.SQLITE -> rs.getLong("it") + } + + /** + * Extract a true/false value from the first column + * + * @param rs A `ResultSet` set to the row with the true/false value to retrieve + * @return The true/false value from the row + */ + fun toExists(rs: ResultSet): Boolean = + when (Configuration.dialect) { + Dialect.POSTGRESQL -> rs.getBoolean("it") + Dialect.SQLITE -> toCount(rs) > 0L + } +} diff --git a/src/common/src/test/kotlin/ParameterTest.kt b/src/common/src/test/kotlin/ParameterTest.kt new file mode 100644 index 0000000..7cc17e3 --- /dev/null +++ b/src/common/src/test/kotlin/ParameterTest.kt @@ -0,0 +1,34 @@ +package solutions.bitbadger.documents.common + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.assertThrows +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ParameterTest { + + @Test + @DisplayName("Construction with colon-prefixed name") + fun ctorWithColon() { + val p = Parameter(":test", ParameterType.STRING, "ABC") + assertEquals(":test", p.name, "Parameter name was incorrect") + assertEquals(ParameterType.STRING, p.type, "Parameter type was incorrect") + assertEquals("ABC", p.value, "Parameter value was incorrect") + } + + @Test + @DisplayName("Construction with at-sign-prefixed name") + fun ctorWithAtSign() { + val p = Parameter("@yo", ParameterType.NUMBER, null) + assertEquals("@yo", p.name, "Parameter name was incorrect") + assertEquals(ParameterType.NUMBER, p.type, "Parameter type was incorrect") + assertNull(p.value, "Parameter value was incorrect") + } + + @Test + @DisplayName("Construction fails with incorrect prefix") + fun ctorFailsForPrefix() { + assertThrows { Parameter("it", ParameterType.JSON, "") } + } +} diff --git a/src/common/src/test/kotlin/ParametersTest.kt b/src/common/src/test/kotlin/ParametersTest.kt new file mode 100644 index 0000000..0394a12 --- /dev/null +++ b/src/common/src/test/kotlin/ParametersTest.kt @@ -0,0 +1,18 @@ +package solutions.bitbadger.documents.common + +import org.junit.jupiter.api.DisplayName +import kotlin.test.Test +import kotlin.test.assertEquals + +class ParametersTest { + + @Test + @DisplayName("replaceNamesInQuery replaces successfully") + fun replaceNamesInQuery() { + val parameters = listOf(Parameter(":data", ParameterType.JSON, "{}"), + Parameter(":data_ext", ParameterType.STRING, "")) + val query = "SELECT data, data_ext FROM tbl WHERE data = :data AND data_ext = :data_ext AND more_data = :data" + assertEquals("SELECT data, data_ext FROM tbl WHERE data = ? AND data_ext = ? AND more_data = ?", + Parameters.replaceNamesInQuery(query, parameters), "Parameters not replaced correctly") + } +} diff --git a/src/sqlite/src/main/kotlin/Query.kt b/src/sqlite/src/main/kotlin/Query.kt index 18863df..df75709 100644 --- a/src/sqlite/src/main/kotlin/Query.kt +++ b/src/sqlite/src/main/kotlin/Query.kt @@ -1,7 +1,7 @@ package solutions.bitbadger.documents.sqlite import solutions.bitbadger.documents.common.* -import solutions.bitbadger.documents.common.Configuration +import solutions.bitbadger.documents.common.Configuration as BaseConfig; import solutions.bitbadger.documents.common.Query /** @@ -30,13 +30,16 @@ object Query { } Op.IN -> { val p = name.derive(it.parameterName) - val values = comp.value as List<*> + val values = comp.value as Collection<*> val paramNames = values.indices.joinToString(", ") { idx -> "${p}_$idx" } "${it.path(Dialect.SQLITE, FieldFormat.SQL)} ${comp.op.sql} ($paramNames)" } Op.IN_ARRAY -> { val p = name.derive(it.parameterName) - val (table, values) = comp.value as Pair> + @Suppress("UNCHECKED_CAST") + val tableAndValues = comp.value as? Pair> + ?: throw IllegalArgumentException("InArray field invalid") + val (table, values) = tableAndValues val paramNames = values.indices.joinToString(", ") { idx -> "${p}_$idx" } "EXISTS (SELECT 1 FROM json_each($table.data, '$.${it.name}') WHERE value IN ($paramNames)" } @@ -54,7 +57,8 @@ object Query { * @return A `WHERE` clause fragment identifying a document by its ID */ fun whereById(docId: TKey): String = - whereByFields(FieldMatch.ANY, listOf(Field.equal(Configuration.idField, docId).withParameterName(":id"))) + whereByFields(FieldMatch.ANY, + listOf(Field.equal(BaseConfig.idField, docId).withParameterName(":id"))) /** * Create an `UPDATE` statement to patch documents diff --git a/src/sqlite/src/test/kotlin/QueryTest.kt b/src/sqlite/src/test/kotlin/QueryTest.kt new file mode 100644 index 0000000..627a6f7 --- /dev/null +++ b/src/sqlite/src/test/kotlin/QueryTest.kt @@ -0,0 +1,99 @@ +package solutions.bitbadger.documents.sqlite + +import org.junit.jupiter.api.DisplayName +import solutions.bitbadger.documents.common.Field +import solutions.bitbadger.documents.common.FieldMatch +import kotlin.test.Test +import kotlin.test.assertEquals + +class QueryTest { + + @Test + @DisplayName("whereByFields generates for a single field with logical operator") + fun whereByFieldSingleLogical() { + assertEquals("data->>'theField' > :test", + Query.whereByFields(FieldMatch.ANY, listOf(Field.greater("theField", 0).withParameterName(":test"))), + "WHERE clause not correct") + } + + @Test + @DisplayName("whereByFields generates for a single field with existence operator") + fun whereByFieldSingleExistence() { + assertEquals("data->>'thatField' IS NULL", + Query.whereByFields(FieldMatch.ANY, listOf(Field.notExists("thatField"))), "WHERE clause not correct") + } + + @Test + @DisplayName("whereByFields generates for a single field with between operator") + fun whereByFieldSingleBetween() { + assertEquals("data->>'aField' BETWEEN :rangemin AND :rangemax", + Query.whereByFields(FieldMatch.ALL, listOf(Field.between("aField", 50, 99).withParameterName(":range"))), + "WHERE clause not correct") + } + + @Test + @DisplayName("whereByFields generates for all multiple fields with logical operator") + fun whereByFieldAllMultipleLogical() { + assertEquals("data->>'theFirst' = :field0 AND data->>'numberTwo' = :field1", + Query.whereByFields(FieldMatch.ALL, listOf(Field.equal("theFirst", "1"), Field.equal("numberTwo", "2"))), + "WHERE clause not correct") + } + + @Test + @DisplayName("whereByFields generates for any multiple fields with existence operator") + fun whereByFieldAnyMultipleExistence() { + assertEquals("data->>'thatField' IS NULL OR data->>'thisField' >= :field0", + Query.whereByFields(FieldMatch.ANY, + listOf(Field.notExists("thatField"), Field.greaterOrEqual("thisField", 18))), + "WHERE clause not correct") + } + + @Test + @DisplayName("whereByFields generates for an In comparison") + fun whereByFieldIn() { + assertEquals("data->>'this' IN (:field0_0, :field0_1, :field0_2)", + Query.whereByFields(FieldMatch.ALL, listOf(Field.any("this", listOf("a", "b", "c")))), + "WHERE clause not correct") + } + + @Test + @DisplayName("whereByFields generates for an InArray comparison") + fun whereByFieldInArray() { + assertEquals("EXISTS (SELECT 1 FROM json_each(the_table.data, '$.this') WHERE value IN (:field0_0, :field0_1)", + Query.whereByFields(FieldMatch.ALL, listOf(Field.inArray("this", "the_table", listOf("a", "b")))), + "WHERE clause not correct") + } + + @Test + @DisplayName("whereById generates correctly") + fun whereById() { + assertEquals("data->>'id' = :id", Query.whereById("abc"), "WHERE clause not correct") + } + + @Test + @DisplayName("patch generates the correct query") + fun patch() { + assertEquals("UPDATE my_table SET data = json_patch(data, json(:data))", Query.patch("my_table"), + "Query not correct") + } + + @Test + @DisplayName("byId generates the correct query") + fun byId() { + assertEquals("test WHERE data->>'id' = :id", Query.byId("test", "14"), "By-ID query not correct") + } + + @Test + @DisplayName("byFields generates the correct query") + fun byFields() { + assertEquals("unit WHERE data->>'That' > :field0", + Query.byFields("unit", FieldMatch.ANY, listOf(Field.greater("That", 14))), "By-fields query not correct") + } + + @Test + @DisplayName("Definition.ensureTable generates the correct query") + fun ensureTable() { + assertEquals("CREATE TABLE IF NOT EXISTS tbl (data TEXT NOT NULL)", Query.Definition.ensureTable("tbl"), + "CREATE TABLE statement not correct") + } +}