diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 0cf8482..ca3012f 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -7,8 +7,10 @@ - + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 161ad45..ba84d0a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -8,6 +8,11 @@ + diff --git a/src/common/src/main/kotlin/AutoId.kt b/src/common/src/main/kotlin/AutoId.kt index f152a8d..e77d78f 100644 --- a/src/common/src/main/kotlin/AutoId.kt +++ b/src/common/src/main/kotlin/AutoId.kt @@ -28,13 +28,15 @@ enum class AutoId { /** * Generate a string of random hex characters * - * @param length The length of the string + * @param length The length of the string (optional; defaults to configured length) * @return A string of random hex characters of the requested length */ - fun generateRandomString(length: Int): String = - kotlin.random.Random.nextBytes((length + 2) / 2) - .joinToString("") { String.format("%02x", it) } - .substring(0, length) + fun generateRandomString(length: Int? = null): String = + (length ?: Configuration.idStringLength).let { len -> + kotlin.random.Random.nextBytes((len + 2) / 2) + .joinToString("") { String.format("%02x", it) } + .substring(0, len) + } /** * Determine if a document needs an automatic ID applied diff --git a/src/common/src/main/kotlin/Comparison.kt b/src/common/src/main/kotlin/Comparison.kt index d6f7c24..01710b5 100644 --- a/src/common/src/main/kotlin/Comparison.kt +++ b/src/common/src/main/kotlin/Comparison.kt @@ -6,4 +6,20 @@ package solutions.bitbadger.documents.common * @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 Comparison(val op: Op, val value: T) { + + /** Is the value for this comparison a numeric value? */ + val isNumeric: Boolean + get() = + if (op == Op.IN || op == Op.BETWEEN) { + val values = value as? Collection<*> + if (values.isNullOrEmpty()) { + false + } else { + val first = values.elementAt(0) + first is Byte || first is Short || first is Int || first is Long + } + } else { + value is Byte || value is Short || value is Int || value is Long + } +} diff --git a/src/common/src/main/kotlin/Configuration.kt b/src/common/src/main/kotlin/Configuration.kt index 34c5e93..d71d43a 100644 --- a/src/common/src/main/kotlin/Configuration.kt +++ b/src/common/src/main/kotlin/Configuration.kt @@ -26,8 +26,15 @@ object Configuration { /** The length of automatic random hex character string */ var idStringLength = 16 + /** The derived dialect value from the connection string */ + private var dialectValue: Dialect? = null + /** The connection string for the JDBC connection */ var connectionString: String? = null + set(value) { + field = value + dialectValue = if (value.isNullOrBlank()) null else Dialect.deriveFromConnectionString(value) + } /** * Retrieve a new connection to the configured database @@ -42,23 +49,14 @@ object Configuration { 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!! - } + /** + * The dialect in use + * + * @param process The process being attempted + * @return The dialect for the current connection + * @throws DocumentException If the dialect has not been set + */ + fun dialect(process: String? = null): Dialect = + dialectValue ?: throw DocumentException( + "Database mode not set" + if (process == null) "" else "; cannot $process") } diff --git a/src/common/src/main/kotlin/Dialect.kt b/src/common/src/main/kotlin/Dialect.kt index bdf686c..9d4535f 100644 --- a/src/common/src/main/kotlin/Dialect.kt +++ b/src/common/src/main/kotlin/Dialect.kt @@ -7,5 +7,22 @@ enum class Dialect { /** PostgreSQL */ POSTGRESQL, /** SQLite */ - SQLITE + SQLITE; + + companion object { + + /** + * Derive the dialect from the given connection string + * + * @param connectionString The connection string from which the dialect will be derived + * @return The dialect for the connection string + * @throws DocumentException If the dialect cannot be determined + */ + fun deriveFromConnectionString(connectionString: String): Dialect = + when { + connectionString.contains("sqlite") -> SQLITE + connectionString.contains("postgresql") -> POSTGRESQL + else -> throw DocumentException("Cannot determine dialect from [$connectionString]") + } + } } diff --git a/src/common/src/main/kotlin/Field.kt b/src/common/src/main/kotlin/Field.kt index 214b2e3..6f45ee3 100644 --- a/src/common/src/main/kotlin/Field.kt +++ b/src/common/src/main/kotlin/Field.kt @@ -8,7 +8,7 @@ package solutions.bitbadger.documents.common * @property parameterName The name of the parameter to use in the query (optional, generated if missing) * @property qualifier A table qualifier to use to address the `data` field (useful for multi-table queries) */ -class Field( +class Field private constructor( val name: String, val comparison: Comparison, val parameterName: String? = null, @@ -42,7 +42,53 @@ class Field( fun path(dialect: Dialect, format: FieldFormat = FieldFormat.SQL): String = (if (qualifier == null) "" else "${qualifier}.") + nameToPath(name, dialect, format) + /** Parameters to bind each value of `IN` and `IN_ARRAY` operations */ + private val inParameterNames: String + get() { + val values = if (comparison.op == Op.IN) { + comparison.value as Collection<*> + } else { + val parts = comparison.value as Pair<*, *> + parts.second as Collection<*> + } + return List(values.size) { idx -> "${parameterName}_$idx" }.joinToString(", ") + } + + /** + * Create a `WHERE` clause fragment for this field + * + * @return The `WHERE` clause for this field + * @throws DocumentException If the field has no parameter name or the database dialect has not been set + */ + fun toWhere(): String { + if (parameterName == null && !listOf(Op.EXISTS, Op.NOT_EXISTS).contains(comparison.op)) + throw DocumentException("Parameter for $name must be specified") + + val dialect = Configuration.dialect("make field WHERE clause") + val fieldName = path(dialect, if (comparison.op == Op.IN_ARRAY) FieldFormat.JSON else FieldFormat.SQL) + val fieldPath = when (dialect) { + Dialect.POSTGRESQL -> if (comparison.isNumeric) "($fieldName)::numeric" else fieldName + Dialect.SQLITE -> fieldName + } + val criteria = when (comparison.op) { + in listOf(Op.EXISTS, Op.NOT_EXISTS) -> "" + Op.BETWEEN -> " ${parameterName}min AND ${parameterName}max" + Op.IN -> " ($inParameterNames)" + Op.IN_ARRAY -> if (dialect == Dialect.POSTGRESQL) " ARRAY['$inParameterNames']" else "" + else -> " $parameterName" + } + + @Suppress("UNCHECKED_CAST") + return if (dialect == Dialect.SQLITE && comparison.op == Op.IN_ARRAY) { + val (table, _) = comparison.value as? Pair ?: throw DocumentException("InArray field invalid") + "EXISTS (SELECT 1 FROM json_each($table.data, '$.$name') WHERE value IN ($inParameterNames)" + } else { + "$fieldPath ${comparison.op.sql} $criteria" + } + } + companion object { + /** * Create a field equality comparison * @@ -50,8 +96,8 @@ class Field( * @param value The value for the comparison * @return A `Field` with the given comparison */ - fun equal(name: String, value: T): Field = - Field(name, Comparison(Op.EQUAL, value)) + fun equal(name: String, value: T) = + Field(name, Comparison(Op.EQUAL, value)) /** * Create a field greater-than comparison @@ -60,7 +106,7 @@ class Field( * @param value The value for the comparison * @return A `Field` with the given comparison */ - fun greater(name: String, value: T): Field = + fun greater(name: String, value: T) = Field(name, Comparison(Op.GREATER, value)) /** @@ -70,7 +116,7 @@ class Field( * @param value The value for the comparison * @return A `Field` with the given comparison */ - fun greaterOrEqual(name: String, value: T): Field = + fun greaterOrEqual(name: String, value: T) = Field(name, Comparison(Op.GREATER_OR_EQUAL, value)) /** @@ -80,7 +126,7 @@ class Field( * @param value The value for the comparison * @return A `Field` with the given comparison */ - fun less(name: String, value: T): Field = + fun less(name: String, value: T) = Field(name, Comparison(Op.LESS, value)) /** @@ -90,7 +136,7 @@ class Field( * @param value The value for the comparison * @return A `Field` with the given comparison */ - fun lessOrEqual(name: String, value: T): Field = + fun lessOrEqual(name: String, value: T) = Field(name, Comparison(Op.LESS_OR_EQUAL, value)) /** @@ -100,7 +146,7 @@ class Field( * @param value The value for the comparison * @return A `Field` with the given comparison */ - fun notEqual(name: String, value: T): Field = + fun notEqual(name: String, value: T) = Field(name, Comparison(Op.NOT_EQUAL, value)) /** @@ -111,7 +157,7 @@ class Field( * @param maxValue The upper value for the comparison * @return A `Field` with the given comparison */ - fun between(name: String, minValue: T, maxValue: T): Field> = + fun between(name: String, minValue: T, maxValue: T) = Field(name, Comparison(Op.BETWEEN, Pair(minValue, maxValue))) /** @@ -121,7 +167,7 @@ class Field( * @param values The values for the comparison * @return A `Field` with the given comparison */ - fun any(name: String, values: List): Field> = + fun any(name: String, values: List) = Field(name, Comparison(Op.IN, values)) /** @@ -132,16 +178,16 @@ class Field( * @param values The values for the comparison * @return A `Field` with the given comparison */ - fun inArray(name: String, tableName: String, values: List): Field>> = + fun inArray(name: String, tableName: String, values: List) = Field(name, Comparison(Op.IN_ARRAY, Pair(tableName, values))) - fun exists(name: String): Field = + fun exists(name: String) = Field(name, Comparison(Op.EXISTS, "")) - fun notExists(name: String): Field = + fun notExists(name: String) = Field(name, Comparison(Op.NOT_EXISTS, "")) - fun named(name: String): Field = + fun named(name: String) = Field(name, Comparison(Op.EQUAL, "")) fun nameToPath(name: String, dialect: Dialect, format: FieldFormat): String { diff --git a/src/common/src/main/kotlin/Main.kt b/src/common/src/main/kotlin/Main.kt deleted file mode 100644 index 7265465..0000000 --- a/src/common/src/main/kotlin/Main.kt +++ /dev/null @@ -1,14 +0,0 @@ -//TIP To Run code, press or -// click the icon in the gutter. -fun main() { - val name = "Kotlin" - //TIP Press with your caret at the highlighted text - // to see how IntelliJ IDEA suggests fixing it. - println("Hello, " + name + "!") - - for (i in 1..5) { - //TIP Press to start debugging your code. We have set one breakpoint - // for you, but you can always add more by pressing . - println("i = $i") - } -} \ No newline at end of file diff --git a/src/common/src/main/kotlin/Parameters.kt b/src/common/src/main/kotlin/Parameters.kt index 4675abc..f43a711 100644 --- a/src/common/src/main/kotlin/Parameters.kt +++ b/src/common/src/main/kotlin/Parameters.kt @@ -12,6 +12,19 @@ import java.sql.Types */ object Parameters { + /** + * Assign parameter names to any fields that do not have them assigned + * + * @param fields The collection of fields to be named + * @return The collection of fields with parameter names assigned + */ + fun nameFields(fields: Collection>): Collection> { + val name = ParameterName() + return fields.map { + if (it.name.isBlank()) it.withParameterName(name.derive(null)) else it + } + } + /** * Replace the parameter names in the query with question marks * diff --git a/src/common/src/main/kotlin/Query.kt b/src/common/src/main/kotlin/Query.kt index 4a0dbad..b5485fe 100644 --- a/src/common/src/main/kotlin/Query.kt +++ b/src/common/src/main/kotlin/Query.kt @@ -9,9 +9,84 @@ object Query { * @param where The `WHERE` clause for the statement * @return The two parts of the query combined with `WHERE` */ - fun statementWhere(statement: String, where: String): String = + fun statementWhere(statement: String, where: String) = "$statement WHERE $where" + /** + * Functions to create `WHERE` clause fragments + */ + object Where { + + /** + * Create a `WHERE` clause fragment to query by one or more fields + * + * @param fields The fields to be queried + * @param howMatched How the fields should be matched (optional, defaults to `ALL`) + * @return A `WHERE` clause fragment to match the given fields + */ + fun byFields(fields: Collection>, howMatched: FieldMatch? = null) = + fields.joinToString(" ${(howMatched ?: FieldMatch.ALL).sql} ") { it.toWhere() } + + /** + * Create a `WHERE` clause fragment to retrieve a document by its ID + * + * @param parameterName The parameter name to use for the ID placeholder (optional, defaults to ":id") + * @param docId The ID value (optional; used for type determinations, string assumed if not provided) + */ + fun byId(parameterName: String = ":id", docId: TKey? = null) = + byFields(listOf(Field.equal(Configuration.idField, docId ?: "").withParameterName(parameterName))) + + /** + * Create a `WHERE` clause fragment to implement a JSON containment query (PostgreSQL only) + * + * @param parameterName The parameter name to use for the JSON placeholder (optional, defaults to ":criteria") + * @return A `WHERE` clause fragment to implement a JSON containment criterion + * @throws DocumentException If called against a SQLite database + */ + fun jsonContains(parameterName: String = ":criteria") = + when (Configuration.dialect("create containment WHERE clause")) { + Dialect.POSTGRESQL -> "data @> $parameterName" + Dialect.SQLITE -> throw DocumentException("JSON containment is not supported") + } + + /** + * Create a `WHERE` clause fragment to implement a JSON path match query (PostgreSQL only) + * + * @param parameterName The parameter name to use for the placeholder (optional, defaults to ":path") + * @return A `WHERE` clause fragment to implement a JSON path match criterion + * @throws DocumentException If called against a SQLite database + */ + fun jsonPathMatches(parameterName: String = ":path") = + when (Configuration.dialect("create JSON path match WHERE clause")) { + Dialect.POSTGRESQL -> "jsonb_path_exists(data, $parameterName::jsonpath)" + Dialect.SQLITE -> throw DocumentException("JSON path match is not supported") + } + } + + /** + * Create a query by a document's ID + * + * @param statement The SQL statement to be run against a document by its ID + * @param docId The ID of the document targeted + * @returns A query addressing a document by its ID + */ + fun byId(statement: String, docId: TKey) = + statementWhere(statement, Where.byId(docId = docId)) + + /** + * Create a query on JSON fields + * + * @param statement The SQL statement to be run against matching fields + * @param howMatched Whether to match any or all of the field conditions + * @param fields The field conditions to be matched + * @return A query addressing documents by field matching conditions + */ + fun byFields(statement: String, howMatched: FieldMatch, fields: Collection>) = + Query.statementWhere(statement, Where.byFields(fields, howMatched)) + + /** + * Functions to create queries to define tables and indexes + */ object Definition { /** @@ -24,6 +99,18 @@ object Query { fun ensureTableFor(tableName: String, dataType: String): String = "CREATE TABLE IF NOT EXISTS $tableName (data $dataType NOT NULL)" + /** + * SQL statement to create a document table in the current dialect + * + * @param tableName The name of the table to create (may include schema) + * @return A query to create a document table + */ + fun ensureTable(tableName: String) = + when (Configuration.dialect("create table creation query")) { + Dialect.POSTGRESQL -> ensureTableFor(tableName, "JSONB") + Dialect.SQLITE -> ensureTableFor(tableName, "TEXT") + } + /** * Split a schema and table name * @@ -71,8 +158,27 @@ object Query { * @param tableName The table into which to insert (may include schema) * @return A query to insert a document */ - fun insert(tableName: String): String = - "INSERT INTO $tableName VALUES (:data)" + fun insert(tableName: String, autoId: AutoId? = null): String { + val id = Configuration.idField + val values = when (Configuration.dialect("create INSERT statement")) { + Dialect.POSTGRESQL -> when (autoId ?: AutoId.DISABLED) { + AutoId.DISABLED -> ":data" + AutoId.NUMBER -> ":data::jsonb || ('{\"$id\":' || " + + "(SELECT COALESCE(MAX((data->>'$id')::numeric), 0) + 1 " + + "FROM $tableName) || '}')::jsonb" + AutoId.UUID -> ":data::jsonb || '{\"$id\":\"${AutoId.generateUUID()}\"}'" + AutoId.RANDOM_STRING -> ":data::jsonb || '{\"$id\":\"${AutoId.generateRandomString()}\"}'" + } + Dialect.SQLITE -> when (autoId ?: AutoId.DISABLED) { + AutoId.DISABLED -> ":data" + AutoId.NUMBER -> "json_set(:data, '$.$id', " + + "(SELECT coalesce(max(data->>'$id'), 0) + 1 FROM $tableName))" + AutoId.UUID -> "json_set(:data, '$.$id', '${AutoId.generateUUID()}')" + AutoId.RANDOM_STRING -> "json_set(:data, '$.$id', '${AutoId.generateRandomString()}')" + } + } + return "INSERT INTO $tableName VALUES ($values)" + } /** * Query to save a document, inserting it if it does not exist and updating it if it does (AKA "upsert") @@ -81,7 +187,8 @@ object Query { * @return A query to save a document */ fun save(tableName: String): String = - "${insert(tableName)} ON CONFLICT ((data->>'${Configuration.idField}')) DO UPDATE SET data = EXCLUDED.data" + insert(tableName, AutoId.DISABLED) + + " ON CONFLICT ((data->>'${Configuration.idField}')) DO UPDATE SET data = EXCLUDED.data" /** * Query to count documents in a table (this query has no `WHERE` clause) @@ -120,6 +227,66 @@ object Query { fun update(tableName: String): String = "UPDATE $tableName SET data = :data" + /** + * Functions to create queries to patch (partially update) JSON documents + */ + object Patch { + + /** + * Create an `UPDATE` statement to patch documents + * + * @param tableName The table to be updated + * @param where The `WHERE` clause for the query + * @return A query to patch documents + */ + private fun patch(tableName: String, where: String): String { + val setValue = when (Configuration.dialect("create patch query")) { + Dialect.POSTGRESQL -> "data || :data" + Dialect.SQLITE -> "json_patch(data, json(:data))" + } + return statementWhere("UPDATE $tableName SET data = $setValue", where) + } + + /** + * A query to patch (partially update) a JSON document by its ID + * + * @param tableName The name of the table where the document is stored + * @param docId The ID of the document to be updated (optional, used for type checking) + * @return A query to patch a JSON document by its ID + */ + fun byId(tableName: String, docId: TKey? = null) = + patch(tableName, Where.byId(docId = docId)) + + /** + * A query to patch (partially update) a JSON document using field match criteria + * + * @param tableName The name of the table where the documents are stored + * @param fields The field criteria + * @param howMatched How the fields should be matched (optional, defaults to `ALL`) + * @return A query to patch JSON documents by field match criteria + */ + fun byFields(tableName: String, fields: Collection>, howMatched: FieldMatch? = null) = + patch(tableName, Where.byFields(fields, howMatched)) + + /** + * A query to patch (partially update) a JSON document by JSON containment (PostgreSQL only) + * + * @param tableName The name of the table where the document is stored + * @return A query to patch JSON documents by JSON containment + */ + fun byContains(tableName: String) = + patch(tableName, Where.jsonContains()) + + /** + * A query to patch (partially update) a JSON document by JSON path match (PostgreSQL only) + * + * @param tableName The name of the table where the document is stored + * @return A query to patch JSON documents by JSON path match + */ + fun byJsonPath(tableName: String) = + patch(tableName, Where.jsonPathMatches()) + } + /** * Query to delete documents from a table (this query has no `WHERE` clause) * diff --git a/src/common/src/main/kotlin/Results.kt b/src/common/src/main/kotlin/Results.kt index 777a228..2f67413 100644 --- a/src/common/src/main/kotlin/Results.kt +++ b/src/common/src/main/kotlin/Results.kt @@ -56,7 +56,7 @@ object Results { * @return The count from the row */ fun toCount(rs: ResultSet): Long = - when (Configuration.dialect) { + when (Configuration.dialect()) { Dialect.POSTGRESQL -> rs.getInt("it").toLong() Dialect.SQLITE -> rs.getLong("it") } @@ -68,7 +68,7 @@ object Results { * @return The true/false value from the row */ fun toExists(rs: ResultSet): Boolean = - when (Configuration.dialect) { + when (Configuration.dialect()) { Dialect.POSTGRESQL -> rs.getBoolean("it") Dialect.SQLITE -> toCount(rs) > 0L } diff --git a/src/common/src/test/kotlin/QueryTest.kt b/src/common/src/test/kotlin/QueryTest.kt index 565243d..73248a2 100644 --- a/src/common/src/test/kotlin/QueryTest.kt +++ b/src/common/src/test/kotlin/QueryTest.kt @@ -67,14 +67,30 @@ class QueryTest { @Test @DisplayName("insert generates correctly") fun insert() { - assertEquals("INSERT INTO $tbl VALUES (:data)", Query.insert(tbl), "INSERT statement not constructed correctly") + try { + Configuration.connectionString = "postgresql" + assertEquals( + "INSERT INTO $tbl VALUES (:data)", + Query.insert(tbl), + "INSERT statement not constructed correctly" + ) + } finally { + Configuration.connectionString = null + } } @Test @DisplayName("save generates correctly") fun save() { - assertEquals("INSERT INTO $tbl VALUES (:data) ON CONFLICT ((data->>'id')) DO UPDATE SET data = EXCLUDED.data", - Query.save(tbl), "INSERT ON CONFLICT UPDATE statement not constructed correctly") + try { + Configuration.connectionString = "postgresql" + assertEquals( + "INSERT INTO $tbl VALUES (:data) ON CONFLICT ((data->>'id')) DO UPDATE SET data = EXCLUDED.data", + Query.save(tbl), "INSERT ON CONFLICT UPDATE statement not constructed correctly" + ) + } finally { + Configuration.connectionString = null + } } @Test diff --git a/src/sqlite/pom.xml b/src/sqlite/pom.xml deleted file mode 100644 index 4b69624..0000000 --- a/src/sqlite/pom.xml +++ /dev/null @@ -1,99 +0,0 @@ - - - 4.0.0 - - solutions.bitbadger.documents - sqlite - 4.0-ALPHA - - - UTF-8 - official - 1.8 - - - - - mavenCentral - https://repo1.maven.org/maven2/ - - - - - src/main/kotlin - src/test/kotlin - - - org.jetbrains.kotlin - kotlin-maven-plugin - 2.1.10 - - - compile - compile - - compile - - - - test-compile - test-compile - - test-compile - - - - - - maven-surefire-plugin - 2.22.2 - - - maven-failsafe-plugin - 2.22.2 - - - org.codehaus.mojo - exec-maven-plugin - 1.6.0 - - MainKt - - - - - - - - org.jetbrains.kotlin - kotlin-test-junit5 - 2.1.10 - test - - - org.junit.jupiter - junit-jupiter - 5.10.0 - test - - - org.jetbrains.kotlin - kotlin-stdlib - 2.1.10 - - - org.xerial - sqlite-jdbc - 3.46.1.2 - - - solutions.bitbadger.documents - common - 4.0-ALPHA - compile - - - - \ No newline at end of file diff --git a/src/sqlite/src/main/kotlin/Configuration.kt b/src/sqlite/src/main/kotlin/Configuration.kt deleted file mode 100644 index b944d0d..0000000 --- a/src/sqlite/src/main/kotlin/Configuration.kt +++ /dev/null @@ -1,26 +0,0 @@ -package solutions.bitbadger.documents.sqlite - -import java.sql.Connection -import java.sql.DriverManager - -/** - * Configuration for SQLite - */ -object Configuration { - - /** The connection string for the SQLite database */ - var connectionString: String? = null - - /** - * Retrieve a new connection to the SQLite database - * - * @return A new connection to the SQLite 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) - } -} diff --git a/src/sqlite/src/main/kotlin/Query.kt b/src/sqlite/src/main/kotlin/Query.kt deleted file mode 100644 index df75709..0000000 --- a/src/sqlite/src/main/kotlin/Query.kt +++ /dev/null @@ -1,106 +0,0 @@ -package solutions.bitbadger.documents.sqlite - -import solutions.bitbadger.documents.common.* -import solutions.bitbadger.documents.common.Configuration as BaseConfig; -import solutions.bitbadger.documents.common.Query - -/** - * Queries with specific syntax in SQLite - */ -object Query { - - /** - * Create a `WHERE` clause fragment to implement a comparison on fields in a JSON document - * - * @param howMatched How the fields should be matched - * @param fields The fields for the comparisons - * @return A `WHERE` clause implementing the comparisons for the given fields - */ - fun whereByFields(howMatched: FieldMatch, fields: Collection>): String { - val name = ParameterName() - return fields.joinToString(" ${howMatched.sql} ") { - val comp = it.comparison - when (comp.op) { - Op.EXISTS, Op.NOT_EXISTS -> { - "${it.path(Dialect.SQLITE, FieldFormat.SQL)} ${it.comparison.op.sql}" - } - Op.BETWEEN -> { - val p = name.derive(it.parameterName) - "${it.path(Dialect.SQLITE, FieldFormat.SQL)} ${comp.op.sql} ${p}min AND ${p}max" - } - Op.IN -> { - val p = name.derive(it.parameterName) - 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) - @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)" - } - else -> { - "${it.path(Dialect.SQLITE, FieldFormat.SQL)} ${comp.op.sql} ${name.derive(it.parameterName)}" - } - } - } - } - - /** - * Create a `WHERE` clause fragment to implement an ID-based query - * - * @param docId The ID of the document - * @return A `WHERE` clause fragment identifying a document by its ID - */ - fun whereById(docId: TKey): String = - whereByFields(FieldMatch.ANY, - listOf(Field.equal(BaseConfig.idField, docId).withParameterName(":id"))) - - /** - * Create an `UPDATE` statement to patch documents - * - * @param tableName The table to be updated - * @return A query to patch documents - */ - fun patch(tableName: String): String = - "UPDATE $tableName SET data = json_patch(data, json(:data))" - - // TODO: fun removeFields(tableName: String, fields: Collection): String - - /** - * Create a query by a document's ID - * - * @param statement The SQL statement to be run against a document by its ID - * @param docId The ID of the document targeted - * @returns A query addressing a document by its ID - */ - fun byId(statement: String, docId: TKey): String = - Query.statementWhere(statement, whereById(docId)) - - /** - * Create a query on JSON fields - * - * @param statement The SQL statement to be run against matching fields - * @param howMatched Whether to match any or all of the field conditions - * @param fields The field conditions to be matched - * @return A query addressing documents by field matching conditions - */ - fun byFields(statement: String, howMatched: FieldMatch, fields: Collection>): String = - Query.statementWhere(statement, whereByFields(howMatched, fields)) - - object Definition { - - /** - * SQL statement to create a document table - * - * @param tableName The name of the table (may include schema) - * @return A query to create the table if it does not exist - */ - fun ensureTable(tableName: String): String = - Query.Definition.ensureTableFor(tableName, "TEXT") - } -} diff --git a/src/sqlite/src/test/kotlin/QueryTest.kt b/src/sqlite/src/test/kotlin/QueryTest.kt deleted file mode 100644 index 627a6f7..0000000 --- a/src/sqlite/src/test/kotlin/QueryTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -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") - } -}