Collapse into top-level module

This commit is contained in:
2025-02-17 12:15:43 -05:00
parent 4249159252
commit b1a9910d50
32 changed files with 37 additions and 40 deletions

75
src/main/kotlin/AutoId.kt Normal file
View File

@@ -0,0 +1,75 @@
package solutions.bitbadger.documents
import kotlin.reflect.full.*
/**
* Strategies for automatic document IDs
*/
enum class AutoId {
/** No automatic IDs will be generated */
DISABLED,
/** Generate a `MAX`-plus-1 numeric ID */
NUMBER,
/** Generate a `UUID` string ID */
UUID,
/** Generate a random hex character string ID */
RANDOM_STRING;
companion object {
/**
* Generate a `UUID` string
*
* @return A `UUID` string
*/
fun generateUUID(): String =
java.util.UUID.randomUUID().toString().replace("-", "")
/**
* Generate a string of random hex characters
*
* @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? = 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
*
* @param strategy The auto ID strategy for which the document is evaluated
* @param document The document whose need of an automatic ID should be determined
* @param idProp The name of the document property containing the ID
* @return `true` if the document needs an automatic ID, `false` if not
* @throws IllegalArgumentException If bad input prevents the determination
*/
fun <T> needsAutoId(strategy: AutoId, document: T, idProp: String): Boolean {
if (document == null) throw IllegalArgumentException("document cannot be null")
if (strategy == DISABLED) return false;
val id = document!!::class.memberProperties.find { it.name == idProp }
if (id == null) throw IllegalArgumentException("$idProp not found in document")
if (strategy == NUMBER) {
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 == String::class.createType()) {
return id.call(document) == ""
}
throw IllegalArgumentException("$idProp was not a string; cannot auto-generate UUID or random string")
}
}
}

View File

@@ -0,0 +1,25 @@
package solutions.bitbadger.documents
/**
* A 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<T>(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
}
}

View File

@@ -0,0 +1,62 @@
package solutions.bitbadger.documents
import kotlinx.serialization.json.Json
import java.sql.Connection
import java.sql.DriverManager
object Configuration {
/**
* 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"
/** The automatic ID strategy to use */
var autoIdStrategy = AutoId.DISABLED
/** 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
*
* @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)
}
/**
* 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")
}

View File

@@ -0,0 +1,40 @@
package solutions.bitbadger.documents
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 <reified TDoc> Connection.customList(query: String, parameters: Collection<Parameter<*>>,
mapFunc: (ResultSet) -> TDoc): List<TDoc> =
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 <reified TDoc> Connection.customSingle(query: String, parameters: Collection<Parameter<*>>,
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<Parameter<*>>) {
Parameters.apply(this, query, parameters).use { it.executeUpdate() }
}

View File

@@ -0,0 +1,28 @@
package solutions.bitbadger.documents
/**
* The SQL dialect to use when building queries
*/
enum class Dialect {
/** PostgreSQL */
POSTGRESQL,
/** 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]")
}
}
}

View File

@@ -0,0 +1,9 @@
package solutions.bitbadger.documents
/**
* 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)

211
src/main/kotlin/Field.kt Normal file
View File

@@ -0,0 +1,211 @@
package solutions.bitbadger.documents
/**
* A field and its comparison
*
* @property name The name of the field in the JSON document
* @property comparison The comparison to apply against the field
* @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<T> private constructor(
val name: String,
val comparison: Comparison<T>,
val parameterName: String? = null,
val qualifier: String? = null) {
/**
* Specify the parameter name for the field
*
* @param paramName The parameter name to use for this field
* @return A new `Field` with the parameter name specified
*/
fun withParameterName(paramName: String): Field<T> =
Field(name, comparison, paramName, qualifier)
/**
* Specify a qualifier (alias) for the document table
*
* @param alias The table alias for this field
* @return A new `Field` with the table qualifier specified
*/
fun withQualifier(alias: String): Field<T> =
Field(name, comparison, parameterName, alias)
/**
* Get the path for this field
*
* @param dialect The SQL dialect to use for the path to the JSON field
* @param format Whether the value should be retrieved as JSON or SQL (optional, default SQL)
* @return The path for the 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<String, *> ?: 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
*
* @param name The name of the field to be compared
* @param value The value for the comparison
* @return A `Field` with the given comparison
*/
fun <T> equal(name: String, value: T) =
Field(name, Comparison(Op.EQUAL, value))
/**
* Create a field greater-than comparison
*
* @param name The name of the field to be compared
* @param value The value for the comparison
* @return A `Field` with the given comparison
*/
fun <T> greater(name: String, value: T) =
Field(name, Comparison(Op.GREATER, value))
/**
* Create a field greater-than-or-equal-to comparison
*
* @param name The name of the field to be compared
* @param value The value for the comparison
* @return A `Field` with the given comparison
*/
fun <T> greaterOrEqual(name: String, value: T) =
Field(name, Comparison(Op.GREATER_OR_EQUAL, value))
/**
* Create a field less-than comparison
*
* @param name The name of the field to be compared
* @param value The value for the comparison
* @return A `Field` with the given comparison
*/
fun <T> less(name: String, value: T) =
Field(name, Comparison(Op.LESS, value))
/**
* Create a field less-than-or-equal-to comparison
*
* @param name The name of the field to be compared
* @param value The value for the comparison
* @return A `Field` with the given comparison
*/
fun <T> lessOrEqual(name: String, value: T) =
Field(name, Comparison(Op.LESS_OR_EQUAL, value))
/**
* Create a field inequality comparison
*
* @param name The name of the field to be compared
* @param value The value for the comparison
* @return A `Field` with the given comparison
*/
fun <T> notEqual(name: String, value: T) =
Field(name, Comparison(Op.NOT_EQUAL, value))
/**
* Create a field range comparison
*
* @param name The name of the field to be compared
* @param minValue The lower value for the comparison
* @param maxValue The upper value for the comparison
* @return A `Field` with the given comparison
*/
fun <T> between(name: String, minValue: T, maxValue: T) =
Field(name, Comparison(Op.BETWEEN, Pair(minValue, maxValue)))
/**
* Create a field where any values match (SQL `IN`)
*
* @param name The name of the field to be compared
* @param values The values for the comparison
* @return A `Field` with the given comparison
*/
fun <T> any(name: String, values: List<T>) =
Field(name, Comparison(Op.IN, values))
/**
* Create a field where values should exist in a document's array
*
* @param name The name of the field to be compared
* @param tableName The name of the document table
* @param values The values for the comparison
* @return A `Field` with the given comparison
*/
fun <T> inArray(name: String, tableName: String, values: List<T>) =
Field(name, Comparison(Op.IN_ARRAY, Pair(tableName, values)))
fun exists(name: String) =
Field(name, Comparison(Op.EXISTS, ""))
fun notExists(name: String) =
Field(name, Comparison(Op.NOT_EXISTS, ""))
fun named(name: String) =
Field(name, Comparison(Op.EQUAL, ""))
fun nameToPath(name: String, dialect: Dialect, format: FieldFormat): String {
val path = StringBuilder("data")
val extra = if (format == FieldFormat.SQL) ">" else ""
if (name.indexOf('.') > -1) {
if (dialect == Dialect.POSTGRESQL) {
path.append("#>", extra, "'{", name.replace('.', ','), "}'")
} else {
val names = name.split('.').toMutableList()
val last = names.removeLast()
names.forEach { path.append("->'", it, "'") }
path.append("->", extra, "'", last, "'")
}
} else {
path.append("->", extra, "'", name, "'")
}
return path.toString()
}
}
}

View File

@@ -0,0 +1,11 @@
package solutions.bitbadger.documents
/**
* The data format for a document field retrieval
*/
enum class FieldFormat {
/** Retrieve the field as a SQL value (string in PostgreSQL, best guess in SQLite */
SQL,
/** Retrieve the field as a JSON value */
JSON
}

View File

@@ -0,0 +1,11 @@
package solutions.bitbadger.documents
/**
* How fields should be matched in by-field queries
*/
enum class FieldMatch(val sql: String) {
/** Match any of the field criteria (`OR`) */
ANY("OR"),
/** Match all the field criteria (`AND`) */
ALL("AND"),
}

29
src/main/kotlin/Op.kt Normal file
View File

@@ -0,0 +1,29 @@
package solutions.bitbadger.documents
/**
* A comparison operator used for fields
*/
enum class Op(val sql: String) {
/** Compare using equality */
EQUAL("="),
/** Compare using greater-than */
GREATER(">"),
/** Compare using greater-than-or-equal-to */
GREATER_OR_EQUAL(">="),
/** Compare using less-than */
LESS("<"),
/** Compare using less-than-or-equal-to */
LESS_OR_EQUAL("<="),
/** Compare using inequality */
NOT_EQUAL("<>"),
/** Compare between two values */
BETWEEN("BETWEEN"),
/** Compare existence in a list of values */
IN("IN"),
/** Compare overlap between an array and a list of values */
IN_ARRAY("?|"),
/** Compare existence */
EXISTS("IS NOT NULL"),
/** Compare nonexistence */
NOT_EXISTS("IS NULL")
}

View File

@@ -0,0 +1,15 @@
package solutions.bitbadger.documents
/**
* 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<T>(val name: String, val type: ParameterType, val value: T) {
init {
if (!name.startsWith(':') && !name.startsWith('@'))
throw DocumentException("Name must start with : or @ ($name)")
}
}

View File

@@ -0,0 +1,18 @@
package solutions.bitbadger.documents
/**
* Derive parameter names; each instance wraps a counter to provide names for anonymous fields
*/
class ParameterName {
private var currentIdx = 0
/**
* Derive the parameter name from the current possibly-null string
*
* @param paramName The name of the parameter as specified by the field
* @return The name from the field, if present, or a derived name if missing
*/
fun derive(paramName: String?): String =
paramName ?: ":field${currentIdx++}"
}

View File

@@ -0,0 +1,13 @@
package solutions.bitbadger.documents
/**
* 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,
}

View File

@@ -0,0 +1,96 @@
package solutions.bitbadger.documents
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 <daniel@bitbadger.solutions>
*/
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<Field<*>>): Collection<Field<*>> {
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
*
* @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<Parameter<*>>) =
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<Parameter<*>>): 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<Pair<Int, Parameter<*>>>()
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)
}
}
}

335
src/main/kotlin/Query.kt Normal file
View File

@@ -0,0 +1,335 @@
package solutions.bitbadger.documents
object Query {
/**
* Combine a query (`SELECT`, `UPDATE`, etc.) and a `WHERE` clause
*
* @param statement The first part of the statement
* @param where The `WHERE` clause for the statement
* @return The two parts of the query combined with `WHERE`
*/
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<Field<*>>, 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 <TKey> 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 <TKey> 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<Field<*>>) =
Query.statementWhere(statement, Where.byFields(fields, howMatched))
/**
* Functions to create queries to define tables and indexes
*/
object Definition {
/**
* SQL statement to create a document table
*
* @param tableName The name of the table to create (may include schema)
* @param dataType The type of data for the column (`JSON`, `JSONB`, etc.)
* @return A query to create a document table
*/
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
*
* @param tableName The name of the table, possibly with a schema
* @return A pair with the first item as the schema and the second as the table name
*/
private fun splitSchemaAndTable(tableName: String): Pair<String, String> {
val parts = tableName.split('.')
return if (parts.size == 1) Pair("", tableName) else Pair(parts[0], parts[1])
}
/**
* SQL statement to create an index on one or more fields in a JSON document
*
* @param tableName The table on which an index should be created (may include schema)
* @param indexName The name of the index to be created
* @param fields One or more fields to include in the index
* @param dialect The SQL dialect to use when creating this index
* @return A query to create the field index
*/
fun ensureIndexOn(tableName: String, indexName: String, fields: Collection<String>, dialect: Dialect): String {
val (_, tbl) = splitSchemaAndTable(tableName)
val jsonFields = fields.joinToString(", ") {
val parts = it.split(' ')
val direction = if (parts.size > 1) " ${parts[1]}" else ""
"(" + Field.nameToPath(parts[0], dialect, FieldFormat.SQL) + ")$direction"
}
return "CREATE INDEX IF NOT EXISTS idx_${tbl}_$indexName ON $tableName ($jsonFields)"
}
/**
* SQL statement to create a key index for a document table
*
* @param tableName The table on which a key index should be created (may include schema)
* @param dialect The SQL dialect to use when creating this index
* @return A query to create the key index
*/
fun ensureKey(tableName: String, dialect: Dialect): String =
ensureIndexOn(tableName, "key", listOf(Configuration.idField), dialect).replace("INDEX", "UNIQUE INDEX")
}
/**
* Query to insert a document
*
* @param tableName The table into which to insert (may include schema)
* @return A query to insert a document
*/
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")
*
* @param tableName The table into which to save (may include schema)
* @return A query to save a document
*/
fun save(tableName: String): String =
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)
*
* @param tableName The table in which to count documents (may include schema)
* @return A query to count documents
*/
fun count(tableName: String): String =
"SELECT COUNT(*) AS it FROM $tableName"
/**
* Query to check for document existence in a table
*
* @param tableName The table in which existence should be checked (may include schema)
* @param where The `WHERE` clause with the existence criteria
* @return A query to check document existence
*/
fun exists(tableName: String, where: String): String =
"SELECT EXISTS (SELECT 1 FROM $tableName WHERE $where) AS it"
/**
* Query to select documents from a table (this query has no `WHERE` clause)
*
* @param tableName The table from which documents should be found (may include schema)
* @return A query to retrieve documents
*/
fun find(tableName: String): String =
"SELECT data FROM $tableName"
/**
* Query to update (replace) a document (this query has no `WHERE` clause)
*
* @param tableName The table in which documents should be replaced (may include schema)
* @return A query to update documents
*/
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 <TKey> 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<Field<*>>, 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 <TKey> 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 <TKey> byJsonPath(tableName: String) =
patch(tableName, Where.jsonPathMatches())
}
/**
* Query to delete documents from a table (this query has no `WHERE` clause)
*
* @param tableName The table in which documents should be deleted (may include schema)
* @return A query to delete documents
*/
fun delete(tableName: String): String =
"DELETE FROM $tableName"
/**
* Create an `ORDER BY` clause for the given fields
*
* @param fields One or more fields by which to order
* @param dialect The SQL dialect for the generated clause
* @return An `ORDER BY` clause for the given fields
*/
fun orderBy(fields: Collection<Field<*>>, dialect: Dialect): String {
if (fields.isEmpty()) return ""
val orderFields = fields.joinToString(", ") {
val (field, direction) =
if (it.name.indexOf(' ') > -1) {
val parts = it.name.split(' ')
Pair(Field.named(parts[0]), " " + parts.drop(1).joinToString(" "))
} else {
Pair<Field<*>, String?>(it, null)
}
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)
}
}
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)
}
"$path${direction ?: ""}"
}
return " ORDER BY $orderFields"
}
}

View File

@@ -0,0 +1,75 @@
package solutions.bitbadger.documents
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 <reified TDoc> fromDocument(field: String, rs: ResultSet): TDoc =
Configuration.json.decodeFromString<TDoc>(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 <reified TDoc> 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 <reified TDoc> toCustomList(stmt: PreparedStatement, mapFunc: (ResultSet) -> TDoc): List<TDoc> =
try {
stmt.executeQuery().use {
val results = mutableListOf<TDoc>()
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
}
}