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

View File

@@ -1,112 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>solutions.bitbadger.documents</groupId>
<artifactId>common</artifactId>
<version>4.0-ALPHA</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<kotlin.code.style>official</kotlin.code.style>
<kotlin.compiler.jvmTarget>1.8</kotlin.compiler.jvmTarget>
<kotlin.version>2.1.0</kotlin.version>
<serialization.version>1.8.0</serialization.version>
</properties>
<repositories>
<repository>
<id>mavenCentral</id>
<url>https://repo1.maven.org/maven2/</url>
</repository>
</repositories>
<build>
<sourceDirectory>src/main/kotlin</sourceDirectory>
<testSourceDirectory>src/test/kotlin</testSourceDirectory>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
<configuration>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-serialization</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>2.22.2</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>1.6.0</version>
<configuration>
<mainClass>MainKt</mainClass>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test-junit5</artifactId>
<version>${kotlin.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<version>${kotlin.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
<version>${serialization.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -1,75 +0,0 @@
package solutions.bitbadger.documents.common
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

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

@@ -1,62 +0,0 @@
package solutions.bitbadger.documents.common
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

@@ -1,40 +0,0 @@
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 <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

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

@@ -1,9 +0,0 @@
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)

View File

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

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

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

View File

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

@@ -1,15 +0,0 @@
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<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

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

@@ -1,13 +0,0 @@
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,
}

View File

@@ -1,96 +0,0 @@
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 <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)
}
}
}

View File

@@ -1,335 +0,0 @@
package solutions.bitbadger.documents.common
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

@@ -1,75 +0,0 @@
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 <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
}
}

View File

@@ -1,160 +0,0 @@
package solutions.bitbadger.documents.common
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class AutoIdTest {
@Test
@DisplayName("Generates a UUID string")
fun generateUUID() {
assertEquals(32, AutoId.generateUUID().length, "The UUID should have been a 32-character string")
}
@Test
@DisplayName("Generates a random hex character string of an even length")
fun generateRandomStringEven() {
val result = AutoId.generateRandomString(8)
assertEquals(8, result.length, "There should have been 8 characters in $result")
}
@Test
@DisplayName("Generates a random hex character string of an odd length")
fun generateRandomStringOdd() {
val result = AutoId.generateRandomString(11)
assertEquals(11, result.length, "There should have been 11 characters in $result")
}
@Test
@DisplayName("Generates different random hex character strings")
fun generateRandomStringIsRandom() {
val result1 = AutoId.generateRandomString(16)
val result2 = AutoId.generateRandomString(16)
assertNotEquals(result1, result2, "There should have been 2 different strings generated")
}
@Test
@DisplayName("needsAutoId fails for null document")
fun needsAutoIdFailsForNullDocument() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.DISABLED, null, "id") }
}
@Test
@DisplayName("needsAutoId fails for missing ID property")
fun needsAutoIdFailsForMissingId() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.UUID, IntIdClass(0), "Id") }
}
@Test
@DisplayName("needsAutoId returns false if disabled")
fun needsAutoIdFalseIfDisabled() {
assertFalse(AutoId.needsAutoId(AutoId.DISABLED, "", ""), "Disabled Auto ID should always return false")
}
@Test
@DisplayName("needsAutoId returns true for Number strategy and byte ID of 0")
fun needsAutoIdTrueForByteWithZero() {
assertTrue(AutoId.needsAutoId(AutoId.NUMBER, ByteIdClass(0), "id"), "Number Auto ID with 0 should return true")
}
@Test
@DisplayName("needsAutoId returns false for Number strategy and byte ID of non-0")
fun needsAutoIdFalseForByteWithNonZero() {
assertFalse(AutoId.needsAutoId(AutoId.NUMBER, ByteIdClass(77), "id"),
"Number Auto ID with 77 should return false")
}
@Test
@DisplayName("needsAutoId returns true for Number strategy and short ID of 0")
fun needsAutoIdTrueForShortWithZero() {
assertTrue(AutoId.needsAutoId(AutoId.NUMBER, ShortIdClass(0), "id"), "Number Auto ID with 0 should return true")
}
@Test
@DisplayName("needsAutoId returns false for Number strategy and short ID of non-0")
fun needsAutoIdFalseForShortWithNonZero() {
assertFalse(AutoId.needsAutoId(AutoId.NUMBER, ShortIdClass(31), "id"),
"Number Auto ID with 31 should return false")
}
@Test
@DisplayName("needsAutoId returns true for Number strategy and int ID of 0")
fun needsAutoIdTrueForIntWithZero() {
assertTrue(AutoId.needsAutoId(AutoId.NUMBER, IntIdClass(0), "id"), "Number Auto ID with 0 should return true")
}
@Test
@DisplayName("needsAutoId returns false for Number strategy and int ID of non-0")
fun needsAutoIdFalseForIntWithNonZero() {
assertFalse(AutoId.needsAutoId(AutoId.NUMBER, IntIdClass(6), "id"), "Number Auto ID with 6 should return false")
}
@Test
@DisplayName("needsAutoId returns true for Number strategy and long ID of 0")
fun needsAutoIdTrueForLongWithZero() {
assertTrue(AutoId.needsAutoId(AutoId.NUMBER, LongIdClass(0), "id"), "Number Auto ID with 0 should return true")
}
@Test
@DisplayName("needsAutoId returns false for Number strategy and long ID of non-0")
fun needsAutoIdFalseForLongWithNonZero() {
assertFalse(AutoId.needsAutoId(AutoId.NUMBER, IntIdClass(2), "id"), "Number Auto ID with 2 should return false")
}
@Test
@DisplayName("needsAutoId fails for Number strategy and non-number ID")
fun needsAutoIdFailsForNumberWithStringId() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.NUMBER, StringIdClass(""), "id") }
}
@Test
@DisplayName("needsAutoId returns true for UUID strategy and blank ID")
fun needsAutoIdTrueForUUIDWithBlank() {
assertTrue(AutoId.needsAutoId(AutoId.UUID, StringIdClass(""), "id"),
"UUID Auto ID with blank should return true")
}
@Test
@DisplayName("needsAutoId returns false for UUID strategy and non-blank ID")
fun needsAutoIdFalseForUUIDNotBlank() {
assertFalse(AutoId.needsAutoId(AutoId.UUID, StringIdClass("howdy"), "id"),
"UUID Auto ID with non-blank should return false")
}
@Test
@DisplayName("needsAutoId fails for UUID strategy and non-string ID")
fun needsAutoIdFailsForUUIDNonString() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.UUID, IntIdClass(5), "id") }
}
@Test
@DisplayName("needsAutoId returns true for Random String strategy and blank ID")
fun needsAutoIdTrueForRandomWithBlank() {
assertTrue(AutoId.needsAutoId(AutoId.RANDOM_STRING, StringIdClass(""), "id"),
"Random String Auto ID with blank should return true")
}
@Test
@DisplayName("needsAutoId returns false for Random String strategy and non-blank ID")
fun needsAutoIdFalseForRandomNotBlank() {
assertFalse(AutoId.needsAutoId(AutoId.RANDOM_STRING, StringIdClass("full"), "id"),
"Random String Auto ID with non-blank should return false")
}
@Test
@DisplayName("needsAutoId fails for Random String strategy and non-string ID")
fun needsAutoIdFailsForRandomNonString() {
assertThrows<IllegalArgumentException> { AutoId.needsAutoId(AutoId.RANDOM_STRING, ShortIdClass(55), "id") }
}
}
data class ByteIdClass(var id: Byte)
data class ShortIdClass(var id: Short)
data class IntIdClass(var id: Int)
data class LongIdClass(var id: Long)
data class StringIdClass(var id: String)

View File

@@ -1,26 +0,0 @@
package solutions.bitbadger.documents.common
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class ConfigurationTest {
@Test
@DisplayName("Default ID field is `id`")
fun defaultIdField() {
assertEquals("id", Configuration.idField, "Default ID field incorrect")
}
@Test
@DisplayName("Default Auto ID strategy is `DISABLED`")
fun defaultAutoId() {
assertEquals(AutoId.DISABLED, Configuration.autoIdStrategy, "Default Auto ID strategy should be `disabled`")
}
@Test
@DisplayName("Default ID string length should be 16")
fun defaultIdStringLength() {
assertEquals(16, Configuration.idStringLength, "Default ID string length should be 16")
}
}

View File

@@ -1,20 +0,0 @@
package solutions.bitbadger.documents.common
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class FieldMatchTest {
@Test
@DisplayName("ANY uses proper SQL")
fun any() {
assertEquals("OR", FieldMatch.ANY.sql, "ANY should use OR")
}
@Test
@DisplayName("ALL uses proper SQL")
fun all() {
assertEquals("AND", FieldMatch.ALL.sql, "ALL should use AND")
}
}

View File

@@ -1,277 +0,0 @@
package solutions.bitbadger.documents.common
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class FieldTest {
@Test
@DisplayName("equal constructs a field")
fun equalCtor() {
val field = Field.equal("Test", 14)
assertEquals("Test", field.name, "Field name not filled correctly")
assertEquals(Op.EQUAL, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals(14, field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("greater constructs a field")
fun greaterCtor() {
val field = Field.greater("Great", "night")
assertEquals("Great", field.name, "Field name not filled correctly")
assertEquals(Op.GREATER, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals("night", field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("greaterOrEqual constructs a field")
fun greaterOrEqualCtor() {
val field = Field.greaterOrEqual("Nice", 88L)
assertEquals("Nice", field.name, "Field name not filled correctly")
assertEquals(Op.GREATER_OR_EQUAL, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals(88L, field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("less constructs a field")
fun lessCtor() {
val field = Field.less("Lesser", "seven")
assertEquals("Lesser", field.name, "Field name not filled correctly")
assertEquals(Op.LESS, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals("seven", field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("lessOrEqual constructs a field")
fun lessOrEqualCtor() {
val field = Field.lessOrEqual("Nobody", "KNOWS")
assertEquals("Nobody", field.name, "Field name not filled correctly")
assertEquals(Op.LESS_OR_EQUAL, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals("KNOWS", field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("notEqual constructs a field")
fun notEqualCtor() {
val field = Field.notEqual("Park", "here")
assertEquals("Park", field.name, "Field name not filled correctly")
assertEquals(Op.NOT_EQUAL, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals("here", field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("between constructs a field")
fun betweenCtor() {
val field = Field.between("Age", 18, 49)
assertEquals("Age", field.name, "Field name not filled correctly")
assertEquals(Op.BETWEEN, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals(18, field.comparison.value.first, "Field comparison min value not filled correctly")
assertEquals(49, field.comparison.value.second, "Field comparison max value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("any constructs a field")
fun inCtor() {
val field = Field.any("Here", listOf(8, 16, 32))
assertEquals("Here", field.name, "Field name not filled correctly")
assertEquals(Op.IN, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals(listOf(8, 16, 32), field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("inArray constructs a field")
fun inArrayCtor() {
val field = Field.inArray("ArrayField", "table", listOf("z"))
assertEquals("ArrayField", field.name, "Field name not filled correctly")
assertEquals(Op.IN_ARRAY, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals("table", field.comparison.value.first, "Field comparison table not filled correctly")
assertEquals(listOf("z"), field.comparison.value.second, "Field comparison values not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("exists constructs a field")
fun existsCtor() {
val field = Field.exists("Groovy")
assertEquals("Groovy", field.name, "Field name not filled correctly")
assertEquals(Op.EXISTS, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals("", field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("notExists constructs a field")
fun notExistsCtor() {
val field = Field.notExists("Groovy")
assertEquals("Groovy", field.name, "Field name not filled correctly")
assertEquals(Op.NOT_EXISTS, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals("", field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("named constructs a field")
fun namedCtor() {
val field = Field.named("Tacos")
assertEquals("Tacos", field.name, "Field name not filled correctly")
assertEquals(Op.EQUAL, field.comparison.op, "Field comparison operation not filled correctly")
assertEquals("", field.comparison.value, "Field comparison value not filled correctly")
assertNull(field.parameterName, "The parameter name should have been null")
assertNull(field.qualifier, "The qualifier should have been null")
}
@Test
@DisplayName("nameToPath creates a simple PostgreSQL SQL name")
fun nameToPathPostgresSimpleSQL() {
assertEquals("data->>'Simple'", Field.nameToPath("Simple", Dialect.POSTGRESQL, FieldFormat.SQL),
"Path not constructed correctly")
}
@Test
@DisplayName("nameToPath creates a simple SQLite SQL name")
fun nameToPathSQLiteSimpleSQL() {
assertEquals("data->>'Simple'", Field.nameToPath("Simple", Dialect.SQLITE, FieldFormat.SQL),
"Path not constructed correctly")
}
@Test
@DisplayName("nameToPath creates a nested PostgreSQL SQL name")
fun nameToPathPostgresNestedSQL() {
assertEquals("data#>>'{A,Long,Path,to,the,Property}'",
Field.nameToPath("A.Long.Path.to.the.Property", Dialect.POSTGRESQL, FieldFormat.SQL),
"Path not constructed correctly")
}
@Test
@DisplayName("nameToPath creates a nested SQLite SQL name")
fun nameToPathSQLiteNestedSQL() {
assertEquals("data->'A'->'Long'->'Path'->'to'->'the'->>'Property'",
Field.nameToPath("A.Long.Path.to.the.Property", Dialect.SQLITE, FieldFormat.SQL),
"Path not constructed correctly")
}
@Test
@DisplayName("nameToPath creates a simple PostgreSQL JSON name")
fun nameToPathPostgresSimpleJSON() {
assertEquals("data->'Simple'", Field.nameToPath("Simple", Dialect.POSTGRESQL, FieldFormat.JSON),
"Path not constructed correctly")
}
@Test
@DisplayName("nameToPath creates a simple SQLite JSON name")
fun nameToPathSQLiteSimpleJSON() {
assertEquals("data->'Simple'", Field.nameToPath("Simple", Dialect.SQLITE, FieldFormat.JSON),
"Path not constructed correctly")
}
@Test
@DisplayName("nameToPath creates a nested PostgreSQL JSON name")
fun nameToPathPostgresNestedJSON() {
assertEquals("data#>'{A,Long,Path,to,the,Property}'",
Field.nameToPath("A.Long.Path.to.the.Property", Dialect.POSTGRESQL, FieldFormat.JSON),
"Path not constructed correctly")
}
@Test
@DisplayName("nameToPath creates a nested SQLite JSON name")
fun nameToPathSQLiteNestedJSON() {
assertEquals("data->'A'->'Long'->'Path'->'to'->'the'->'Property'",
Field.nameToPath("A.Long.Path.to.the.Property", Dialect.SQLITE, FieldFormat.JSON),
"Path not constructed correctly")
}
@Test
@DisplayName("withParameterName adjusts the parameter name")
fun withParameterName() {
assertEquals(":name", Field.equal("Bob", "Tom").withParameterName(":name").parameterName,
"Parameter name not filled correctly")
}
@Test
@DisplayName("withQualifier adjust the table qualifier")
fun withQualifier() {
assertEquals("joe", Field.equal("Bill", "Matt").withQualifier("joe").qualifier,
"Qualifier not filled correctly")
}
@Test
@DisplayName("path generates for simple unqualified PostgreSQL field")
fun pathPostgresSimpleUnqualified() {
assertEquals("data->>'SomethingCool'",
Field.greaterOrEqual("SomethingCool", 18).path(Dialect.POSTGRESQL, FieldFormat.SQL), "Path not correct")
}
@Test
@DisplayName("path generates for simple qualified PostgreSQL field")
fun pathPostgresSimpleQualified() {
assertEquals("this.data->>'SomethingElse'",
Field.less("SomethingElse", 9).withQualifier("this").path(Dialect.POSTGRESQL, FieldFormat.SQL),
"Path not correct")
}
@Test
@DisplayName("path generates for nested unqualified PostgreSQL field")
fun pathPostgresNestedUnqualified() {
assertEquals("data#>>'{My,Nested,Field}'",
Field.equal("My.Nested.Field", "howdy").path(Dialect.POSTGRESQL, FieldFormat.SQL), "Path not correct")
}
@Test
@DisplayName("path generates for nested qualified PostgreSQL field")
fun pathPostgresNestedQualified() {
assertEquals("bird.data#>>'{Nest,Away}'",
Field.equal("Nest.Away", "doc").withQualifier("bird").path(Dialect.POSTGRESQL, FieldFormat.SQL),
"Path not correct")
}
@Test
@DisplayName("path generates for simple unqualified SQLite field")
fun pathSQLiteSimpleUnqualified() {
assertEquals("data->>'SomethingCool'",
Field.greaterOrEqual("SomethingCool", 18).path(Dialect.SQLITE, FieldFormat.SQL), "Path not correct")
}
@Test
@DisplayName("path generates for simple qualified SQLite field")
fun pathSQLiteSimpleQualified() {
assertEquals("this.data->>'SomethingElse'",
Field.less("SomethingElse", 9).withQualifier("this").path(Dialect.SQLITE, FieldFormat.SQL),
"Path not correct")
}
@Test
@DisplayName("path generates for nested unqualified SQLite field")
fun pathSQLiteNestedUnqualified() {
assertEquals("data->'My'->'Nested'->>'Field'",
Field.equal("My.Nested.Field", "howdy").path(Dialect.SQLITE, FieldFormat.SQL), "Path not correct")
}
@Test
@DisplayName("path generates for nested qualified SQLite field")
fun pathSQLiteNestedQualified() {
assertEquals("bird.data->'Nest'->>'Away'",
Field.equal("Nest.Away", "doc").withQualifier("bird").path(Dialect.SQLITE, FieldFormat.SQL),
"Path not correct")
}
}

View File

@@ -1,74 +0,0 @@
package solutions.bitbadger.documents.common
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class OpTest {
@Test
@DisplayName("EQUAL uses proper SQL")
fun equalSQL() {
assertEquals("=", Op.EQUAL.sql, "The SQL for equal is incorrect")
}
@Test
@DisplayName("GREATER uses proper SQL")
fun greaterSQL() {
assertEquals(">", Op.GREATER.sql, "The SQL for greater is incorrect")
}
@Test
@DisplayName("GREATER_OR_EQUAL uses proper SQL")
fun greaterOrEqualSQL() {
assertEquals(">=", Op.GREATER_OR_EQUAL.sql, "The SQL for greater-or-equal is incorrect")
}
@Test
@DisplayName("LESS uses proper SQL")
fun lessSQL() {
assertEquals("<", Op.LESS.sql, "The SQL for less is incorrect")
}
@Test
@DisplayName("LESS_OR_EQUAL uses proper SQL")
fun lessOrEqualSQL() {
assertEquals("<=", Op.LESS_OR_EQUAL.sql, "The SQL for less-or-equal is incorrect")
}
@Test
@DisplayName("NOT_EQUAL uses proper SQL")
fun notEqualSQL() {
assertEquals("<>", Op.NOT_EQUAL.sql, "The SQL for not-equal is incorrect")
}
@Test
@DisplayName("BETWEEN uses proper SQL")
fun betweenSQL() {
assertEquals("BETWEEN", Op.BETWEEN.sql, "The SQL for between is incorrect")
}
@Test
@DisplayName("IN uses proper SQL")
fun inSQL() {
assertEquals("IN", Op.IN.sql, "The SQL for in is incorrect")
}
@Test
@DisplayName("IN_ARRAY uses proper SQL")
fun inArraySQL() {
assertEquals("?|", Op.IN_ARRAY.sql, "The SQL for in-array is incorrect")
}
@Test
@DisplayName("EXISTS uses proper SQL")
fun existsSQL() {
assertEquals("IS NOT NULL", Op.EXISTS.sql, "The SQL for exists is incorrect")
}
@Test
@DisplayName("NOT_EXISTS uses proper SQL")
fun notExistsSQL() {
assertEquals("IS NULL", Op.NOT_EXISTS.sql, "The SQL for not-exists is incorrect")
}
}

View File

@@ -1,26 +0,0 @@
package solutions.bitbadger.documents.common
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class ParameterNameTest {
@Test
@DisplayName("derive works when given existing names")
fun withExisting() {
val names = ParameterName()
assertEquals(":taco", names.derive(":taco"), "Name should have been :taco")
assertEquals(":field0", names.derive(null), "Counter should not have advanced for named field")
}
@Test
@DisplayName("derive works when given all anonymous fields")
fun allAnonymous() {
val names = ParameterName()
assertEquals(":field0", names.derive(null), "Anonymous field name should have been returned")
assertEquals(":field1", names.derive(null), "Counter should have advanced from previous call")
assertEquals(":field2", names.derive(null), "Counter should have advanced from previous call")
assertEquals(":field3", names.derive(null), "Counter should have advanced from previous call")
}
}

View File

@@ -1,34 +0,0 @@
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<DocumentException> { Parameter("it", ParameterType.JSON, "") }
}
}

View File

@@ -1,18 +0,0 @@
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")
}
}

View File

@@ -1,197 +0,0 @@
package solutions.bitbadger.documents.common
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
class QueryTest {
/** Test table name */
private val tbl = "test_table"
@Test
@DisplayName("statementWhere generates correctly")
fun statementWhere() {
assertEquals("x WHERE y", Query.statementWhere("x", "y"), "Statements not combined correctly")
}
@Test
@DisplayName("Definition.ensureTableFor generates correctly")
fun ensureTableFor() {
assertEquals("CREATE TABLE IF NOT EXISTS my.table (data JSONB NOT NULL)",
Query.Definition.ensureTableFor("my.table", "JSONB"), "CREATE TABLE statement not constructed correctly")
}
@Test
@DisplayName("Definition.ensureKey generates correctly with schema")
fun ensureKeyWithSchema() {
assertEquals("CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON test.table ((data->>'id'))",
Query.Definition.ensureKey("test.table", Dialect.POSTGRESQL),
"CREATE INDEX for key statement with schema not constructed correctly")
}
@Test
@DisplayName("Definition.ensureKey generates correctly without schema")
fun ensureKeyWithoutSchema() {
assertEquals("CREATE UNIQUE INDEX IF NOT EXISTS idx_${tbl}_key ON $tbl ((data->>'id'))",
Query.Definition.ensureKey(tbl, Dialect.SQLITE),
"CREATE INDEX for key statement without schema not constructed correctly")
}
@Test
@DisplayName("Definition.ensureIndexOn generates multiple fields and directions")
fun ensureIndexOnMultipleFields() {
assertEquals(
"CREATE INDEX IF NOT EXISTS idx_table_gibberish ON test.table ((data->>'taco'), (data->>'guac') DESC, (data->>'salsa') ASC)",
Query.Definition.ensureIndexOn("test.table", "gibberish", listOf("taco", "guac DESC", "salsa ASC"),
Dialect.POSTGRESQL),
"CREATE INDEX for multiple field statement not constructed correctly")
}
@Test
@DisplayName("Definition.ensureIndexOn generates nested PostgreSQL field")
fun ensureIndexOnNestedPostgres() {
assertEquals("CREATE INDEX IF NOT EXISTS idx_${tbl}_nest ON $tbl ((data#>>'{a,b,c}'))",
Query.Definition.ensureIndexOn(tbl, "nest", listOf("a.b.c"), Dialect.POSTGRESQL),
"CREATE INDEX for nested PostgreSQL field incorrect")
}
@Test
@DisplayName("Definition.ensureIndexOn generates nested SQLite field")
fun ensureIndexOnNestedSQLite() {
assertEquals("CREATE INDEX IF NOT EXISTS idx_${tbl}_nest ON $tbl ((data->'a'->'b'->>'c'))",
Query.Definition.ensureIndexOn(tbl, "nest", listOf("a.b.c"), Dialect.SQLITE),
"CREATE INDEX for nested SQLite field incorrect")
}
@Test
@DisplayName("insert generates correctly")
fun insert() {
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() {
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
@DisplayName("count generates correctly")
fun count() {
assertEquals("SELECT COUNT(*) AS it FROM $tbl", Query.count(tbl), "Count query not constructed correctly")
}
@Test
@DisplayName("exists generates correctly")
fun exists() {
assertEquals("SELECT EXISTS (SELECT 1 FROM $tbl WHERE turkey) AS it", Query.exists(tbl, "turkey"),
"Exists query not constructed correctly")
}
@Test
@DisplayName("find generates correctly")
fun find() {
assertEquals("SELECT data FROM $tbl", Query.find(tbl), "Find query not constructed correctly")
}
@Test
@DisplayName("update generates successfully")
fun update() {
assertEquals("UPDATE $tbl SET data = :data", Query.update(tbl), "Update query not constructed correctly")
}
@Test
@DisplayName("delete generates successfully")
fun delete() {
assertEquals("DELETE FROM $tbl", Query.delete(tbl), "Delete query not constructed correctly")
}
@Test
@DisplayName("orderBy generates for no fields")
fun orderByNone() {
assertEquals("", Query.orderBy(listOf(), Dialect.POSTGRESQL), "ORDER BY should have been blank (PostgreSQL)")
assertEquals("", Query.orderBy(listOf(), Dialect.SQLITE), "ORDER BY should have been blank (SQLite)")
}
@Test
@DisplayName("orderBy generates single, no direction for PostgreSQL")
fun orderBySinglePostgres() {
assertEquals(" ORDER BY data->>'TestField'",
Query.orderBy(listOf(Field.named("TestField")), Dialect.POSTGRESQL), "ORDER BY not constructed correctly")
}
@Test
@DisplayName("orderBy generates single, no direction for SQLite")
fun orderBySingleSQLite() {
assertEquals(" ORDER BY data->>'TestField'", Query.orderBy(listOf(Field.named("TestField")), Dialect.SQLITE),
"ORDER BY not constructed correctly")
}
@Test
@DisplayName("orderBy generates multiple with direction for PostgreSQL")
fun orderByMultiplePostgres() {
assertEquals(" ORDER BY data#>>'{Nested,Test,Field}' DESC, data->>'AnotherField', data->>'It' DESC",
Query.orderBy(
listOf(Field.named("Nested.Test.Field DESC"), Field.named("AnotherField"), Field.named("It DESC")),
Dialect.POSTGRESQL),
"ORDER BY not constructed correctly")
}
@Test
@DisplayName("orderBy generates multiple with direction for SQLite")
fun orderByMultipleSQLite() {
assertEquals(" ORDER BY data->'Nested'->'Test'->>'Field' DESC, data->>'AnotherField', data->>'It' DESC",
Query.orderBy(
listOf(Field.named("Nested.Test.Field DESC"), Field.named("AnotherField"), Field.named("It DESC")),
Dialect.SQLITE),
"ORDER BY not constructed correctly")
}
@Test
@DisplayName("orderBy generates numeric ordering PostgreSQL")
fun orderByNumericPostgres() {
assertEquals(" ORDER BY (data->>'Test')::numeric",
Query.orderBy(listOf(Field.named("n:Test")), Dialect.POSTGRESQL), "ORDER BY not constructed correctly")
}
@Test
@DisplayName("orderBy generates numeric ordering for SQLite")
fun orderByNumericSQLite() {
assertEquals(" ORDER BY data->>'Test'", Query.orderBy(listOf(Field.named("n:Test")), Dialect.SQLITE),
"ORDER BY not constructed correctly")
}
@Test
@DisplayName("orderBy generates case-insensitive ordering for PostgreSQL")
fun orderByCIPostgres() {
assertEquals(" ORDER BY LOWER(data#>>'{Test,Field}') DESC NULLS FIRST",
Query.orderBy(listOf(Field.named("i:Test.Field DESC NULLS FIRST")), Dialect.POSTGRESQL),
"ORDER BY not constructed correctly")
}
@Test
@DisplayName("orderBy generates case-insensitive ordering for SQLite")
fun orderByCISQLite() {
assertEquals(" ORDER BY data->'Test'->>'Field' COLLATE NOCASE ASC NULLS LAST",
Query.orderBy(listOf(Field.named("i:Test.Field ASC NULLS LAST")), Dialect.SQLITE),
"ORDER BY not constructed correctly")
}
}